Skip to content

Commit 3a72c83

Browse files
thePunderWomanjosephperrott
authored andcommitted
refactor(github-actions): Update issue labeler to apply milestones
This updates the issue labeler to also run and apply the needsTriage and Backlog milestones when appropriate. This should eliminate the need for the ngbot entirely.
1 parent 583cd52 commit 3a72c83

File tree

4 files changed

+289
-16
lines changed

4 files changed

+289
-16
lines changed

.github/workflows/dev-infra.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
pull_request_target:
55
types: [opened, synchronize, reopened]
66
issues:
7-
types: [opened]
7+
types: [opened, labeled]
88

99
# Declare default permissions as read only.
1010
permissions:

github-actions/labeling/issue/lib/issue-labeling.spec.ts

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {Octokit} from '@octokit/rest';
22
import * as core from '@actions/core';
3-
import {IssueLabeling as _IssueLabeling} from './issue-labeling.js';
3+
import {context} from '@actions/github';
4+
import {
5+
IssueLabeling as _IssueLabeling,
6+
NEEDS_TRIAGE_MILESTONE,
7+
BACKLOG_MILESTONE,
8+
} from './issue-labeling.js';
49

510
class IssueLabeling extends _IssueLabeling {
611
setGit(git: any) {
@@ -15,8 +20,23 @@ describe('IssueLabeling', () => {
1520
let getIssue: jasmine.Spy;
1621

1722
beforeEach(() => {
23+
// Set up GitHub Action context defaults for tests
24+
context.payload = {action: 'opened'};
25+
context.eventName = 'issues';
26+
spyOnProperty(context, 'issue', 'get').and.returnValue({
27+
owner: 'angular',
28+
repo: 'dev-infra',
29+
number: 123,
30+
});
31+
1832
mockGit = jasmine.createSpyObj('Octokit', ['paginate', 'issues', 'pulls']);
19-
mockGit.issues = jasmine.createSpyObj('issues', ['addLabels', 'get', 'listLabelsForRepo']);
33+
mockGit.issues = jasmine.createSpyObj('issues', [
34+
'addLabels',
35+
'get',
36+
'listLabelsForRepo',
37+
'listMilestones',
38+
'update',
39+
]);
2040

2141
// Mock paginate to return the result of the promise if it's a list, or just execute the callback
2242
(mockGit.paginate as jasmine.Spy).and.callFake((fn: any, args: any) => {
@@ -27,10 +47,17 @@ describe('IssueLabeling', () => {
2747
{name: 'bug', description: 'Bug report'},
2848
]);
2949
}
50+
if (fn === mockGit.issues.listMilestones) {
51+
return Promise.resolve([
52+
{number: 1, title: NEEDS_TRIAGE_MILESTONE},
53+
{number: 2, title: BACKLOG_MILESTONE},
54+
]);
55+
}
3056
return Promise.resolve([]);
3157
});
3258

3359
(mockGit.issues.addLabels as unknown as jasmine.Spy).and.returnValue(Promise.resolve({}));
60+
(mockGit.issues.update as unknown as jasmine.Spy).and.returnValue(Promise.resolve({}));
3461
getIssue = mockGit.issues.get as unknown as jasmine.Spy;
3562
getIssue.and.resolveTo({
3663
data: {
@@ -44,6 +71,9 @@ describe('IssueLabeling', () => {
4471
models: jasmine.createSpyObj('models', ['generateContent']),
4572
};
4673

74+
// By default, mock AI returns a safe non-matching value so it doesn't crash un-stubbed tests.
75+
mockAI.models.generateContent.and.returnValue(Promise.resolve({text: 'none'}));
76+
4777
spyOn(IssueLabeling.prototype, 'getGenerativeAI').and.returnValue(mockAI);
4878
issueLabeling = new IssueLabeling();
4979
issueLabeling.setGit(mockGit as unknown as Octokit);
@@ -56,21 +86,43 @@ describe('IssueLabeling', () => {
5686
expect(issueLabeling.repoAreaLabels.has('bug')).toBe(false);
5787
});
5888

59-
it('should apply a label when Gemini is confident', async () => {
89+
it('should apply a label and milestone when Gemini is confident on opened', async () => {
6090
mockAI.models.generateContent.and.returnValue(
6191
Promise.resolve({
6292
text: 'area: core',
6393
}),
6494
);
6595

66-
await issueLabeling.initialize();
96+
let getCallCount = 0;
97+
getIssue.and.callFake(() => {
98+
getCallCount++;
99+
if (getCallCount === 1) {
100+
return Promise.resolve({
101+
data: {title: 'Tough Issue', body: 'Complex Body', labels: []},
102+
});
103+
}
104+
return Promise.resolve({
105+
data: {
106+
title: 'Tough Issue',
107+
body: 'Complex Body',
108+
labels: ['area: core'],
109+
},
110+
});
111+
});
112+
67113
await issueLabeling.run();
68114

69115
expect(mockGit.issues.addLabels).toHaveBeenCalledWith(
70116
jasmine.objectContaining({
71117
labels: ['area: core'],
72118
}),
73119
);
120+
expect(mockGit.issues.update).toHaveBeenCalledWith(
121+
jasmine.objectContaining({
122+
issue_number: 123,
123+
milestone: 1,
124+
}),
125+
);
74126
});
75127

76128
it('should NOT apply a label when Gemini returns "ambiguous"', async () => {
@@ -80,7 +132,6 @@ describe('IssueLabeling', () => {
80132
}),
81133
);
82134

83-
await issueLabeling.initialize();
84135
await issueLabeling.run();
85136

86137
expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
@@ -93,24 +144,85 @@ describe('IssueLabeling', () => {
93144
}),
94145
);
95146

96-
await issueLabeling.initialize();
97147
await issueLabeling.run();
98148

99149
expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
100150
});
101151

102-
it('should skip labeling when issue already has an area label', async () => {
152+
it('should apply needsTriage milestone when an area label is added manually', async () => {
153+
context.payload = {action: 'labeled', label: {name: 'area: core'}};
103154
getIssue.and.resolveTo({
104155
data: {
105156
title: 'Tough Issue',
106157
body: 'Complex Body',
107-
labels: [{name: 'area: core'}],
158+
labels: ['area: core'],
108159
},
109160
});
110-
await issueLabeling.initialize();
111161

112162
await issueLabeling.run();
113163

114-
expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
164+
expect(mockGit.issues.update).toHaveBeenCalledWith(
165+
jasmine.objectContaining({
166+
issue_number: 123,
167+
milestone: 1,
168+
}),
169+
);
170+
});
171+
172+
it('should apply Backlog milestone when a priority label is added manually', async () => {
173+
context.payload = {action: 'labeled', label: {name: 'P0'}};
174+
getIssue.and.resolveTo({
175+
data: {
176+
title: 'Tough Issue',
177+
body: 'Complex Body',
178+
labels: ['P0'],
179+
},
180+
});
181+
182+
await issueLabeling.run();
183+
184+
expect(mockGit.issues.update).toHaveBeenCalledWith(
185+
jasmine.objectContaining({
186+
issue_number: 123,
187+
milestone: 2,
188+
}),
189+
);
190+
});
191+
192+
it('should NOT overwrite an existing milestone when applying a new one', async () => {
193+
context.payload = {action: 'labeled', label: {name: 'P1'}};
194+
getIssue.and.resolveTo({
195+
data: {
196+
title: 'Tough Issue',
197+
body: 'Complex Body',
198+
labels: ['P1'],
199+
milestone: {title: 'Release 20', number: 99},
200+
},
201+
});
202+
203+
await issueLabeling.run();
204+
205+
expect(mockGit.issues.update).not.toHaveBeenCalled();
206+
});
207+
208+
it('should transition milestone from needsTriage to Backlog', async () => {
209+
context.payload = {action: 'labeled', label: {name: 'P2'}};
210+
getIssue.and.resolveTo({
211+
data: {
212+
title: 'Tough Issue',
213+
body: 'Complex Body',
214+
labels: ['area: core', 'P2'],
215+
milestone: {title: NEEDS_TRIAGE_MILESTONE, number: 1},
216+
},
217+
});
218+
219+
await issueLabeling.run();
220+
221+
expect(mockGit.issues.update).toHaveBeenCalledWith(
222+
jasmine.objectContaining({
223+
issue_number: 123,
224+
milestone: 2,
225+
}),
226+
);
115227
});
116228
});

github-actions/labeling/issue/lib/issue-labeling.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {components} from '@octokit/openapi-types';
55
import {miscLabels} from '../../../../ng-dev/pr/common/labels/index.js';
66
import {Labeling} from '../../shared/labeling.js';
77

8+
export const NEEDS_TRIAGE_MILESTONE = 'needsTriage';
9+
export const BACKLOG_MILESTONE = 'Backlog';
10+
811
export class IssueLabeling extends Labeling {
912
readonly type = 'Issue';
1013
/** Set of area labels available in the current repository. */
@@ -13,15 +16,44 @@ export class IssueLabeling extends Labeling {
1316
issueData?: components['schemas']['issue'];
1417

1518
async run() {
16-
core.info(`Updating labels for ${this.type} #${context.issue.number}`);
19+
const {owner, repo, number} = context.issue;
20+
core.info(`Processing ${this.type} #${number}...`);
21+
22+
if (!this.issueData) {
23+
await this.initialize();
24+
}
25+
26+
// 1. Run auto-labeler first (it safely skips if an area label is already present).
27+
await this.runAutoLabeling();
1728

29+
// 2. Re-fetch the latest issue state to ensure we capture any newly added labels.
30+
const updatedIssue = await this.git.issues.get({
31+
owner,
32+
repo,
33+
issue_number: number,
34+
});
35+
const labels = updatedIssue.data.labels.map((l: string | {name?: string}) =>
36+
typeof l === 'string' ? l : l.name || '',
37+
);
38+
39+
const hasAreaLabel = labels.some((l) => l.startsWith('area: '));
40+
const hasPriorityLabel = labels.some((l) => /^P[0-5]$/.test(l));
41+
42+
if (hasPriorityLabel) {
43+
await this.applyMilestoneIfFound(BACKLOG_MILESTONE);
44+
} else if (hasAreaLabel) {
45+
await this.applyMilestoneIfFound(NEEDS_TRIAGE_MILESTONE);
46+
}
47+
}
48+
49+
async runAutoLabeling() {
1850
// Determine if the issue already has an area label, if it does we can exit early.
1951
if (
2052
this.issueData?.labels.some((label: string | {name?: string}) =>
2153
(typeof label === 'string' ? label : label.name)?.startsWith('area: '),
2254
)
2355
) {
24-
core.info('Issue already has an area label. Skipping.');
56+
core.info('Issue already has an area label. Skipping auto-labeling.');
2557
return;
2658
}
2759

@@ -72,6 +104,69 @@ If no area label applies, respond with "none".
72104
}
73105
}
74106

107+
async applyMilestoneIfFound(targetMilestoneTitle: string) {
108+
const {owner, repo, number} = context.issue;
109+
core.info(`Checking for milestone with title "${targetMilestoneTitle}" in ${owner}/${repo}...`);
110+
111+
try {
112+
const milestones = await this.git.paginate(this.git.issues.listMilestones, {
113+
owner,
114+
repo,
115+
state: 'open',
116+
});
117+
118+
const found = milestones.find(
119+
(m) => m.title.toLowerCase() === targetMilestoneTitle.toLowerCase(),
120+
);
121+
122+
if (found) {
123+
const currentIssue = await this.git.issues.get({
124+
owner,
125+
repo,
126+
issue_number: number,
127+
});
128+
const currentMilestone = currentIssue.data.milestone;
129+
130+
if (currentMilestone) {
131+
if (
132+
currentMilestone.title.toLowerCase() === NEEDS_TRIAGE_MILESTONE.toLowerCase() &&
133+
targetMilestoneTitle.toLowerCase() === BACKLOG_MILESTONE.toLowerCase()
134+
) {
135+
core.info(
136+
`Transitioning milestone from "${currentMilestone.title}" to "${found.title}"...`,
137+
);
138+
} else if (currentMilestone.number === found.number) {
139+
core.info(`Issue already has milestone "${found.title}". Skipping.`);
140+
return;
141+
} else {
142+
core.info(
143+
`Issue already has milestone "${currentMilestone.title}". Skipping overwrite with "${targetMilestoneTitle}".`,
144+
);
145+
return;
146+
}
147+
}
148+
149+
core.info(
150+
`Found milestone "${found.title}" (ID: ${found.number}). Applying to issue #${number}...`,
151+
);
152+
await this.git.issues.update({
153+
owner,
154+
repo,
155+
issue_number: number,
156+
milestone: found.number,
157+
});
158+
core.info('Successfully applied milestone.');
159+
} else {
160+
core.info(
161+
`Milestone "${targetMilestoneTitle}" was not found in this repository. Skipping.`,
162+
);
163+
}
164+
} catch (e) {
165+
core.error(`Failed to check or apply milestone "${targetMilestoneTitle}".`);
166+
core.error(e as Error);
167+
}
168+
}
169+
75170
getGenerativeAI() {
76171
const apiKey = core.getInput('google-generative-ai-key', {required: true});
77172
return new GoogleGenAI({apiKey});

0 commit comments

Comments
 (0)