diff --git a/site/src/api.rs b/site/src/api.rs index 2c3a445a8..66462881f 100644 --- a/site/src/api.rs +++ b/site/src/api.rs @@ -269,9 +269,22 @@ pub mod github { pub sha: String, } + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct CommitTree { + pub sha: String, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct InnerCommit { + #[serde(default)] + pub message: String, + pub tree: CommitTree, + } + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Commit { pub sha: String, + pub commit: InnerCommit, pub parents: Vec, } diff --git a/site/src/github.rs b/site/src/github.rs index 08c17e747..bcbc2135e 100644 --- a/site/src/github.rs +++ b/site/src/github.rs @@ -1,5 +1,6 @@ use crate::api::{github, ServerResult}; use crate::load::{Config, InputData, TryCommit}; +use anyhow::Context as _; use hashbrown::HashSet; use serde::Deserialize; @@ -11,6 +12,10 @@ lazy_static::lazy_static! { Regex::new(r#"(?:\W|^)@rust-timer\s+build\s+(\w+)(?:\W|$)"#).unwrap(); static ref BODY_QUEUE: Regex = Regex::new(r#"(?:\W|^)@rust-timer\s+queue(?:\W|$)"#).unwrap(); + static ref BODY_MAKE_PR_FOR: Regex = + Regex::new(r#"(?:\W|^)@rust-timer\s+make-pr-for\s+(\w+)(?:\W|$)"#).unwrap(); + static ref BODY_UDPATE_PR_FOR: Regex = + Regex::new(r#"(?:\W|^)@rust-timer\s+update-branch-for\s+(\w+)(?:\W|$)"#).unwrap(); } async fn get_authorized_users() -> ServerResult> { @@ -83,44 +88,394 @@ pub async fn handle_github( } } + let captures = BODY_MAKE_PR_FOR + .captures_iter(&request.comment.body) + .collect::>(); + for capture in captures { + if let Some(rollup_merge) = capture.get(1).map(|c| c.as_str().to_owned()) { + let rollup_merge = + rollup_merge.trim_start_matches("https://github.com/rust-lang/rust/commit/"); + let client = reqwest::Client::new(); + pr_and_try_for_rollup( + &client, + &data, + &request.issue.repository_url, + &rollup_merge, + &request.comment.html_url, + ) + .await + .map_err(|e| e.to_string())?; + } + } + + let captures = BODY_UDPATE_PR_FOR + .captures_iter(&request.comment.body) + .collect::>(); + for capture in captures { + if let Some(rollup_merge) = capture.get(1).map(|c| c.as_str().to_owned()) { + let rollup_merge = + rollup_merge.trim_start_matches("https://github.com/rust-lang/rust/commit/"); + + // This just creates or updates the branch for this merge commit. + // Intended for resolving the race condition of master merging in + // between us updating the commit and merging things. + let client = reqwest::Client::new(); + let branch = + branch_for_rollup(&client, data, &request.issue.repository_url, rollup_merge) + .await + .map_err(|e| e.to_string())?; + post_comment( + &data.config, + request.issue.number, + &format!("Master base SHA: {}", branch.master_base_sha), + ) + .await; + } + } + Ok(github::Response) } -async fn enqueue_sha( - request: github::Request, +// Returns the PR number +async fn pr_and_try_for_rollup( + client: &reqwest::Client, data: &InputData, - commit: String, -) -> ServerResult { + repository_url: &str, + rollup_merge_sha: &str, + origin_url: &str, +) -> anyhow::Result { + log::trace!( + "creating PR for {:?} {:?}", + repository_url, + rollup_merge_sha + ); + let branch = branch_for_rollup(client, data, repository_url, rollup_merge_sha).await?; + + let pr = create_pr( + client, + data, + repository_url, + &format!( + "[DO NOT MERGE] perf-test for #{}", + branch.rolled_up_pr_number + ), + &format!("rust-timer:{}", branch.name), + "master", + &format!( + "This is an automatically generated pull request (from [here]({})) to \ + run perf tests for #{} which merged in a rollup. + + r? @ghost", + origin_url, branch.rolled_up_pr_number + ), + ) + .await + .context("Created PR")?; + + // This provides the master SHA so that we can check that we only queue + // an appropriate try build. If there's ever a race condition, i.e., + // master was pushed while this command was running, the user will have to + // take manual action to detect it. + // + // Eventually we'll want to handle this automatically, but that's a ways + // off: we'd need to store the state in the database and handle the try + // build starting and generally that's a lot of work for not too much gain. + post_comment( + &data.config, + pr.number, + &format!( + "@bors try @rust-timer queue\n + The try commit's (master) parent should be {master}. If it isn't, \ + then please: + + * Stop this try build (`try-`). + * Run `@rust-timer update-pr-for {merge}`. + * Rerun `bors try`. + + You do not need to reinvoke the queue command as long as the perf \ + build hasn't yet started.", + master = branch.master_base_sha, + merge = rollup_merge_sha, + ), + ) + .await; + + Ok(pr.number) +} + +struct RollupBranch { + master_base_sha: String, + rolled_up_pr_number: u32, + name: String, +} + +async fn branch_for_rollup( + client: &reqwest::Client, + data: &InputData, + repository_url: &str, + rollup_merge_sha: &str, +) -> anyhow::Result { + let rollup_merge = get_commit(&client, &data, repository_url, rollup_merge_sha) + .await + .context("got rollup merge")?; + + let old_master_commit = + get_commit(&client, &data, repository_url, &rollup_merge.parents[0].sha) + .await + .context("success master get")?; + + let current_master_commit = get_commit(&client, &data, repository_url, "master") + .await + .context("success master get")?; + + let revert_sha = create_commit( + &client, + &data, + repository_url, + &format!("Revert to {}", old_master_commit.sha), + &old_master_commit.commit.tree.sha, + &[¤t_master_commit.sha], + ) + .await + .context("create revert")?; + + let merge_sha = create_commit( + &client, + &data, + repository_url, + &format!( + "rust-timer simulated merge of {}\n\nOriginal message:\n{}", + rollup_merge.sha, rollup_merge.commit.message + ), + &rollup_merge.commit.tree.sha, + &[&revert_sha], + ) + .await + .context("create merge commit")?; + + let rolled_up_pr_number = if let Some(stripped) = rollup_merge + .commit + .message + .strip_prefix("Rollup merge of #") + { + stripped + .split_whitespace() + .next() + .unwrap() + .parse::() + .unwrap() + } else { + anyhow::bail!( + "not a rollup merge commit: {:?}", + rollup_merge.commit.message + ) + }; + + let branch = format!("try-for-{}", rolled_up_pr_number); + create_ref( + &client, + &data, + repository_url, + &format!("refs/heads/{}", branch), + &merge_sha, + ) + .await + .context("created branch")?; + + Ok(RollupBranch { + rolled_up_pr_number, + master_base_sha: current_master_commit.sha, + name: branch, + }) +} + +#[derive(serde::Serialize)] +struct CreateRefRequest<'a> { + // Must start with `refs/` and have at least two slashes. + // e.g. `refs/heads/master`. + #[serde(rename = "ref")] + ref_: &'a str, + sha: &'a str, +} + +pub async fn create_ref( + client: &reqwest::Client, + data: &InputData, + repository_url: &str, + ref_: &str, + sha: &str, +) -> anyhow::Result<()> { let timer_token = data .config .keys .github .clone() .expect("needs rust-timer token"); - let client = reqwest::Client::new(); - let url = format!("{}/commits/{}", request.issue.repository_url, commit); + let url = format!("{}/git/refs", repository_url); + let response = client + .post(&url) + .json(&CreateRefRequest { ref_, sha }) + .header(USER_AGENT, "perf-rust-lang-org-server") + .basic_auth("rust-timer", Some(timer_token)) + .send() + .await + .context("POST git/refs failed")?; + if response.status() != reqwest::StatusCode::CREATED { + anyhow::bail!("{:?} != 201 CREATED", response.status()); + } + + Ok(()) +} + +#[derive(serde::Serialize)] +struct CreatePrRequest<'a> { + title: &'a str, + // username:branch if cross-repo + head: &'a str, + // branch to pull into (e.g, master) + base: &'a str, + #[serde(rename = "body")] + description: &'a str, +} + +#[derive(Debug, serde::Deserialize)] +pub struct CreatePrResponse { + pub number: u32, + pub html_url: String, + pub comments_url: String, +} + +pub async fn create_pr( + client: &reqwest::Client, + data: &InputData, + repository_url: &str, + title: &str, + head: &str, + base: &str, + description: &str, +) -> anyhow::Result { + let timer_token = data + .config + .keys + .github + .clone() + .expect("needs rust-timer token"); + let url = format!("{}/pulls", repository_url); + let response = client + .post(&url) + .json(&CreatePrRequest { + title, + head, + base, + description, + }) + .header(USER_AGENT, "perf-rust-lang-org-server") + .basic_auth("rust-timer", Some(timer_token)) + .send() + .await + .context("POST pulls failed")?; + if response.status() != reqwest::StatusCode::CREATED { + anyhow::bail!("{:?} != 201 CREATED", response.status()); + } + + Ok(response.json().await.context("deserializing failed")?) +} + +#[derive(serde::Serialize)] +struct CreateCommitRequest<'a> { + message: &'a str, + tree: &'a str, + parents: &'a [&'a str], +} + +#[derive(serde::Deserialize)] +struct CreateCommitResponse { + sha: String, +} + +pub async fn create_commit( + client: &reqwest::Client, + data: &InputData, + repository_url: &str, + message: &str, + tree: &str, + parents: &[&str], +) -> anyhow::Result { + let timer_token = data + .config + .keys + .github + .clone() + .expect("needs rust-timer token"); + let url = format!("{}/git/commits", repository_url); + let commit_response = client + .post(&url) + .json(&CreateCommitRequest { + message, + tree, + parents, + }) + .header(USER_AGENT, "perf-rust-lang-org-server") + .basic_auth("rust-timer", Some(timer_token)) + .send() + .await + .context("POST git/commits failed")?; + if commit_response.status() != reqwest::StatusCode::CREATED { + anyhow::bail!("{:?} != 201 CREATED", commit_response.status()); + } + + Ok(commit_response + .json::() + .await + .context("deserializing failed")? + .sha) +} + +pub async fn get_commit( + client: &reqwest::Client, + data: &InputData, + repository_url: &str, + sha: &str, +) -> anyhow::Result { + let timer_token = data + .config + .keys + .github + .clone() + .expect("needs rust-timer token"); + let url = format!("{}/commits/{}", repository_url, sha); let commit_response = client .get(&url) .header(USER_AGENT, "perf-rust-lang-org-server") .basic_auth("rust-timer", Some(timer_token)) .send() .await - .map_err(|_| String::from("cannot get commit"))?; + .context("cannot get commit")?; let commit_response = match commit_response.text().await { Ok(c) => c, Err(err) => { - return Err(format!("Failed to decode response for {}: {:?}", url, err)); - } - }; - let commit_response: github::Commit = match serde_json::from_str(&commit_response) { - Ok(c) => c, - Err(e) => { - return Err(format!( - "cannot deserialize commit ({:?}): {:?}", - commit_response, e - )); + anyhow::bail!("Failed to decode response for {}: {:?}", url, err); } }; + match serde_json::from_str(&commit_response) { + Ok(c) => Ok(c), + Err(e) => Err(anyhow::anyhow!( + "cannot deserialize commit ({}): {:?}", + commit_response, + e + )), + } +} + +async fn enqueue_sha( + request: github::Request, + data: &InputData, + commit: String, +) -> ServerResult { + let client = reqwest::Client::new(); + let commit_response = get_commit(&client, data, &request.issue.repository_url, &commit) + .await + .map_err(|e| e.to_string())?; if commit_response.parents.len() != 2 { log::error!( "Bors try commit {} unexpectedly has {} parents.",