Skip to content

Commit 79c8ce7

Browse files
authored
Automate setting closed issue milestones (#37872)
1 parent be6a920 commit 79c8ce7

2 files changed

Lines changed: 168 additions & 150 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# This workflow automatically labels issues with the preview/RC version when their fixing PR
2+
# is merged into main or a release branch, and sets their milestone to the current version.
3+
# All version information is read from eng/Versions.props at the merge commit.
4+
5+
name: Label and milestone closed issues
6+
7+
on:
8+
pull_request_target:
9+
types: [closed]
10+
branches:
11+
- main
12+
- release/**
13+
14+
permissions:
15+
issues: write
16+
contents: read
17+
pull-requests: read
18+
19+
jobs:
20+
label:
21+
if: github.event.pull_request.merged == true
22+
runs-on: ubuntu-latest
23+
steps:
24+
- name: Label issues and update milestones
25+
uses: actions/github-script@v8
26+
with:
27+
script: |
28+
const owner = context.repo.owner;
29+
const repo = context.repo.repo;
30+
const prNumber = context.payload.pull_request.number;
31+
const mergeCommitSha = context.payload.pull_request.merge_commit_sha;
32+
33+
// Find issues closed by this PR using GraphQL (include current milestone)
34+
const query = `
35+
query($owner: String!, $repo: String!, $prNumber: Int!) {
36+
repository(owner: $owner, name: $repo) {
37+
pullRequest(number: $prNumber) {
38+
closingIssuesReferences(first: 50) {
39+
nodes {
40+
number
41+
milestone {
42+
title
43+
}
44+
}
45+
}
46+
}
47+
}
48+
}
49+
`;
50+
51+
const result = await github.graphql(query, { owner, repo, prNumber });
52+
const closingIssues = result.repository.pullRequest.closingIssuesReferences.nodes;
53+
54+
if (closingIssues.length === 0) {
55+
console.log('No closing issues linked to this PR, skipping');
56+
return;
57+
}
58+
59+
// Read version info from eng/Versions.props at the actual merge commit
60+
const { data: versionFileData } = await github.rest.repos.getContent({
61+
owner,
62+
repo,
63+
path: 'eng/Versions.props',
64+
ref: mergeCommitSha
65+
});
66+
const versionFileContent = Buffer.from(versionFileData.content, 'base64').toString('utf-8');
67+
68+
const versionPrefixMatch = versionFileContent.match(/<VersionPrefix>(\d+\.\d+\.\d+)<\/VersionPrefix>/);
69+
if (!versionPrefixMatch) {
70+
throw new Error('Could not parse VersionPrefix from eng/Versions.props');
71+
}
72+
const versionPrefix = versionPrefixMatch[1];
73+
74+
const preReleaseLabelMatch = versionFileContent.match(/<PreReleaseVersionLabel>(preview|rc)<\/PreReleaseVersionLabel>/);
75+
const preReleaseIterationMatch = versionFileContent.match(/<PreReleaseVersionIteration>(\d+)<\/PreReleaseVersionIteration>/);
76+
77+
let label;
78+
if (preReleaseLabelMatch && preReleaseIterationMatch) {
79+
label = `${preReleaseLabelMatch[1]}-${preReleaseIterationMatch[1]}`;
80+
}
81+
82+
console.log(`Version: ${versionPrefix}, label: ${label ?? 'none'}`);
83+
84+
// Label all closing issues
85+
// (don't filter by state to avoid race conditions where GitHub
86+
// hasn't closed the issue yet when this workflow runs)
87+
const errors = [];
88+
if (label) {
89+
for (const issue of closingIssues) {
90+
console.log(`Adding label '${label}' to issue #${issue.number}`);
91+
try {
92+
await github.rest.issues.addLabels({
93+
owner,
94+
repo,
95+
issue_number: issue.number,
96+
labels: [label]
97+
});
98+
} catch (error) {
99+
errors.push(`Failed to add label to issue #${issue.number}: ${error.message}`);
100+
}
101+
}
102+
}
103+
104+
// Look up the target milestone (e.g. "11.0.0", "10.0.5") via GraphQL
105+
const targetMilestoneName = versionPrefix;
106+
const milestoneResult = await github.graphql(`
107+
query($owner: String!, $repo: String!, $title: String!) {
108+
repository(owner: $owner, name: $repo) {
109+
milestones(query: $title, first: 1) {
110+
nodes {
111+
number
112+
title
113+
}
114+
}
115+
}
116+
}
117+
`, { owner, repo, title: targetMilestoneName });
118+
const milestoneNode = milestoneResult.repository.milestones.nodes
119+
.find(m => m.title === targetMilestoneName);
120+
if (!milestoneNode) {
121+
throw new Error(`Milestone '${targetMilestoneName}' not found`);
122+
}
123+
124+
// Set the milestone on closing issues, applying a "min" strategy:
125+
// only update if the issue has no version milestone or the target is earlier
126+
const targetVersion = parseVersion(versionPrefix);
127+
for (const issue of closingIssues) {
128+
const currentTitle = issue.milestone?.title;
129+
const currentVersion = currentTitle ? parseVersion(currentTitle) : null;
130+
131+
if (currentVersion && compareVersions(currentVersion, targetVersion) <= 0) {
132+
console.log(`Issue #${issue.number} already has milestone '${currentTitle}' <= '${targetMilestoneName}', skipping`);
133+
continue;
134+
}
135+
136+
const from = currentTitle ? `'${currentTitle}'` : 'none';
137+
console.log(`Setting milestone on issue #${issue.number} from ${from} to '${targetMilestoneName}'`);
138+
try {
139+
await github.rest.issues.update({
140+
owner,
141+
repo,
142+
issue_number: issue.number,
143+
milestone: milestoneNode.number
144+
});
145+
} catch (error) {
146+
errors.push(`Failed to set milestone on issue #${issue.number}: ${error.message}`);
147+
}
148+
}
149+
150+
if (errors.length > 0) {
151+
throw new Error(`Errors processing issues:\n${errors.join('\n')}`);
152+
}
153+
154+
console.log(`Done. Processed ${closingIssues.length} issue(s) with label '${label ?? 'none'}' and milestone '${targetMilestoneName}'`);
155+
156+
// Parses a milestone title as a semver version, or returns null for
157+
// non-version milestones (e.g. "Backlog", "MQ", "Discussions")
158+
function parseVersion(title) {
159+
const m = title.match(/^(\d+)\.(\d+)\.(\d+)$/);
160+
return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : null;
161+
}
162+
163+
function compareVersions(a, b) {
164+
for (let i = 0; i < 3; i++) {
165+
if (a[i] !== b[i]) return a[i] - b[i];
166+
}
167+
return 0;
168+
}

.github/workflows/label-preview-version.yml

Lines changed: 0 additions & 150 deletions
This file was deleted.

0 commit comments

Comments
 (0)