diff --git a/apps/control-plane/src/app.ts b/apps/control-plane/src/app.ts index b9e1bf3..a7f859e 100644 --- a/apps/control-plane/src/app.ts +++ b/apps/control-plane/src/app.ts @@ -11,6 +11,7 @@ import { matchDaemon, createGitHubAuth, postComment, + updateComment, buildManifest, exchangeManifestCode, saveCredentials, @@ -1411,7 +1412,35 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { // Dispatch to worker dispatchSession(discovery, legacyWorkerClient, sessionStore, sessionId, sessionRequest); + // Post initial PR comment for pull_request events + let prCommentId: number | undefined; + if (event.type === 'pull_request') { + const daemonRole = daemon.role; + const initialBody = [ + `### 🐾 paws — ${daemonRole}`, + '', + '**Status:** ⏳ Running...', + '', + '---', + `*[View session](/sessions/${sessionId})*`, + ].join('\n'); + + prCommentId = await postComment( + { auth: githubAuth }, + event.installationId, + event.issueUrl, + initialBody, + ).catch((err) => { + createLogger('github').error('Failed to post initial PR comment', { + sessionId, + error: String(err), + }); + return undefined; + }); + } + // Listen for completion to post results back + const startTime = Date.now(); const resultListener = ( updatedId: string, session: import('./store/sessions.js').StoredSession, @@ -1422,6 +1451,57 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { sessionEvents.off('update', resultListener); + // For PR events, update the existing comment + if (event.type === 'pull_request' && prCommentId) { + const durationSec = Math.round((Date.now() - startTime) / 1000); + const repoParts = event.repo.split('/'); + const owner = repoParts[0] ?? ''; + const repo = repoParts[1] ?? ''; + const daemonRole = daemon.role; + let body: string; + + if (session.status === 'completed') { + const output = + (session.output as string) ?? session.stdout ?? 'Agent completed with no output.'; + body = [ + `### 🐾 paws — ${daemonRole}`, + '', + `**Status:** ✅ Completed in ${durationSec}s`, + '', + output, + '', + '---', + `*[View session](/sessions/${sessionId})*`, + ].join('\n'); + } else { + const reason = session.stderr ?? session.status; + body = [ + `### 🐾 paws — ${daemonRole}`, + '', + `**Status:** ❌ Failed (${reason})`, + '', + '---', + `*[View session](/sessions/${sessionId})*`, + ].join('\n'); + } + + updateComment( + { auth: githubAuth }, + event.installationId, + owner, + repo, + prCommentId, + body, + ).catch((err: unknown) => { + createLogger('github').error('Failed to update PR comment', { + sessionId, + error: String(err), + }); + }); + return; + } + + // For mentions, post a new comment with results const resultBody = session.status === 'completed' ? ((session.output as string) ?? session.stdout ?? 'Agent completed with no output.') diff --git a/apps/control-plane/src/routes/auth.ts b/apps/control-plane/src/routes/auth.ts index 2801bb0..c0bf5bb 100644 --- a/apps/control-plane/src/routes/auth.ts +++ b/apps/control-plane/src/routes/auth.ts @@ -41,7 +41,9 @@ export function createAuthRoutes(adminDeps?: AuthRouteDeps) { adminDeps.promoteToAdmin(auth.email); log.info('First OIDC user promoted to admin', { email: auth.email }); } - } catch { /* auth not ready yet */ } + } catch { + /* auth not ready yet */ + } } return response; } catch (err) { @@ -51,13 +53,21 @@ export function createAuthRoutes(adminDeps?: AuthRouteDeps) { }); app.get('/auth/logout', async (c) => { - try { await revokeSession(c); } catch { /* ignore */ } + try { + await revokeSession(c); + } catch { + /* ignore */ + } deleteCookie(c, 'oidc-auth', { path: '/' }); return c.redirect('/'); }); app.post('/auth/logout', async (c) => { - try { await revokeSession(c); } catch { /* ignore */ } + try { + await revokeSession(c); + } catch { + /* ignore */ + } deleteCookie(c, 'oidc-auth', { path: '/' }); return c.redirect('/'); }); diff --git a/packages/integrations/src/callback.test.ts b/packages/integrations/src/callback.test.ts index a190264..6701d8a 100644 --- a/packages/integrations/src/callback.test.ts +++ b/packages/integrations/src/callback.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { postComment } from './callback.js'; +import { postComment, updateComment } from './callback.js'; import type { CallbackDeps } from './callback.js'; const mockAuth = { @@ -16,20 +16,22 @@ beforeEach(() => { }); describe('postComment', () => { - test('posts comment successfully', async () => { + test('posts comment and returns comment ID', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 201, + json: async () => ({ id: 42 }), }); globalThis.fetch = mockFetch as unknown as typeof fetch; - await postComment( + const commentId = await postComment( deps, 12345, 'https://api.github.com/repos/org/repo/issues/42', 'Session completed successfully.', ); + expect(commentId).toBe(42); expect(mockFetch).toHaveBeenCalledOnce(); expect(mockFetch).toHaveBeenCalledWith( 'https://api.github.com/repos/org/repo/issues/42/comments', @@ -62,3 +64,42 @@ describe('postComment', () => { // The retry logic is tested implicitly by the non-retryable error test above. // TODO: re-enable when bun test supports async timer advancement. }); + +describe('updateComment', () => { + test('updates comment successfully via PATCH', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + }); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + await updateComment(deps, 12345, 'org', 'repo', 99, 'Updated body'); + + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.github.com/repos/org/repo/issues/comments/99', + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + Authorization: 'Bearer ghs_test_token', + }), + body: JSON.stringify({ body: 'Updated body' }), + }), + ); + }); + + test('throws on non-retryable error (404)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + text: async () => 'not found', + }); + globalThis.fetch = mockFetch as unknown as typeof fetch; + + await expect(updateComment(deps, 12345, 'org', 'repo', 99, 'body')).rejects.toThrow( + 'GitHub comment update failed: 404 not found', + ); + + expect(mockFetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/integrations/src/callback.ts b/packages/integrations/src/callback.ts index da4a0c1..1d0a077 100644 --- a/packages/integrations/src/callback.ts +++ b/packages/integrations/src/callback.ts @@ -4,13 +4,19 @@ export interface CallbackDeps { auth: GitHubAuth; } -/** Post a comment on a GitHub issue/PR */ +const GITHUB_HEADERS = { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', +} as const; + +/** Post a comment on a GitHub issue/PR. Returns the comment ID. */ export async function postComment( deps: CallbackDeps, installationId: number, issueUrl: string, body: string, -): Promise { +): Promise { const token = await deps.auth.getInstallationToken(installationId); const commentsUrl = `${issueUrl}/comments`; @@ -20,14 +26,15 @@ export async function postComment( method: 'POST', headers: { Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', + ...GITHUB_HEADERS, }, body: JSON.stringify({ body }), }); - if (res.ok) return; + if (res.ok) { + const json = (await res.json()) as { id: number }; + return json.id; + } if (res.status === 403 || res.status === 429) { lastError = new Error(`GitHub API ${res.status}: ${await res.text()}`); @@ -40,3 +47,40 @@ export async function postComment( throw lastError ?? new Error('Failed to post comment after retries'); } + +/** Update an existing comment by ID */ +export async function updateComment( + deps: CallbackDeps, + installationId: number, + owner: string, + repo: string, + commentId: number, + body: string, +): Promise { + const token = await deps.auth.getInstallationToken(installationId); + const url = `https://api.github.com/repos/${owner}/${repo}/issues/comments/${commentId}`; + + let lastError: Error | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + const res = await fetch(url, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + ...GITHUB_HEADERS, + }, + body: JSON.stringify({ body }), + }); + + if (res.ok) return; + + if (res.status === 403 || res.status === 429) { + lastError = new Error(`GitHub API ${res.status}: ${await res.text()}`); + await new Promise((r) => setTimeout(r, (attempt + 1) * 2000)); + continue; + } + + throw new Error(`GitHub comment update failed: ${res.status} ${await res.text()}`); + } + + throw lastError ?? new Error('Failed to update comment after retries'); +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 0db63a5..6524176 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -3,7 +3,7 @@ export { createGitHubAuth } from './github-auth.js'; export type { GitHubAuth } from './github-auth.js'; export { matchDaemon } from './router.js'; export type { MatchResult } from './router.js'; -export { postComment } from './callback.js'; +export { postComment, updateComment } from './callback.js'; export type { CallbackDeps } from './callback.js'; export type { GitHubEvent, GitHubAppConfig, GitHubDaemon } from './types.js'; export {