Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/control-plane/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
matchDaemon,
createGitHubAuth,
postComment,
updateComment,
buildManifest,
exchangeManifestCode,
saveCredentials,
Expand Down Expand Up @@ -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,
Expand All @@ -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.')
Expand Down
16 changes: 13 additions & 3 deletions apps/control-plane/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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('/');
});
Expand Down
47 changes: 44 additions & 3 deletions packages/integrations/src/callback.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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',
Expand Down Expand Up @@ -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();
});
});
56 changes: 50 additions & 6 deletions packages/integrations/src/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
): Promise<number> {
const token = await deps.auth.getInstallationToken(installationId);
const commentsUrl = `${issueUrl}/comments`;

Expand All @@ -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()}`);
Expand All @@ -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<void> {
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');
}
2 changes: 1 addition & 1 deletion packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading