From bb07e366a6e1e80fcfafaa815f9caaedee842efb Mon Sep 17 00:00:00 2001 From: Sakibul Islam Date: Fri, 30 May 2025 11:29:54 +0100 Subject: [PATCH] Add queue page - Add queue table - Add queue statistics - Redirect homepage to queue page - Replace original homepage with /help page --- ...a7a72f05ef3ba1644f43ae5e5d54bd64ca533.json | 40 ++++++ src/database/client.rs | 14 +- src/database/mod.rs | 26 ++++ src/database/operations.rs | 32 +++++ src/github/error.rs | 22 +++ src/github/mod.rs | 2 + src/github/server.rs | 64 ++++++++- src/lib.rs | 2 +- src/templates.rs | 21 ++- templates/base.html | 12 ++ templates/help.html | 135 ++++++++++++++++++ templates/index.html | 135 ------------------ templates/not_found.html | 40 +++--- templates/queue.html | 71 +++++++++ 14 files changed, 444 insertions(+), 172 deletions(-) create mode 100644 .sqlx/query-4e0aaa8eea9ccadab0fc5514558a7a72f05ef3ba1644f43ae5e5d54bd64ca533.json create mode 100644 src/github/error.rs create mode 100644 templates/base.html create mode 100644 templates/help.html delete mode 100644 templates/index.html create mode 100644 templates/queue.html diff --git a/.sqlx/query-4e0aaa8eea9ccadab0fc5514558a7a72f05ef3ba1644f43ae5e5d54bd64ca533.json b/.sqlx/query-4e0aaa8eea9ccadab0fc5514558a7a72f05ef3ba1644f43ae5e5d54bd64ca533.json new file mode 100644 index 00000000..aff9821a --- /dev/null +++ b/.sqlx/query-4e0aaa8eea9ccadab0fc5514558a7a72f05ef3ba1644f43ae5e5d54bd64ca533.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n name as \"name: GithubRepoName\",\n (\n tree_state,\n treeclosed_src\n ) AS \"tree_state!: TreeState\",\n created_at\n FROM repository\n WHERE name LIKE $1\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "name: GithubRepoName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "tree_state!: TreeState", + "type_info": "Record" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + null, + false + ] + }, + "hash": "4e0aaa8eea9ccadab0fc5514558a7a72f05ef3ba1644f43ae5e5d54bd64ca533" +} diff --git a/src/database/client.rs b/src/database/client.rs index f9ccf331..0c389d6d 100644 --- a/src/database/client.rs +++ b/src/database/client.rs @@ -12,11 +12,11 @@ use super::operations::{ approve_pull_request, create_build, create_pull_request, create_workflow, delegate_pull_request, find_build, find_pr_by_build, get_nonclosed_pull_requests, get_nonclosed_pull_requests_by_base_branch, get_prs_with_unknown_mergeable_state, - get_pull_request, get_repository, get_running_builds, get_workflow_urls_for_build, - get_workflows_for_build, insert_repo_if_not_exists, set_pr_priority, set_pr_rollup, - set_pr_status, unapprove_pull_request, undelegate_pull_request, update_build_status, - update_mergeable_states_by_base_branch, update_pr_build_id, update_pr_mergeable_state, - update_workflow_status, upsert_pull_request, upsert_repository, + get_pull_request, get_repository, get_repository_by_name, get_running_builds, + get_workflow_urls_for_build, get_workflows_for_build, insert_repo_if_not_exists, + set_pr_priority, set_pr_rollup, set_pr_status, unapprove_pull_request, undelegate_pull_request, + update_build_status, update_mergeable_states_by_base_branch, update_pr_build_id, + update_pr_mergeable_state, update_workflow_status, upsert_pull_request, upsert_repository, }; use super::{ApprovalInfo, DelegatedPermission, MergeableState, RunId}; @@ -272,6 +272,10 @@ impl PgDbClient { insert_repo_if_not_exists(&self.pool, repo, tree_state).await } + pub async fn repo_by_name(&self, repo_name: &str) -> anyhow::Result> { + get_repository_by_name(&self.pool, repo_name).await + } + pub async fn upsert_repository( &self, repo: &GithubRepoName, diff --git a/src/database/mod.rs b/src/database/mod.rs index 90f01bfa..d8c34152 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -251,6 +251,18 @@ pub enum BuildStatus { Timeouted, } +impl Display for BuildStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BuildStatus::Pending => write!(f, "pending"), + BuildStatus::Success => write!(f, "success"), + BuildStatus::Failure => write!(f, "failure"), + BuildStatus::Cancelled => write!(f, "cancelled"), + BuildStatus::Timeouted => write!(f, "timeouted"), + } + } +} + /// Represents a single (merged) commit. #[derive(Debug, sqlx::Type)] #[sqlx(type_name = "build")] @@ -354,6 +366,20 @@ impl TreeState { pub fn is_closed(&self) -> bool { matches!(self, TreeState::Closed { .. }) } + + pub fn priority(&self) -> Option { + match self { + TreeState::Closed { priority, .. } => Some(*priority), + TreeState::Open => None, + } + } + + pub fn comment_source(&self) -> Option<&str> { + match self { + TreeState::Closed { source, .. } => Some(source), + TreeState::Open => None, + } + } } impl sqlx::Type for TreeState { diff --git a/src/database/operations.rs b/src/database/operations.rs index aff241fa..e5e095bc 100644 --- a/src/database/operations.rs +++ b/src/database/operations.rs @@ -817,6 +817,38 @@ pub(crate) async fn insert_repo_if_not_exists( .await } +/// Returns the first match found for a repository by name without owner (via `/{repo_name}`). +pub(crate) async fn get_repository_by_name( + executor: impl PgExecutor<'_>, + repo_name: &str, +) -> anyhow::Result> { + measure_db_query("get_repository_by_name", || async { + let search_pattern = format!("%/{repo_name}"); + let repo = sqlx::query_as!( + RepoModel, + r#" + SELECT + id, + name as "name: GithubRepoName", + ( + tree_state, + treeclosed_src + ) AS "tree_state!: TreeState", + created_at + FROM repository + WHERE name LIKE $1 + LIMIT 1 + "#, + search_pattern + ) + .fetch_optional(executor) + .await?; + + Ok(repo) + }) + .await +} + /// Updates the tree state of a repository. pub(crate) async fn upsert_repository( executor: impl PgExecutor<'_>, diff --git a/src/github/error.rs b/src/github/error.rs new file mode 100644 index 00000000..e1efe0f5 --- /dev/null +++ b/src/github/error.rs @@ -0,0 +1,22 @@ +use anyhow::Error as AnyhowError; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +pub struct AppError(pub AnyhowError); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let msg = format!("Something went wrong: {}", self.0); + tracing::error!("{msg}"); + (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response() + } +} + +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} diff --git a/src/github/mod.rs b/src/github/mod.rs index 61ba5924..d51aafd4 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -7,11 +7,13 @@ use std::str::FromStr; use url::Url; pub mod api; +mod error; mod labels; pub mod server; mod webhook; pub use api::operations::MergeError; +pub use error::AppError; pub use labels::{LabelModification, LabelTrigger}; pub use webhook::WebhookSecret; diff --git a/src/github/server.rs b/src/github/server.rs index fd35a641..241d1b48 100644 --- a/src/github/server.rs +++ b/src/github/server.rs @@ -4,18 +4,22 @@ use crate::bors::mergeable_queue::{ handle_mergeable_queue_item, }; use crate::bors::{ - BorsContext, RepositoryState, handle_bors_global_event, handle_bors_repository_event, + BorsContext, RepositoryState, RollupMode, handle_bors_global_event, + handle_bors_repository_event, }; use crate::github::webhook::GitHubWebhook; use crate::github::webhook::WebhookSecret; -use crate::templates::{HtmlTemplate, IndexTemplate, NotFoundTemplate, RepositoryView}; +use crate::templates::{ + HelpTemplate, HtmlTemplate, NotFoundTemplate, PullRequestStats, QueueTemplate, RepositoryView, +}; use crate::{BorsGlobalEvent, BorsRepositoryEvent, PgDbClient, TeamApiClient}; +use super::AppError; use anyhow::Error; use axum::Router; -use axum::extract::State; +use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use octocrab::Octocrab; use std::any::Any; @@ -66,6 +70,8 @@ pub type ServerStateRef = Arc; pub fn create_app(state: ServerState) -> Router { Router::new() .route("/", get(index_handler)) + .route("/help", get(help_handler)) + .route("/queue/{repo_name}", get(queue_handler)) .route("/github", post(github_webhook_handler)) .route("/health", get(health_handler)) .layer(ConcurrencyLimitLayer::new(100)) @@ -87,7 +93,11 @@ async fn health_handler() -> impl IntoResponse { (StatusCode::OK, "") } -async fn index_handler(State(state): State) -> impl IntoResponse { +async fn index_handler() -> impl IntoResponse { + Redirect::permanent("/queue/rust") +} + +async fn help_handler(State(state): State) -> impl IntoResponse { let mut repos = Vec::with_capacity(state.repositories.len()); for repo in state.repositories.keys() { let treeclosed = state @@ -103,7 +113,49 @@ async fn index_handler(State(state): State) -> impl IntoResponse }); } - HtmlTemplate(IndexTemplate { repos }) + HtmlTemplate(HelpTemplate { repos }) +} + +async fn queue_handler( + Path(repo_name): Path, + State(state): State, +) -> Result { + let repo = match state.db.repo_by_name(&repo_name).await? { + Some(repo) => repo, + None => { + return Ok(( + StatusCode::NOT_FOUND, + format!("Repository {repo_name} not found"), + ) + .into_response()); + } + }; + + let prs = state.db.get_nonclosed_pull_requests(&repo.name).await?; + + // TODO: add failed count + let (approved_count, rolled_up_count) = prs.iter().fold((0, 0), |(approved, rolled_up), pr| { + let is_approved = if pr.is_approved() { 1 } else { 0 }; + let is_rolled_up = if matches!(pr.rollup, Some(RollupMode::Always)) { + 1 + } else { + 0 + }; + (approved + is_approved, rolled_up + is_rolled_up) + }); + + Ok(HtmlTemplate(QueueTemplate { + repo_name: repo.name.name().to_string(), + repo_url: format!("https://github.com/{}", repo.name), + tree_state: repo.tree_state, + stats: PullRequestStats { + total_count: prs.len(), + approved_count, + rolled_up_count, + }, + prs, + }) + .into_response()) } /// Axum handler that receives a webhook and sends it to a webhook channel. diff --git a/src/lib.rs b/src/lib.rs index 690bc3c4..48181756 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,7 @@ mod utils; pub use bors::{BorsContext, CommandParser, event::BorsGlobalEvent, event::BorsRepositoryEvent}; pub use database::{PgDbClient, TreeState}; pub use github::{ - WebhookSecret, + AppError, WebhookSecret, api::create_github_client, api::load_repositories, server::{BorsProcess, ServerState, create_app, create_bors_process}, diff --git a/src/templates.rs b/src/templates.rs index 56afabd7..0a51750a 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,3 +1,4 @@ +use crate::database::{MergeableState::*, PullRequestModel, TreeState}; use askama::Template; use axum::response::{Html, IntoResponse, Response}; use http::StatusCode; @@ -21,8 +22,8 @@ where } #[derive(Template)] -#[template(path = "index.html")] -pub struct IndexTemplate { +#[template(path = "help.html")] +pub struct HelpTemplate { pub repos: Vec, } @@ -31,6 +32,22 @@ pub struct RepositoryView { pub treeclosed: bool, } +pub struct PullRequestStats { + pub total_count: usize, + pub approved_count: usize, + pub rolled_up_count: usize, +} + +#[derive(Template)] +#[template(path = "queue.html")] +pub struct QueueTemplate { + pub repo_name: String, + pub repo_url: String, + pub stats: PullRequestStats, + pub prs: Vec, + pub tree_state: TreeState, +} + #[derive(Template)] #[template(path = "not_found.html")] pub struct NotFoundTemplate {} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 00000000..438eb070 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,12 @@ + + + + + + {% block title %}{% endblock %} + {% block head %}{% endblock %} + + + {% block body %}{% endblock %} + + \ No newline at end of file diff --git a/templates/help.html b/templates/help.html new file mode 100644 index 00000000..9f0316bf --- /dev/null +++ b/templates/help.html @@ -0,0 +1,135 @@ +{% extends "base.html" %} + +{% block title %}Bors{% endblock %} + +{% block head %} + +{% endblock %} + +{% block body %} +
+

Bors

+ +

Repositories

+ +
    + {% for repo in repos %} +
  • + {{ repo.name }} + {% if repo.treeclosed %} [TREECLOSED] {% endif %} +
  • + {% endfor %} +
+ +
+ +

Bors Cheatsheet

+ +

Commands

+ +

+ Here's a quick reference for the commands bors accepts. Commands must be + posted as comments on the PR they refer to. Comments may include + multiple commands. Bors will only listen to official reviewers that it + is configured to listen to. A comment must mention the GitHub account + bors is configured to use (e.g. for the Rust project this is + @bors). +

+ +
    +
  • info: Get information about the current PR.
  • +
  • + ping: Send a ping to bors to check that it responds. +
  • +
  • help: Print help message with available commands.
  • +
  • + rollup=<never|iffy|maybe|always>: Mark the PR as + "always", "maybe", "iffy", or "never" rollup-able. +
  • +
  • rollup: Short for rollup=always.
  • +
  • rollup-: Short for rollup=maybe.
  • +
  • + p=<priority>: Set the priority of the approved PR + (defaults to 0). +
  • +
  • + r=<user>: Approve a PR on behalf of specified user. +
  • +
  • r+: Approve a PR.
  • +
  • r-: Unapprove a PR.
  • +
  • + try: Start a try build based on the most recent commit + from the main branch. +
  • +
  • + try parent=<sha>: Start a try build based on the + specified parent commit. +
  • +
  • + try parent=last: Start a try build based on the parent + commit of the last try build. +
  • +
  • + try jobs=<job1,job2,...>: Start a try build with + specific CI jobs (up to 10). +
  • +
  • try cancel: Cancel a running try build.
  • +
  • + delegate=<try|review>: Delegate try or review + permissions to the PR author. +
  • +
  • + delegate+: Delegate review permissions to the PR author. +
  • +
  • + delegate-: Remove any previously granted delegation. +
  • +
+ +

Examples

+ +
    +
  • @bors r+ p=1
  • +
  • @bors try parent=abcd123
  • +
  • @bors rollup=always
  • +
  • @bors delegate=review
  • +
  • @bors delegate=try
  • +
  • + @bors try @rust-timer queue: Short-hand for compile-perf + benchmarking of PRs. +
  • +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 9f3100b3..00000000 --- a/templates/index.html +++ /dev/null @@ -1,135 +0,0 @@ - - - - - Bors - - - -
-

Bors

- -

Repositories

- -
    - {% for repo in repos %} -
  • - {{repo.name}} - {% if repo.treeclosed %} [TREECLOSED] {% endif %} -
  • - {% endfor %} -
- -
- -

Bors Cheatsheet

- -

Commands

- -

- Here's a quick reference for the commands bors accepts. Commands must be - posted as comments on the PR they refer to. Comments may include - multiple commands. Bors will only listen to official reviewers that it - is configured to listen to. A comment must mention the GitHub account - bors is configured to use. (e.g. for the Rust project this is @bors) - Note that bors will only recognize comments in open PRs. -

- -
    -
  • info: Get information about the current PR.
  • -
  • - ping: Send a ping to bors to check that it responds. -
  • -
  • help: Print help message with available commands.
  • -
  • - rollup=<never|iffy|maybe|always>: Mark the PR as - "always", "maybe", "iffy", or "never" rollup-able. -
  • -
  • rollup: Short for rollup=always.
  • -
  • rollup-: Short for rollup=maybe.
  • -
  • - p=<priority>: Set the priority of the approved PR - (defaults to 0). -
  • -
  • - r=<user>: Approve a PR on behalf of specified user. -
  • -
  • r+: Approve a PR.
  • -
  • r-: Unapprove a PR.
  • -
  • - try: Start a try build based on the most recent commit - from the main branch. -
  • -
  • - try parent=<sha>: Start a try build based on the - specified parent commit. -
  • -
  • - try parent=last: Start a try build based on the parent - commit of the last try build. -
  • -
  • - try jobs=<job1,job2,...>: Start a try build with - specific CI jobs (up to 10). -
  • -
  • try cancel: Cancel a running try build.
  • -
  • - delegate=<try|review>: Delegate try or review - permissions to the PR author. -
  • -
  • - delegate+: Delegate review permissions to the PR author. -
  • -
  • - delegate-: Remove any previously granted delegation. -
  • -
- -

Examples

- -
    -
  • @bors r+ p=1
  • -
  • @bors try parent=abcd123
  • -
  • @bors rollup=always
  • -
  • @bors delegate=review
  • -
  • @bors delegate=try
  • -
  • - @bors try @rust-timer queue: Short-hand for compile-perf - benchmarking of PRs. -
  • -
-
- - diff --git a/templates/not_found.html b/templates/not_found.html index eb4979a4..53c6f3f6 100644 --- a/templates/not_found.html +++ b/templates/not_found.html @@ -1,23 +1,17 @@ - - - - - Page not found - - - -

Page not found

-

Go back to the index

- - +{% extends "base.html" %} + +{% block title %}Page not found{% endblock %} + +{% block head %} + +{% endblock %} + +{% block body %} +

Page not found

+

Go back to the index

+{% endblock %} diff --git a/templates/queue.html b/templates/queue.html new file mode 100644 index 00000000..3968aed6 --- /dev/null +++ b/templates/queue.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}Bors queue - {{ repo_name }} {% if tree_state.is_closed() %} [TREECLOSED] {% endif %}{% endblock %} + +{% block body %} +

+ Bors queue - {{ repo_name }} + {% if tree_state.is_closed() %} + {% if let Some(comment_source) = tree_state.comment_source() %} + {% if let Some(priority) = tree_state.priority() %} + [TREECLOSED below priority {{ priority }}] + {% endif %} + {% endif %} + {% endif %} +

+ +

+ {{ stats.total_count }} total, {{ stats.approved_count }} approved, + {{ stats.rolled_up_count }} rolled up +

+ + + + + + + + + + + + + {% for pr in prs %} + + + + + + + + + {% endfor %} + +
#StatusMergeableApproved byPriorityRollup
+ {{ pr.number.0 }} + + {% if let Some(try_build) = pr.try_build %} + {{ try_build.status }} (try) + {% else %} + {% if pr.is_approved() %} + approved + {% endif %} + {% endif %} + + {% match pr.mergeable_state %} + {% when Mergeable %} + yes + {% when HasConflicts %} + no + {% when Unknown %} + {% endmatch %} + + {% if let Some(approver) = pr.approver() %} + {{ approver }} + {% endif %} + {{ pr.priority.unwrap_or(0) }} + {% if let Some(rollup) = pr.rollup %} + {{ rollup }} + {% endif %} +
+{% endblock %}