Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: a pull requests card on the reports tab

The reports tab gets a "pull requests" card under the issue report. It lists
the open PRs of every repo, or of one picked in a repo select, with the
columns a reviewer or merger filters on: author, labels, requested
reviewers, base branch, WIP (the forge's draft flag), approvals against the
base branch's protection rule, has all approvals, conflicts, protected base
and CI. Every column sorts, the small-set columns filter by picking values,
and the browser keeps the filters and the repo. No filter applies at first.

swarm-controller resolves each row so the page makes no calls of its own:
GET /api/pr-report and GET /api/repos/{org}/{repo}/pr-report. The required
approvals come from the first matching branch protection rule (an
unprotected base asks for none); an approval counts when it is the
reviewer's latest decision, on the current head, neither stale nor
dismissed and official.

A free-text table cell gets a minimum width, so a table with many columns
scrolls sideways instead of wrapping a title to a word a line.
This commit is contained in:
iris 2026-11-07 10:06:59 +01:00 • committed by mara
commit f633963b77
5 changed files with 1132 additions and 8 deletions

View file

@ -56,6 +56,7 @@ mod meta_repo;
pub mod objects;
pub mod operator_profiles;
pub(crate) mod pr_comment;
pub mod pr_report;
pub mod pull_diff;
pub mod review_request;
pub mod runner_token;

View file

@ -0,0 +1,515 @@
//! The open pull requests of a repo as the swarm UI's PR report reads them:
//! each with what a reviewer or merger wants to filter on already resolved, so
//! the page does no calls of its own. Read-only, over the controller's forge
//! token.
use anyhow::{Context, Result};
use forgejo_api::structs::{
BranchProtection, CombinedStatus, CommitStatusState, PullRequest, PullReview,
RepoListPullRequestsQuery, RepoListPullRequestsQueryState,
};
use futures_util::{StreamExt as _, TryStreamExt as _};
use serde::Serialize;
use std::collections::HashMap;
use utoipa::ToSchema;
use super::Client;
/// What the commit statuses on a PR's head say together.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum CiState {
Success,
Pending,
Failure,
}
/// One open pull request, resolved for the PR report.
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
pub struct PrReportRow {
/// `owner/name`, on every row so the all-repos and the one-repo report
/// share one row type.
pub repo: String,
pub number: i64,
pub title: String,
pub html_url: Option<String>,
pub author: Option<String>,
pub labels: Vec<String>,
pub assignees: Vec<String>,
pub requested_reviewers: Vec<String>,
pub base_branch: String,
pub head_branch: String,
pub created_unix: Option<i64>,
pub updated_unix: Option<i64>,
/// The forge's draft flag (setting a `WIP:` title is what sets it).
pub wip: bool,
/// The forge cannot merge the PR as it is: its head conflicts with the
/// base.
pub has_conflicts: bool,
/// The base branch has a branch protection rule. A stacked PR targets a
/// feature branch, so `false` filters those out.
pub targets_protected_branch: bool,
/// Approvals the protection rule asks for; 0 when the base is not
/// protected.
pub required_approvals: u32,
/// Approvals that count now: the reviewer's latest review is an approval
/// of the current head, not dismissed, not stale, and counts toward the
/// rule (the forge's `official`).
pub approvals: u32,
/// Reviewers whose latest review asks for changes and is not dismissed.
pub changes_requested: u32,
/// `None` when the head has no commit status at all.
pub ci: Option<CiState>,
}
/// Whether `branch` is named by a branch protection pattern: a name, or a glob
/// where `*` stops at a `/` and `**` does not.
fn glob_matches(pattern: &str, branch: &str) -> bool {
fn go(p: &[u8], s: &[u8]) -> bool {
match p.split_first() {
None => s.is_empty(),
Some((b'*', rest)) => {
if let Some(rest) = rest.strip_prefix(b"*") {
// `**`: any run, slashes included.
(0..=s.len()).any(|i| go(rest, &s[i..]))
} else {
// `*`: any run up to the next slash.
let reach = s.iter().position(|&c| c == b'/').unwrap_or(s.len());
(0..=reach).any(|i| go(rest, &s[i..]))
}
}
Some((&c, rest)) => s
.split_first()
.is_some_and(|(&d, tail)| c == d && go(rest, tail)),
}
}
go(pattern.as_bytes(), branch.as_bytes())
}
/// The protection rule for `base`, as the approvals it asks for: `None` when
/// no rule names the branch. The first matching rule wins, which is how the
/// forge orders them.
fn required_approvals_for(rules: &[BranchProtection], base: &str) -> Option<u32> {
rules
.iter()
.find(|r| {
r.rule_name
.as_deref()
.or(r.branch_name.as_deref())
.is_some_and(|p| glob_matches(p, base))
})
.map(|r| u32::try_from(r.required_approvals.unwrap_or(0)).unwrap_or(0))
}
/// How a PR's reviews stand against its current head.
#[derive(Debug, PartialEq, Eq)]
struct Tally {
approvals: u32,
changes_requested: u32,
}
/// Each reviewer's latest review decides: an approval counts when it is of
/// `head_sha`, not dismissed, not stale and official; a review asking for
/// changes stands until it is dismissed or the reviewer reviews again. A
/// review that only comments does not replace an earlier decision.
fn tally_reviews(reviews: &[PullReview], head_sha: &str) -> Tally {
let mut latest: HashMap<&str, &PullReview> = HashMap::new();
for review in reviews {
let Some(login) = review.user.as_ref().and_then(|u| u.login.as_deref()) else {
continue;
};
let decides = matches!(
review.state.as_deref(),
Some("APPROVED" | "REQUEST_CHANGES")
);
if decides && review.dismissed != Some(true) {
latest.insert(login, review);
} else if decides {
// A dismissed decision no longer stands, and neither does the
// one it replaced.
latest.remove(login);
}
}
let mut tally = Tally {
approvals: 0,
changes_requested: 0,
};
for review in latest.values() {
match review.state.as_deref() {
Some("APPROVED") => {
if review.commit_id.as_deref() == Some(head_sha)
&& review.stale != Some(true)
&& review.official == Some(true)
{
tally.approvals += 1;
}
}
_ => tally.changes_requested += 1,
}
}
tally
}
/// The commit statuses on a head, as one state; `None` when it has none.
fn ci_state(status: &CombinedStatus) -> Option<CiState> {
if status.total_count.unwrap_or(0) == 0 {
return None;
}
Some(match status.state? {
CommitStatusState::Success | CommitStatusState::Skipped => CiState::Success,
CommitStatusState::Pending | CommitStatusState::Warning => CiState::Pending,
CommitStatusState::Failure | CommitStatusState::Error => CiState::Failure,
})
}
fn row(
repo: &str,
pr: &PullRequest,
rules: &[BranchProtection],
reviews: &[PullReview],
status: &CombinedStatus,
) -> Option<PrReportRow> {
let base_branch = pr.base.as_ref()?.r#ref.clone().unwrap_or_default();
let head = pr.head.as_ref()?;
let head_sha = head.sha.clone().unwrap_or_default();
let protection = required_approvals_for(rules, &base_branch);
let required_approvals = protection.unwrap_or(0);
let tally = tally_reviews(reviews, &head_sha);
let logins = |users: &Option<Vec<forgejo_api::structs::User>>| -> Vec<String> {
users
.iter()
.flatten()
.filter_map(|u| u.login.clone())
.collect()
};
Some(PrReportRow {
repo: repo.to_owned(),
number: pr.number?,
title: pr.title.clone().unwrap_or_default(),
html_url: pr.html_url.as_ref().map(ToString::to_string),
author: pr.user.as_ref().and_then(|u| u.login.clone()),
labels: pr
.labels
.iter()
.flatten()
.filter_map(|l| l.name.clone())
.collect(),
assignees: logins(&pr.assignees),
requested_reviewers: logins(&pr.requested_reviewers),
base_branch,
head_branch: head.r#ref.clone().unwrap_or_default(),
created_unix: pr.created_at.as_ref().map(|t| t.unix_timestamp()),
updated_unix: pr.updated_at.as_ref().map(|t| t.unix_timestamp()),
wip: pr.draft.unwrap_or(false),
has_conflicts: pr.mergeable == Some(false),
targets_protected_branch: protection.is_some(),
required_approvals,
approvals: tally.approvals,
changes_requested: tally.changes_requested,
ci: ci_state(status),
})
}
impl Client {
/// Every open pull request in `owner/repo`, resolved for the PR report.
///
/// The reviews and the commit statuses are one call per PR, run with
/// bounded concurrency like [`Client::issue_report`]'s dependency
/// lookups; the branch protection rules are one call for the repo.
///
/// # Errors
/// The forge refused or did not answer one of the calls.
pub async fn pr_report(&self, owner: &str, repo: &str) -> Result<Vec<PrReportRow>> {
const CONCURRENCY: usize = 8;
let query = RepoListPullRequestsQuery {
state: Some(RepoListPullRequestsQueryState::Open),
sort: None,
milestone: None,
labels: None,
poster: None,
base: None,
head: None,
};
let pulls = self
.api
.call(|api| {
Box::pin(
api.repo_list_pull_requests(owner, repo, query.clone())
.all(),
)
})
.await
.with_context(|| format!("list open pull requests in {owner}/{repo}"))?;
if pulls.is_empty() {
return Ok(Vec::new());
}
let rules = self
.api
.call(|api| api.repo_list_branch_protection(owner, repo).into_future())
.await
.with_context(|| format!("list branch protections of {owner}/{repo}"))?;
let full_name = format!("{owner}/{repo}");
let rules = &rules;
let full_name = &full_name;
let mut rows: Vec<PrReportRow> = futures_util::stream::iter(pulls)
.map(|pr| async move {
let Some(number) = pr.number else {
return Ok::<_, anyhow::Error>(None);
};
let head_sha = pr.head.as_ref().and_then(|h| h.sha.clone());
let reviews = self
.api
.call(|api| Box::pin(api.repo_list_pull_reviews(owner, repo, number).all()))
.await
.with_context(|| format!("list reviews of {owner}/{repo}#{number}"))?;
let status = match head_sha {
Some(sha) => {
self.api
.call(|api| {
api.repo_get_combined_status_by_ref(owner, repo, &sha)
.into_future()
})
.await
.with_context(|| {
format!("read the statuses of {owner}/{repo}#{number}")
})?
.1
}
None => CombinedStatus {
commit_url: None,
repository: None,
sha: None,
state: None,
statuses: None,
total_count: Some(0),
url: None,
},
};
Ok(row(full_name, &pr, rules, &reviews, &status))
})
.buffer_unordered(CONCURRENCY)
.try_filter_map(|row| async move { Ok(row) })
.try_collect()
.await?;
rows.sort_by_key(|r| std::cmp::Reverse(r.number));
Ok(rows)
}
/// The PR report of every repo with an open pull request. A repo whose
/// report fails is logged and skipped, as [`Client::issue_report_all`]
/// does: one flaky repo should not blank out the others' rows.
///
/// # Errors
/// The forge refused to list the repos.
pub async fn pr_report_all(&self) -> Result<Vec<PrReportRow>> {
const CONCURRENCY: usize = 4;
let repos = self.list_repos_with_open_pulls().await?;
let rows: Vec<Vec<PrReportRow>> = futures_util::stream::iter(repos)
.map(|full_name| async move {
let Some((owner, repo)) = full_name.split_once('/') else {
tracing::warn!(%full_name, "pr_report_all: repo full_name has no '/'");
return Vec::new();
};
match self.pr_report(owner, repo).await {
Ok(rows) => rows,
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
%full_name,
"pr_report_all: one repo's report failed, skipping it"
);
Vec::new()
}
}
})
.buffer_unordered(CONCURRENCY)
.collect()
.await;
Ok(rows.into_iter().flatten().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(name: &str, approvals: i64) -> BranchProtection {
serde_json::from_value(serde_json::json!({
"rule_name": name,
"required_approvals": approvals,
}))
.expect("a branch protection")
}
fn review(login: &str, state: &str, commit: &str, flags: &[(&str, bool)]) -> PullReview {
let mut v = serde_json::json!({
"user": { "login": login },
"state": state,
"commit_id": commit,
"official": true,
});
for (k, b) in flags {
v[*k] = serde_json::json!(b);
}
serde_json::from_value(v).expect("a review")
}
#[test]
fn a_pattern_names_a_branch() {
assert!(glob_matches("main", "main"));
assert!(!glob_matches("main", "main2"));
assert!(glob_matches("release/*", "release/1.0"));
assert!(!glob_matches("release/*", "release/1.0/hotfix"));
assert!(glob_matches("release/**", "release/1.0/hotfix"));
assert!(glob_matches("*", "main"));
assert!(!glob_matches("*", "iris/foo"));
}
#[test]
fn the_first_matching_rule_sets_the_approvals() {
let rules = [rule("main", 1), rule("*", 2)];
assert_eq!(required_approvals_for(&rules, "main"), Some(1));
assert_eq!(required_approvals_for(&rules, "dev"), Some(2));
assert_eq!(required_approvals_for(&rules, "iris/feature"), None);
assert_eq!(required_approvals_for(&[], "main"), None);
}
#[test]
fn an_approval_of_the_head_counts_once_per_reviewer() {
let reviews = [
review("argus", "APPROVED", "h1", &[]),
review("mara", "APPROVED", "h1", &[]),
review("mara", "COMMENT", "h1", &[]),
];
let t = tally_reviews(&reviews, "h1");
assert_eq!(
t,
Tally {
approvals: 2,
changes_requested: 0
}
);
}
#[test]
fn an_old_stale_dismissed_or_unofficial_approval_does_not_count() {
for (r, why) in [
(review("a", "APPROVED", "old", &[]), "of an older head"),
(review("a", "APPROVED", "h1", &[("stale", true)]), "stale"),
(
review("a", "APPROVED", "h1", &[("dismissed", true)]),
"dismissed",
),
(
review("a", "APPROVED", "h1", &[("official", false)]),
"not official",
),
] {
assert_eq!(tally_reviews(&[r], "h1").approvals, 0, "{why}");
}
}
#[test]
fn a_later_review_replaces_an_earlier_decision() {
let reviews = [
review("argus", "REQUEST_CHANGES", "h1", &[]),
review("argus", "APPROVED", "h1", &[]),
];
let t = tally_reviews(&reviews, "h1");
assert_eq!((t.approvals, t.changes_requested), (1, 0));
let reviews = [
review("argus", "APPROVED", "h1", &[]),
review("argus", "REQUEST_CHANGES", "h1", &[]),
];
let t = tally_reviews(&reviews, "h1");
assert_eq!((t.approvals, t.changes_requested), (0, 1));
let reviews = [review(
"argus",
"REQUEST_CHANGES",
"h1",
&[("dismissed", true)],
)];
assert_eq!(tally_reviews(&reviews, "h1").changes_requested, 0);
}
#[test]
fn the_commit_statuses_read_as_one_state() {
let status = |state: Option<CommitStatusState>, n: i64| CombinedStatus {
commit_url: None,
repository: None,
sha: None,
state,
statuses: None,
total_count: Some(n),
url: None,
};
assert_eq!(ci_state(&status(None, 0)), None);
assert_eq!(
ci_state(&status(Some(CommitStatusState::Pending), 0)),
None,
"no statuses is not pending"
);
assert_eq!(
ci_state(&status(Some(CommitStatusState::Success), 3)),
Some(CiState::Success)
);
assert_eq!(
ci_state(&status(Some(CommitStatusState::Error), 3)),
Some(CiState::Failure)
);
assert_eq!(
ci_state(&status(Some(CommitStatusState::Pending), 3)),
Some(CiState::Pending)
);
}
#[test]
fn a_row_resolves_protection_approvals_and_conflicts() {
let pr: PullRequest = serde_json::from_value(serde_json::json!({
"number": 7,
"title": "WIP: a thing",
"draft": true,
"mergeable": false,
"user": { "login": "iris" },
"labels": [{ "name": "type/chore" }],
"assignees": [{ "login": "iris" }],
"requested_reviewers": [{ "login": "argus" }],
"base": { "ref": "main" },
"head": { "ref": "iris/thing", "sha": "h1" },
"created_at": "2026-11-07T08:00:00Z",
"updated_at": "2026-11-07T09:00:00Z",
}))
.expect("a pull request");
let status: CombinedStatus = serde_json::from_value(serde_json::json!({
"state": "success", "total_count": 2
}))
.expect("a combined status");
let reviews = [review("argus", "APPROVED", "h1", &[])];
let r = row("o/r", &pr, &[rule("main", 2)], &reviews, &status).expect("a row");
assert_eq!((r.number, r.wip, r.has_conflicts), (7, true, true));
assert_eq!(
(
r.targets_protected_branch,
r.required_approvals,
r.approvals
),
(true, 2, 1)
);
assert_eq!(r.ci, Some(CiState::Success));
assert_eq!(r.author.as_deref(), Some("iris"));
assert_eq!(r.requested_reviewers, ["argus"]);
assert_eq!(r.updated_unix, Some(1_794_042_000));
// The same PR into a branch no rule names has nothing to approve.
let r = row("o/r", &pr, &[], &reviews, &status).expect("a row");
assert_eq!(
(r.targets_protected_branch, r.required_approvals),
(false, 0)
);
}
}

View file

@ -70,6 +70,7 @@ mod notify_flows;
mod notify_router_api;
mod observability_grant;
mod otel_http_client;
mod pr_report;
mod proxy_credential;
mod pulls;
mod queue_identity;

View file

@ -0,0 +1,149 @@
//! The swarm UI's PR report: every open pull request of the forge, or of one
//! repo, with the attributes a reviewer or merger filters on already
//! resolved. The logic is [`forge::Client::pr_report`]; this module is the
//! HTTP surface over it, the same shape as [`crate::issue_report`]. The repo
//! dropdown's source is `GET /api/pulls/repos`.
use axum::{
Json,
extract::{Path, State},
};
use crate::forge;
use crate::issue_report::issue_report_error_status;
use crate::routes::{AppState, error_problem};
/// The 503 detail when the forge did not answer within
/// [`forge::FORGE_DEADLINE`].
const DEADLINE_DETAIL: &str = "the forge did not answer within the deadline";
/// Every open pull request across every repo that has one.
#[utoipa::path(
get,
path = "/api/pr-report",
responses(
(status = 200, description = "every open pull request, newest number first within a repo", body = Vec<forge::pr_report::PrReportRow>),
(status = 503, description = "no forge is configured on this host, the report could not be built, or the forge did not answer within two minutes (problem+json)", body = String),
),
tag = "pulls"
)]
pub async fn get_pr_report_all(
State(state): State<AppState>,
) -> Result<Json<Vec<forge::pr_report::PrReportRow>>, problem_details::ProblemDetails> {
let Some(client) = state.forge.get() else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no forge is configured on this host",
));
};
let Some(built) = forge::within_forge_deadline("PR report", client.pr_report_all()).await
else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
DEADLINE_DETAIL,
));
};
match built {
Ok(rows) => Ok(Json(rows)),
Err(e) => {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "building the all-repos PR report failed");
Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
&detail,
))
}
}
}
/// The open pull requests of `{org}/{repo}`. A 404 from the forge for the
/// repo itself is a 404 here; anything else stays 503, as for the issue
/// report ([`issue_report_error_status`]).
#[utoipa::path(
get,
path = "/api/repos/{org}/{repo}/pr-report",
params(
("org" = String, Path, description = "repo owner (user or org)"),
("repo" = String, Path, description = "repo name"),
),
responses(
(status = 200, description = "every open pull request in this repo", body = Vec<forge::pr_report::PrReportRow>),
(status = 404, description = "no such repo (problem+json)", body = String),
(status = 503, description = "no forge is configured on this host, the report could not be built, or the forge did not answer within two minutes (problem+json)", body = String),
),
tag = "pulls"
)]
pub async fn get_pr_report(
State(state): State<AppState>,
Path((org, repo)): Path<(String, String)>,
) -> Result<Json<Vec<forge::pr_report::PrReportRow>>, problem_details::ProblemDetails> {
let Some(client) = state.forge.get() else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no forge is configured on this host",
));
};
let Some(built) =
forge::within_forge_deadline("repo PR report", client.pr_report(&org, &repo)).await
else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
DEADLINE_DETAIL,
));
};
match built {
Ok(rows) => Ok(Json(rows)),
Err(e) => {
let status = issue_report_error_status(&e);
if status == axum::http::StatusCode::NOT_FOUND {
Err(error_problem(status, &format!("{org}/{repo} not found")))
} else {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, %org, %repo, "building PR report failed");
Err(error_problem(status, &detail))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Each route against a forge that accepts the connection and never
/// answers: 503 at the deadline. The paused clock jumps to the deadline
/// once nothing else can run, so this takes no wall time.
#[tokio::test(start_paused = true)]
async fn every_route_is_503_when_the_forge_never_answers() {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt as _;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
for uri in ["/api/pr-report", "/api/repos/o/r/pr-report"] {
let (state, _) = crate::routes::tests::state_with_roster();
assert!(
state
.forge
.set(std::sync::Arc::new(forge::Client::for_test_forge(
url.clone()
)))
.is_ok()
);
let start = tokio::time::Instant::now();
let req = Request::get(uri).body(Body::empty()).expect("a request");
let res = crate::startup::build_app(state)
.oneshot(req)
.await
.expect("infallible");
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains(super::DEADLINE_DETAIL), "{uri}: {body}");
assert_eq!(start.elapsed(), forge::FORGE_DEADLINE, "{uri}");
}
}
}

View file

@ -31,9 +31,9 @@ use crate::{
agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts,
archetypes, auth, backend_env, builder, claude_login, config_drift, config_pr, egress_allow,
egress_deny, forge, forge_account, github_account, gitlab_account, inference, issue_report,
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pulls,
queue_identity, review_classifier, schedules, status, subagent_term, term_stream, wanted,
webhook,
linked_accounts, matrix_account, message_stream, notify_flows, notify_router_api, pr_report,
pulls, queue_identity, review_classifier, schedules, status, subagent_term, term_stream,
wanted, webhook,
};
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
@ -80,7 +80,7 @@ pub(crate) fn socket_path() -> PathBuf {
(name = "egress", description = "the destinations agents may reach through their hive's egress proxy"),
(name = "notify", description = "the notification router's flows: built-ins switched off, and custom flows"),
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page), and the classifier's swarm-wide settings"),
(name = "pulls", description = "open pull requests, for the try-out page's dropdowns"),
(name = "pulls", description = "open pull requests: the try-out page's dropdowns and the PR report"),
(name = "alerts", description = "the swarm Grafana's current alerts (swarm-ui's alerts page)"),
(name = "builder", description = "the swarm builder's finished builds of agent config PRs, which their commit statuses link to"),
)
@ -667,6 +667,8 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
.routes(routes!(issue_report::get_repos))
.routes(routes!(issue_report::get_issue_report_all))
.routes(routes!(issue_report::get_issue_report))
.routes(routes!(pr_report::get_pr_report_all))
.routes(routes!(pr_report::get_pr_report))
.routes(routes!(pulls::get_pull_repos))
.routes(routes!(pulls::get_pulls))
.routes(routes!(review_classifier::post_try_out))