Skip to content
Open
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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ github-actions/previews/pack-and-upload-artifact/inject-artifact-metadata.js
github-actions/previews/upload-artifacts-to-firebase/extract-artifact-metadata.js
github-actions/previews/upload-artifacts-to-firebase/fetch-workflow-artifact.js
github-actions/saucelabs/set-saucelabs-env.js
github-actions/issue-labeling/main.js
github-actions/slash-commands/main.js
github-actions/unified-status-check/main.js

Expand Down
63 changes: 63 additions & 0 deletions github-actions/issue-labeling/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
load("//tools:defaults.bzl", "esbuild_checked_in", "jasmine_test", "ts_project")

package(default_visibility = ["//github-actions/issue-labeling:__subpackages__"])

npm_link_all_packages()

ts_project(
name = "lib",
srcs = glob(
["lib/*.ts"],
exclude = ["lib/*.spec.ts"],
),
tsconfig = "//github-actions:tsconfig",
deps = [
":node_modules/@actions/core",
":node_modules/@actions/github",
":node_modules/@google/generative-ai",
":node_modules/@octokit/openapi-types",
":node_modules/@octokit/rest",
":node_modules/@types/node",
"//github-actions:utils",
],
)

ts_project(
name = "test_lib",
testonly = True,
srcs = glob(["lib/*.spec.ts"]),
tsconfig = "//github-actions:tsconfig_test",
deps = [
":lib",
":node_modules/@actions/core",
":node_modules/@actions/github",
":node_modules/@google/generative-ai",
":node_modules/@octokit/openapi-types",
":node_modules/@octokit/rest",
":node_modules/@types/jasmine",
":node_modules/@types/node",
"//github-actions:utils",
],
)

jasmine_test(
name = "test",
data = [
":test_lib",
],
env = {
"GITHUB_REPOSITORY": "angular/angular",
},
)

esbuild_checked_in(
name = "main",
srcs = [
":lib",
],
entry_point = "lib/main.ts",
format = "esm",
platform = "node",
target = "node22",
)
12 changes: 12 additions & 0 deletions github-actions/issue-labeling/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: 'Issue Labeling'
description: 'Automatically labels issues using Gemini based on their content'
inputs:
angular-robot-key:
description: 'The private key for the Angular Robot'
required: true
google-generative-ai-key:
description: 'The API key for Google Generative AI'
required: true
runs:
using: 'node22'
main: 'main.js'
128 changes: 128 additions & 0 deletions github-actions/issue-labeling/lib/issue-labeling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import * as core from '@actions/core';
import {context} from '@actions/github';
import {GoogleGenerativeAI} from '@google/generative-ai';
import {Octokit} from '@octokit/rest';
import {ANGULAR_ROBOT, getAuthTokenFor, revokeActiveInstallationToken} from '../../utils.js';
import {components} from '@octokit/openapi-types';

export class IssueLabeling {
static run = async () => {
const token = await getAuthTokenFor(ANGULAR_ROBOT);
const git = new Octokit({auth: token});
try {
const inst = new this(git, context, core);
await inst.run();
} finally {
await revokeActiveInstallationToken(git);
}
};

/** Set of area labels available in the current repository. */
repoAreaLabels = new Set<string>();
/** The issue data fetched from Github. */
issueData?: components['schemas']['issue'];

constructor(
private git: Octokit,
private githubContext: typeof context,
private coreService: typeof core,
Comment on lines +27 to +28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we providing these are parameters for the constructor instead of just using them from the global imports.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For testing purposes so we can override them in setup.

) {}

async run() {
const {issue} = this.githubContext;
if (!issue || !issue.number) {
this.coreService.info('No issue context found. Skipping.');
return;
}
this.coreService.info(`Issue #${issue.number}`);

// Initialize labels and issue data
await this.initialize();

const model = this.getGenerativeModel();

const prompt = `
You are a helper for an open source repository.
Your task is to allow the user to categorize the issue with an "area: " label.
The following is the issue title and body:

Title: ${this.issueData!.title}
Body:
${this.issueData!.body}

The available area labels are:
${Array.from(this.repoAreaLabels)
.map((label) => ` - ${label}`)
.join('\n')}

Based on the content, which area label is the best fit?
Respond ONLY with the exact label name (e.g. "area: core").
If you are strictly unsure or if multiple labels match equally well, respond with "ambiguous".
If no area label applies, respond with "none".
`;

try {
const result = await model.generateContent(prompt);
const response = result.response;
const text = response.text().trim();

this.coreService.info(`Gemini suggested label: ${text}`);

if (this.repoAreaLabels.has(text)) {
await this.addLabel(text);
} else {
this.coreService.info(
`Generated label "${text}" is not in the list of valid area labels or is "ambiguous"/"none".`,
);
}
} catch (e) {
this.coreService.error('Failed to generate content from Gemini.');
this.coreService.setFailed(e as Error);
}
}

getGenerativeModel() {
const apiKey = this.coreService.getInput('google-generative-ai-key', {required: true});
const genAI = new GoogleGenerativeAI(apiKey);
return genAI.getGenerativeModel({model: 'gemini-2.0-flash'});
}

async addLabel(label: string) {
const {number: issue_number, owner, repo} = this.githubContext.issue;
try {
await this.git.issues.addLabels({repo, owner, issue_number, labels: [label]});
this.coreService.info(`Added ${label} label to Issue #${issue_number}`);
} catch (err) {
this.coreService.error(`Failed to add ${label} label to Issue #${issue_number}`);
this.coreService.debug(err as string);
}
}

async initialize() {
const {owner, repo} = this.githubContext.issue;
await Promise.all([
this.git
.paginate(this.git.issues.listLabelsForRepo, {owner, repo})
.then((labels) =>
labels
.filter((l) => l.name.startsWith('area: '))
.forEach((l) => this.repoAreaLabels.add(l.name)),
),
this.git.issues
.get({owner, repo, issue_number: this.githubContext.issue.number})
.then((resp) => {
this.issueData = resp.data;
}),
]);

if (this.repoAreaLabels.size === 0) {
this.coreService.warning('No area labels found in the repository.');
return;
}

if (!this.issueData) {
this.coreService.error('Failed to fetch issue data.');
return;
}
}
}
134 changes: 134 additions & 0 deletions github-actions/issue-labeling/lib/main.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {Octokit} from '@octokit/rest';
import * as core from '@actions/core';
import {context} from '@actions/github';
import {GenerativeModel} from '@google/generative-ai';
import {IssueLabeling} from './issue-labeling.js';

describe('IssueLabeling', () => {
let mockGit: {
paginate: jasmine.Spy;
issues: {
listLabelsForRepo: jasmine.Spy;
addLabels: jasmine.Spy;
get: jasmine.Spy;
};
};
let mockModel: jasmine.SpyObj<GenerativeModel>;
let mockContext: typeof context;
let mockCore: jasmine.SpyObj<typeof core>;
let issueLabeling: IssueLabeling;

beforeEach(() => {
mockGit = {
paginate: jasmine.createSpy('paginate'),
issues: {
listLabelsForRepo: jasmine.createSpy('listLabelsForRepo'),
addLabels: jasmine.createSpy('addLabels'),
get: jasmine.createSpy('get'),
},
};

mockGit.issues.addLabels.and.returnValue(Promise.resolve({}));
mockGit.issues.get.and.returnValue(
Promise.resolve({
data: {
title: 'Tough Issue',
body: 'Complex Body',
},
}),
);
mockGit.paginate.and.callFake((fn: any, opts: any) => {
// Return value matching listLabelsForRepo signature
return Promise.resolve([{name: 'area: core'}, {name: 'area: router'}, {name: 'bug'}]);
});

mockModel = jasmine.createSpyObj<GenerativeModel>('GenerativeModel', ['generateContent']);

// Partially mock context as needed
mockContext = {
issue: {
number: 123,
owner: 'angular',
repo: 'angular',
},
repo: {
owner: 'angular',
repo: 'angular',
},
} as unknown as typeof context;

mockCore = jasmine.createSpyObj<typeof core>('core', [
'getInput',
'info',
'error',
'warning',
'debug',
'setFailed',
]);
mockCore.getInput.and.returnValue('mock-ai-key');

// We must cast the mock to Octokit because the mock only implements the subset used by the class.
// This is standard for mocking large interfaces like Octokit.
issueLabeling = new IssueLabeling(mockGit as unknown as Octokit, mockContext, mockCore);

spyOn(issueLabeling, 'getGenerativeModel').and.returnValue(mockModel);
});

it('should initialize labels correctly', async () => {
await issueLabeling.initialize();
expect(issueLabeling.repoAreaLabels.has('area: core')).toBe(true);
expect(issueLabeling.repoAreaLabels.has('area: router')).toBe(true);
expect(issueLabeling.repoAreaLabels.has('bug')).toBe(false);
});

it('should apply a label when Gemini is confident', async () => {
mockModel.generateContent.and.returnValue(
Promise.resolve({
response: {
text: () => 'area: core',
} as any, // Cast response structure as any because it's deeply nested and hard to construct manually
}),
);

await issueLabeling.run();

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

it('should NOT apply a label when Gemini returns "ambiguous"', async () => {
mockModel.generateContent.and.returnValue(
Promise.resolve({
response: {
text: () => 'ambiguous',
} as any,
}),
);

await issueLabeling.run();

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

it('should NOT apply a label when Gemini returns an invalid label', async () => {
mockModel.generateContent.and.returnValue(
Promise.resolve({
response: {
text: () => 'area: invalid',
} as any,
}),
);

await issueLabeling.run();

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

it('should initialize and run with manual instantiation check', () => {
expect(issueLabeling).toBeDefined();
expect(mockCore.getInput).not.toHaveBeenCalled(); // until run is called
});
});
16 changes: 16 additions & 0 deletions github-actions/issue-labeling/lib/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as core from '@actions/core';
import {context} from '@actions/github';
import {IssueLabeling} from './issue-labeling.js';

// Only run if the action is executed in a repository within the Angular org.
if (context.repo.owner === 'angular') {
IssueLabeling.run().catch((e: Error) => {
console.error(e);
core.setFailed(e.message);
});
} else {
core.warning(
'Automatic labeling was skipped as this action is only meant to run ' +
'in repos belonging to the Angular organization.',
);
}
Loading