-
Notifications
You must be signed in to change notification settings - Fork 61
feat(github-actions): Add action to let gemini label issues automatically #3430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
thePunderWoman
wants to merge
1
commit into
angular:main
Choose a base branch
from
thePunderWoman:gemini-issue-labeler
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) {} | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.', | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.