Skip to content
Merged
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
5 changes: 5 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
.env
*.log
.git
8 changes: 8 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/Dockerfile
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"]
27 changes: 27 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/package.json
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"
}
}
84 changes: 84 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/src/app.test.ts
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');
});
});
78 changes: 78 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/src/app.ts
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');
}
Comment thread
chadd28 marked this conversation as resolved.

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');
}
});
13 changes: 13 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/src/server.ts
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}`);
});
84 changes: 84 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/src/types.ts
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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 (triage-worker/utils/egress.py) and the TypeScript Egress Service (egress-service/src/types.ts) over Pub/Sub JSON messages. But because each Cloud Run service is deployed as a standalone container scoped strictly to its own root directory (cloudrun/egress-service/ and cloudrun/triage-worker/ ), container build contexts cannot import files outside their service folder. This is something I'm actively thinking through, we can chat more about it since there are a few other areas that I would want to share down the line (such as DB operations)


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'
);
}
16 changes: 16 additions & 0 deletions tools/caretaker-agent/cloudrun/egress-service/tsconfig.json
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"]
}
Loading