-
Notifications
You must be signed in to change notification settings - Fork 14.2k
feat(caretaker): egress cloud run service skeleton #28167
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 all commits
764cfd5
0a7b9a2
45b88e0
ab43dd7
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,5 @@ | ||
| node_modules | ||
| dist | ||
| .env | ||
| *.log | ||
| .git |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| FROM node:20-slim | ||
| WORKDIR /app | ||
| COPY package*.json ./ | ||
| RUN npm ci | ||
| COPY . . | ||
| RUN npm run build | ||
| EXPOSE 8080 | ||
| CMD ["node", "dist/server.js"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "name": "egress-service", | ||
| "version": "1.0.0", | ||
| "description": "GitHub Egress Pub/Sub Cloud Run worker service", | ||
| "main": "dist/server.js", | ||
| "scripts": { | ||
| "dev": "tsx watch src/server.ts", | ||
| "build": "tsc", | ||
| "start": "node dist/server.js", | ||
| "test": "vitest run" | ||
| }, | ||
| "dependencies": { | ||
| "@octokit/auth-app": "^8.2.0", | ||
| "@octokit/rest": "^20.1.1", | ||
| "dotenv": "^16.4.5", | ||
| "express": "^4.19.2" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/express": "^4.17.21", | ||
| "@types/node": "^20.12.12", | ||
| "@types/supertest": "^6.0.3", | ||
| "supertest": "^7.1.4", | ||
| "tsx": "^4.9.3", | ||
| "typescript": "^5.4.5", | ||
| "vitest": "^1.6.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import request from 'supertest'; | ||
| import { app } from './app.js'; | ||
|
|
||
| /** | ||
| * Helper function simulating GCP Cloud Pub/Sub HTTP Push message wrapper. | ||
| * Encodes the payload object into Base64 format inside message.data. | ||
| */ | ||
| function createPubSubPushEnvelope(payload: unknown): { | ||
| message: { data: string }; | ||
| } { | ||
| const jsonString = | ||
| typeof payload === 'string' ? payload : JSON.stringify(payload); | ||
| const base64Data = Buffer.from(jsonString).toString('base64'); | ||
| return { message: { data: base64Data } }; | ||
| } | ||
|
|
||
| describe('Egress Service App Router', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('GET / should return 200 OK with structured health debug info', async () => { | ||
| const res = await request(app).get('/'); | ||
| expect(res.status).toBe(200); | ||
| expect(res.body).toEqual({ | ||
| status: 'healthy', | ||
| service: 'caretaker-egress-service', | ||
| revision: 'local', | ||
| }); | ||
| }); | ||
|
|
||
| it('POST / should return 400 if Pub/Sub envelope is invalid', async () => { | ||
| const res = await request(app).post('/').send('not a json object'); | ||
| expect(res.status).toBe(400); | ||
| }); | ||
|
|
||
| it('POST / should return 400 if message.data is missing', async () => { | ||
| const res = await request(app).post('/').send({ message: {} }); | ||
| expect(res.status).toBe(400); | ||
| expect(res.text).toBe('Missing message.data'); | ||
| }); | ||
|
|
||
| it('POST / should return 400 if message.data is invalid JSON', async () => { | ||
| const invalidEnvelope = createPubSubPushEnvelope('invalid-raw-json-string'); | ||
| const res = await request(app).post('/').send(invalidEnvelope); | ||
| expect(res.status).toBe(400); | ||
| expect(res.text).toBe('Malformed payload: invalid JSON'); | ||
| }); | ||
|
|
||
| it('POST / should return 400 if egress payload is missing required fields', async () => { | ||
| const incompleteEvent = { action: 'COMMENT', payload: { owner: 'google' } }; | ||
| const res = await request(app) | ||
| .post('/') | ||
| .send(createPubSubPushEnvelope(incompleteEvent)); | ||
| expect(res.status).toBe(400); | ||
| expect(res.text).toContain('Malformed payload'); | ||
| }); | ||
|
|
||
| it('POST / should trigger handleEgressEvent stub and return 200 for valid payloads', async () => { | ||
| const validEvent = { | ||
| action: 'COMMENT', | ||
| payload: { | ||
| owner: 'google-gemini', | ||
| repo: 'gemini-cli', | ||
| issueNumber: 100, | ||
| commentBody: 'Test comment', | ||
| }, | ||
| }; | ||
|
|
||
| const res = await request(app) | ||
| .post('/') | ||
| .send(createPubSubPushEnvelope(validEvent)); | ||
|
|
||
| expect(res.status).toBe(200); | ||
| expect(res.text).toBe('OK'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import express from 'express'; | ||
| import dotenv from 'dotenv'; | ||
| import { | ||
| isPubSubMessageEnvelope, | ||
| isEgressEvent, | ||
| type EgressEvent, | ||
| } from './types.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<void> { | ||
| 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()); | ||
|
|
||
| // Health check endpoint for Cloud Run liveness/readiness probes | ||
| app.get('/', (_req, res) => { | ||
| res.json({ | ||
| status: 'healthy', | ||
| service: process.env.K_SERVICE || 'caretaker-egress-service', | ||
| revision: process.env.K_REVISION || 'local', | ||
| }); | ||
| }); | ||
|
|
||
| // Pub/Sub push subscription endpoint | ||
| app.post('/', async (req, res) => { | ||
| if (!isPubSubMessageEnvelope(req.body)) { | ||
| return res.status(400).send('Invalid Pub/Sub message envelope'); | ||
| } | ||
|
|
||
| const data = req.body.message?.data; | ||
| if (!data) { | ||
| return res.status(400).send('Missing message.data'); | ||
| } | ||
|
|
||
| let event: unknown; | ||
| try { | ||
| const jsonStr = Buffer.from(data, 'base64').toString('utf-8'); | ||
| event = JSON.parse(jsonStr); | ||
| } catch { | ||
| return res.status(400).send('Malformed payload: invalid JSON'); | ||
| } | ||
|
|
||
| if (!isEgressEvent(event)) { | ||
| return res | ||
| .status(400) | ||
| .send('Malformed payload: missing or invalid required egress fields'); | ||
| } | ||
|
|
||
| try { | ||
| await handleEgressEvent(event); | ||
| console.log( | ||
| `[EGRESS] Successfully executed ${event.action} for ${event.payload.owner}/${event.payload.repo}#${event.payload.issueNumber}`, | ||
| ); | ||
| return res.status(200).send('OK'); | ||
| } catch (err) { | ||
| console.error('[EGRESS_ERROR] Error handling egress event execution:', err); | ||
| return res | ||
| .status(500) | ||
| .send(err instanceof Error ? err.message : 'Internal Server Error'); | ||
| } | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { app } from './app.js'; | ||
|
|
||
| const port = process.env.PORT || 8080; | ||
|
|
||
| app.listen(port, () => { | ||
| console.log(`Egress service listening on port ${port}`); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| export type EgressAction = 'COMMENT' | 'LABEL' | 'PATCH'; | ||
|
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. Are any of these contracts between the different jobs or service components? If so, can we make sure to not duplicate them? Ideally we put our contracts in a common place where all components can reference them.
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. That is a great point. This contract is shared between the Python Triage Worker ( |
||
|
|
||
| export interface EgressEventPayload { | ||
| owner: string; | ||
| repo: string; | ||
| issueNumber: number; | ||
| commentBody?: string; | ||
| labels?: string[]; | ||
| patchContent?: string; | ||
| branchName?: string; | ||
| } | ||
|
|
||
| export interface EgressEvent { | ||
| action: EgressAction; | ||
| payload: EgressEventPayload; | ||
| } | ||
|
|
||
| export interface PubSubMessage { | ||
| data?: string; | ||
| messageId?: string; | ||
| publishTime?: string; | ||
| attributes?: Record<string, string>; | ||
| } | ||
|
|
||
| /** | ||
| * Standard GCP Cloud Pub/Sub HTTP Push message wrapper envelope. | ||
| * | ||
| * @see https://cloud.google.com/pubsub/docs/push#delivery_format | ||
| */ | ||
| export interface PubSubMessageEnvelope { | ||
| message?: PubSubMessage; | ||
| subscription?: string; | ||
| } | ||
|
|
||
| function isObject(obj: unknown): obj is Record<string, unknown> { | ||
| return typeof obj === 'object' && obj !== null; | ||
| } | ||
|
|
||
| /** | ||
| * Type guard for PubSubMessageEnvelope to eliminate unsafe 'as' casts. | ||
| */ | ||
| export function isPubSubMessageEnvelope( | ||
| obj: unknown, | ||
| ): obj is PubSubMessageEnvelope { | ||
| if (!isObject(obj)) { | ||
| return false; | ||
| } | ||
| if ('message' in obj) { | ||
| if (obj.message !== undefined && !isObject(obj.message)) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Type guard for EgressEvent. | ||
| */ | ||
| export function isEgressEvent(obj: unknown): obj is EgressEvent { | ||
| if (!isObject(obj)) { | ||
| return false; | ||
| } | ||
| if ( | ||
| typeof obj.action !== 'string' || | ||
| !['COMMENT', 'LABEL', 'PATCH'].includes(obj.action) | ||
| ) { | ||
| return false; | ||
| } | ||
| if (!isObject(obj.payload)) { | ||
| return false; | ||
| } | ||
| const payload = obj.payload; | ||
| return ( | ||
| typeof payload.owner === 'string' && | ||
| typeof payload.repo === 'string' && | ||
| typeof payload.issueNumber === 'number' | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "module": "NodeNext", | ||
| "moduleResolution": "NodeNext", | ||
| "lib": ["ES2022"], | ||
| "outDir": "./dist", | ||
| "rootDir": "./src", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "forceConsistentCasingInFileNames": true | ||
| }, | ||
| "include": ["src/**/*.ts"], | ||
| "exclude": ["node_modules", "dist", "**/*.test.ts"] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.