diff --git a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts index 60b72c8a..b0bb8146 100644 --- a/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts +++ b/examples/notion-essay-pr/notion-essay-pr.smoke.test.ts @@ -143,7 +143,8 @@ class MockNotionEssayRuntime { createPullRequest: async (args) => { this.githubPullRequests.push(args); return { number: 17, url: 'https://github.com/AgentWorkforce/proactive-agents/pull/17' }; - } + }, + mergePullRequest: async () => ({ merged: true, sha: 'merge-sha' }) } }; } diff --git a/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts b/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts index 3906870d..7bb669aa 100644 --- a/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts +++ b/packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts @@ -156,7 +156,8 @@ class MockNotionEssayRuntime { createPullRequest: async (args) => { this.githubPullRequests.push(args); return { number: 17, url: 'https://github.com/AgentWorkforce/proactive-agents/pull/17' }; - } + }, + mergePullRequest: async () => ({ merged: true, sha: 'merge-sha' }) } }; } diff --git a/packages/runtime/src/clients/github.test.ts b/packages/runtime/src/clients/github.test.ts index f125edab..ff5a7b05 100644 --- a/packages/runtime/src/clients/github.test.ts +++ b/packages/runtime/src/clients/github.test.ts @@ -82,6 +82,150 @@ test('github.createPullRequest writes a draft pull request file under pulls/', a } }); +test('github.mergePullRequest writes a merge draft under pulls//merge.json', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root, writebackTimeoutMs: 0 }); + const result = await client.mergePullRequest({ + owner: 'o', + repo: 'r', + number: 42, + method: 'rebase', + commitTitle: 'Merge PR #42', + commitMessage: 'Ship the feature.', + sha: 'reviewed-head' + }); + + assert.deepEqual(result, { merged: false }); + const mergePath = path.join(root, 'github/repos/o/r/pulls/42/merge.json'); + assert.deepEqual(JSON.parse(await readFile(mergePath, 'utf8')), { + merge_method: 'rebase', + commit_title: 'Merge PR #42', + commit_message: 'Ship the feature.', + sha: 'reviewed-head' + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github.mergePullRequest preserves empty commit metadata and omits the merge method by default', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root, writebackTimeoutMs: 0 }); + const result = await client.mergePullRequest({ + owner: 'o', + repo: 'r', + number: 42, + commitTitle: '', + commitMessage: '' + }); + + assert.deepEqual(result, { merged: false }); + const mergePath = path.join(root, 'github/repos/o/r/pulls/42/merge.json'); + assert.deepEqual(JSON.parse(await readFile(mergePath, 'utf8')), { + commit_title: '', + commit_message: '' + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github.mergePullRequest waits for a GitHub merge receipt', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root, writebackTimeoutMs: 1_000, writebackPollMs: 10 }); + const mergePath = path.join(root, 'github/repos/o/r/pulls/42/merge.json'); + const resultPromise = client.mergePullRequest({ + owner: 'o', + repo: 'r', + number: 42, + method: 'squash', + sha: 'reviewed-head' + }); + + const receiptWritePromise = (async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await writeFile(mergePath, `${JSON.stringify({ merged: true, sha: 'merge-sha' })}\n`, 'utf8'); + })(); + + const [result] = await Promise.all([resultPromise, receiptWritePromise]); + assert.deepEqual(result, { merged: true, sha: 'merge-sha' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github.mergePullRequest accepts string merge receipts', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root, writebackTimeoutMs: 1_000, writebackPollMs: 10 }); + const mergePath = path.join(root, 'github/repos/o/r/pulls/42/merge.json'); + const resultPromise = client.mergePullRequest({ + owner: 'o', + repo: 'r', + number: 42 + }); + + const receiptWritePromise = (async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await writeFile(mergePath, `${JSON.stringify({ merged: 'true', sha: 'merge-sha' })}\n`, 'utf8'); + })(); + + const [result] = await Promise.all([resultPromise, receiptWritePromise]); + assert.deepEqual(result, { merged: true, sha: 'merge-sha' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github.mergePullRequest respects explicit failed merge receipts with sha values', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root, writebackTimeoutMs: 1_000, writebackPollMs: 10 }); + const mergePath = path.join(root, 'github/repos/o/r/pulls/42/merge.json'); + const resultPromise = client.mergePullRequest({ + owner: 'o', + repo: 'r', + number: 42 + }); + + const receiptWritePromise = (async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await writeFile(mergePath, `${JSON.stringify({ merged: false, sha: 'head-sha' })}\n`, 'utf8'); + })(); + + const [result] = await Promise.all([resultPromise, receiptWritePromise]); + assert.deepEqual(result, { merged: false, sha: 'head-sha' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github.mergePullRequest treats identifier-only receipts as merged', async () => { + const root = await tempMount(); + try { + const client = createGithubClient({ relayfileMountRoot: root, writebackTimeoutMs: 1_000, writebackPollMs: 10 }); + const mergePath = path.join(root, 'github/repos/o/r/pulls/42/merge.json'); + const resultPromise = client.mergePullRequest({ + owner: 'o', + repo: 'r', + number: 42 + }); + + const receiptWritePromise = (async () => { + await new Promise((resolve) => setTimeout(resolve, 25)); + await writeFile(mergePath, `${JSON.stringify({ id: 'merge-sha' })}\n`, 'utf8'); + })(); + + const [result] = await Promise.all([resultPromise, receiptWritePromise]); + assert.deepEqual(result, { merged: true, sha: 'merge-sha' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('github.upsertIssue updates an existing flat issue match', async () => { const root = await tempMount(); try { diff --git a/packages/runtime/src/clients/github.ts b/packages/runtime/src/clients/github.ts index d44cc731..85192ac7 100644 --- a/packages/runtime/src/clients/github.ts +++ b/packages/runtime/src/clients/github.ts @@ -39,6 +39,15 @@ export interface GithubClient { base: string; files?: Record; }): Promise<{ number: number; url: string }>; + mergePullRequest(args: { + owner: string; + repo: string; + number: number; + method?: 'merge' | 'squash' | 'rebase'; + commitTitle?: string; + commitMessage?: string; + sha?: string; + }): Promise<{ merged: boolean; sha?: string }>; upsertIssue(args: { owner: string; repo: string; @@ -157,6 +166,34 @@ export function createGithubClient(opts: IntegrationClientOptions): GithubClient return { number: Number.isFinite(number) ? number : 0, url: result.receipt?.url ?? result.path }; }, + async mergePullRequest(args) { + const result = await writeJsonFile( + opts, + 'github', + 'mergePullRequest', + `${repoRoot(args.owner, args.repo)}/pulls/${encodeSegment(args.number)}/merge.json`, + { + ...(args.method !== undefined ? { merge_method: args.method } : {}), + ...(args.commitTitle !== undefined ? { commit_title: args.commitTitle } : {}), + ...(args.commitMessage !== undefined ? { commit_message: args.commitMessage } : {}), + ...(args.sha !== undefined ? { sha: args.sha } : {}) + } + ); + const sha = + typeof result.receipt?.sha === 'string' + ? result.receipt.sha + : typeof result.receipt?.id === 'string' + ? result.receipt.id + : typeof result.receipt?.externalId === 'string' + ? result.receipt.externalId + : undefined; + const receiptMerged = result.receipt?.merged; + return { + merged: receiptMerged === true || receiptMerged === 'true' || (receiptMerged === undefined && Boolean(sha)), + ...(sha ? { sha } : {}) + }; + }, + async upsertIssue(args) { const issueDir = `${repoRoot(args.owner, args.repo)}/issues`; const flatIssues = await listJsonFiles(opts, 'github', 'upsertIssue.find.flat', issueDir); diff --git a/packages/runtime/src/clients/request.ts b/packages/runtime/src/clients/request.ts index 45acaa5a..295aa1c1 100644 --- a/packages/runtime/src/clients/request.ts +++ b/packages/runtime/src/clients/request.ts @@ -61,6 +61,9 @@ export interface WritebackReceipt { url?: string; id?: string; identifier?: string; + externalId?: string; + merged?: boolean | string; + sha?: string; [key: string]: unknown; } @@ -237,7 +240,10 @@ async function waitForReceipt( isRecord(parsed) && (typeof parsed.created === 'string' || typeof parsed.path === 'string' || - typeof parsed.id === 'string') + typeof parsed.id === 'string' || + typeof parsed.externalId === 'string' || + typeof parsed.merged === 'boolean' || + typeof parsed.merged === 'string') ) { return parsed as WritebackReceipt; }