Skip to content

Add queue page #291

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 9 additions & 5 deletions src/database/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<Option<RepoModel>> {
get_repository_by_name(&self.pool, repo_name).await
}

pub async fn upsert_repository(
&self,
repo: &GithubRepoName,
Expand Down
26 changes: 26 additions & 0 deletions src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,18 @@ pub enum BuildStatus {
Timeouted,
}

impl Display for BuildStatus {
Copy link
Member

@Kobzol Kobzol Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now (keep it like it is), but I'd like to point out that the presentation of the build status should ideally be implemented in each presentation layer (currently we only have one - the website). It's possible that we might want to render it in different ways at multiple places, and probably the logic for deciding the visualization shouldn't live in the database module.

It can be done by creating a function in the website module that renders the status, or creating some helper presentation wrapper (e.g. BuildStatusTableDisplay) that would wrap the status and implement Display itself.

Implementing Display on the core "domain" types is a bit of an anti-pattern, even though it's quite tempting to do, and we already did it for other structs (but there we kind of abuse Display to serialize the structs form/into the DB 😆).

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")]
Expand Down Expand Up @@ -354,6 +366,20 @@ impl TreeState {
pub fn is_closed(&self) -> bool {
matches!(self, TreeState::Closed { .. })
}

pub fn priority(&self) -> Option<u32> {
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<sqlx::Postgres> for TreeState {
Expand Down
32 changes: 32 additions & 0 deletions src/database/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<RepoModel>> {
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<'_>,
Expand Down
22 changes: 22 additions & 0 deletions src/github/error.rs
Original file line number Diff line number Diff line change
@@ -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<E> From<E> for AppError
where
E: Into<AnyhowError>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
2 changes: 2 additions & 0 deletions src/github/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
64 changes: 58 additions & 6 deletions src/github/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,6 +70,8 @@ pub type ServerStateRef = Arc<ServerState>;
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))
Expand All @@ -87,7 +93,11 @@ async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, "")
}

async fn index_handler(State(state): State<ServerStateRef>) -> impl IntoResponse {
async fn index_handler() -> impl IntoResponse {
Redirect::permanent("/queue/rust")
}

async fn help_handler(State(state): State<ServerStateRef>) -> impl IntoResponse {
let mut repos = Vec::with_capacity(state.repositories.len());
for repo in state.repositories.keys() {
let treeclosed = state
Expand All @@ -103,7 +113,49 @@ async fn index_handler(State(state): State<ServerStateRef>) -> impl IntoResponse
});
}

HtmlTemplate(IndexTemplate { repos })
HtmlTemplate(HelpTemplate { repos })
}

async fn queue_handler(
Path(repo_name): Path<String>,
State(state): State<ServerStateRef>,
) -> Result<impl IntoResponse, AppError> {
let repo = match state.db.repo_by_name(&repo_name).await? {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should fetch the repo from the app state (repositories where the GitHub app is installed), not from the DB. Because right now when you open bors with an empty DB, and it is installed on repository X, it will display "repository not found", because that repo doesn't yet have any entry in the DB (it is currently only created when the tree is closed, or some other similar operation).

Or, alternatively (and I think that this would be even better, because then we wouldn't need to consider the edge case where a known repo is not in the DB), when the bot starts and fetches the repositories where it is installed, it should eagerly upsert all the found repos into the DB. Could you please do that in a separate PR? Thank you!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working on it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this can be resolved.

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.
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
21 changes: 19 additions & 2 deletions src/templates.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::database::{MergeableState::*, PullRequestModel, TreeState};
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
use http::StatusCode;
Expand All @@ -21,8 +22,8 @@ where
}

#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate {
#[template(path = "help.html")]
pub struct HelpTemplate {
pub repos: Vec<RepositoryView>,
}

Expand All @@ -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<PullRequestModel>,
pub tree_state: TreeState,
}

#[derive(Template)]
#[template(path = "not_found.html")]
pub struct NotFoundTemplate {}
12 changes: 12 additions & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
Loading