Skip to content

Commit d7fc9fe

Browse files
authored
Merge pull request #458 from Sakib25800/rollup-logic
Add rollup logic
2 parents 3bb5fa2 + a889ab0 commit d7fc9fe

File tree

13 files changed

+406
-28
lines changed

13 files changed

+406
-28
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ chrono = "0.4"
5656

5757
# Utilities
5858
itertools = "0.14"
59+
rand = { version = "0.9", features = ["alloc"] }
5960

6061
# Text processing
6162
pulldown-cmark = "0.13"

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ required.
1919
| `--app-id` | `APP_ID` | | GitHub app ID of the bors bot. |
2020
| `--private-key` | `PRIVATE_KEY` | | Private key of the GitHub app. |
2121
| `--webhook-secret` | `WEBHOOK_SECRET` | | Key used to authenticate GitHub webhooks. |
22+
| `--client-id` | `OAUTH_CLIENT_ID` | | GitHub OAuth client ID for rollup UI (optional). |
23+
| `--client-secret` | `OAUTH_CLIENT_SECRET`| | GitHub OAuth client secret for rollup UI (optional). |
2224
| `--db` | `DATABASE_URL` | | Database connection string. Only PostgreSQL is supported. |
2325
| `--cmd-prefix` | `CMD_PREFIX` | @bors | Prefix used to invoke bors commands in PR comments. |
2426

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

50+
### OAuth app
51+
If you want to create rollups, you will need to create a GitHub OAuth app configured like so:
52+
1. In the [developer settings](https://github.com/settings/developers), go to "OAuth Apps" and create a new application.
53+
2. Set the Authorization callback URL to `<http address of bors>/oauth/callback`.
54+
3. Note the generated Client ID and Client secret, and pass them through the CLI flags or via your environment configuration.
55+
4856
### How to add a repository to bors
4957
Here is a guide on how to add a repository so that this bot can be used on it:
5058
1) Add a file named `rust-bors.toml` to the root of the main branch of the repository. The configuration struct that

src/bin/bors.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::time::Duration;
66

77
use anyhow::Context;
88
use bors::{
9-
BorsContext, BorsGlobalEvent, BorsProcess, CommandParser, PgDbClient, ServerState,
9+
BorsContext, BorsGlobalEvent, BorsProcess, CommandParser, OAuthConfig, PgDbClient, ServerState,
1010
TeamApiClient, TreeState, WebhookSecret, create_app, create_bors_process, create_github_client,
1111
load_repositories,
1212
};
@@ -49,6 +49,14 @@ struct Opts {
4949
#[arg(long, env = "PRIVATE_KEY")]
5050
private_key: String,
5151

52+
/// GitHub OAuth client ID for rollups.
53+
#[arg(long, env = "CLIENT_ID")]
54+
client_id: Option<String>,
55+
56+
/// GitHub OAuth client secret for rollups.
57+
#[arg(long, env = "CLIENT_SECRET")]
58+
client_secret: Option<String>,
59+
5260
/// Secret used to authenticate webhooks.
5361
#[arg(long, env = "WEBHOOK_SECRET")]
5462
webhook_secret: String,
@@ -214,10 +222,26 @@ fn try_main(opts: Opts) -> anyhow::Result<()> {
214222
}
215223
};
216224

225+
let oauth_config = match (opts.client_id.clone(), opts.client_secret.clone()) {
226+
(Some(client_id), Some(client_secret)) => Some(OAuthConfig::new(client_id, client_secret)),
227+
(None, None) => None,
228+
(Some(_), None) => {
229+
return Err(anyhow::anyhow!(
230+
"CLIENT_ID is set but CLIENT_SECRET is missing. Both must be set or neither."
231+
));
232+
}
233+
(None, Some(_)) => {
234+
return Err(anyhow::anyhow!(
235+
"CLIENT_SECRET is set but CLIENT_ID is missing. Both must be set or neither."
236+
));
237+
}
238+
};
239+
217240
let state = ServerState::new(
218241
repository_tx,
219242
global_tx,
220243
WebhookSecret::new(opts.webhook_secret),
244+
oauth_config,
221245
repos,
222246
db,
223247
opts.cmd_prefix.into(),

src/database/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,11 @@ impl PullRequestModel {
418418
}
419419

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

src/github/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use url::Url;
99
pub mod api;
1010
mod error;
1111
mod labels;
12+
mod rollup;
1213
pub mod server;
1314
mod webhook;
1415

src/github/rollup.rs

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
use super::GithubRepoName;
2+
use super::error::AppError;
3+
use super::server::ServerStateRef;
4+
use anyhow::Context;
5+
use axum::extract::{Query, State};
6+
use axum::http::StatusCode;
7+
use axum::response::{IntoResponse, Redirect};
8+
use octocrab::OctocrabBuilder;
9+
use octocrab::params::repos::Reference;
10+
use rand::{Rng, distr::Alphanumeric};
11+
use std::collections::HashMap;
12+
use tracing::Instrument;
13+
14+
/// Query parameters received from GitHub's OAuth callback.
15+
///
16+
/// 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
17+
#[derive(serde::Deserialize)]
18+
pub struct OAuthCallbackQuery {
19+
/// Temporary code from GitHub to exchange for an access token (expires in 10m).
20+
pub code: String,
21+
/// State passed in the initial OAuth request - contains rollup info created from the queue page.
22+
pub state: String,
23+
}
24+
25+
#[derive(serde::Deserialize)]
26+
pub struct OAuthState {
27+
pub pr_nums: Vec<u32>,
28+
pub repo_name: String,
29+
pub repo_owner: String,
30+
}
31+
32+
pub async fn oauth_callback_handler(
33+
Query(callback): Query<OAuthCallbackQuery>,
34+
State(state): State<ServerStateRef>,
35+
) -> Result<impl IntoResponse, AppError> {
36+
let oauth_config = state.oauth.as_ref().ok_or_else(|| {
37+
let error =
38+
anyhow::anyhow!("OAuth not configured. Please set CLIENT_ID and CLIENT_SECRET.");
39+
tracing::error!("{error}");
40+
error
41+
})?;
42+
43+
let oauth_state: OAuthState = serde_json::from_str(&callback.state)
44+
.map_err(|_| anyhow::anyhow!("Invalid state parameter"))?;
45+
46+
tracing::info!("Exchanging OAuth code for access token");
47+
let client = reqwest::Client::new();
48+
let token_response = client
49+
.post("https://github.com/login/oauth/access_token")
50+
.form(&[
51+
("client_id", oauth_config.client_id()),
52+
("client_secret", oauth_config.client_secret()),
53+
("code", &callback.code),
54+
])
55+
.send()
56+
.await
57+
.context("Failed to send OAuth token exchange request to GitHub")?
58+
.text()
59+
.await
60+
.context("Failed to read OAuth token response from GitHub")?;
61+
62+
tracing::debug!("Extracting access token from OAuth response");
63+
let oauth_token_params: HashMap<String, String> =
64+
url::form_urlencoded::parse(token_response.as_bytes())
65+
.into_owned()
66+
.collect();
67+
let access_token = oauth_token_params
68+
.get("access_token")
69+
.ok_or_else(|| anyhow::anyhow!("No access token in response"))?;
70+
71+
tracing::info!("Retrieved OAuth access token, creating rollup");
72+
73+
let span = tracing::info_span!(
74+
"create_rollup",
75+
repo = %format!("{}/{}", oauth_state.repo_owner, oauth_state.repo_name),
76+
pr_nums = ?oauth_state.pr_nums
77+
);
78+
79+
match create_rollup(state, oauth_state, access_token)
80+
.instrument(span)
81+
.await
82+
{
83+
Ok(pr_url) => {
84+
tracing::info!("Rollup created successfully, redirecting to: {pr_url}");
85+
Ok(Redirect::temporary(&pr_url).into_response())
86+
}
87+
Err(error) => {
88+
tracing::error!("Failed to create rollup: {error}");
89+
Ok((
90+
StatusCode::INTERNAL_SERVER_ERROR,
91+
format!("Failed to create rollup: {error}"),
92+
)
93+
.into_response())
94+
}
95+
}
96+
}
97+
98+
/// Creates a rollup PR by merging multiple approved PRs into a single branch
99+
/// in the user's fork, then opens a PR to the upstream repository.
100+
async fn create_rollup(
101+
state: ServerStateRef,
102+
oauth_state: OAuthState,
103+
access_token: &str,
104+
) -> anyhow::Result<String> {
105+
let OAuthState {
106+
repo_name,
107+
repo_owner,
108+
pr_nums,
109+
} = oauth_state;
110+
111+
let gh_client = OctocrabBuilder::new()
112+
.user_access_token(access_token.to_string())
113+
.build()?;
114+
let user = gh_client.current().user().await?;
115+
let username = user.login;
116+
117+
tracing::info!("User {username} is creating a rollup with PRs: {pr_nums:?}");
118+
119+
// Ensure user has a fork
120+
match gh_client.repos(&username, &repo_name).get().await {
121+
Ok(repo) => repo,
122+
Err(_) => {
123+
anyhow::bail!(
124+
"You must have a fork of {username}/{repo_name} named {repo_name} under your account",
125+
);
126+
}
127+
};
128+
129+
// Validate PRs
130+
let mut rollup_prs = Vec::new();
131+
for num in pr_nums {
132+
match state
133+
.db
134+
.get_pull_request(
135+
&GithubRepoName::new(&repo_owner, &repo_name),
136+
(num as u64).into(),
137+
)
138+
.await?
139+
{
140+
Some(pr) => {
141+
if !pr.is_rollupable() {
142+
let error = format!("PR #{num} cannot be included in rollup");
143+
tracing::error!("{error}");
144+
anyhow::bail!(error);
145+
}
146+
rollup_prs.push(pr);
147+
}
148+
None => anyhow::bail!("PR #{num} not found"),
149+
}
150+
}
151+
152+
if rollup_prs.is_empty() {
153+
anyhow::bail!("No pull requests are marked for rollup");
154+
}
155+
156+
// Sort PRs by number
157+
rollup_prs.sort_by_key(|pr| pr.number.0);
158+
159+
// Fetch the first PR from GitHub to determine the target base branch
160+
let first_pr_github = gh_client
161+
.pulls(&repo_owner, &repo_name)
162+
.get(rollup_prs[0].number.0)
163+
.await?;
164+
let base_ref = first_pr_github.base.ref_field.clone();
165+
166+
// Fetch the current SHA of the base branch - this is the commit our
167+
// rollup branch starts from.
168+
let base_branch_ref = gh_client
169+
.repos(&repo_owner, &repo_name)
170+
.get_ref(&Reference::Branch(base_ref.clone()))
171+
.await?;
172+
let base_sha = match base_branch_ref.object {
173+
octocrab::models::repos::Object::Commit { sha, .. } => sha,
174+
octocrab::models::repos::Object::Tag { sha, .. } => sha,
175+
_ => unreachable!(),
176+
};
177+
178+
let branch_suffix: String = rand::rng()
179+
.sample_iter(Alphanumeric)
180+
.take(7)
181+
.map(char::from)
182+
.collect();
183+
let branch_name = format!("rollup-{branch_suffix}");
184+
185+
// Create the branch on the user's fork
186+
gh_client
187+
.repos(&username, &repo_name)
188+
.create_ref(
189+
&octocrab::params::repos::Reference::Branch(branch_name.clone()),
190+
base_sha,
191+
)
192+
.await
193+
.map_err(|error| {
194+
anyhow::anyhow!("Could not create rollup branch {branch_name}: {error}",)
195+
})?;
196+
197+
let mut successes = Vec::new();
198+
let mut failures = Vec::new();
199+
200+
// Merge each PR's commits into the rollup branch
201+
for pr in rollup_prs {
202+
let pr_github = gh_client
203+
.pulls(&repo_owner, &repo_name)
204+
.get(pr.number.0)
205+
.await?;
206+
207+
// Skip PRs that don't target the same base branch
208+
if pr_github.base.ref_field != base_ref {
209+
failures.push(pr);
210+
continue;
211+
}
212+
213+
let head_sha = pr_github.head.sha.clone();
214+
let merge_msg = format!(
215+
"Rollup merge of #{} - {}, r={}\n\n{}\n\n{}",
216+
pr.number.0,
217+
pr_github.head.ref_field,
218+
pr.approver().unwrap_or("unknown"),
219+
pr.title,
220+
&pr_github.body.unwrap_or_default()
221+
);
222+
223+
// Merge the PR's head commit into the rollup branch
224+
let merge_attempt = gh_client
225+
.repos(&username, &repo_name)
226+
.merge(&head_sha, &branch_name)
227+
.commit_message(&merge_msg)
228+
.send()
229+
.await;
230+
231+
match merge_attempt {
232+
Ok(_) => {
233+
successes.push(pr);
234+
}
235+
Err(error) => {
236+
if let octocrab::Error::GitHub { source, .. } = &error {
237+
if source.status_code == http::StatusCode::CONFLICT {
238+
failures.push(pr);
239+
continue;
240+
}
241+
242+
anyhow::bail!(
243+
"Merge failed with GitHub error (status {}): {}",
244+
source.status_code,
245+
source.message
246+
);
247+
}
248+
249+
anyhow::bail!("Merge failed with unexpected error: {error}");
250+
}
251+
}
252+
}
253+
254+
let mut body = "Successful merges:\n\n".to_string();
255+
for pr in &successes {
256+
body.push_str(&format!(" - #{} ({})\n", pr.number.0, pr.title));
257+
}
258+
259+
if !failures.is_empty() {
260+
body.push_str("\nFailed merges:\n\n");
261+
for pr in &failures {
262+
body.push_str(&format!(" - #{} ({})\n", pr.number.0, pr.title));
263+
}
264+
}
265+
body.push_str("\nr? @ghost\n@rustbot modify labels: rollup");
266+
267+
let title = format!("Rollup of {} pull requests", successes.len());
268+
269+
// Create the rollup PR from the user's fork branch to the base branch
270+
let pr = gh_client
271+
.pulls(&repo_owner, &repo_name)
272+
.create(&title, format!("{username}:{branch_name}"), &base_ref)
273+
.body(&body)
274+
.send()
275+
.await?;
276+
let pr_url = pr
277+
.html_url
278+
.as_ref()
279+
.ok_or_else(|| anyhow::anyhow!("GitHub returned PR without html_url"))?
280+
.to_string();
281+
282+
Ok(pr_url)
283+
}

0 commit comments

Comments
 (0)