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 0000000000..c3851a2170 Binary files /dev/null and b/docs/design/evidence/task-311-github-gig-preview-1440.png differ 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 0000000000..e242915a66 Binary files /dev/null and b/docs/design/evidence/task-311-github-gig-preview-390.png differ diff --git a/docs/integrations/github-activity-gig.md b/docs/integrations/github-activity-gig.md new file mode 100644 index 0000000000..a4b26ac402 --- /dev/null +++ b/docs/integrations/github-activity-gig.md @@ -0,0 +1,76 @@ +# GitHub activity gig + +The `commonly-tools` GitHub App (ID `4973525`) sends its App-wide webhook to +`POST /api/webhooks/github`. A delivery is visible in a pod only after an +administrator explicitly binds its installation ID and repository to that pod. +The installation ID in each delivery is distinct from the App ID. +The webhook ignores every unbound repository with HTTP 204 after one indexed +`GithubGigBinding` lookup. It makes no GitHub API call on that path. + +## Enable and bind + +The Helm value `githubGig.fromSecret` defaults to `false`. When enabled, the +backend receives `GITHUB_GIG_WEBHOOK_SECRET` from the `api-keys` ExternalSecret +key selected by `externalSecrets.keyPrefix`: `commonly-dev-github-gig-webhook-secret` +on dev or `commonly-staging-github-gig-webhook-secret` on staging. It also +receives `GITHUB_GIG_ENABLED=true`. +The Secret Manager key must exist before this value is enabled; ExternalSecrets +rejects the whole bundle if a referenced remote key is missing. Configure the +same secret in the GitHub App webhook settings. Keep the flag off until the +Saturday deploy walk. + +An administrator first creates or selects the existing `github-app` connection. +Then: + +```http +PUT /api/integrations/github-app/:connectionId/gig-pod +Content-Type: application/json + +{"podId":"","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).