Skip to content

Commit 08988f9

Browse files
thePunderWomanjosephperrott
authored andcommitted
feat(github-actions): Add action to let gemini label issues automatically
this adds a github action that looks at newly opened issues. Gemini should read the issue details and take a best guess at applying an area label for us.
1 parent 7c08ac2 commit 08988f9

12 files changed

Lines changed: 27839 additions & 1 deletion

File tree

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ github-actions/previews/pack-and-upload-artifact/inject-artifact-metadata.js
1515
github-actions/previews/upload-artifacts-to-firebase/extract-artifact-metadata.js
1616
github-actions/previews/upload-artifacts-to-firebase/fetch-workflow-artifact.js
1717
github-actions/saucelabs/set-saucelabs-env.js
18+
github-actions/issue-labeling/main.js
1819
github-actions/slash-commands/main.js
1920
github-actions/unified-status-check/main.js
2021

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
2+
load("//tools:defaults.bzl", "esbuild_checked_in", "jasmine_test", "ts_project")
3+
4+
package(default_visibility = ["//github-actions/issue-labeling:__subpackages__"])
5+
6+
npm_link_all_packages()
7+
8+
ts_project(
9+
name = "lib",
10+
srcs = glob(
11+
["lib/*.ts"],
12+
exclude = ["lib/*.spec.ts"],
13+
),
14+
tsconfig = "//github-actions:tsconfig",
15+
deps = [
16+
":node_modules/@actions/core",
17+
":node_modules/@actions/github",
18+
":node_modules/@google/generative-ai",
19+
":node_modules/@octokit/openapi-types",
20+
":node_modules/@octokit/rest",
21+
":node_modules/@types/node",
22+
"//github-actions:utils",
23+
],
24+
)
25+
26+
ts_project(
27+
name = "test_lib",
28+
testonly = True,
29+
srcs = glob(["lib/*.spec.ts"]),
30+
tsconfig = "//github-actions:tsconfig_test",
31+
deps = [
32+
":lib",
33+
":node_modules/@actions/core",
34+
":node_modules/@actions/github",
35+
":node_modules/@google/generative-ai",
36+
":node_modules/@octokit/openapi-types",
37+
":node_modules/@octokit/rest",
38+
":node_modules/@types/jasmine",
39+
":node_modules/@types/node",
40+
"//github-actions:utils",
41+
],
42+
)
43+
44+
jasmine_test(
45+
name = "test",
46+
data = [
47+
"issue-context.json",
48+
":test_lib",
49+
],
50+
env = {
51+
"GITHUB_REPOSITORY": "angular/angular",
52+
"GITHUB_EVENT_PATH": "$(location :issue-context.json)",
53+
},
54+
)
55+
56+
esbuild_checked_in(
57+
name = "main",
58+
srcs = [
59+
":lib",
60+
],
61+
entry_point = "lib/main.ts",
62+
format = "esm",
63+
platform = "node",
64+
target = "node22",
65+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: 'Issue Labeling'
2+
description: 'Automatically labels issues using Gemini based on their content'
3+
inputs:
4+
angular-robot-key:
5+
description: 'The private key for the Angular Robot'
6+
required: true
7+
google-generative-ai-key:
8+
description: 'The API key for Google Generative AI'
9+
required: true
10+
runs:
11+
using: 'node22'
12+
main: 'main.js'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"issue": {
3+
"number": 123,
4+
"owner": "angular",
5+
"repo": "angular",
6+
"title": "Tough Issue",
7+
"body": "Complex Body"
8+
},
9+
"repo": {
10+
"owner": "angular",
11+
"repo": "angular"
12+
}
13+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import * as core from '@actions/core';
2+
import {context} from '@actions/github';
3+
import {GoogleGenerativeAI} from '@google/generative-ai';
4+
import {Octokit} from '@octokit/rest';
5+
import {ANGULAR_ROBOT, getAuthTokenFor, revokeActiveInstallationToken} from '../../utils.js';
6+
import {components} from '@octokit/openapi-types';
7+
8+
export class IssueLabeling {
9+
static run = async () => {
10+
const token = await getAuthTokenFor(ANGULAR_ROBOT);
11+
const git = new Octokit({auth: token});
12+
try {
13+
const inst = new this(git, core);
14+
await inst.run();
15+
} finally {
16+
await revokeActiveInstallationToken(git);
17+
}
18+
};
19+
20+
/** Set of area labels available in the current repository. */
21+
repoAreaLabels = new Set<string>();
22+
/** The issue data fetched from Github. */
23+
issueData?: components['schemas']['issue'];
24+
25+
constructor(
26+
private git: Octokit,
27+
private coreService: typeof core,
28+
) {}
29+
30+
async run() {
31+
const {issue} = context;
32+
if (!issue || !issue.number) {
33+
this.coreService.info('No issue context found. Skipping.');
34+
return;
35+
}
36+
this.coreService.info(`Issue #${issue.number}`);
37+
38+
// Initialize labels and issue data
39+
await this.initialize();
40+
41+
const model = this.getGenerativeModel();
42+
43+
const prompt = `
44+
You are a helper for an open source repository.
45+
Your task is to allow the user to categorize the issue with an "area: " label.
46+
The following is the issue title and body:
47+
48+
Title: ${this.issueData!.title}
49+
Body:
50+
${this.issueData!.body}
51+
52+
The available area labels are:
53+
${Array.from(this.repoAreaLabels)
54+
.map((label) => ` - ${label}`)
55+
.join('\n')}
56+
57+
Based on the content, which area label is the best fit?
58+
Respond ONLY with the exact label name (e.g. "area: core").
59+
If you are strictly unsure or if multiple labels match equally well, respond with "ambiguous".
60+
If no area label applies, respond with "none".
61+
`;
62+
63+
try {
64+
const result = await model.generateContent(prompt);
65+
const response = result.response;
66+
const text = response.text().trim();
67+
68+
this.coreService.info(`Gemini suggested label: ${text}`);
69+
70+
if (this.repoAreaLabels.has(text)) {
71+
await this.addLabel(text);
72+
} else {
73+
this.coreService.info(
74+
`Generated label "${text}" is not in the list of valid area labels or is "ambiguous"/"none".`,
75+
);
76+
}
77+
} catch (e) {
78+
this.coreService.error('Failed to generate content from Gemini.');
79+
this.coreService.setFailed(e as Error);
80+
}
81+
}
82+
83+
getGenerativeModel() {
84+
const apiKey = this.coreService.getInput('google-generative-ai-key', {required: true});
85+
const genAI = new GoogleGenerativeAI(apiKey);
86+
return genAI.getGenerativeModel({model: 'gemini-2.0-flash'});
87+
}
88+
89+
async addLabel(label: string) {
90+
const {number: issue_number, owner, repo} = context.issue;
91+
try {
92+
await this.git.issues.addLabels({repo, owner, issue_number, labels: [label]});
93+
this.coreService.info(`Added ${label} label to Issue #${issue_number}`);
94+
} catch (err) {
95+
this.coreService.error(`Failed to add ${label} label to Issue #${issue_number}`);
96+
this.coreService.debug(err as string);
97+
}
98+
}
99+
100+
async initialize() {
101+
const {owner, repo} = context.issue;
102+
await Promise.all([
103+
this.git
104+
.paginate(this.git.issues.listLabelsForRepo, {owner, repo})
105+
.then((labels) =>
106+
labels
107+
.filter((l) => l.name.startsWith('area: '))
108+
.forEach((l) => this.repoAreaLabels.add(l.name)),
109+
),
110+
this.git.issues.get({owner, repo, issue_number: context.issue.number}).then((resp) => {
111+
this.issueData = resp.data;
112+
}),
113+
]);
114+
115+
if (this.repoAreaLabels.size === 0) {
116+
this.coreService.warning('No area labels found in the repository.');
117+
return;
118+
}
119+
120+
if (!this.issueData) {
121+
this.coreService.error('Failed to fetch issue data.');
122+
return;
123+
}
124+
}
125+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {Octokit} from '@octokit/rest';
2+
import * as core from '@actions/core';
3+
import {context} from '@actions/github';
4+
import {GenerativeModel} from '@google/generative-ai';
5+
import {IssueLabeling} from './issue-labeling.js';
6+
7+
describe('IssueLabeling', () => {
8+
let mockGit: {
9+
paginate: jasmine.Spy;
10+
issues: {
11+
listLabelsForRepo: jasmine.Spy;
12+
addLabels: jasmine.Spy;
13+
get: jasmine.Spy;
14+
};
15+
};
16+
let mockModel: jasmine.SpyObj<GenerativeModel>;
17+
let mockCore: jasmine.SpyObj<typeof core>;
18+
let issueLabeling: IssueLabeling;
19+
20+
beforeEach(() => {
21+
mockGit = {
22+
paginate: jasmine.createSpy('paginate'),
23+
issues: {
24+
listLabelsForRepo: jasmine.createSpy('listLabelsForRepo'),
25+
addLabels: jasmine.createSpy('addLabels'),
26+
get: jasmine.createSpy('get'),
27+
},
28+
};
29+
30+
mockGit.issues.addLabels.and.returnValue(Promise.resolve({}));
31+
mockGit.issues.get.and.returnValue(
32+
Promise.resolve({
33+
data: {
34+
title: 'Tough Issue',
35+
body: 'Complex Body',
36+
},
37+
}),
38+
);
39+
mockGit.paginate.and.callFake((fn: any, opts: any) => {
40+
// Return value matching listLabelsForRepo signature
41+
return Promise.resolve([{name: 'area: core'}, {name: 'area: router'}, {name: 'bug'}]);
42+
});
43+
44+
mockModel = jasmine.createSpyObj<GenerativeModel>('GenerativeModel', ['generateContent']);
45+
mockCore = jasmine.createSpyObj<typeof core>('core', [
46+
'getInput',
47+
'info',
48+
'error',
49+
'warning',
50+
'debug',
51+
'setFailed',
52+
]);
53+
mockCore.getInput.and.returnValue('mock-ai-key');
54+
mockCore.error.and.callFake(console.error);
55+
mockCore.info.and.callFake(console.info);
56+
mockCore.warning.and.callFake(console.warn);
57+
mockCore.debug.and.callFake(console.debug);
58+
mockCore.setFailed.and.callFake(console.error);
59+
60+
// We must cast the mock to Octokit because the mock only implements the subset used by the class.
61+
// This is standard for mocking large interfaces like Octokit.
62+
issueLabeling = new IssueLabeling(mockGit as unknown as Octokit, mockCore);
63+
64+
spyOn(issueLabeling, 'getGenerativeModel').and.returnValue(mockModel);
65+
});
66+
67+
it('should initialize labels correctly', async () => {
68+
await issueLabeling.initialize();
69+
expect(issueLabeling.repoAreaLabels.has('area: core')).toBe(true);
70+
expect(issueLabeling.repoAreaLabels.has('area: router')).toBe(true);
71+
expect(issueLabeling.repoAreaLabels.has('bug')).toBe(false);
72+
});
73+
74+
it('should apply a label when Gemini is confident', async () => {
75+
mockModel.generateContent.and.returnValue(
76+
Promise.resolve({
77+
response: {
78+
text: () => 'area: core',
79+
} as any, // Cast response structure as any because it's deeply nested and hard to construct manually
80+
}),
81+
);
82+
83+
await issueLabeling.run();
84+
85+
expect(mockGit.issues.addLabels).toHaveBeenCalledWith(
86+
jasmine.objectContaining({
87+
labels: ['area: core'],
88+
}),
89+
);
90+
});
91+
92+
it('should NOT apply a label when Gemini returns "ambiguous"', async () => {
93+
mockModel.generateContent.and.returnValue(
94+
Promise.resolve({
95+
response: {
96+
text: () => 'ambiguous',
97+
} as any,
98+
}),
99+
);
100+
101+
await issueLabeling.run();
102+
103+
expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
104+
});
105+
106+
it('should NOT apply a label when Gemini returns an invalid label', async () => {
107+
mockModel.generateContent.and.returnValue(
108+
Promise.resolve({
109+
response: {
110+
text: () => 'area: invalid',
111+
} as any,
112+
}),
113+
);
114+
115+
await issueLabeling.run();
116+
117+
expect(mockGit.issues.addLabels).not.toHaveBeenCalled();
118+
});
119+
120+
it('should initialize and run with manual instantiation check', () => {
121+
expect(issueLabeling).toBeDefined();
122+
expect(mockCore.getInput).not.toHaveBeenCalled(); // until run is called
123+
});
124+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as core from '@actions/core';
2+
import {context} from '@actions/github';
3+
import {IssueLabeling} from './issue-labeling.js';
4+
5+
// Only run if the action is executed in a repository within the Angular org.
6+
if (context.repo.owner === 'angular') {
7+
IssueLabeling.run().catch((e: Error) => {
8+
console.error(e);
9+
core.setFailed(e.message);
10+
});
11+
} else {
12+
core.warning(
13+
'Automatic labeling was skipped as this action is only meant to run ' +
14+
'in repos belonging to the Angular organization.',
15+
);
16+
}

0 commit comments

Comments
 (0)