mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-04 07:07:16 -07:00
feat(caretaker): egress cloud run service skeleton (#28167)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
.git
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type EgressAction = 'COMMENT' | 'LABEL' | 'PATCH';
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user