Skip to content

Commit d7df9ee

Browse files
authored
Merge pull request #1751 from ehuss/cargo-milestone
Milestone cargo PRs.
2 parents df11506 + fa84ceb commit d7df9ee

File tree

2 files changed

+219
-44
lines changed

2 files changed

+219
-44
lines changed

src/github.rs

Lines changed: 152 additions & 39 deletions
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);
@@ -760,49 +764,25 @@ impl Issue {
760764
Ok(())
761765
}
762766

767+
/// Sets the milestone of the issue or PR.
768+
///
769+
/// This will create the milestone if it does not exist. The new milestone
770+
/// will start in the "open" state.
763771
pub async fn set_milestone(&self, client: &GithubClient, title: &str) -> anyhow::Result<()> {
764772
log::trace!(
765773
"Setting milestone for rust-lang/rust#{} to {}",
766774
self.number,
767775
title
768776
);
769777

770-
let create_url = format!("{}/milestones", self.repository().url());
771-
let resp = client
772-
.send_req(
773-
client
774-
.post(&create_url)
775-
.body(serde_json::to_vec(&MilestoneCreateBody { title }).unwrap()),
776-
)
777-
.await;
778-
// Explicitly do *not* try to return Err(...) if this fails -- that's
779-
// fine, it just means the milestone was already created.
780-
log::trace!("Created milestone: {:?}", resp);
781-
782-
let list_url = format!("{}/milestones", self.repository().url());
783-
let milestone_list: Vec<Milestone> = client.json(client.get(&list_url)).await?;
784-
let milestone_no = if let Some(milestone) = milestone_list.iter().find(|v| v.title == title)
785-
{
786-
milestone.number
787-
} else {
788-
anyhow::bail!(
789-
"Despite just creating milestone {} on {}, it does not exist?",
790-
title,
791-
self.repository()
792-
)
793-
};
778+
let full_repo_name = self.repository().full_repo_name();
779+
let milestone = client
780+
.get_or_create_milestone(&full_repo_name, title, "open")
781+
.await?;
794782

795-
#[derive(serde::Serialize)]
796-
struct SetMilestone {
797-
milestone: u64,
798-
}
799-
let url = format!("{}/issues/{}", self.repository().url(), self.number);
800783
client
801-
.send_req(client.patch(&url).json(&SetMilestone {
802-
milestone: milestone_no,
803-
}))
804-
.await
805-
.context("failed to set milestone")?;
784+
.set_milestone(&full_repo_name, &milestone, self.number)
785+
.await?;
806786
Ok(())
807787
}
808788

@@ -901,11 +881,6 @@ pub struct PullRequestFile {
901881
pub blob_url: String,
902882
}
903883

904-
#[derive(serde::Serialize)]
905-
struct MilestoneCreateBody<'a> {
906-
title: &'a str,
907-
}
908-
909884
#[derive(Debug, serde::Deserialize)]
910885
pub struct Milestone {
911886
number: u64,
@@ -1261,6 +1236,33 @@ impl Repository {
12611236
)
12621237
}
12631238

1239+
/// Returns a list of commits between the SHA ranges of start (exclusive)
1240+
/// and end (inclusive).
1241+
pub async fn commits_in_range(
1242+
&self,
1243+
client: &GithubClient,
1244+
start: &str,
1245+
end: &str,
1246+
) -> anyhow::Result<Vec<GithubCommit>> {
1247+
let mut commits = Vec::new();
1248+
let mut page = 1;
1249+
loop {
1250+
let url = format!("{}/commits?sha={end}&per_page=100&page={page}", self.url());
1251+
let mut this_page: Vec<GithubCommit> = client
1252+
.json(client.get(&url))
1253+
.await
1254+
.with_context(|| format!("failed to fetch commits for {url}"))?;
1255+
if let Some(idx) = this_page.iter().position(|commit| commit.sha == start) {
1256+
this_page.truncate(idx);
1257+
commits.extend(this_page);
1258+
return Ok(commits);
1259+
} else {
1260+
commits.extend(this_page);
1261+
}
1262+
page += 1;
1263+
}
1264+
}
1265+
12641266
/// Retrieves a git commit for the given SHA.
12651267
pub async fn git_commit(&self, client: &GithubClient, sha: &str) -> anyhow::Result<GitCommit> {
12661268
let url = format!("{}/git/commits/{sha}", self.url());
@@ -1631,6 +1633,40 @@ impl Repository {
16311633
})?;
16321634
Ok(())
16331635
}
1636+
1637+
/// Get or create a [`Milestone`].
1638+
///
1639+
/// This will not change the state if it already exists.
1640+
pub async fn get_or_create_milestone(
1641+
&self,
1642+
client: &GithubClient,
1643+
title: &str,
1644+
state: &str,
1645+
) -> anyhow::Result<Milestone> {
1646+
client
1647+
.get_or_create_milestone(&self.full_name, title, state)
1648+
.await
1649+
}
1650+
1651+
/// Set the milestone of an issue or PR.
1652+
pub async fn set_milestone(
1653+
&self,
1654+
client: &GithubClient,
1655+
milestone: &Milestone,
1656+
issue_num: u64,
1657+
) -> anyhow::Result<()> {
1658+
client
1659+
.set_milestone(&self.full_name, milestone, issue_num)
1660+
.await
1661+
}
1662+
1663+
pub async fn get_issue(&self, client: &GithubClient, issue_num: u64) -> anyhow::Result<Issue> {
1664+
let url = format!("{}/pulls/{issue_num}", self.url());
1665+
client
1666+
.json(client.get(&url))
1667+
.await
1668+
.with_context(|| format!("{} failed to get issue {issue_num}", self.full_name))
1669+
}
16341670
}
16351671

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

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

src/handlers/milestone_prs.rs

Lines changed: 67 additions & 5 deletions
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)