Skip to content

Commit fa84ceb

Browse files
committed
Milestone cargo PRs.
1 parent 0a68321 commit fa84ceb

File tree

2 files changed

+219
-44
lines changed

2 files changed

+219
-44
lines changed

src/github.rs

+152-39
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,10 @@ impl IssueRepository {
426426
)
427427
}
428428

429+
fn full_repo_name(&self) -> String {
430+
format!("{}/{}", self.organization, self.repository)
431+
}
432+
429433
async fn has_label(&self, client: &GithubClient, label: &str) -> anyhow::Result<bool> {
430434
#[allow(clippy::redundant_pattern_matching)]
431435
let url = format!("{}/labels/{}", self.url(), label);
@@ -745,49 +749,25 @@ impl Issue {
745749
Ok(())
746750
}
747751

752+
/// Sets the milestone of the issue or PR.
753+
///
754+
/// This will create the milestone if it does not exist. The new milestone
755+
/// will start in the "open" state.
748756
pub async fn set_milestone(&self, client: &GithubClient, title: &str) -> anyhow::Result<()> {
749757
log::trace!(
750758
"Setting milestone for rust-lang/rust#{} to {}",
751759
self.number,
752760
title
753761
);
754762

755-
let create_url = format!("{}/milestones", self.repository().url());
756-
let resp = client
757-
.send_req(
758-
client
759-
.post(&create_url)
760-
.body(serde_json::to_vec(&MilestoneCreateBody { title }).unwrap()),
761-
)
762-
.await;
763-
// Explicitly do *not* try to return Err(...) if this fails -- that's
764-
// fine, it just means the milestone was already created.
765-
log::trace!("Created milestone: {:?}", resp);
766-
767-
let list_url = format!("{}/milestones", self.repository().url());
768-
let milestone_list: Vec<Milestone> = client.json(client.get(&list_url)).await?;
769-
let milestone_no = if let Some(milestone) = milestone_list.iter().find(|v| v.title == title)
770-
{
771-
milestone.number
772-
} else {
773-
anyhow::bail!(
774-
"Despite just creating milestone {} on {}, it does not exist?",
775-
title,
776-
self.repository()
777-
)
778-
};
763+
let full_repo_name = self.repository().full_repo_name();
764+
let milestone = client
765+
.get_or_create_milestone(&full_repo_name, title, "open")
766+
.await?;
779767

780-
#[derive(serde::Serialize)]
781-
struct SetMilestone {
782-
milestone: u64,
783-
}
784-
let url = format!("{}/issues/{}", self.repository().url(), self.number);
785768
client
786-
.send_req(client.patch(&url).json(&SetMilestone {
787-
milestone: milestone_no,
788-
}))
789-
.await
790-
.context("failed to set milestone")?;
769+
.set_milestone(&full_repo_name, &milestone, self.number)
770+
.await?;
791771
Ok(())
792772
}
793773

@@ -886,11 +866,6 @@ pub struct PullRequestFile {
886866
pub blob_url: String,
887867
}
888868

889-
#[derive(serde::Serialize)]
890-
struct MilestoneCreateBody<'a> {
891-
title: &'a str,
892-
}
893-
894869
#[derive(Debug, serde::Deserialize)]
895870
pub struct Milestone {
896871
number: u64,
@@ -1246,6 +1221,33 @@ impl Repository {
12461221
)
12471222
}
12481223

1224+
/// Returns a list of commits between the SHA ranges of start (exclusive)
1225+
/// and end (inclusive).
1226+
pub async fn commits_in_range(
1227+
&self,
1228+
client: &GithubClient,
1229+
start: &str,
1230+
end: &str,
1231+
) -> anyhow::Result<Vec<GithubCommit>> {
1232+
let mut commits = Vec::new();
1233+
let mut page = 1;
1234+
loop {
1235+
let url = format!("{}/commits?sha={end}&per_page=100&page={page}", self.url());
1236+
let mut this_page: Vec<GithubCommit> = client
1237+
.json(client.get(&url))
1238+
.await
1239+
.with_context(|| format!("failed to fetch commits for {url}"))?;
1240+
if let Some(idx) = this_page.iter().position(|commit| commit.sha == start) {
1241+
this_page.truncate(idx);
1242+
commits.extend(this_page);
1243+
return Ok(commits);
1244+
} else {
1245+
commits.extend(this_page);
1246+
}
1247+
page += 1;
1248+
}
1249+
}
1250+
12491251
/// Retrieves a git commit for the given SHA.
12501252
pub async fn git_commit(&self, client: &GithubClient, sha: &str) -> anyhow::Result<GitCommit> {
12511253
let url = format!("{}/git/commits/{sha}", self.url());
@@ -1616,6 +1618,40 @@ impl Repository {
16161618
})?;
16171619
Ok(())
16181620
}
1621+
1622+
/// Get or create a [`Milestone`].
1623+
///
1624+
/// This will not change the state if it already exists.
1625+
pub async fn get_or_create_milestone(
1626+
&self,
1627+
client: &GithubClient,
1628+
title: &str,
1629+
state: &str,
1630+
) -> anyhow::Result<Milestone> {
1631+
client
1632+
.get_or_create_milestone(&self.full_name, title, state)
1633+
.await
1634+
}
1635+
1636+
/// Set the milestone of an issue or PR.
1637+
pub async fn set_milestone(
1638+
&self,
1639+
client: &GithubClient,
1640+
milestone: &Milestone,
1641+
issue_num: u64,
1642+
) -> anyhow::Result<()> {
1643+
client
1644+
.set_milestone(&self.full_name, milestone, issue_num)
1645+
.await
1646+
}
1647+
1648+
pub async fn get_issue(&self, client: &GithubClient, issue_num: u64) -> anyhow::Result<Issue> {
1649+
let url = format!("{}/pulls/{issue_num}", self.url());
1650+
client
1651+
.json(client.get(&url))
1652+
.await
1653+
.with_context(|| format!("{} failed to get issue {issue_num}", self.full_name))
1654+
}
16191655
}
16201656

16211657
pub struct Query<'a> {
@@ -2126,6 +2162,83 @@ impl GithubClient {
21262162
.await
21272163
.with_context(|| format!("{} failed to get repo", full_name))
21282164
}
2165+
2166+
/// Get or create a [`Milestone`].
2167+
///
2168+
/// This will not change the state if it already exists.
2169+
async fn get_or_create_milestone(
2170+
&self,
2171+
full_repo_name: &str,
2172+
title: &str,
2173+
state: &str,
2174+
) -> anyhow::Result<Milestone> {
2175+
let url = format!(
2176+
"{}/repos/{full_repo_name}/milestones",
2177+
Repository::GITHUB_API_URL
2178+
);
2179+
let resp = self
2180+
.send_req(self.post(&url).json(&serde_json::json!({
2181+
"title": title,
2182+
"state": state,
2183+
})))
2184+
.await;
2185+
match resp {
2186+
Ok((body, _dbg)) => {
2187+
let milestone = serde_json::from_slice(&body)?;
2188+
log::trace!("Created milestone: {milestone:?}");
2189+
return Ok(milestone);
2190+
}
2191+
Err(e) => {
2192+
if e.downcast_ref::<reqwest::Error>().map_or(false, |e| {
2193+
matches!(e.status(), Some(StatusCode::UNPROCESSABLE_ENTITY))
2194+
}) {
2195+
// fall-through, it already exists
2196+
} else {
2197+
return Err(e.context(format!(
2198+
"failed to create milestone {url} with title {title}"
2199+
)));
2200+
}
2201+
}
2202+
}
2203+
// In the case where it already exists, we need to search for its number.
2204+
let mut page = 1;
2205+
loop {
2206+
let url = format!(
2207+
"{}/repos/{full_repo_name}/milestones?page={page}&state=all",
2208+
Repository::GITHUB_API_URL
2209+
);
2210+
let milestones: Vec<Milestone> = self
2211+
.json(self.get(&url))
2212+
.await
2213+
.with_context(|| format!("failed to get milestones {url} searching for {title}"))?;
2214+
if milestones.is_empty() {
2215+
anyhow::bail!("expected to find milestone with title {title}");
2216+
}
2217+
if let Some(milestone) = milestones.into_iter().find(|m| m.title == title) {
2218+
return Ok(milestone);
2219+
}
2220+
page += 1;
2221+
}
2222+
}
2223+
2224+
/// Set the milestone of an issue or PR.
2225+
async fn set_milestone(
2226+
&self,
2227+
full_repo_name: &str,
2228+
milestone: &Milestone,
2229+
issue_num: u64,
2230+
) -> anyhow::Result<()> {
2231+
let url = format!(
2232+
"{}/repos/{full_repo_name}/issues/{issue_num}",
2233+
Repository::GITHUB_API_URL
2234+
);
2235+
self.send_req(self.patch(&url).json(&serde_json::json!({
2236+
"milestone": milestone.number
2237+
})))
2238+
.await
2239+
.with_context(|| format!("failed to set milestone for {url} to milestone {milestone:?}"))?;
2240+
Ok(())
2241+
}
21292242
}
21302243

21312244
#[derive(Debug, serde::Deserialize)]

src/handlers/milestone_prs.rs

+67-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::{
2-
github::{Event, IssuesAction},
2+
github::{Event, GithubClient, IssuesAction},
33
handlers::Context,
44
};
55
use anyhow::Context as _;
6+
use regex::Regex;
67
use reqwest::StatusCode;
78
use tracing as log;
89

@@ -42,7 +43,7 @@ pub async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> {
4243
};
4344

4445
// Fetch the version from the upstream repository.
45-
let version = if let Some(version) = get_version_standalone(ctx, merge_sha).await? {
46+
let version = if let Some(version) = get_version_standalone(&ctx.github, merge_sha).await? {
4647
version
4748
} else {
4849
log::error!("could not find the version of {:?}", merge_sha);
@@ -62,12 +63,21 @@ pub async fn handle(ctx: &Context, event: &Event) -> anyhow::Result<()> {
6263
// eventually automate it separately.
6364
e.issue.set_milestone(&ctx.github, &version).await?;
6465

66+
let files = e.issue.diff(&ctx.github).await?;
67+
if let Some(files) = files {
68+
if let Some(cargo) = files.iter().find(|fd| fd.path == "src/tools/cargo") {
69+
milestone_cargo(&ctx.github, &version, &cargo.diff).await?;
70+
}
71+
}
72+
6573
Ok(())
6674
}
6775

68-
async fn get_version_standalone(ctx: &Context, merge_sha: &str) -> anyhow::Result<Option<String>> {
69-
let resp = ctx
70-
.github
76+
async fn get_version_standalone(
77+
gh: &GithubClient,
78+
merge_sha: &str,
79+
) -> anyhow::Result<Option<String>> {
80+
let resp = gh
7181
.raw()
7282
.get(&format!(
7383
"https://raw.githubusercontent.com/rust-lang/rust/{}/src/version",
@@ -96,3 +106,55 @@ async fn get_version_standalone(ctx: &Context, merge_sha: &str) -> anyhow::Resul
96106
.to_string(),
97107
))
98108
}
109+
110+
/// Milestones all PRs in the cargo repo when the submodule is synced in
111+
/// rust-lang/rust.
112+
async fn milestone_cargo(
113+
gh: &GithubClient,
114+
release_version: &str,
115+
submodule_diff: &str,
116+
) -> anyhow::Result<()> {
117+
// Determine the start/end range of commits in this submodule update by
118+
// looking at the diff content which indicates the old and new hash.
119+
let subproject_re = Regex::new("Subproject commit ([0-9a-f]+)").unwrap();
120+
let mut caps = subproject_re.captures_iter(submodule_diff);
121+
let cargo_start_hash = &caps.next().unwrap()[1];
122+
let cargo_end_hash = &caps.next().unwrap()[1];
123+
assert!(caps.next().is_none());
124+
125+
// Get all of the git commits in the cargo repo.
126+
let cargo_repo = gh.repository("rust-lang/cargo").await?;
127+
let commits = cargo_repo
128+
.commits_in_range(gh, cargo_start_hash, cargo_end_hash)
129+
.await?;
130+
131+
// For each commit, look for a message from bors that indicates which
132+
// PR was merged.
133+
//
134+
// GitHub has a specific API for this at
135+
// /repos/{owner}/{repo}/commits/{commit_sha}/pulls
136+
// <https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-pull-requests-associated-with-a-commit>,
137+
// but it is a little awkward to use, only works on the default branch,
138+
// and this is a bit simpler/faster. However, it is sensitive to the
139+
// specific messages generated by bors, and won't catch things merged
140+
// without bors.
141+
let merge_re = Regex::new("(?:Auto merge of|Merge pull request) #([0-9]+)").unwrap();
142+
143+
let pr_nums = commits.iter().filter_map(|commit| {
144+
merge_re.captures(&commit.commit.message).map(|cap| {
145+
cap.get(1)
146+
.unwrap()
147+
.as_str()
148+
.parse::<u64>()
149+
.expect("digits only")
150+
})
151+
});
152+
let milestone = cargo_repo
153+
.get_or_create_milestone(gh, release_version, "closed")
154+
.await?;
155+
for pr_num in pr_nums {
156+
cargo_repo.set_milestone(gh, &milestone, pr_num).await?;
157+
}
158+
159+
Ok(())
160+
}

0 commit comments

Comments
 (0)