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 -%}