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
3 changes: 2 additions & 1 deletion examples/notion-essay-pr/notion-essay-pr.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
}
};
}
Expand Down
3 changes: 2 additions & 1 deletion packages/deploy/test/e2e/notion-essay-pr.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
}
};
}
Expand Down
144 changes: 144 additions & 0 deletions packages/runtime/src/clients/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>/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 {
Expand Down
37 changes: 37 additions & 0 deletions packages/runtime/src/clients/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export interface GithubClient {
base: string;
files?: Record<string, string>;
}): 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;
Expand Down Expand Up @@ -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<GithubIssueFile>(opts, 'github', 'upsertIssue.find.flat', issueDir);
Expand Down
8 changes: 7 additions & 1 deletion packages/runtime/src/clients/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export interface WritebackReceipt {
url?: string;
id?: string;
identifier?: string;
externalId?: string;
merged?: boolean | string;
sha?: string;
[key: string]: unknown;
}

Expand Down Expand Up @@ -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;
}
Expand Down
Loading