Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/dev-infra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
issues:
types: [opened]
types: [opened, labeled]

# Declare default permissions as read only.
permissions:
Expand Down
132 changes: 122 additions & 10 deletions github-actions/labeling/issue/lib/issue-labeling.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {Octokit} from '@octokit/rest';
import * as core from '@actions/core';
import {IssueLabeling as _IssueLabeling} from './issue-labeling.js';
import {context} from '@actions/github';
import {
IssueLabeling as _IssueLabeling,
NEEDS_TRIAGE_MILESTONE,
BACKLOG_MILESTONE,
} from './issue-labeling.js';

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

beforeEach(() => {
// Set up GitHub Action context defaults for tests
context.payload = {action: 'opened'};
context.eventName = 'issues';
spyOnProperty(context, 'issue', 'get').and.returnValue({
owner: 'angular',
repo: 'dev-infra',
number: 123,
});

mockGit = jasmine.createSpyObj('Octokit', ['paginate', 'issues', 'pulls']);
mockGit.issues = jasmine.createSpyObj('issues', ['addLabels', 'get', 'listLabelsForRepo']);
mockGit.issues = jasmine.createSpyObj('issues', [
'addLabels',
'get',
'listLabelsForRepo',
'listMilestones',
'update',
]);

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

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

// By default, mock AI returns a safe non-matching value so it doesn't crash un-stubbed tests.
mockAI.models.generateContent.and.returnValue(Promise.resolve({text: 'none'}));

spyOn(IssueLabeling.prototype, 'getGenerativeAI').and.returnValue(mockAI);
issueLabeling = new IssueLabeling();
issueLabeling.setGit(mockGit as unknown as Octokit);
Expand All @@ -56,21 +86,43 @@ describe('IssueLabeling', () => {
expect(issueLabeling.repoAreaLabels.has('bug')).toBe(false);
});

it('should apply a label when Gemini is confident', async () => {
it('should apply a label and milestone when Gemini is confident on opened', async () => {
mockAI.models.generateContent.and.returnValue(
Promise.resolve({
text: 'area: core',
}),
);

await issueLabeling.initialize();
let getCallCount = 0;
getIssue.and.callFake(() => {
getCallCount++;
if (getCallCount === 1) {
return Promise.resolve({
data: {title: 'Tough Issue', body: 'Complex Body', labels: []},
});
}
return Promise.resolve({
data: {
title: 'Tough Issue',
body: 'Complex Body',
labels: ['area: core'],
},
});
});

await issueLabeling.run();

expect(mockGit.issues.addLabels).toHaveBeenCalledWith(
jasmine.objectContaining({
labels: ['area: core'],
}),
);
expect(mockGit.issues.update).toHaveBeenCalledWith(
jasmine.objectContaining({
issue_number: 123,
milestone: 1,
}),
);
});

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

await issueLabeling.initialize();
await issueLabeling.run();

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

await issueLabeling.initialize();
await issueLabeling.run();

expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
});

it('should skip labeling when issue already has an area label', async () => {
it('should apply needsTriage milestone when an area label is added manually', async () => {
context.payload = {action: 'labeled', label: {name: 'area: core'}};
getIssue.and.resolveTo({
data: {
title: 'Tough Issue',
body: 'Complex Body',
labels: [{name: 'area: core'}],
labels: ['area: core'],
},
});
await issueLabeling.initialize();

await issueLabeling.run();

expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
expect(mockGit.issues.update).toHaveBeenCalledWith(
jasmine.objectContaining({
issue_number: 123,
milestone: 1,
}),
);
});

it('should apply Backlog milestone when a priority label is added manually', async () => {
context.payload = {action: 'labeled', label: {name: 'P0'}};
getIssue.and.resolveTo({
data: {
title: 'Tough Issue',
body: 'Complex Body',
labels: ['P0'],
},
});

await issueLabeling.run();

expect(mockGit.issues.update).toHaveBeenCalledWith(
jasmine.objectContaining({
issue_number: 123,
milestone: 2,
}),
);
});

it('should NOT overwrite an existing milestone when applying a new one', async () => {
context.payload = {action: 'labeled', label: {name: 'P1'}};
getIssue.and.resolveTo({
data: {
title: 'Tough Issue',
body: 'Complex Body',
labels: ['P1'],
milestone: {title: 'Release 20', number: 99},
},
});

await issueLabeling.run();

expect(mockGit.issues.update).not.toHaveBeenCalled();
});

it('should transition milestone from needsTriage to Backlog', async () => {
context.payload = {action: 'labeled', label: {name: 'P2'}};
getIssue.and.resolveTo({
data: {
title: 'Tough Issue',
body: 'Complex Body',
labels: ['area: core', 'P2'],
milestone: {title: NEEDS_TRIAGE_MILESTONE, number: 1},
},
});

await issueLabeling.run();

expect(mockGit.issues.update).toHaveBeenCalledWith(
jasmine.objectContaining({
issue_number: 123,
milestone: 2,
}),
);
});
});
99 changes: 97 additions & 2 deletions github-actions/labeling/issue/lib/issue-labeling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {components} from '@octokit/openapi-types';
import {miscLabels} from '../../../../ng-dev/pr/common/labels/index.js';
import {Labeling} from '../../shared/labeling.js';

export const NEEDS_TRIAGE_MILESTONE = 'needsTriage';
export const BACKLOG_MILESTONE = 'Backlog';

export class IssueLabeling extends Labeling {
readonly type = 'Issue';
/** Set of area labels available in the current repository. */
Expand All @@ -13,15 +16,44 @@ export class IssueLabeling extends Labeling {
issueData?: components['schemas']['issue'];

async run() {
core.info(`Updating labels for ${this.type} #${context.issue.number}`);
const {owner, repo, number} = context.issue;
core.info(`Processing ${this.type} #${number}...`);

if (!this.issueData) {
await this.initialize();
}

// 1. Run auto-labeler first (it safely skips if an area label is already present).
await this.runAutoLabeling();

// 2. Re-fetch the latest issue state to ensure we capture any newly added labels.
const updatedIssue = await this.git.issues.get({
owner,
repo,
issue_number: number,
});
const labels = updatedIssue.data.labels.map((l: string | {name?: string}) =>
typeof l === 'string' ? l : l.name || '',
);

const hasAreaLabel = labels.some((l) => l.startsWith('area: '));
const hasPriorityLabel = labels.some((l) => /^P[0-5]$/.test(l));

if (hasPriorityLabel) {
await this.applyMilestoneIfFound(BACKLOG_MILESTONE);
} else if (hasAreaLabel) {
await this.applyMilestoneIfFound(NEEDS_TRIAGE_MILESTONE);
}
}

async runAutoLabeling() {
// Determine if the issue already has an area label, if it does we can exit early.
if (
this.issueData?.labels.some((label: string | {name?: string}) =>
(typeof label === 'string' ? label : label.name)?.startsWith('area: '),
)
) {
core.info('Issue already has an area label. Skipping.');
core.info('Issue already has an area label. Skipping auto-labeling.');
return;
}

Expand Down Expand Up @@ -72,6 +104,69 @@ If no area label applies, respond with "none".
}
}

async applyMilestoneIfFound(targetMilestoneTitle: string) {
const {owner, repo, number} = context.issue;
core.info(`Checking for milestone with title "${targetMilestoneTitle}" in ${owner}/${repo}...`);

try {
const milestones = await this.git.paginate(this.git.issues.listMilestones, {
owner,
repo,
state: 'open',
});

const found = milestones.find(
(m) => m.title.toLowerCase() === targetMilestoneTitle.toLowerCase(),
);

if (found) {
const currentIssue = await this.git.issues.get({
owner,
repo,
issue_number: number,
});
const currentMilestone = currentIssue.data.milestone;

if (currentMilestone) {
if (
currentMilestone.title.toLowerCase() === NEEDS_TRIAGE_MILESTONE.toLowerCase() &&
targetMilestoneTitle.toLowerCase() === BACKLOG_MILESTONE.toLowerCase()
) {
core.info(
`Transitioning milestone from "${currentMilestone.title}" to "${found.title}"...`,
);
} else if (currentMilestone.number === found.number) {
core.info(`Issue already has milestone "${found.title}". Skipping.`);
return;
} else {
core.info(
`Issue already has milestone "${currentMilestone.title}". Skipping overwrite with "${targetMilestoneTitle}".`,
);
return;
}
}

core.info(
`Found milestone "${found.title}" (ID: ${found.number}). Applying to issue #${number}...`,
);
await this.git.issues.update({
owner,
repo,
issue_number: number,
milestone: found.number,
});
core.info('Successfully applied milestone.');
} else {
core.info(
`Milestone "${targetMilestoneTitle}" was not found in this repository. Skipping.`,
);
}
} catch (e) {
core.error(`Failed to check or apply milestone "${targetMilestoneTitle}".`);
core.error(e as Error);
}
}

getGenerativeAI() {
const apiKey = core.getInput('google-generative-ai-key', {required: true});
return new GoogleGenAI({apiKey});
Expand Down
Loading
Loading