Skip to content
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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ chrono = "0.4"

# Utilities
itertools = "0.14"
rand = { version = "0.9", features = ["alloc"] }

# Text processing
pulldown-cmark = "0.13"
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ required.
| `--app-id` | `APP_ID` | | GitHub app ID of the bors bot. |
| `--private-key` | `PRIVATE_KEY` | | Private key of the GitHub app. |
| `--webhook-secret` | `WEBHOOK_SECRET` | | Key used to authenticate GitHub webhooks. |
| `--client-id` | `OAUTH_CLIENT_ID` | | GitHub OAuth client ID for rollup UI (optional). |
| `--client-secret` | `OAUTH_CLIENT_SECRET`| | GitHub OAuth client secret for rollup UI (optional). |
| `--db` | `DATABASE_URL` | | Database connection string. Only PostgreSQL is supported. |
| `--cmd-prefix` | `CMD_PREFIX` | @bors | Prefix used to invoke bors commands in PR comments. |

Expand All @@ -45,6 +47,12 @@ atomically using the GitHub API.
### GitHub app
If you want to attach `bors` to a GitHub app, you should point its webhooks at `<http address of bors>/github`.

### OAuth app
If you want to create rollups, you will need to create a GitHub OAuth app configured like so:
1. In the [developer settings](https://github.com/settings/developers), go to "OAuth Apps" and create a new application.
2. Set the Authorization callback URL to `<http address of bors>/oauth/callback`.
3. Note the generated Client ID and Client secret, and pass them through the CLI flags or via your environment configuration.

### How to add a repository to bors
Here is a guide on how to add a repository so that this bot can be used on it:
1) Add a file named `rust-bors.toml` to the root of the main branch of the repository. The configuration struct that
Expand Down
26 changes: 25 additions & 1 deletion src/bin/bors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::time::Duration;

use anyhow::Context;
use bors::{
BorsContext, BorsGlobalEvent, BorsProcess, CommandParser, PgDbClient, ServerState,
BorsContext, BorsGlobalEvent, BorsProcess, CommandParser, OAuthConfig, PgDbClient, ServerState,
TeamApiClient, TreeState, WebhookSecret, create_app, create_bors_process, create_github_client,
load_repositories,
};
Expand Down Expand Up @@ -49,6 +49,14 @@ struct Opts {
#[arg(long, env = "PRIVATE_KEY")]
private_key: String,

/// GitHub OAuth client ID for rollups.
#[arg(long, env = "CLIENT_ID")]
client_id: Option<String>,

/// GitHub OAuth client secret for rollups.
#[arg(long, env = "CLIENT_SECRET")]
client_secret: Option<String>,

/// Secret used to authenticate webhooks.
#[arg(long, env = "WEBHOOK_SECRET")]
webhook_secret: String,
Expand Down Expand Up @@ -214,10 +222,26 @@ fn try_main(opts: Opts) -> anyhow::Result<()> {
}
};

let oauth_config = match (opts.client_id.clone(), opts.client_secret.clone()) {
(Some(client_id), Some(client_secret)) => Some(OAuthConfig::new(client_id, client_secret)),
(None, None) => None,
(Some(_), None) => {
return Err(anyhow::anyhow!(
"CLIENT_ID is set but CLIENT_SECRET is missing. Both must be set or neither."
));
}
(None, Some(_)) => {
return Err(anyhow::anyhow!(
"CLIENT_SECRET is set but CLIENT_ID is missing. Both must be set or neither."
));
}
};

let state = ServerState::new(
repository_tx,
global_tx,
WebhookSecret::new(opts.webhook_secret),
oauth_config,
repos,
db,
opts.cmd_prefix.into(),
Expand Down
6 changes: 4 additions & 2 deletions src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,9 +418,11 @@ impl PullRequestModel {
}

/// Determines if this PR can be included in a rollup.
/// A PR is rollupable if it has been approved and rollup is not `RollupMode::Never`
/// A PR is rollupable if it has been approved, does not have a pending build and rollup is not `RollupMode::Never`.
pub fn is_rollupable(&self) -> bool {
self.is_approved() && !matches!(self.rollup, Some(RollupMode::Never))
self.is_approved()
&& !matches!(self.rollup, Some(RollupMode::Never))
&& !matches!(self.queue_status(), QueueStatus::Pending(..))
}
}

Expand Down
1 change: 1 addition & 0 deletions src/github/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use url::Url;
pub mod api;
mod error;
mod labels;
mod rollup;
pub mod server;
mod webhook;

Expand Down
283 changes: 283 additions & 0 deletions src/github/rollup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
use super::GithubRepoName;
use super::error::AppError;
use super::server::ServerStateRef;
use anyhow::Context;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect};
use octocrab::OctocrabBuilder;
use octocrab::params::repos::Reference;
use rand::{Rng, distr::Alphanumeric};
use std::collections::HashMap;
use tracing::Instrument;

/// Query parameters received from GitHub's OAuth callback.
///
/// Documentation: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github
#[derive(serde::Deserialize)]
pub struct OAuthCallbackQuery {
/// Temporary code from GitHub to exchange for an access token (expires in 10m).
pub code: String,
/// State passed in the initial OAuth request - contains rollup info created from the queue page.
pub state: String,
}

#[derive(serde::Deserialize)]
pub struct OAuthState {
pub pr_nums: Vec<u32>,
pub repo_name: String,
pub repo_owner: String,
}

pub async fn oauth_callback_handler(
Query(callback): Query<OAuthCallbackQuery>,
State(state): State<ServerStateRef>,
) -> Result<impl IntoResponse, AppError> {
let oauth_config = state.oauth.as_ref().ok_or_else(|| {
let error =
anyhow::anyhow!("OAuth not configured. Please set CLIENT_ID and CLIENT_SECRET.");
tracing::error!("{error}");
error
})?;

let oauth_state: OAuthState = serde_json::from_str(&callback.state)
.map_err(|_| anyhow::anyhow!("Invalid state parameter"))?;

tracing::info!("Exchanging OAuth code for access token");
let client = reqwest::Client::new();
let token_response = client
.post("https://github.com/login/oauth/access_token")
.form(&[
("client_id", oauth_config.client_id()),
("client_secret", oauth_config.client_secret()),
("code", &callback.code),
])
.send()
.await
.context("Failed to send OAuth token exchange request to GitHub")?
.text()
.await
.context("Failed to read OAuth token response from GitHub")?;

tracing::debug!("Extracting access token from OAuth response");
let oauth_token_params: HashMap<String, String> =
url::form_urlencoded::parse(token_response.as_bytes())
.into_owned()
.collect();
let access_token = oauth_token_params
.get("access_token")
.ok_or_else(|| anyhow::anyhow!("No access token in response"))?;

tracing::info!("Retrieved OAuth access token, creating rollup");

let span = tracing::info_span!(
"create_rollup",
repo = %format!("{}/{}", oauth_state.repo_owner, oauth_state.repo_name),
pr_nums = ?oauth_state.pr_nums
);

match create_rollup(state, oauth_state, access_token)
.instrument(span)
.await
{
Ok(pr_url) => {
tracing::info!("Rollup created successfully, redirecting to: {pr_url}");
Ok(Redirect::temporary(&pr_url).into_response())
}
Err(error) => {
tracing::error!("Failed to create rollup: {error}");
Ok((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create rollup: {error}"),
)
.into_response())
}
}
}

/// Creates a rollup PR by merging multiple approved PRs into a single branch
/// in the user's fork, then opens a PR to the upstream repository.
async fn create_rollup(
state: ServerStateRef,
oauth_state: OAuthState,
access_token: &str,
) -> anyhow::Result<String> {
let OAuthState {
repo_name,
repo_owner,
pr_nums,
} = oauth_state;

let gh_client = OctocrabBuilder::new()
.user_access_token(access_token.to_string())
.build()?;
let user = gh_client.current().user().await?;
let username = user.login;

tracing::info!("User {username} is creating a rollup with PRs: {pr_nums:?}");

// Ensure user has a fork
match gh_client.repos(&username, &repo_name).get().await {
Ok(repo) => repo,
Err(_) => {
anyhow::bail!(
"You must have a fork of {username}/{repo_name} named {repo_name} under your account",
);
}
};

// Validate PRs
let mut rollup_prs = Vec::new();
for num in pr_nums {
match state
.db
.get_pull_request(
&GithubRepoName::new(&repo_owner, &repo_name),
(num as u64).into(),
)
.await?
{
Some(pr) => {
if !pr.is_rollupable() {
let error = format!("PR #{num} cannot be included in rollup");
tracing::error!("{error}");
anyhow::bail!(error);
}
rollup_prs.push(pr);
}
None => anyhow::bail!("PR #{num} not found"),
}
}

if rollup_prs.is_empty() {
anyhow::bail!("No pull requests are marked for rollup");
}

// Sort PRs by number
rollup_prs.sort_by_key(|pr| pr.number.0);

// Fetch the first PR from GitHub to determine the target base branch
let first_pr_github = gh_client
.pulls(&repo_owner, &repo_name)
.get(rollup_prs[0].number.0)
.await?;
let base_ref = first_pr_github.base.ref_field.clone();

// Fetch the current SHA of the base branch - this is the commit our
// rollup branch starts from.
let base_branch_ref = gh_client
.repos(&repo_owner, &repo_name)
.get_ref(&Reference::Branch(base_ref.clone()))
.await?;
let base_sha = match base_branch_ref.object {
octocrab::models::repos::Object::Commit { sha, .. } => sha,
octocrab::models::repos::Object::Tag { sha, .. } => sha,
_ => unreachable!(),
};

let branch_suffix: String = rand::rng()
.sample_iter(Alphanumeric)
.take(7)
.map(char::from)
.collect();
let branch_name = format!("rollup-{branch_suffix}");

// Create the branch on the user's fork
gh_client
.repos(&username, &repo_name)
.create_ref(
&octocrab::params::repos::Reference::Branch(branch_name.clone()),
base_sha,
)
.await
.map_err(|error| {
anyhow::anyhow!("Could not create rollup branch {branch_name}: {error}",)
})?;

let mut successes = Vec::new();
let mut failures = Vec::new();

// Merge each PR's commits into the rollup branch
for pr in rollup_prs {
let pr_github = gh_client
.pulls(&repo_owner, &repo_name)
.get(pr.number.0)
.await?;

// Skip PRs that don't target the same base branch
if pr_github.base.ref_field != base_ref {
failures.push(pr);
continue;
}

let head_sha = pr_github.head.sha.clone();
let merge_msg = format!(
"Rollup merge of #{} - {}, r={}\n\n{}\n\n{}",
pr.number.0,
pr_github.head.ref_field,
pr.approver().unwrap_or("unknown"),
pr.title,
&pr_github.body.unwrap_or_default()
);

// Merge the PR's head commit into the rollup branch
let merge_attempt = gh_client
.repos(&username, &repo_name)
.merge(&head_sha, &branch_name)
.commit_message(&merge_msg)
.send()
.await;

match merge_attempt {
Ok(_) => {
successes.push(pr);
}
Err(error) => {
if let octocrab::Error::GitHub { source, .. } = &error {
if source.status_code == http::StatusCode::CONFLICT {
failures.push(pr);
continue;
}

anyhow::bail!(
"Merge failed with GitHub error (status {}): {}",
source.status_code,
source.message
);
}

anyhow::bail!("Merge failed with unexpected error: {error}");
}
}
}

let mut body = "Successful merges:\n\n".to_string();
for pr in &successes {
body.push_str(&format!(" - #{} ({})\n", pr.number.0, pr.title));
}

if !failures.is_empty() {
body.push_str("\nFailed merges:\n\n");
for pr in &failures {
body.push_str(&format!(" - #{} ({})\n", pr.number.0, pr.title));
}
}
body.push_str("\nr? @ghost\n@rustbot modify labels: rollup");

let title = format!("Rollup of {} pull requests", successes.len());

// Create the rollup PR from the user's fork branch to the base branch
let pr = gh_client
.pulls(&repo_owner, &repo_name)
.create(&title, format!("{username}:{branch_name}"), &base_ref)
.body(&body)
.send()
.await?;
let pr_url = pr
.html_url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("GitHub returned PR without html_url"))?
.to_string();

Ok(pr_url)
}
Loading