Repository navigation
feat(caretaker-egress): implement octokit github action handler for egress service #28303
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
Changes from 2 commits
a3e2e46
dcb2fbc
5178842
18f12bb
c8bf116
ae5d7e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
|
|
||
| const mockCreateComment = vi.fn(); | ||
| const mockAddLabels = vi.fn(); | ||
|
|
||
| vi.mock('@octokit/rest', () => { | ||
| return { | ||
| Octokit: vi.fn().mockImplementation(() => ({ | ||
| rest: { | ||
| issues: { | ||
| createComment: mockCreateComment, | ||
| addLabels: mockAddLabels, | ||
| }, | ||
| }, | ||
| })), | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock('@octokit/auth-app', () => ({ | ||
| createAppAuth: vi.fn(), | ||
| })); | ||
|
|
||
| describe('GitHub Actions Handler', () => { | ||
| let handleEgressEvent: (typeof import('./github.js'))['handleEgressEvent']; | ||
|
|
||
| beforeEach(async () => { | ||
| vi.resetModules(); | ||
| vi.clearAllMocks(); | ||
| vi.stubEnv('GH_APP_ID', '12345'); | ||
| vi.stubEnv('GH_PRIVATE_KEY', 'test-key'); | ||
| vi.stubEnv('GH_INSTALLATION_ID', '67890'); | ||
| const mod = await import('./github.js'); | ||
| handleEgressEvent = mod.handleEgressEvent; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllEnvs(); | ||
| }); | ||
|
|
||
| it('should throw an error if environment variables are missing', async () => { | ||
| vi.stubEnv('GH_APP_ID', ''); | ||
| await expect( | ||
| handleEgressEvent({ | ||
| action: 'COMMENT', | ||
| payload: { owner: 'o', repo: 'r', issueNumber: 1, commentBody: 'hi' }, | ||
| }), | ||
| ).rejects.toThrow(/Missing required environment variable: GH_APP_ID/); | ||
| }); | ||
|
|
||
| it('should throw an error if commentBody is empty or whitespace only', async () => { | ||
| await expect( | ||
| handleEgressEvent({ | ||
| action: 'COMMENT', | ||
| payload: { owner: 'o', repo: 'r', issueNumber: 1, commentBody: ' ' }, | ||
| }), | ||
| ).rejects.toThrow(/Missing or empty commentBody/); | ||
| }); | ||
|
|
||
| it('should call createComment for COMMENT action', async () => { | ||
| mockCreateComment.mockResolvedValueOnce({}); | ||
| await handleEgressEvent({ | ||
| action: 'COMMENT', | ||
| payload: { | ||
| owner: 'google', | ||
| repo: 'cli', | ||
| issueNumber: 10, | ||
| commentBody: 'Hello world', | ||
| }, | ||
| }); | ||
|
|
||
| expect(mockCreateComment).toHaveBeenCalledWith({ | ||
| owner: 'google', | ||
| repo: 'cli', | ||
| issue_number: 10, | ||
| body: 'Hello world', | ||
| }); | ||
| }); | ||
|
|
||
| it('should call addLabels for LABEL action', async () => { | ||
| mockAddLabels.mockResolvedValueOnce({}); | ||
| await handleEgressEvent({ | ||
| action: 'LABEL', | ||
| payload: { | ||
| owner: 'google', | ||
| repo: 'cli', | ||
| issueNumber: 10, | ||
| labels: ['effort/small'], | ||
| }, | ||
| }); | ||
|
|
||
| expect(mockAddLabels).toHaveBeenCalledWith({ | ||
| owner: 'google', | ||
| repo: 'cli', | ||
| issue_number: 10, | ||
| labels: ['effort/small'], | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { Octokit } from '@octokit/rest'; | ||
| import { createAppAuth } from '@octokit/auth-app'; | ||
| import { EgressEvent } from '../types.js'; | ||
|
|
||
| function getRequiredEnvVar(name: string): string { | ||
| const value = process.env[name]; | ||
| if (!value) { | ||
| throw new Error(`Missing required environment variable: ${name}`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| let cachedOctokit: Octokit | null = null; | ||
|
|
||
| function getOctokit(): Octokit { | ||
| if (!cachedOctokit) { | ||
| const appId = getRequiredEnvVar('GH_APP_ID'); | ||
| const privateKey = getRequiredEnvVar('GH_PRIVATE_KEY'); | ||
| const installationId = getRequiredEnvVar('GH_INSTALLATION_ID'); | ||
|
|
||
| cachedOctokit = new Octokit({ | ||
| authStrategy: createAppAuth, | ||
| auth: { | ||
| appId: Number(appId), | ||
| privateKey: privateKey.replace(/\\n/g, '\n'), | ||
| installationId: Number(installationId), | ||
| }, | ||
| }); | ||
| } | ||
| return cachedOctokit; | ||
| } | ||
|
|
||
| export async function handleEgressEvent(event: EgressEvent): Promise<void> { | ||
| const octokit = getOctokit(); | ||
| const { action, payload } = event; | ||
| const { owner, repo, issueNumber } = payload; | ||
|
|
||
| switch (action) { | ||
| case 'COMMENT': | ||
| if (!payload.commentBody || payload.commentBody.trim() === '') { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor nit: I notice that you have action as a discriminated union of strings and then separately have a payload with the union of all of the supported properties. TypeScript has this nifty feature where you can instead do a discriminated union of the request type. Something like: type CommentAction = {
action: 'comment';
owner: string;
repo: string;
issueNumber: number;
commentBody?: string;
labels?: string[];
}
type LabelAction = {
action: 'comment';
owner: string;
repo: string;
issueNumber: number;
labels?: string[];
}
type EgressAction = CommentAction | LabelAction;The nice thing about this is that when you check for |
||
| throw new Error('Missing or empty commentBody for COMMENT action'); | ||
| } | ||
| console.log( | ||
| `[EGRESS_GITHUB] Posting comment to ${owner}/${repo}#${issueNumber}...`, | ||
| ); | ||
| await octokit.rest.issues.createComment({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In my prototype, we enforce that the agent can only comment on issues in which it was mentioned or its own issues or PRs, to avoid the possibility of it pinging random contributors. Do we do that here?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Per the design, the Egress Service acts purely as a stateless execution worker (the 'Hands'). Upstream event filtering (e.g. evaluating newly created issues for I've also added inline documentation above Let me know what you think about this or if we should implement filtering in |
||
| owner, | ||
| repo, | ||
| issue_number: issueNumber, | ||
| body: payload.commentBody, | ||
| }); | ||
| break; | ||
|
|
||
| case 'LABEL': | ||
| if (!payload.labels || !Array.isArray(payload.labels)) { | ||
| throw new Error('Missing or invalid labels array for LABEL action'); | ||
| } | ||
| console.log( | ||
| `[EGRESS_GITHUB] Adding labels [${payload.labels.join(', ')}] to ${owner}/${repo}#${issueNumber}...`, | ||
| ); | ||
| await octokit.rest.issues.addLabels({ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we have or need the ability to remove labels? e.g.: I think untriaged items start with a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not currently used in my initial implementation, but it's definitely good to have as we build out the full workflow. I've added support for the |
||
| owner, | ||
| repo, | ||
| issue_number: issueNumber, | ||
| labels: payload.labels, | ||
| }); | ||
| break; | ||
|
|
||
| case 'PATCH': | ||
| console.log('[EGRESS] Patching action triggered (not yet implemented).'); | ||
| break; | ||
|
chadd28 marked this conversation as resolved.
Outdated
|
||
|
|
||
| default: | ||
| console.log(`[EGRESS] Unknown action: ${action}`); | ||
| break; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.