diff --git a/.sqlx/query-93a79579cfa03fd0fd64efb5b7f0a0b07c231bdc98114ef1d7bb82651611c07a.json b/.sqlx/query-93a79579cfa03fd0fd64efb5b7f0a0b07c231bdc98114ef1d7bb82651611c07a.json new file mode 100644 index 00000000..cec5a491 --- /dev/null +++ b/.sqlx/query-93a79579cfa03fd0fd64efb5b7f0a0b07c231bdc98114ef1d7bb82651611c07a.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n repository as \"repository: GithubRepoName\",\n branch,\n kind as \"kind: BuildKind\",\n commit_sha,\n status as \"status: BuildStatus\",\n parent,\n created_at as \"created_at: DateTime\",\n check_run_id,\n duration as \"duration: PgDuration\"\n FROM build\n WHERE repository = $1 AND\n kind = 'auto' AND\n status = 'success'\n ORDER BY created_at DESC\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "repository: GithubRepoName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "branch", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind: BuildKind", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "commit_sha", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status: BuildStatus", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "parent", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at: DateTime", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "check_run_id", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "duration: PgDuration", + "type_info": "Interval" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "93a79579cfa03fd0fd64efb5b7f0a0b07c231bdc98114ef1d7bb82651611c07a" +} diff --git a/src/database/client.rs b/src/database/client.rs index 36bbbf48..d4c3bc9d 100644 --- a/src/database/client.rs +++ b/src/database/client.rs @@ -1,13 +1,13 @@ use super::operations::{ approve_pull_request, clear_auto_build, create_build, create_workflow, delegate_pull_request, - delete_tagged_bot_comment, find_build, find_pr_by_build, get_nonclosed_pull_requests, - get_pending_builds, get_prs_with_stale_mergeability_or_approved, get_pull_request, - get_repository, get_repository_by_name, get_tagged_bot_comments, get_workflow_urls_for_build, - get_workflows_for_build, insert_repo_if_not_exists, is_rollup, record_tagged_bot_comment, - set_pr_assignees, set_pr_mergeability_state, set_pr_priority, set_pr_rollup_mode, - set_pr_status, set_stale_mergeability_status_by_base_branch, unapprove_pull_request, - undelegate_pull_request, update_build, update_pr_try_build_id, update_workflow_status, - upsert_pull_request, upsert_repository, + delete_tagged_bot_comment, find_build, find_pr_by_build, get_last_n_successful_auto_builds, + get_nonclosed_pull_requests, get_pending_builds, get_prs_with_stale_mergeability_or_approved, + get_pull_request, get_repository, get_repository_by_name, get_tagged_bot_comments, + get_workflow_urls_for_build, get_workflows_for_build, insert_repo_if_not_exists, is_rollup, + record_tagged_bot_comment, set_pr_assignees, set_pr_mergeability_state, set_pr_priority, + set_pr_rollup_mode, set_pr_status, set_stale_mergeability_status_by_base_branch, + unapprove_pull_request, undelegate_pull_request, update_build, update_pr_try_build_id, + update_workflow_status, upsert_pull_request, upsert_repository, }; use super::{ ApprovalInfo, DelegatedPermission, MergeableState, PrimaryKey, RunId, UpdateBuildParams, @@ -406,6 +406,15 @@ impl PgDbClient { pub async fn is_rollup(&self, pr: &PullRequestModel) -> anyhow::Result { is_rollup(&self.pool, pr.id).await } + + /// Return the last N auto builds that have succeeded on the given repository. + pub async fn get_last_n_successful_auto_builds( + &self, + repo: &GithubRepoName, + n: u32, + ) -> anyhow::Result> { + get_last_n_successful_auto_builds(&self.pool, repo, n).await + } } pub enum ExclusiveOperationOutcome { diff --git a/src/database/operations.rs b/src/database/operations.rs index 434c2936..62d2d85e 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -1165,6 +1165,43 @@ pub(crate) async fn is_rollup( .await } +pub(crate) async fn get_last_n_successful_auto_builds( + executor: impl PgExecutor<'_>, + repo: &GithubRepoName, + n: u32, +) -> anyhow::Result> { + measure_db_query("get_last_n_successful_auto_builds", || async { + let builds = sqlx::query_as!( + BuildModel, + r#" + SELECT + id, + repository as "repository: GithubRepoName", + branch, + kind as "kind: BuildKind", + commit_sha, + status as "status: BuildStatus", + parent, + created_at as "created_at: DateTime", + check_run_id, + duration as "duration: PgDuration" + FROM build + WHERE repository = $1 AND + kind = 'auto' AND + status = 'success' + ORDER BY created_at DESC + LIMIT $2 + "#, + repo as &GithubRepoName, + n as i32 + ) + .fetch_all(executor) + .await?; + Ok(builds) + }) + .await +} + #[cfg(test)] mod tests { use crate::bors::PullRequestStatus; diff --git a/src/server/mod.rs b/src/server/mod.rs index 586f2108..c06698c0 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,7 +1,7 @@ use crate::bors::event::BorsEvent; use crate::bors::{CommandPrefix, RepositoryState, format_help}; use crate::database::{ApprovalStatus, QueueStatus}; -use crate::github::{GithubRepoName, rollup}; +use crate::github::{GithubRepoName, PullRequestNumber, rollup}; use crate::templates::{ HelpTemplate, HtmlTemplate, NotFoundTemplate, PullRequestStats, QueueTemplate, RepositoryView, RollupsInfo, @@ -16,12 +16,14 @@ use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use axum_embed::ServeEmbed; +use chrono::Utc; use http::{Request, StatusCode}; use pulldown_cmark::Parser; use rust_embed::Embed; use serde::de::Error; use serde::{Deserialize, Deserializer}; use std::any::Any; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; @@ -332,7 +334,17 @@ pub async fn queue_handler( } }; - let prs = db.get_nonclosed_pull_requests(&repo.name).await?; + // Perform the queries concurrently to save a bit of time + let (prs, rollups, last_ten_builds) = futures::future::join3( + db.get_nonclosed_pull_requests(&repo.name), + db.get_nonclosed_rollups(&repo.name), + db.get_last_n_successful_auto_builds(&repo.name, 10), + ) + .await; + let prs = prs?; + let rollups = rollups?; + let last_ten_builds = last_ten_builds?; + let prs = sort_queue_prs(prs); // Note: this assumed that there is ever at most a single pending build @@ -345,19 +357,80 @@ pub async fn queue_handler( None => None, }; - let (in_queue_count, failed_count) = prs.iter().fold((0, 0), |(in_queue, failed), pr| { - let (in_queue_inc, failed_inc) = match pr.queue_status() { + let average_auto_duration = { + let total_duration = last_ten_builds + .iter() + .filter_map(|build| build.duration) + .map(|d| d.0) + .sum::(); + let total_duration = if total_duration.is_zero() { + // Default guess of 3 hours + Duration::from_secs(3600 * 3) + } else { + total_duration + }; + let count = last_ten_builds.len() as u32; + let count = if count > 0 { count } else { 1 }; + total_duration / count + }; + + let mut in_queue_count = 0; + let mut failed_count = 0; + + // PR number -> expected remaining duration + let mut in_queue: HashMap = HashMap::new(); + for pr in &prs { + let status = pr.queue_status(); + let (in_queue_inc, failed_inc) = match &status { QueueStatus::Approved(..) => (1, 0), QueueStatus::ReadyForMerge(..) => (1, 0), QueueStatus::Pending(..) => (1, 0), QueueStatus::Failed(..) => (0, 1), QueueStatus::NotApproved | QueueStatus::NotOpen => (0, 0), }; + in_queue_count += in_queue_inc; + failed_count += failed_inc; + + match &status { + QueueStatus::Pending(_, _) => { + // Try to guess already elapsed time of the pending workflow + let subtract_duration = if let Some(workflow) = &pending_workflow { + (Utc::now() - workflow.created_at) + .to_std() + .unwrap_or_default() + } else { + Duration::ZERO + }; + in_queue.insert(pr.number, average_auto_duration - subtract_duration); + } + // For an approved PR, assume that it will take the average auto build duration + QueueStatus::Approved(_) => { + in_queue.insert(pr.number, average_auto_duration); + } + QueueStatus::Failed(_, _) + | QueueStatus::ReadyForMerge(_, _) + | QueueStatus::NotOpen + | QueueStatus::NotApproved => {} + } + } - (in_queue + in_queue_inc, failed + failed_inc) - }); + let mut expected_remaining_duration = Duration::ZERO; - let rollups = db.get_nonclosed_rollups(&repo.name).await?; + // Rollup members whose rollup is in the queue, and thus its duration will be counted + let rollup_members: HashSet = rollups + .iter() + .filter(|(rollup, _)| in_queue.contains_key(*rollup)) + .flat_map(|(_, member)| member) + .copied() + .collect(); + + for (pr, remaining_duration) in in_queue { + // For a rollup member, we will count its rollup instead + if rollup_members.contains(&pr) { + continue; + } + expected_remaining_duration += remaining_duration; + } Ok(HtmlTemplate(QueueTemplate { oauth_client_id: oauth @@ -376,6 +449,7 @@ pub async fn queue_handler( pending_workflow, selected_rollup_prs: params.pull_requests.map(|prs| prs.0).unwrap_or_default(), rollups_info: RollupsInfo::from(rollups), + expected_remaining_duration, }) .into_response()) } diff --git a/src/templates.rs b/src/templates.rs index d0992bd1..82ce4fc8 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -9,6 +9,8 @@ use axum::response::{Html, IntoResponse, Response}; use http::StatusCode; use itertools::Itertools; use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use std::time::Duration; /// Build status to display on the queue page. pub fn status_text(pr: &PullRequestModel) -> String { @@ -84,6 +86,37 @@ pub struct QueueTemplate { pub selected_rollup_prs: Vec, // Active workflow for an active pending auto build pub pending_workflow: Option, + // Guesstimated duration to merge all current approved/pending PRs in the queue + pub expected_remaining_duration: Duration, +} + +impl QueueTemplate { + fn format_duration(&self, duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let days = total_seconds / 86400; + let hours = (total_seconds % 86400) / 3600; + let minutes = (total_seconds % 3600) / 60; + + let mut output = String::new(); + if days > 0 { + write!(output, "{days}d").unwrap(); + } + + if hours > 0 { + if !output.is_empty() { + output.push(' '); + } + write!(output, "{hours}h").unwrap(); + } + + if days == 0 && minutes > 0 { + if !output.is_empty() { + output.push(' '); + } + write!(output, "{minutes}m").unwrap(); + } + output + } } #[derive(Template)] diff --git a/web/templates/queue.html b/web/templates/queue.html index 0ea856b4..541dc816 100644 --- a/web/templates/queue.html +++ b/web/templates/queue.html @@ -192,6 +192,13 @@

{{ stats.total_count }} total, {{ stats.in_queue_count }} in queue, {{ stats.failed_count }} failed

+ {%- if !expected_remaining_duration.is_zero() -%} +
|
+

+ Estimated time to merge queue: {{ format_duration(*expected_remaining_duration) }} +

+ {%- endif -%}