From a3e2e460dea1ab4aafdf3e16f2c76c5c34430828 Mon Sep 17 00:00:00 2001 From: Chad Diao Date: Mon, 6 Jul 2026 10:33:58 -0700 Subject: [PATCH 1/6] feat(caretaker-egress): implement octokit github action handler for egress service --- .../egress-service/src/actions/github.test.ts | 104 ++++++++++++++++++ .../egress-service/src/actions/github.ts | 83 ++++++++++++++ .../cloudrun/egress-service/src/app.test.ts | 9 +- .../cloudrun/egress-service/src/app.ts | 19 +--- 4 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts create mode 100644 tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts new file mode 100644 index 00000000000..b0891abc576 --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts @@ -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'], + }); + }); +}); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts new file mode 100644 index 00000000000..b0c728d540d --- /dev/null +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts @@ -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 { + const octokit = getOctokit(); + const { action, payload } = event; + const { owner, repo, issueNumber } = payload; + + switch (action) { + case 'COMMENT': + if (!payload.commentBody || payload.commentBody.trim() === '') { + 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({ + 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({ + owner, + repo, + issue_number: issueNumber, + labels: payload.labels, + }); + break; + + case 'PATCH': + console.log('[EGRESS] Patching action triggered (not yet implemented).'); + break; + + default: + console.log(`[EGRESS] Unknown action: ${action}`); + break; + } +} diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts index a680c7b2a1c..d88a9549205 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts @@ -6,7 +6,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import request from 'supertest'; + +vi.mock('./actions/github.js', () => ({ + handleEgressEvent: vi.fn(), +})); + import { app } from './app.js'; +import { handleEgressEvent } from './actions/github.js'; /** * Helper function simulating GCP Cloud Pub/Sub HTTP Push message wrapper. @@ -63,7 +69,7 @@ describe('Egress Service App Router', () => { expect(res.text).toContain('Malformed payload'); }); - it('POST / should trigger handleEgressEvent stub and return 200 for valid payloads', async () => { + it('POST / should trigger handleEgressEvent handler and return 200 for valid payloads', async () => { const validEvent = { action: 'COMMENT', payload: { @@ -80,5 +86,6 @@ describe('Egress Service App Router', () => { expect(res.status).toBe(200); expect(res.text).toBe('OK'); + expect(handleEgressEvent).toHaveBeenCalledWith(validEvent); }); }); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/app.ts b/tools/caretaker-agent/cloudrun/egress-service/src/app.ts index b7b3a045498..636ef4b6722 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/app.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/app.ts @@ -6,26 +6,11 @@ import express from 'express'; import dotenv from 'dotenv'; -import { - isPubSubMessageEnvelope, - isEgressEvent, - type EgressEvent, -} from './types.js'; +import { isPubSubMessageEnvelope, isEgressEvent } from './types.js'; +import { handleEgressEvent } from './actions/github.js'; dotenv.config(); -/** - * Top-down stub handler for Egress events. - * Octokit GitHub REST API integration will be added in a follow-up PR. - * - * @param event - The validated EgressEvent object decoded from Pub/Sub push envelope. - */ -export async function handleEgressEvent(event: EgressEvent): Promise { - console.log( - `[EGRESS_STUB] Received ${event.action} event for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`, - ); -} - export const app = express(); app.use(express.json()); From dcb2fbc74be2703bd4e2ea9dae2cc24e674f427b Mon Sep 17 00:00:00 2001 From: Chad Diao Date: Tue, 7 Jul 2026 09:24:07 -0700 Subject: [PATCH 2/6] test(caretaker-egress): add HTTP 500 error handling test case --- .../cloudrun/egress-service/src/app.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts index d88a9549205..4e8b3cb8569 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts @@ -88,4 +88,32 @@ describe('Egress Service App Router', () => { expect(res.text).toBe('OK'); expect(handleEgressEvent).toHaveBeenCalledWith(validEvent); }); + + it('POST / should return 500 if handleEgressEvent fails', async () => { + const validEvent = { + action: 'LABEL', + payload: { + owner: 'google-gemini', + repo: 'gemini-cli', + issueNumber: 42, + labels: ['bug'], + }, + }; + + vi.mocked(handleEgressEvent).mockRejectedValueOnce( + new Error('GitHub API Error'), + ); + + // Suppress console.error during expected failure test + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const res = await request(app) + .post('/') + .send(createPubSubPushEnvelope(validEvent)); + + expect(res.status).toBe(500); + expect(res.text).toBe('GitHub API Error'); + + consoleSpy.mockRestore(); + }); }); From 5178842dcee335949dc93a057eecba2b4ac3715b Mon Sep 17 00:00:00 2001 From: Chad Diao Date: Tue, 7 Jul 2026 09:52:53 -0700 Subject: [PATCH 3/6] fix(caretaker-egress): address code review feedback on action errors, target repo env validation, and IAM OIDC docs --- .../egress-service/src/actions/github.test.ts | 67 +++++++++++++++---- .../egress-service/src/actions/github.ts | 21 ++++-- .../cloudrun/egress-service/src/app.ts | 6 +- 3 files changed, 73 insertions(+), 21 deletions(-) diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts index b0891abc576..fd0db7cf1db 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts @@ -9,8 +9,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const mockCreateComment = vi.fn(); const mockAddLabels = vi.fn(); -vi.mock('@octokit/rest', () => { - return { +vi.mock('@octokit/rest', () => ({ Octokit: vi.fn().mockImplementation(() => ({ rest: { issues: { @@ -19,8 +18,7 @@ vi.mock('@octokit/rest', () => { }, }, })), - }; -}); + })); vi.mock('@octokit/auth-app', () => ({ createAppAuth: vi.fn(), @@ -35,6 +33,8 @@ describe('GitHub Actions Handler', () => { vi.stubEnv('GH_APP_ID', '12345'); vi.stubEnv('GH_PRIVATE_KEY', 'test-key'); vi.stubEnv('GH_INSTALLATION_ID', '67890'); + vi.stubEnv('ALLOWED_OWNER', 'google-gemini'); + vi.stubEnv('ALLOWED_REPO', 'gemini-cli'); const mod = await import('./github.js'); handleEgressEvent = mod.handleEgressEvent; }); @@ -43,12 +43,33 @@ describe('GitHub Actions Handler', () => { vi.unstubAllEnvs(); }); + it('should throw an error for unauthorized repository target', async () => { + await expect( + handleEgressEvent({ + action: 'COMMENT', + payload: { + owner: 'unauthorized-org', + repo: 'other-repo', + issueNumber: 1, + commentBody: 'hi', + }, + }), + ).rejects.toThrow( + /Unauthorized repository target: unauthorized-org\/other-repo/, + ); + }); + 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' }, + payload: { + owner: 'google-gemini', + repo: 'gemini-cli', + issueNumber: 1, + commentBody: 'hi', + }, }), ).rejects.toThrow(/Missing required environment variable: GH_APP_ID/); }); @@ -57,7 +78,12 @@ describe('GitHub Actions Handler', () => { await expect( handleEgressEvent({ action: 'COMMENT', - payload: { owner: 'o', repo: 'r', issueNumber: 1, commentBody: ' ' }, + payload: { + owner: 'google-gemini', + repo: 'gemini-cli', + issueNumber: 1, + commentBody: ' ', + }, }), ).rejects.toThrow(/Missing or empty commentBody/); }); @@ -67,16 +93,16 @@ describe('GitHub Actions Handler', () => { await handleEgressEvent({ action: 'COMMENT', payload: { - owner: 'google', - repo: 'cli', + owner: 'google-gemini', + repo: 'gemini-cli', issueNumber: 10, commentBody: 'Hello world', }, }); expect(mockCreateComment).toHaveBeenCalledWith({ - owner: 'google', - repo: 'cli', + owner: 'google-gemini', + repo: 'gemini-cli', issue_number: 10, body: 'Hello world', }); @@ -87,18 +113,31 @@ describe('GitHub Actions Handler', () => { await handleEgressEvent({ action: 'LABEL', payload: { - owner: 'google', - repo: 'cli', + owner: 'google-gemini', + repo: 'gemini-cli', issueNumber: 10, labels: ['effort/small'], }, }); expect(mockAddLabels).toHaveBeenCalledWith({ - owner: 'google', - repo: 'cli', + owner: 'google-gemini', + repo: 'gemini-cli', issue_number: 10, labels: ['effort/small'], }); }); + + it('should throw an error for unsupported PATCH action', async () => { + await expect( + handleEgressEvent({ + action: 'PATCH', + payload: { + owner: 'google-gemini', + repo: 'gemini-cli', + issueNumber: 1, + }, + }), + ).rejects.toThrow(/PATCH action is not yet implemented/); + }); }); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts index b0c728d540d..598d734241b 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts @@ -6,7 +6,7 @@ import { Octokit } from '@octokit/rest'; import { createAppAuth } from '@octokit/auth-app'; -import { EgressEvent } from '../types.js'; +import type { EgressEvent } from '../types.js'; function getRequiredEnvVar(name: string): string { const value = process.env[name]; @@ -37,10 +37,21 @@ function getOctokit(): Octokit { } export async function handleEgressEvent(event: EgressEvent): Promise { - const octokit = getOctokit(); const { action, payload } = event; const { owner, repo, issueNumber } = payload; + const allowedOwner = getRequiredEnvVar('ALLOWED_OWNER'); + const allowedRepo = getRequiredEnvVar('ALLOWED_REPO'); + + if ( + owner.toLowerCase() !== allowedOwner.toLowerCase() || + repo.toLowerCase() !== allowedRepo.toLowerCase() + ) { + throw new Error(`Unauthorized repository target: ${owner}/${repo}`); + } + + const octokit = getOctokit(); + switch (action) { case 'COMMENT': if (!payload.commentBody || payload.commentBody.trim() === '') { @@ -73,11 +84,9 @@ export async function handleEgressEvent(event: EgressEvent): Promise { break; case 'PATCH': - console.log('[EGRESS] Patching action triggered (not yet implemented).'); - break; + throw new Error('PATCH action is not yet implemented'); default: - console.log(`[EGRESS] Unknown action: ${action}`); - break; + throw new Error(`Unknown or unsupported egress action: ${action}`); } } diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/app.ts b/tools/caretaker-agent/cloudrun/egress-service/src/app.ts index 636ef4b6722..3b33bac6c8f 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/app.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/app.ts @@ -23,7 +23,11 @@ app.get('/', (_req, res) => { }); }); -// Pub/Sub push subscription endpoint +/** + * Pub/Sub push subscription endpoint. + * Note: Authentication is enforced by GCP Cloud Run IAM (`roles/run.invoker`) + * using GCP-managed OIDC bearer tokens on the Pub/Sub push subscription. + */ app.post('/', async (req, res) => { if (!isPubSubMessageEnvelope(req.body)) { return res.status(400).send('Invalid Pub/Sub message envelope'); From 18f12bbaaf08c3eb003b3da3f2ed8d3f538eb8c7 Mon Sep 17 00:00:00 2001 From: Chad Diao Date: Tue, 7 Jul 2026 14:31:13 -0700 Subject: [PATCH 4/6] style(caretaker-egress): format github.test.ts with prettier --- .../egress-service/src/actions/github.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts index fd0db7cf1db..e6f040da2e1 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts @@ -10,15 +10,15 @@ const mockCreateComment = vi.fn(); const mockAddLabels = vi.fn(); vi.mock('@octokit/rest', () => ({ - Octokit: vi.fn().mockImplementation(() => ({ - rest: { - issues: { - createComment: mockCreateComment, - addLabels: mockAddLabels, - }, + Octokit: vi.fn().mockImplementation(() => ({ + rest: { + issues: { + createComment: mockCreateComment, + addLabels: mockAddLabels, }, - })), - })); + }, + })), +})); vi.mock('@octokit/auth-app', () => ({ createAppAuth: vi.fn(), From c8bf116f1dab8822afe82e030a75fbb4622586dc Mon Sep 17 00:00:00 2001 From: Chad Diao Date: Tue, 7 Jul 2026 16:21:29 -0700 Subject: [PATCH 5/6] feat(caretaker-egress): add UNLABEL action handler and document upstream event filtering --- .../egress-service/src/actions/github.test.ts | 22 +++++++++++++++++++ .../egress-service/src/actions/github.ts | 21 ++++++++++++++++++ .../cloudrun/egress-service/src/types.ts | 4 ++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts index e6f040da2e1..d97ea32885b 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const mockCreateComment = vi.fn(); const mockAddLabels = vi.fn(); +const mockRemoveLabel = vi.fn(); vi.mock('@octokit/rest', () => ({ Octokit: vi.fn().mockImplementation(() => ({ @@ -15,6 +16,7 @@ vi.mock('@octokit/rest', () => ({ issues: { createComment: mockCreateComment, addLabels: mockAddLabels, + removeLabel: mockRemoveLabel, }, }, })), @@ -128,6 +130,26 @@ describe('GitHub Actions Handler', () => { }); }); + it('should call removeLabel for UNLABEL action', async () => { + mockRemoveLabel.mockResolvedValueOnce({}); + await handleEgressEvent({ + action: 'UNLABEL', + payload: { + owner: 'google-gemini', + repo: 'gemini-cli', + issueNumber: 10, + labels: ['need-triage'], + }, + }); + + expect(mockRemoveLabel).toHaveBeenCalledWith({ + owner: 'google-gemini', + repo: 'gemini-cli', + issue_number: 10, + name: 'need-triage', + }); + }); + it('should throw an error for unsupported PATCH action', async () => { await expect( handleEgressEvent({ diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts index 598d734241b..ff833a3d976 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/actions/github.ts @@ -53,6 +53,10 @@ export async function handleEgressEvent(event: EgressEvent): Promise { const octokit = getOctokit(); switch (action) { + // Note: The Egress Service operates as a stateless execution worker ("Hands"). + // Upstream event filtering (e.g. evaluating newly created issues for NEEDS_INFO + // or verifying bot mention/author criteria) is performed in the Triage Worker + // before publishing action payloads to the egress-actions topic. case 'COMMENT': if (!payload.commentBody || payload.commentBody.trim() === '') { throw new Error('Missing or empty commentBody for COMMENT action'); @@ -83,6 +87,23 @@ export async function handleEgressEvent(event: EgressEvent): Promise { }); break; + case 'UNLABEL': + if (!payload.labels || !Array.isArray(payload.labels)) { + throw new Error('Missing or invalid labels array for UNLABEL action'); + } + console.log( + `[EGRESS_GITHUB] Removing labels [${payload.labels.join(', ')}] from ${owner}/${repo}#${issueNumber}...`, + ); + for (const name of payload.labels) { + await octokit.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name, + }); + } + break; + case 'PATCH': throw new Error('PATCH action is not yet implemented'); diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/types.ts b/tools/caretaker-agent/cloudrun/egress-service/src/types.ts index bdac528e42f..9cf15d6b3d3 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/types.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -export type EgressAction = 'COMMENT' | 'LABEL' | 'PATCH'; +export type EgressAction = 'COMMENT' | 'LABEL' | 'UNLABEL' | 'PATCH'; export interface EgressEventPayload { owner: string; @@ -68,7 +68,7 @@ export function isEgressEvent(obj: unknown): obj is EgressEvent { } if ( typeof obj.action !== 'string' || - !['COMMENT', 'LABEL', 'PATCH'].includes(obj.action) + !['COMMENT', 'LABEL', 'UNLABEL', 'PATCH'].includes(obj.action) ) { return false; } From ae5d7e471336c2c5cc4acec51203338d850171a9 Mon Sep 17 00:00:00 2001 From: Chad Diao Date: Tue, 7 Jul 2026 17:14:36 -0700 Subject: [PATCH 6/6] refactor(caretaker-egress): use discriminated union for EgressEvent and document isEgressEvent --- .../cloudrun/egress-service/src/types.ts | 80 ++++++++++++++----- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/tools/caretaker-agent/cloudrun/egress-service/src/types.ts b/tools/caretaker-agent/cloudrun/egress-service/src/types.ts index 9cf15d6b3d3..db567218173 100644 --- a/tools/caretaker-agent/cloudrun/egress-service/src/types.ts +++ b/tools/caretaker-agent/cloudrun/egress-service/src/types.ts @@ -4,23 +4,47 @@ * SPDX-License-Identifier: Apache-2.0 */ -export type EgressAction = 'COMMENT' | 'LABEL' | 'UNLABEL' | 'PATCH'; - -export interface EgressEventPayload { +export interface BaseEgressPayload { owner: string; repo: string; issueNumber: number; - commentBody?: string; - labels?: string[]; - patchContent?: string; - branchName?: string; } -export interface EgressEvent { - action: EgressAction; - payload: EgressEventPayload; +export interface CommentEgressEvent { + action: 'COMMENT'; + payload: BaseEgressPayload & { + commentBody: string; + }; +} + +export interface LabelEgressEvent { + action: 'LABEL'; + payload: BaseEgressPayload & { + labels: string[]; + }; +} + +export interface UnlabelEgressEvent { + action: 'UNLABEL'; + payload: BaseEgressPayload & { + labels: string[]; + }; } +export interface PatchEgressEvent { + action: 'PATCH'; + payload: BaseEgressPayload & { + patchContent?: string; + branchName?: string; + }; +} + +export type EgressEvent = + | CommentEgressEvent + | LabelEgressEvent + | UnlabelEgressEvent + | PatchEgressEvent; + export interface PubSubMessage { data?: string; messageId?: string; @@ -63,22 +87,36 @@ export function isPubSubMessageEnvelope( * Type guard for EgressEvent. */ export function isEgressEvent(obj: unknown): obj is EgressEvent { - if (!isObject(obj)) { - return false; - } if ( + !isObject(obj) || typeof obj.action !== 'string' || - !['COMMENT', 'LABEL', 'UNLABEL', 'PATCH'].includes(obj.action) + !isObject(obj.payload) ) { return false; } - if (!isObject(obj.payload)) { + + // Validate base target repository properties required for all actions + const payload = obj.payload; + if ( + typeof payload.owner !== 'string' || + typeof payload.repo !== 'string' || + typeof payload.issueNumber !== 'number' + ) { return false; } - const payload = obj.payload; - return ( - typeof payload.owner === 'string' && - typeof payload.repo === 'string' && - typeof payload.issueNumber === 'number' - ); + + // Validate action-specific payload requirements for discriminated union + switch (obj.action) { + case 'COMMENT': + return typeof payload.commentBody === 'string'; + case 'LABEL': + case 'UNLABEL': + return Array.isArray(payload.labels); + case 'PATCH': + // Note: PATCH action is not yet implemented in handleEgressEvent, so return true + // to let base validation pass until patch payload fields are defined. + return true; + default: + return false; + } }