From cf42251f3ddc42fbe268b85193cec5fc2928dc30 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:18:29 -0700 Subject: [PATCH] feat: route signed GitHub activity into bound pods Add a flag-guarded GitHub App webhook with raw-body signature validation, explicit repository-to-pod binding, permanent delivery deduplication, and closed-catalog activity cards. Complete a matching task when its linked PR merges. Keep provider prose outside agent-visible content pending ruling. Written by Sprint Impl, a Commonly agent Co-authored-by: sprint-impl Agent-Model: codex/gpt-6-sol/medium --- .../rateLimitIpKeySeparation.test.js | 23 +- .../unit/routes/github.webhook.test.js | 196 ++++++++++++++++++ .../integrations.githubGigBinding.test.js | 79 +++++++ .../unit/services/githubGigService.test.js | 79 +++++++ backend/models/GithubGigBinding.ts | 25 +++ backend/models/GithubGigDelivery.ts | 24 +++ backend/models/Task.ts | 4 + backend/routes/integrations.ts | 44 ++++ backend/routes/webhooks/github.ts | 95 +++++++++ backend/server.ts | 5 + backend/services/githubGigService.ts | 116 +++++++++++ backend/services/installable/eventHandlers.ts | 4 + .../task-311-github-gig-preview-1440.png | Bin 0 -> 21970 bytes .../task-311-github-gig-preview-390.png | Bin 0 -> 17933 bytes docs/integrations/github-activity-gig.md | 76 +++++++ .../preview/github-gig-card.html | 36 ++++ frontend/src/v2/__tests__/V2GigCard.test.tsx | 36 ++++ .../v2/__tests__/v2-layout-invariants.test.ts | 12 ++ frontend/src/v2/components/V2GigCard.tsx | 48 +++++ frontend/src/v2/components/V2MessageRow.tsx | 4 + frontend/src/v2/hooks/useV2PodDetail.ts | 1 + frontend/src/v2/v2.css | 24 +++ .../templates/core/backend-deployment.yaml | 9 + .../templates/ops/ops-runner-deployment.yaml | 9 + .../commonly/templates/secrets/api-keys.yaml | 6 + k8s/helm/commonly/values.yaml | 3 + 26 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/unit/routes/github.webhook.test.js create mode 100644 backend/__tests__/unit/routes/integrations.githubGigBinding.test.js create mode 100644 backend/__tests__/unit/services/githubGigService.test.js create mode 100644 backend/models/GithubGigBinding.ts create mode 100644 backend/models/GithubGigDelivery.ts create mode 100644 backend/routes/webhooks/github.ts create mode 100644 backend/services/githubGigService.ts create mode 100644 docs/design/evidence/task-311-github-gig-preview-1440.png create mode 100644 docs/design/evidence/task-311-github-gig-preview-390.png create mode 100644 docs/integrations/github-activity-gig.md create mode 100644 frontend/design-system/preview/github-gig-card.html create mode 100644 frontend/src/v2/__tests__/V2GigCard.test.tsx create mode 100644 frontend/src/v2/components/V2GigCard.tsx diff --git a/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js b/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js index 8d97613b4f..471b8e0839 100644 --- a/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js +++ b/backend/__tests__/unit/middleware/rateLimitIpKeySeparation.test.js @@ -101,6 +101,9 @@ const walked = []; const seen = new Set(); const remainingOf = (res) => { + // The stub reads the limiter's request state, so a route can suppress public + // RateLimit headers without becoming invisible to this census. + if (res.headers['x-probe-remaining'] !== undefined) return Number(res.headers['x-probe-remaining']); if (res.headers['ratelimit-remaining'] !== undefined) return Number(res.headers['ratelimit-remaining']); const combined = res.headers.ratelimit; const m = combined && String(combined).match(/remaining=(\d+)/); @@ -109,7 +112,12 @@ const remainingOf = (res) => { const single = (instance) => { const stub = express(); - stub.all('/', instance, (_req, res) => res.status(204).end()); + stub.all('/', instance, (req, res) => { + if (typeof req.rateLimit?.remaining === 'number') { + res.set('x-probe-remaining', String(req.rateLimit.remaining)); + } + res.status(204).end(); + }); return stub; }; @@ -205,6 +213,19 @@ describe('TASK-126 ip key separation', () => { expect(rec.r3).toBe(rec.r1); }); + it('reads a counted limiter even when public RateLimit headers are disabled', async () => { + const silent = rateLimit({ + windowMs: 60000, + max: 5, + standardHeaders: false, + legacyHeaders: false, + keyGenerator: (req) => cloudflareIpRateLimitKeyGenerator(req), + }); + const rec = await observe(silent, 252); + expect(rec.r2).toBe(rec.r1 - 1); + expect(rec.r3).toBe(rec.r1); + }); + // What this measures (wren 73671, ruling (b)), not what it might be read as: // a request with no credential, no param and no body — built from nothing and // carrying only `cf-connecting-ip` — is bucketed per `cf-connecting-ip` on diff --git a/backend/__tests__/unit/routes/github.webhook.test.js b/backend/__tests__/unit/routes/github.webhook.test.js new file mode 100644 index 0000000000..1bf144e7d9 --- /dev/null +++ b/backend/__tests__/unit/routes/github.webhook.test.js @@ -0,0 +1,196 @@ +const crypto = require('crypto'); +const express = require('express'); +const request = require('supertest'); + +const mockBinding = jest.fn(); +const mockCreateReceipt = jest.fn(); +const mockDeleteReceipt = jest.fn(); +const mockHandle = jest.fn(); +jest.mock('../../../services/githubGigService', () => ({ + githubLocation: jest.requireActual('../../../services/githubGigService').githubLocation, + boundPodForGithubEvent: (...args) => mockBinding(...args), +})); +jest.mock('../../../models/GithubGigDelivery', () => ({ + create: (...args) => mockCreateReceipt(...args), + deleteOne: (...args) => mockDeleteReceipt(...args), +})); +jest.mock('../../../services/installable/eventHandlers', () => ({ + eventHandlers: { 'github.gig': (...args) => mockHandle(...args) }, +})); + +const route = require('../../../routes/webhooks/github'); +const app = express(); +app.use('/api/webhooks/github', route.githubGigRawBody); +app.use('/api/webhooks/github', route.githubGigRawError); +app.use(express.json()); +app.use('/api/webhooks/github', route); + +const payload = { + action: 'opened', installation: { id: 123 }, + repository: { owner: { login: 'Team-Commonly' }, name: 'commonly' }, + pull_request: { number: 42, title: 'External text' }, +}; +const deliver = (body, overrides = {}, target = app) => { + const signature = `sha256=${crypto.createHmac('sha256', 'secret').update(body).digest('hex')}`; + return request(target).post('/api/webhooks/github') + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .set('X-GitHub-Delivery', '123e4567-e89b-12d3-a456-426614174000') + .set(overrides) + .send(body); +}; + +beforeEach(() => { + process.env.GITHUB_GIG_WEBHOOK_SECRET = 'secret'; + process.env.GITHUB_GIG_ENABLED = 'true'; + mockBinding.mockReset().mockResolvedValue(null); + mockCreateReceipt.mockReset().mockResolvedValue({}); + mockDeleteReceipt.mockReset().mockResolvedValue({}); + mockHandle.mockReset().mockResolvedValue(undefined); +}); +afterAll(() => { + delete process.env.GITHUB_GIG_WEBHOOK_SECRET; + delete process.env.GITHUB_GIG_ENABLED; +}); + +test('missing and invalid signatures return 401 before parsing or lookup', async () => { + const missing = await request(app).post('/api/webhooks/github') + .set('Content-Type', 'application/json').send('{bad json'); + const invalid = await deliver('{bad json', { 'X-Hub-Signature-256': 'sha256=0000' }); + expect(missing.status).toBe(401); + expect(invalid.status).toBe(401); + expect(missing.text).toBe(''); + expect(mockBinding).not.toHaveBeenCalled(); + expect(mockCreateReceipt).not.toHaveBeenCalled(); +}); + +test('a well-formed signature made with another secret returns 401 before lookup', async () => { + const body = JSON.stringify(payload); + const wrongDigest = crypto.createHmac('sha256', 'another-secret').update(body).digest('hex'); + const response = await deliver(body, { 'X-Hub-Signature-256': `sha256=${wrongDigest}` }); + expect(response.status).toBe(401); + expect(response.text).toBe(''); + expect(mockBinding).not.toHaveBeenCalled(); + expect(mockCreateReceipt).not.toHaveBeenCalled(); +}); + +test('a signed delivery is silent when the gig is disabled', async () => { + process.env.GITHUB_GIG_ENABLED = 'false'; + const response = await deliver(JSON.stringify(payload)); + expect(response.status).toBe(204); + expect(mockBinding).not.toHaveBeenCalled(); + expect(mockCreateReceipt).not.toHaveBeenCalled(); + expect(mockHandle).not.toHaveBeenCalled(); +}); + +test('signature covers the raw bytes, including whitespace', async () => { + const body = JSON.stringify(payload, null, 2); + const response = await deliver(body); + expect(response.status).toBe(204); + expect(mockBinding).toHaveBeenCalledWith({ + installationId: '123', owner: 'Team-Commonly', repo: 'commonly', number: '42', + }); + expect(mockCreateReceipt).not.toHaveBeenCalled(); +}); + +test('an unbound App-wide delivery is silent and does not write a receipt', async () => { + const response = await deliver(JSON.stringify(payload)); + expect(response.status).toBe(204); + expect(mockBinding).toHaveBeenCalledTimes(1); + expect(mockCreateReceipt).not.toHaveBeenCalled(); + expect(mockHandle).not.toHaveBeenCalled(); +}); + +test('a bound delivery is claimed once and replay does not post twice', async () => { + mockBinding.mockResolvedValue('aaaaaaaaaaaaaaaaaaaaaaaa'); + const seenHashes = new Set(); + mockCreateReceipt.mockImplementation(async ({ bodyHash }) => { + if (seenHashes.has(bodyHash)) throw Object.assign(new Error('duplicate body'), { code: 11000 }); + seenHashes.add(bodyHash); + }); + const first = await deliver(JSON.stringify(payload)); + await new Promise((resolve) => setImmediate(resolve)); + const replay = await deliver(JSON.stringify(payload), { 'X-GitHub-Delivery': '123e4567-e89b-12d3-a456-426614174001' }); + expect(first.status).toBe(204); + expect(replay.status).toBe(204); + expect(mockCreateReceipt).toHaveBeenCalledTimes(2); + expect(mockCreateReceipt.mock.calls[0][0].bodyHash).toBe(crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex')); + expect(mockCreateReceipt.mock.calls[1][0].bodyHash).toBe(mockCreateReceipt.mock.calls[0][0].bodyHash); + expect(mockHandle).toHaveBeenCalledTimes(1); +}); + +test('a handler failure releases the receipt and returns 503 for retry', async () => { + mockBinding.mockResolvedValue('aaaaaaaaaaaaaaaaaaaaaaaa'); + mockHandle.mockRejectedValueOnce(new Error('outsider payload secret')).mockResolvedValueOnce(undefined); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const first = await deliver(JSON.stringify(payload)); + expect(first.status).toBe(503); + expect(first.text).toBe(''); + expect(mockDeleteReceipt).toHaveBeenCalledWith({ deliveryId: '123e4567-e89b-12d3-a456-426614174000' }); + expect(JSON.stringify(warn.mock.calls)).not.toContain('outsider payload secret'); + const retry = await deliver(JSON.stringify(payload)); + expect(retry.status).toBe(204); + expect(mockHandle).toHaveBeenCalledTimes(2); + } finally { warn.mockRestore(); } +}); + +test('the production raw parser accepts a large signed delivery and returns an empty 413 above its ceiling', async () => { + const large = JSON.stringify({ ...payload, padding: 'x'.repeat(200 * 1024) }); + expect((await deliver(large)).status).toBe(204); + const tooLarge = await request(app).post('/api/webhooks/github') + .set('Content-Type', 'application/json').send('x'.repeat(26 * 1024 * 1024)); + expect(tooLarge.status).toBe(413); + expect(tooLarge.text).toBe(''); + expect(mockBinding).toHaveBeenCalledTimes(1); +}); + +test('the source bucket counts invalid signatures but still returns empty 401 on overflow', async () => { + const limitedApp = express(); + limitedApp.use('/api/webhooks/github', route.githubGigRawBody); + limitedApp.use('/api/webhooks/github', route.githubGigRawError); + limitedApp.use('/api/webhooks/github', route.createGithubGigRateLimit(2)); + limitedApp.use('/api/webhooks/github', route); + const bad = { 'X-Hub-Signature-256': 'sha256=0000' }; + const first = await deliver(JSON.stringify(payload), bad, limitedApp); + const second = await deliver(JSON.stringify(payload), bad, limitedApp); + const third = await deliver(JSON.stringify(payload), bad, limitedApp); + const valid = await deliver(JSON.stringify(payload), {}, limitedApp); + expect([first, second, third].map((res) => [res.status, res.text])).toEqual([[401, ''], [401, ''], [401, '']]); + expect([first, second, third].map((res) => res.headers['ratelimit-limit'])).toEqual([undefined, undefined, undefined]); + expect(valid.status).toBe(429); + expect(mockBinding).not.toHaveBeenCalled(); +}); + +test('the production route mounts its source limiter once', () => { + const post = route.stack.find((layer) => layer.route?.path === '/'); + expect(post.route.stack).toHaveLength(3); +}); + +test('checks remain off until checks:read is granted', async () => { + const check = { ...payload, action: 'completed', check_run: { conclusion: 'failure', pull_requests: [{ number: 42 }] } }; + const response = await deliver(JSON.stringify(check), { 'X-GitHub-Event': 'check_run' }); + expect(response.status).toBe(204); + expect(mockBinding).not.toHaveBeenCalled(); +}); + +test.each([ + ['pull_request_review', 'submitted', { pull_request: { number: 42 }, review: { state: 'approved', body: 'outsider' } }], + ['issues', 'opened', { issue: { number: 42, title: 'outsider' } }], + ['issues', 'closed', { issue: { number: 42, body: 'outsider' } }], +])('%s %s routes only when bound', async (eventType, action, fields) => { + mockBinding.mockResolvedValue('aaaaaaaaaaaaaaaaaaaaaaaa'); + const body = JSON.stringify({ + action, installation: payload.installation, repository: payload.repository, ...fields, + }); + const response = await deliver(body, { + 'X-GitHub-Event': eventType, + 'X-GitHub-Delivery': `123e4567-e89b-12d3-a456-42661417400${action === 'closed' ? 1 : eventType === 'issues' ? 2 : 3}`, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(response.status).toBe(204); + expect(mockBinding).toHaveBeenCalledTimes(1); + expect(mockCreateReceipt).toHaveBeenCalledTimes(1); + expect(mockHandle).toHaveBeenCalledWith(expect.objectContaining({ eventType })); +}); diff --git a/backend/__tests__/unit/routes/integrations.githubGigBinding.test.js b/backend/__tests__/unit/routes/integrations.githubGigBinding.test.js new file mode 100644 index 0000000000..73fbb82828 --- /dev/null +++ b/backend/__tests__/unit/routes/integrations.githubGigBinding.test.js @@ -0,0 +1,79 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); + +jest.mock('../../../middleware/auth', () => (req, _res, next) => { req.user = { id: 'bbbbbbbbbbbbbbbbbbbbbbbb' }; next(); }); +jest.mock('../../../middleware/adminAuth', () => (req, res, next) => ( + req.get('x-admin') === 'yes' ? next() : res.status(403).end() +)); + +let mongod; +let app; +let Pod; +let Integration; +let GithubGigBinding; +let GithubGigDelivery; +const podId = new mongoose.Types.ObjectId(); +const adminId = new mongoose.Types.ObjectId('bbbbbbbbbbbbbbbbbbbbbbbb'); + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + Pod = require('../../../models/Pod'); + Integration = require('../../../models/Integration'); + GithubGigBinding = require('../../../models/GithubGigBinding'); + GithubGigDelivery = require('../../../models/GithubGigDelivery'); + app = express(); + app.use(express.json()); + app.use('/api/integrations', require('../../../routes/integrations')); + await Pod.create({ _id: podId, name: 'Build', createdBy: adminId, members: [adminId] }); +}); +afterAll(async () => { await mongoose.disconnect(); if (mongod) await mongod.stop(); }); + +test('only an admin can bind and unbind a connected App repository to a real pod', async () => { + const row = await Integration.create({ + installationId: '7654321', type: 'github-app', scope: 'user', status: 'connected', + config: { installationId: '7654321', owner: 'Team-Commonly', repo: 'commonly' }, + createdBy: adminId, isActive: true, + }); + const path = `/api/integrations/github-app/${row._id}/gig-pod`; + const denied = await request(app).put(path).send({ podId: String(podId) }); + expect(denied.status).toBe(403); + expect(await GithubGigBinding.countDocuments()).toBe(0); + + const missing = await request(app).put(path).set('x-admin', 'yes').send({ podId: String(new mongoose.Types.ObjectId()) }); + expect(missing.status).toBe(404); + const dm = await Pod.create({ name: 'DM', type: 'agent-dm', createdBy: adminId, members: [adminId] }); + const dmBinding = await request(app).put(path).set('x-admin', 'yes').send({ podId: String(dm._id) }); + expect(dmBinding.status).toBe(400); + const adminPod = await Pod.create({ name: 'Agent admin', type: 'agent-admin', createdBy: adminId, members: [adminId] }); + const adminPodBinding = await request(app).put(path).set('x-admin', 'yes').send({ podId: String(adminPod._id) }); + expect(adminPodBinding.status).toBe(400); + const otherUser = new mongoose.Types.ObjectId(); + const otherPod = await Pod.create({ name: 'Private chat', type: 'chat', createdBy: otherUser, members: [otherUser] }); + const nonMemberBinding = await request(app).put(path).set('x-admin', 'yes').send({ podId: String(otherPod._id) }); + expect(nonMemberBinding.status).toBe(403); + expect(await GithubGigBinding.countDocuments()).toBe(0); + const bound = await request(app).put(path).set('x-admin', 'yes').send({ podId: String(podId) }); + expect(bound.status).toBe(200); + expect(String((await GithubGigBinding.findOne({ installationId: '7654321' })).podId)).toBe(String(podId)); + + const secondRepo = await request(app).put(path).set('x-admin', 'yes') + .send({ podId: String(podId), owner: 'TEAM-COMMONLY', repo: 'ANOTHER-REPO' }); + expect(secondRepo.status).toBe(200); + expect(await GithubGigBinding.countDocuments({ installationId: '7654321' })).toBe(2); + expect(await GithubGigBinding.countDocuments({ installationId: '7654321', owner: 'team-commonly', repo: 'commonly' })).toBe(1); + expect(await GithubGigBinding.countDocuments({ installationId: '7654321', owner: 'team-commonly', repo: 'another-repo' })).toBe(1); + + const unbound = await request(app).delete(path).set('x-admin', 'yes'); + expect(unbound.status).toBe(204); + expect(await GithubGigBinding.countDocuments()).toBe(1); +}); + +test('a new delivery id cannot replay the same signed body', async () => { + await GithubGigDelivery.init(); + const bodyHash = 'a'.repeat(64); + await GithubGigDelivery.create({ deliveryId: 'delivery-1', bodyHash }); + await expect(GithubGigDelivery.create({ deliveryId: 'delivery-2', bodyHash })).rejects.toMatchObject({ code: 11000 }); +}); diff --git a/backend/__tests__/unit/services/githubGigService.test.js b/backend/__tests__/unit/services/githubGigService.test.js new file mode 100644 index 0000000000..5cf6bb5bba --- /dev/null +++ b/backend/__tests__/unit/services/githubGigService.test.js @@ -0,0 +1,79 @@ +const mockIntegrationFind = jest.fn(); +const mockTaskUpdate = jest.fn(); +const mockPost = jest.fn(); +const mockEmit = jest.fn(); +jest.mock('../../../models/GithubGigBinding', () => ({ findOne: (...args) => mockIntegrationFind(...args) })); +jest.mock('../../../models/Task', () => ({ findOneAndUpdate: (...args) => mockTaskUpdate(...args) })); +jest.mock('../../../services/agentIdentityService', () => ({ getOrCreateAgentUser: jest.fn().mockResolvedValue({ _id: 'bot' }) })); +jest.mock('../../../services/agentMessageService', () => ({ _postToTarget: (...args) => mockPost(...args) })); +jest.mock('../../../services/taskEventService', () => ({ emitTaskUpdated: (...args) => mockEmit(...args) })); + +const { + githubLocation, boundPodForGithubEvent, buildGithubGigCard, + completeTaskForMergedPull, handleGithubGig, +} = require('../../../services/githubGigService'); + +const podId = 'aaaaaaaaaaaaaaaaaaaaaaaa'; +const location = { installationId: '7654321', owner: 'Team-Commonly', repo: 'commonly', number: '42' }; +const payload = { + action: 'closed', installation: { id: 7654321 }, + repository: { owner: { login: 'Team-Commonly' }, name: 'commonly' }, + pull_request: { number: 42, merged: true, title: '' }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockPost.mockResolvedValue({ message: { id: 'm1' } }); + mockTaskUpdate.mockResolvedValue(null); +}); + +test('accepts only structural repository coordinates and numeric ids', () => { + expect(githubLocation(payload)).toEqual(location); + expect(githubLocation({ ...payload, repository: { owner: { login: 'github.com.evil' }, name: 'repo/x' } })).toBeNull(); + expect(githubLocation({ ...payload, pull_request: { number: '1/../../admin' } })).toBeNull(); +}); + +test('one indexed connection lookup selects an explicitly bound pod', async () => { + mockIntegrationFind.mockReturnValue({ select: () => ({ lean: async () => ({ podId }) }) }); + expect(await boundPodForGithubEvent({ ...location, repo: 'COMMONLY' })).toBe(podId); + expect(mockIntegrationFind).toHaveBeenCalledWith(expect.objectContaining({ + installationId: location.installationId, owner: 'team-commonly', repo: 'commonly', + })); +}); + +test('card excludes outsider text and builds a canonical GitHub URL', () => { + const card = buildGithubGigCard('pull_request', payload, location); + expect(JSON.stringify(card)).not.toContain('outsider'); + expect(card.elements[2].href).toBe('https://github.com/Team-Commonly/commonly/pull/42'); + expect(card.elements[0].children[0].text).toBe('Merged'); +}); + +test('a merged PR completes only its matching open task and emits a board update', async () => { + const task = { taskId: 'TASK-11', status: 'done' }; + mockTaskUpdate.mockResolvedValue(task); + const href = 'https://github.com/Team-Commonly/commonly/pull/42'; + await completeTaskForMergedPull(podId, href); + const [filter, update] = mockTaskUpdate.mock.calls[0]; + expect(String(filter.podId)).toBe(podId); + expect(filter.prUrl).toBe(href); + expect(filter.status).toEqual({ $in: ['pending', 'claimed'] }); + expect(update.$set.status).toBe('done'); + expect(mockEmit).toHaveBeenCalledWith(podId, task, 'updated'); +}); + +test('posts a display card while agent-visible content stays fixed', async () => { + await handleGithubGig({ eventType: 'pull_request', payload, podId, location }); + const posted = mockPost.mock.calls[0][0]; + expect(posted.content).toBe('[GitHub activity]'); + expect(posted.payload.kind).toBe('gig-card'); + expect(posted.noOutboundRelay).toBe(true); + expect(JSON.stringify(posted.payload)).not.toContain('outsider'); + expect(mockTaskUpdate).toHaveBeenCalledTimes(1); + expect(mockTaskUpdate.mock.invocationCallOrder[0]).toBeLessThan(mockPost.mock.invocationCallOrder[0]); +}); + +test('a task write failure prevents the card post, allowing a retry', async () => { + mockTaskUpdate.mockRejectedValueOnce(new Error('temporary task write failure')); + await expect(handleGithubGig({ eventType: 'pull_request', payload, podId, location })).rejects.toThrow('temporary'); + expect(mockPost).not.toHaveBeenCalled(); +}); diff --git a/backend/models/GithubGigBinding.ts b/backend/models/GithubGigBinding.ts new file mode 100644 index 0000000000..0ca534a728 --- /dev/null +++ b/backend/models/GithubGigBinding.ts @@ -0,0 +1,25 @@ +import mongoose, { Document, Model, Schema, Types } from 'mongoose'; + +/** Explicit opt-in for one App installation/repository to post in one pod. */ +export interface IGithubGigBinding extends Document { + installationId: string; + owner: string; + repo: string; + podId: Types.ObjectId; + createdBy: Types.ObjectId; +} + +const GithubGigBindingSchema = new Schema({ + installationId: { type: String, required: true }, + owner: { type: String, required: true }, + repo: { type: String, required: true }, + podId: { type: Schema.Types.ObjectId, ref: 'Pod', required: true }, + createdBy: { type: Schema.Types.ObjectId, ref: 'User', required: true }, +}, { timestamps: true }); + +GithubGigBindingSchema.index({ installationId: 1, owner: 1, repo: 1 }, { unique: true }); + +export const GithubGigBinding: Model = mongoose.model('GithubGigBinding', GithubGigBindingSchema); +export default GithubGigBinding; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/backend/models/GithubGigDelivery.ts b/backend/models/GithubGigDelivery.ts new file mode 100644 index 0000000000..0674a2eef1 --- /dev/null +++ b/backend/models/GithubGigDelivery.ts @@ -0,0 +1,24 @@ +import mongoose, { Document, Model, Schema } from 'mongoose'; + +/** Permanent replay receipt: manual GitHub redelivery has no 24-hour limit. */ +export interface IGithubGigDelivery extends Document { + deliveryId: string; + bodyHash: string; + createdAt: Date; +} + +const GithubGigDeliverySchema = new Schema({ + deliveryId: { type: String, required: true, unique: true }, + bodyHash: { type: String, required: true }, +}, { timestamps: { createdAt: true, updatedAt: false } }); + +// Older receipts may predate body hashing. Index only new receipts so an +// upgrade does not collide on their missing bodyHash field. +GithubGigDeliverySchema.index({ bodyHash: 1 }, { + unique: true, partialFilterExpression: { bodyHash: { $type: 'string' } }, +}); + +export const GithubGigDelivery: Model = mongoose.model('GithubGigDelivery', GithubGigDeliverySchema); +export default GithubGigDelivery; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/backend/models/Task.ts b/backend/models/Task.ts index de34039dc4..f205ba7115 100644 --- a/backend/models/Task.ts +++ b/backend/models/Task.ts @@ -87,6 +87,10 @@ const TaskSchema = new Schema( TaskSchema.index({ podId: 1, status: 1 }); TaskSchema.index({ podId: 1, assignee: 1, status: 1 }); +TaskSchema.index( + { podId: 1, prUrl: 1, status: 1 }, + { partialFilterExpression: { prUrl: { $type: 'string' } } }, +); TaskSchema.index({ podId: 1, taskId: 1 }, { unique: true }); // Partial, NOT sparse: a compound sparse index still indexes any doc where // podId is present (i.e. all of them), so the second no-sourceRef task in a diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index 4766a66ff8..a5fb63f368 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -13,6 +13,8 @@ const hostedMcpConnectRoutes = require('./hostedMcpConnect'); // eslint-disable-next-line global-require const Integration = require('../models/Integration'); // eslint-disable-next-line global-require +const GithubGigBinding = require('../models/GithubGigBinding'); +// eslint-disable-next-line global-require const DiscordIntegration = require('../models/DiscordIntegration'); // eslint-disable-next-line global-require const DiscordService = require('../services/discordService'); @@ -267,6 +269,48 @@ router.post('/github-app', writeIntegrationsRateLimit, auth, adminAuth, async (r } }); +// Explicit admin bind for the App-wide activity webhook. Connection creation +// alone never opts a repository into a pod or agent-visible activity. +router.put('/github-app/:id/gig-pod', writeIntegrationsRateLimit, auth, adminAuth, async (req: AuthReq, res: Res) => { + const podId = String(req.body?.podId || ''); + const integrationId = String(req.params?.id || ''); + if (!Types.ObjectId.isValid(podId)) return res.status(400).json({ message: 'Valid podId required' }); + if (!Types.ObjectId.isValid(integrationId)) return res.status(404).json({ message: 'GitHub App connection not found' }); + const integration = await Integration.findOne({ _id: integrationId, type: 'github-app', isActive: true, status: 'connected' }).lean(); + if (!integration) return res.status(404).json({ message: 'GitHub App connection not found' }); + const owner = String(req.body?.owner || integration.config?.owner || '').toLowerCase(); + const repo = String(req.body?.repo || integration.config?.repo || '').toLowerCase(); + if (!/^[A-Za-z0-9_.-]{1,100}$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo)) { + return res.status(400).json({ message: 'Valid owner and repo required' }); + } + const pod = await Pod.findById(new Types.ObjectId(podId)).select('type members').lean(); + if (!pod) return res.status(404).json({ message: 'Pod not found' }); + if (pod.type === 'agent-room' || pod.type === 'agent-dm' || pod.type === 'agent-admin') { + return res.status(400).json({ message: 'GitHub activity requires a shared pod' }); + } + if (!pod.members?.some((member: unknown) => String(member) === String(req.user?.id))) { + return res.status(403).json({ message: 'Only pod members can bind GitHub activity' }); + } + const binding = await GithubGigBinding.findOneAndUpdate( + { installationId: String(integration.installationId), owner, repo }, + { $set: { podId: new Types.ObjectId(podId) }, $setOnInsert: { createdBy: new Types.ObjectId(String(req.user?.id)) } }, + { new: true, upsert: true }, + ); + return res.json({ binding }); +}); + +router.delete('/github-app/:id/gig-pod', writeIntegrationsRateLimit, auth, adminAuth, async (req: AuthReq, res: Res) => { + const integrationId = String(req.params?.id || ''); + if (!Types.ObjectId.isValid(integrationId)) return res.status(404).json({ message: 'GitHub App connection not found' }); + const integration = await Integration.findOne({ _id: integrationId, type: 'github-app' }).lean(); + if (!integration) return res.status(404).json({ message: 'GitHub App connection not found' }); + const owner = String(req.body?.owner || integration.config?.owner || '').toLowerCase(); + const repo = String(req.body?.repo || integration.config?.repo || '').toLowerCase(); + if (!/^[A-Za-z0-9_.-]{1,100}$/.test(owner) || !/^[A-Za-z0-9_.-]{1,100}$/.test(repo)) return res.status(400).json({ message: 'Valid owner and repo required' }); + await GithubGigBinding.deleteOne({ installationId: String(integration.installationId), owner, repo }); + return res.status(204).json({}); +}); + router.post('/ingest', ingestAuth, async (req: AuthReq, res: Res) => { try { const { provider, integrationId, event, messages } = (req.body || {}) as { provider?: string; integrationId?: string; event?: unknown; messages?: unknown[] }; diff --git a/backend/routes/webhooks/github.ts b/backend/routes/webhooks/github.ts new file mode 100644 index 0000000000..6580d99e00 --- /dev/null +++ b/backend/routes/webhooks/github.ts @@ -0,0 +1,95 @@ +import { createHash, createHmac, timingSafeEqual } from 'crypto'; +import express from 'express'; +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const rateLimit = require('express-rate-limit'); +import { boundPodForGithubEvent, githubLocation } from '../../services/githubGigService'; +import GithubGigDelivery from '../../models/GithubGigDelivery'; +import { eventHandlers } from '../../services/installable/eventHandlers'; +import { cloudflareIpRateLimitKeyGenerator } from '../../middleware/ipRateLimit'; + +const router = express.Router(); +const DELIVERY_ID = /^[0-9a-fA-F-]{20,80}$/; +export const githubGigRawBody = express.raw({ type: 'application/json', limit: '25mb' }); +export const githubGigRawError = (error: any, _req: express.Request, res: express.Response, next: express.NextFunction) => { + if (error?.type === 'entity.too.large') { res.status(413).end(); return; } + next(error); +}; + +export const verifyGithubGigSignature = (body: unknown, signature: unknown, secret: unknown): boolean => { + if (!Buffer.isBuffer(body) || typeof secret !== 'string' || !secret || typeof signature !== 'string') return false; + if (!/^sha256=[a-fA-F0-9]{64}$/.test(signature)) return false; + const expected = createHmac('sha256', secret).update(body).digest(); + const given = Buffer.from(signature.slice(7), 'hex'); + return given.length === expected.length && timingSafeEqual(given, expected); +}; + +export const createGithubGigRateLimit = (max = 3_000) => rateLimit({ + windowMs: 60_000, + max, + standardHeaders: false, + legacyHeaders: false, + keyGenerator: (req: express.Request) => `github-gig:${cloudflareIpRateLimitKeyGenerator(req)}`, + handler: (req: express.Request, res: express.Response) => { + // Count bad signatures too, while preserving their empty 401 on overflow. + const signed = verifyGithubGigSignature(req.body, req.get('X-Hub-Signature-256'), process.env.GITHUB_GIG_WEBHOOK_SECRET); + res.status(signed ? 429 : 401).end(); + }, +}); +const githubGigRateLimit = createGithubGigRateLimit(); + +router.post('/', githubGigRateLimit, (req, res, next) => { + // Verify on raw bytes before parsing, looking up a binding, or claiming a + // delivery. Signature failures have one indistinguishable response. + if (!verifyGithubGigSignature(req.body, req.get('X-Hub-Signature-256'), process.env.GITHUB_GIG_WEBHOOK_SECRET)) { + res.status(401).end(); + return; + } + next(); +}, async (req, res) => { + if (process.env.GITHUB_GIG_ENABLED !== 'true') return res.status(204).end(); + let payload: any; + try { payload = JSON.parse((req.body as Buffer).toString('utf8')); } catch { return res.status(400).end(); } + const eventType = String(req.get('X-GitHub-Event') || ''); + const action = String(payload?.action || ''); + const supported = (eventType === 'pull_request' + && (action === 'opened' || (action === 'closed' && payload?.pull_request?.merged === true))) + || (eventType === 'pull_request_review' && action === 'submitted') + || (eventType === 'issues' && (action === 'opened' || action === 'closed')) + || (eventType === 'check_run' && action === 'completed' && payload?.check_run?.conclusion === 'failure' + && process.env.GITHUB_GIG_CHECKS_READ_ENABLED === 'true'); + if (!supported) return res.status(204).end(); + const location = githubLocation(payload); + if (!location) return res.status(204).end(); + let podId: string | null; + try { podId = await boundPodForGithubEvent(location); } catch { return res.status(503).end(); } + // App-wide deliveries from unbound repositories do not create even a + // deduplication row. The one connection lookup is indexed by installationId. + if (!podId) return res.status(204).end(); + const deliveryId = String(req.get('X-GitHub-Delivery') || ''); + if (!DELIVERY_ID.test(deliveryId)) return res.status(400).end(); + // The delivery header is outside the HMAC; a replay with a new id must + // still meet the same permanent receipt. The digest is of signed raw bytes. + const bodyHash = createHash('sha256').update(req.body as Buffer).digest('hex'); + try { + await GithubGigDelivery.create({ deliveryId, bodyHash }); + } catch (error) { + if ((error as { code?: number }).code === 11000) return res.status(204).end(); + return res.status(503).end(); + } + try { + // Finish before acknowledging. On failure, release the receipt so GitHub + // can retry; only completed work keeps the permanent replay key. + await eventHandlers['github.gig']({ eventType, payload, podId, location } as any); + return res.status(204).end(); + } catch { + console.warn('[github-gig] bound delivery processing failed', { deliveryId, eventType, podId }); + try { await GithubGigDelivery.deleteOne({ deliveryId }); } catch { + console.warn('[github-gig] receipt release failed', { deliveryId, eventType, podId }); + } + return res.status(503).end(); + } +}); + +export default router; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/backend/server.ts b/backend/server.ts index 5e2f1cc94f..ee40cc4402 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -26,6 +26,7 @@ const discordWebhookRoutes = require('./routes/webhooks/discord'); const slackWebhookRoutes = require('./routes/webhooks/slack'); const groupMeWebhookRoutes = require('./routes/webhooks/groupme'); const telegramWebhookRoutes = require('./routes/webhooks/telegram'); +const githubGigWebhookRoutes = require('./routes/webhooks/github'); const discordRoutes = require('./routes/discord'); const githubRoutes = require('./routes/github'); const analyticsRoutes = require('./routes/analytics'); @@ -222,6 +223,9 @@ app.use( ); app.use('/api/webhooks/groupme', express.json()); app.use('/api/webhooks/telegram', express.json()); +// GitHub signs the exact bytes. This parser must precede global JSON parsing. +app.use('/api/webhooks/github', githubGigWebhookRoutes.githubGigRawBody); +app.use('/api/webhooks/github', githubGigWebhookRoutes.githubGigRawError); // JSON parsing for all other routes app.use(express.json()); @@ -261,6 +265,7 @@ app.use('/api/webhooks/discord', discordWebhookRoutes); app.use('/api/webhooks/slack', slackWebhookRoutes); app.use('/api/webhooks/groupme', groupMeWebhookRoutes); app.use('/api/webhooks/telegram', telegramWebhookRoutes); +app.use('/api/webhooks/github', githubGigWebhookRoutes); app.use('/api/discord', discordRoutes); app.use('/api/github', githubRoutes); app.use('/api/analytics', analyticsRoutes); diff --git a/backend/services/githubGigService.ts b/backend/services/githubGigService.ts new file mode 100644 index 0000000000..81acf1a556 --- /dev/null +++ b/backend/services/githubGigService.ts @@ -0,0 +1,116 @@ +/** GitHub App activity projected into an explicitly bound pod. */ +import { Types } from 'mongoose'; +import Task from '../models/Task'; +import GithubGigBinding from '../models/GithubGigBinding'; + +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const AgentIdentityService = require('./agentIdentityService'); +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const AgentMessageService = require('./agentMessageService'); +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const { emitTaskUpdated } = require('./taskEventService'); + +type Element = + | { type: 'Text'; text: string } + | { type: 'Badge'; text: string } + | { type: 'KeyValue'; label: string; value: string } + | { type: 'Link'; text: string; href: string } + | { type: 'List'; rows: string[] } + | { type: 'Stack'; children: Element[] }; + +const SLUG = /^[A-Za-z0-9_.-]{1,100}$/; +const NUMBER = /^[1-9][0-9]{0,8}$/; + +export const githubLocation = (payload: any): { + installationId: string; owner: string; repo: string; number: string; +} | null => { + const installationId = String(payload?.installation?.id ?? ''); + const owner = String(payload?.repository?.owner?.login ?? ''); + const repo = String(payload?.repository?.name ?? ''); + const number = String(payload?.pull_request?.number ?? payload?.issue?.number ?? payload?.check_run?.pull_requests?.[0]?.number ?? ''); + if (!NUMBER.test(installationId) || !SLUG.test(owner) || !SLUG.test(repo) || !NUMBER.test(number)) return null; + return { installationId, owner, repo, number }; +}; + +export const boundPodForGithubEvent = async (location: ReturnType): Promise => { + if (!location) return null; + // One compound indexed read. A tool connection can cover one repository, + // while the App webhook can deliver many; activity subscriptions are their + // own explicit pod binding and do not change broker grant ownership. + const row = await GithubGigBinding.findOne({ + installationId: location.installationId, owner: location.owner.toLowerCase(), repo: location.repo.toLowerCase(), + }).select('podId').lean(); + const podId = row?.podId; + return Types.ObjectId.isValid(String(podId || '')) ? String(podId) : null; +}; + +export const buildGithubGigCard = (eventType: string, payload: any, location: NonNullable>) => { + const { owner, repo, number } = location; + const issueEvent = eventType === 'issues'; + const pull = payload.pull_request; + const review = payload.review; + const merged = eventType === 'pull_request' && payload.action === 'closed' && pull?.merged === true; + const eventLabel = merged ? 'Merged' : eventType === 'pull_request_review' ? 'Review submitted' + : eventType === 'check_run' ? 'Check failed' + : issueEvent ? (payload.action === 'closed' ? 'Issue closed' : 'Issue opened') : 'PR opened'; + const href = `https://github.com/${owner}/${repo}/${issueEvent ? 'issues' : 'pull'}/${number}`; + const elements: Element[] = [ + { type: 'Stack', children: [ + { type: 'Badge', text: eventLabel }, + // Until Wren rules on outsider text, the card names the event and id; + // it never copies a PR title, issue body, or review body from GitHub. + { type: 'Text', text: `${issueEvent ? 'Issue' : 'Pull request'} #${number}` }, + ] }, + { type: 'KeyValue', label: 'Repository', value: `${owner}/${repo}` }, + { type: 'Link', text: `Open ${issueEvent ? 'issue' : 'pull request'} #${number}`, href }, + ]; + if (review?.state) { + const state = String(review.state).toLowerCase(); + const label = ({ approved: 'Approved', changes_requested: 'Changes requested', commented: 'Commented', dismissed: 'Dismissed' } as Record)[state]; + if (label) elements.push({ type: 'KeyValue', label: 'Review', value: label }); + } + if (eventType === 'check_run') elements.push({ type: 'KeyValue', label: 'Check', value: 'Failure' }); + const card = { kind: 'gig-card', source: 'github', elements }; + // A closed catalog and a hard size bound keep the payload a small display + // object. Freeform descriptions/bodies never enter it or the context text. + if (Buffer.byteLength(JSON.stringify(card), 'utf8') > 32 * 1024) return null; + return card; +}; + +export const completeTaskForMergedPull = async (podId: string, href: string): Promise => { + const at = new Date(); + const task = await Task.findOneAndUpdate( + { podId: new Types.ObjectId(podId), prUrl: href, status: { $in: ['pending', 'claimed'] } }, + { $set: { status: 'done', completedAt: at }, $push: { updates: { + text: 'Completed automatically — linked pull request merged', author: 'system', authorId: null, createdAt: at, + } } }, + { new: true }, + ); + if (task) emitTaskUpdated(podId, task, 'updated'); +}; + +export const handleGithubGig = async ({ eventType, payload, podId, location }: { + eventType: string; payload: any; podId: string; location: NonNullable>; +}): Promise => { + if (!location || !podId) return; + const card = buildGithubGigCard(eventType, payload, location); + if (!card) return; + if (eventType === 'pull_request' && payload.action === 'closed' && payload.pull_request?.merged === true) { + // Complete first: the update is idempotent on status, so a later card + // failure can be retried without re-completing the task. + await completeTaskForMergedPull(podId, `https://github.com/${location.owner}/${location.repo}/pull/${location.number}`); + } + const bot = await AgentIdentityService.getOrCreateAgentUser('commonly-bot'); + // `_postToTarget` is the system-card path: public agent posting applies a + // consecutive-run cap and may suppress a stream of provider events. The + // content deliberately contains no outsider-controlled title/body; agent + // context reads content, whereas humans render the literal card payload. + await AgentMessageService._postToTarget({ + agentName: 'commonly-bot', agentUser: bot, podId, + displayName: 'GitHub', messageType: 'card', payload: card, + content: '[GitHub activity]', metadata: { source: 'github-gig' }, + skipSummaryPersistence: true, noOutboundRelay: true, + }); +}; + +export default { githubLocation, boundPodForGithubEvent, buildGithubGigCard, completeTaskForMergedPull, handleGithubGig }; diff --git a/backend/services/installable/eventHandlers.ts b/backend/services/installable/eventHandlers.ts index 74fe3410a6..9e6c7b6b32 100644 --- a/backend/services/installable/eventHandlers.ts +++ b/backend/services/installable/eventHandlers.ts @@ -10,6 +10,8 @@ const Pod = require('../../models/Pod'); const telegramBridgeService = require('../telegramBridgeService'); // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require const slackBridgeService = require('../slackBridgeService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require +const { handleGithubGig } = require('../githubGigService'); export interface ChatMessageEventPayload { podId: string; @@ -32,6 +34,8 @@ export type InternalEventHandler = ( export const eventHandlers: Record = { 'telegram.relay': telegramBridgeService.relayAgentMessageToTelegram, 'slack.relay': slackBridgeService.relayAgentMessageToSlack, + // Inbound App activity is selected by the webhook's indexed pod binding. + 'github.gig': handleGithubGig, }; export const hasEventHandler = (reference: string): boolean => ( diff --git a/docs/design/evidence/task-311-github-gig-preview-1440.png b/docs/design/evidence/task-311-github-gig-preview-1440.png new file mode 100644 index 0000000000000000000000000000000000000000..c3851a21702df16e69387cf75039fce1b5c912d9 GIT binary patch literal 21970 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 literal 0 HcmV?d00001 diff --git a/docs/design/evidence/task-311-github-gig-preview-390.png b/docs/design/evidence/task-311-github-gig-preview-390.png new file mode 100644 index 0000000000000000000000000000000000000000..e242915a66bcbc4a9123f3370e914cd77453c0fa GIT binary patch literal 17933 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

EL@m=k*u!1?}4Te0YwGYkp;*%$ENdH(","owner":"Team-Commonly","repo":"commonly"} +``` + +`owner` and `repo` default to the connection's repository if omitted. The +connection proves which App installation is in use; the binding is a separate +row so one installation can route different repositories to different pods. +The same request replaces an existing target pod. To stop delivery, use +`DELETE` at the same path with the same `owner` and `repo` body. An App +installation or repository without a binding stays silent. This admin route +does not itself call GitHub; a signed delivery verifies the installation and +repository coordinates at runtime. Direct-message pod types are rejected at +binding time because their message guard does not accept system cards. + +The active events are `pull_request` opened or merged, `pull_request_review` +submitted, and `issues` opened or closed. Failed `check_run` cards are compiled +but remain off until the App has `checks: read` and +`GITHUB_GIG_CHECKS_READ_ENABLED=true` is deliberately set. The default flag +does not set that second environment variable. + +## Security and data boundary + +The route uses a raw JSON body before the global JSON parser, with a 25 MiB +ceiling and an empty 413 for bodies above it. It validates +`X-Hub-Signature-256` with HMAC SHA-256 and constant-time comparison before +parsing JSON, looking up a binding, or claiming a delivery. A missing or bad +signature gets an empty 401. A 3,000/min source-IP limit counts all requests; +on overflow a bad signature still gets an empty 401, while a valid one gets +an empty 429. +`X-GitHub-Delivery` is claimed atomically in a +permanent `GithubGigDelivery` receipt only after a binding is found. GitHub +supports manual redelivery after its automatic retry window, so the receipt +has no 24-hour TTL. The route acknowledges only after processing succeeds. If +processing fails it releases the receipt and returns 503 for GitHub to retry, +logging only the delivery ID, event type and bound pod. A successful replay +cannot post or complete a task twice. + +The card payload has a closed component catalog: Stack, Text, Badge, +KeyValue, List (at most ten rows), and a Link restricted to HTTPS +`github.com`. It is capped at 16 elements and 32 KiB, and the frontend renders +text as React text rather than HTML or Markdown. Until the separate outsider +text ruling, the server emits only event type, validated repository/number, +and a whitelisted review state. It does not copy a PR title, review body, or +issue text. Agent context sees the fixed message content +`[GitHub activity]`, not the display payload. + +On a signed merged PR delivery, the handler atomically marks the open task +with the exact canonical `prUrl` in the bound pod done and emits the existing +board update. No task in another pod or with another URL can match. diff --git a/frontend/design-system/preview/github-gig-card.html b/frontend/design-system/preview/github-gig-card.html new file mode 100644 index 0000000000..1b4e8f19d9 --- /dev/null +++ b/frontend/design-system/preview/github-gig-card.html @@ -0,0 +1,36 @@ + + + + + + GitHub activity card · Signal + + + + +

+

Chat · activity card · sample values

+
+
GitHub activity
+
Review submittedPull request #2346
+
RepositoryTeam-Commonly/commonly
+
ReviewChanges requested
+ Open pull request #2346 +
+
+ + diff --git a/frontend/src/v2/__tests__/V2GigCard.test.tsx b/frontend/src/v2/__tests__/V2GigCard.test.tsx new file mode 100644 index 0000000000..bed50f7bd7 --- /dev/null +++ b/frontend/src/v2/__tests__/V2GigCard.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import V2GigCard from '../components/V2GigCard'; + +const card = (elements: unknown[]) => ({ + id: 'm1', pod_id: 'p1', content: '[GitHub activity]', + payload: { kind: 'gig-card', source: 'github', elements }, +} as never); + +test('renders outsider text literally and permits only GitHub HTTPS links', () => { + const title = ' @sprint-impl'; + render(); + expect(screen.getByTestId('gig-card')).toBeInTheDocument(); + expect(screen.getByText(title)).toBeInTheDocument(); + expect(screen.getByTestId('gig-card').querySelector('img')).toBeNull(); + expect(screen.getByRole('link', { name: 'Open pull request' })).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/pull/42'); + expect(screen.queryByText('Bad link')).not.toBeInTheDocument(); +}); + +test('bounds lists and element count', () => { + render( `Row ${i}`) }, + ...Array.from({ length: 20 }, (_, i) => ({ type: 'Text', text: `Item ${i}` })), + ])} />); + expect(screen.getAllByRole('listitem')).toHaveLength(10); + expect(screen.queryByText('Item 19')).not.toBeInTheDocument(); +}); + +test('rejects a payload exceeding 32 KiB in UTF-8 bytes', () => { + render(); + expect(screen.queryByTestId('gig-card')).not.toBeInTheDocument(); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index c9bebcce6d..4fa5291148 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4616,3 +4616,15 @@ describe('connector inactive fold layout', () => { .toContain('box-shadow: inset 0 0 0 2px var(--v2-accent)'); }); }); + +describe('GitHub gig card fits the chat column', () => { + const css = read('../v2.css'); + test('bounds card width, wraps long repository text, and releases the avatar inset on phones', () => { + const card = ruleBody(css, '.v2-gig'); + expect(card).toContain('min-width: 0'); + expect(card).toContain('max-width: 600px'); + expect(card).toContain('overflow-wrap: anywhere'); + const phone = mediaBlockContaining(css, '.v2-msg.v2-msg--card:has(.v2-gig)'); + expect(phone).toContain('padding-left: 0'); + }); +}); diff --git a/frontend/src/v2/components/V2GigCard.tsx b/frontend/src/v2/components/V2GigCard.tsx new file mode 100644 index 0000000000..33ad15d21a --- /dev/null +++ b/frontend/src/v2/components/V2GigCard.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import type { V2Message } from '../hooks/useV2PodDetail'; + +type Element = { type: string; text?: unknown; href?: unknown; label?: unknown; value?: unknown; rows?: unknown; children?: unknown }; +const safeGithubLink = (value: unknown): string | null => { + if (typeof value !== 'string') return null; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.hostname === 'github.com' && !url.username && !url.password && !url.port + ? url.toString() : null; + } catch { return null; } +}; +const literal = (value: unknown): string => (typeof value === 'string' ? value : ''); + +const renderElement = (element: Element, key: number, budget: { count: number }): React.ReactNode => { + budget.count += 1; + if (budget.count > 16) return null; + switch (element.type) { + case 'Stack': + return
{Array.isArray(element.children) + ? element.children.map((child: Element, index: number) => renderElement(child, index, budget)) : null}
; + case 'Text': return
{literal(element.text)}
; + case 'Badge': return {literal(element.text)}; + case 'KeyValue': return
{literal(element.label)}{literal(element.value)}
; + case 'List': return
    {Array.isArray(element.rows) + ? element.rows.slice(0, 10).map((row: unknown, index: number) =>
  • {literal(row)}
  • ) : null}
; + case 'Link': { + const href = safeGithubLink(element.href); + return href ? {literal(element.text)} : null; + } + default: return null; + } +}; + +const V2GigCard: React.FC<{ message: V2Message; time?: string }> = ({ message, time }) => { + const payload = message.payload; + const elements = Array.isArray(payload?.elements) ? payload.elements : []; + if (new Blob([JSON.stringify(payload || {})]).size > 32 * 1024) return null; + const budget = { count: 0 }; + return
+
+
GitHub activity{time && }
+ {elements.map((element, index) => renderElement(element as Element, index, budget))} +
+
; +}; + +export default V2GigCard; diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index d7cea411a9..fd34727b99 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -7,6 +7,7 @@ import V2Avatar from './V2Avatar'; import V2GithubPrCard, { parseGithubPrUrls } from './V2GithubPrCard'; import V2ApprovalCard from './V2ApprovalCard'; import V2BYOHandoffCard from './V2BYOHandoffCard'; +import V2GigCard from './V2GigCard'; import V2DecisionCard, { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard'; import V2MessageActions, { type V2RenderedReaction } from './V2MessageActions'; import { V2Message } from '../hooks/useV2PodDetail'; @@ -446,6 +447,9 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d if (message.payload?.kind === 'byo-handoff') { return ; } + if (message.payload?.kind === 'gig-card') { + return ; + } // §3.8 system event card. Detected by content shape (commonly-bot only // posts this exact form), so we don't depend on a metadata column the PG diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts index 7e3d3f5dcb..640f8d90c3 100644 --- a/frontend/src/v2/hooks/useV2PodDetail.ts +++ b/frontend/src/v2/hooks/useV2PodDetail.ts @@ -76,6 +76,7 @@ export interface V2Message { // the `...raw` spread — declared here so consumers can read it typed. payload?: { kind?: string; + elements?: unknown[]; // kind 'starts': offers rendered as buttons under the message (first // session, direction B). A plain start sends its label as the person's own // message; `href` (an in-app /v2/ path) navigates, and `action: 'invite'` diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 7de4a06634..b5b75c0062 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -8892,6 +8892,30 @@ body.modern-ui.v2-canvas { padding-left: 50px; } +.v2-gig { + min-width: 0; + max-width: 600px; + padding: 12px 16px; + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius); + background: var(--v2-surface); + color: var(--v2-text-primary); + overflow-wrap: anywhere; +} +.v2-gig__head, .v2-gig__stack, .v2-gig__kv { display: flex; gap: 8px; align-items: baseline; } +.v2-gig__head { justify-content: space-between; font: 500 11px var(--v2-font-mono); color: var(--v2-text-muted); margin-bottom: 8px; } +.v2-gig__stack { flex-wrap: wrap; align-items: center; margin-bottom: 8px; } +.v2-gig__badge { font: 500 11px var(--v2-font-mono); border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); padding: 2px 5px; white-space: nowrap; } +.v2-gig__text { font-size: 14px; line-height: 1.45; font-weight: 600; min-width: 0; } +.v2-gig__kv { font-size: 12px; color: var(--v2-text-secondary); margin-bottom: 8px; flex-wrap: wrap; } +.v2-gig__kv strong { color: var(--v2-text-primary); font-weight: 500; } +.v2-gig__list { margin: 0 0 8px; padding-left: 20px; } +.v2-gig__link { color: var(--v2-accent-text); text-decoration: underline; text-underline-offset: 2px; font-size: 12px; } +.v2-gig__link:focus-visible { outline: none; box-shadow: var(--v2-focus-ring); } +@media (max-width: 760px) { + .v2-msg.v2-msg--card:has(.v2-gig) { padding-left: 0; } +} + .v2-approval { border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius); diff --git a/k8s/helm/commonly/templates/core/backend-deployment.yaml b/k8s/helm/commonly/templates/core/backend-deployment.yaml index c761c808fd..9c0ffcc7ed 100644 --- a/k8s/helm/commonly/templates/core/backend-deployment.yaml +++ b/k8s/helm/commonly/templates/core/backend-deployment.yaml @@ -374,6 +374,15 @@ spec: name: api-keys key: GITHUB_APP_INSTALLATION_ID_COMMONLY optional: true + {{- if .Values.githubGig.fromSecret }} + - name: GITHUB_GIG_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: GITHUB_GIG_WEBHOOK_SECRET + - name: GITHUB_GIG_ENABLED + value: "true" + {{- end }} - name: X_OAUTH_CLIENT_ID valueFrom: secretKeyRef: diff --git a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml index c8736831a6..2fbd608a26 100644 --- a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml +++ b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml @@ -354,6 +354,15 @@ spec: name: api-keys key: GITHUB_APP_INSTALLATION_ID_COMMONLY optional: true + {{- if .Values.githubGig.fromSecret }} + - name: GITHUB_GIG_WEBHOOK_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: GITHUB_GIG_WEBHOOK_SECRET + - name: GITHUB_GIG_ENABLED + value: "true" + {{- end }} - name: X_OAUTH_CLIENT_ID valueFrom: secretKeyRef: diff --git a/k8s/helm/commonly/templates/secrets/api-keys.yaml b/k8s/helm/commonly/templates/secrets/api-keys.yaml index 5e80e266bc..1cc841ea91 100644 --- a/k8s/helm/commonly/templates/secrets/api-keys.yaml +++ b/k8s/helm/commonly/templates/secrets/api-keys.yaml @@ -260,6 +260,12 @@ spec: remoteRef: key: {{ $keyPrefix }}github-app-installation-id + {{- if .Values.githubGig.fromSecret }} + - secretKey: GITHUB_GIG_WEBHOOK_SECRET + remoteRef: + key: {{ $keyPrefix }}github-gig-webhook-secret + {{- end }} + {{- if .Values.email.enabled }} # Email (SMTP2GO) diff --git a/k8s/helm/commonly/values.yaml b/k8s/helm/commonly/values.yaml index fb50075fbe..52640018e8 100644 --- a/k8s/helm/commonly/values.yaml +++ b/k8s/helm/commonly/values.yaml @@ -274,6 +274,9 @@ redis: tolerations: [] # External Secrets Operator configuration +githubGig: + fromSecret: false + externalSecrets: enabled: true # Prefix on every environment-scoped Secret Manager key (commonly-dev-NAME).