diff --git a/packages/core/test/pr-sync.test.ts b/packages/core/test/pr-sync.test.ts index a536a65..9077618 100644 --- a/packages/core/test/pr-sync.test.ts +++ b/packages/core/test/pr-sync.test.ts @@ -6,7 +6,7 @@ import type { Shell, ShellPromise } from "../tools/shared.ts"; import { createPrSyncTool } from "../tools/pr-sync.ts"; describe("pr_sync", () => { - test("creates a PR with explicit head branch and assignees", async () => { + test("creates a PR with explicit head branch, labels, and assignees", async () => { const executedCommands: string[] = []; const shell = createMockShell(executedCommands, [ { @@ -21,6 +21,7 @@ describe("pr_sync", () => { body: "Uses explicit head branch when creating PRs.", base: "main", head: "feature/pr-head", + labels: ["enhancement", "ready for review"], assignees: ["octocat", "hubot"], }, createToolContextForDirectory("/tmp/repo")); @@ -29,6 +30,8 @@ describe("pr_sync", () => { assert.equal(result.action, "created"); assert.match(executedCommands[0], /--base main/); assert.match(executedCommands[0], /--head feature\/pr-head/); + assert.match(executedCommands[0], /--label enhancement/); + assert.match(executedCommands[0], /--label ready for review/); assert.match(executedCommands[0], /--assignee octocat/); assert.match(executedCommands[0], /--assignee hubot/); }); @@ -122,6 +125,7 @@ describe("pr_sync", () => { const output = await tool.execute({ title: "Tighten review automation", body: "Updated body", + labels: ["automation"], assignees: ["octocat"], refUrl: "https://github.com/acme/repo/pull/9", review: { approve: true }, @@ -131,9 +135,77 @@ describe("pr_sync", () => { assert.equal(result.action, "updated_and_approved"); assert.match(executedCommands[1], /--title Tighten review automation/); assert.match(executedCommands[1], /--body Updated body/); + assert.match(executedCommands[1], /--add-label automation/); assert.match(executedCommands[1], /--add-assignee octocat/); }); + test("removes labels from an existing PR", async () => { + const executedCommands: string[] = []; + const shell = createMockShell(executedCommands, [ + { + contains: "gh pr view https://github.com/acme/repo/pull/9 --json number,url", + stdout: JSON.stringify({ number: 9, url: "https://github.com/acme/repo/pull/9" }), + }, + { + contains: "gh pr edit https://github.com/acme/repo/pull/9", + stdout: "", + }, + ]); + + const tool = createPrSyncTool(shell); + const output = await tool.execute({ + refUrl: "https://github.com/acme/repo/pull/9", + removeLabels: ["blocked", "needs review"], + }, createToolContextForDirectory("/tmp/repo")); + + const result = JSON.parse(output); + assert.equal(result.action, "updated"); + assert.match(executedCommands[1], /--remove-label blocked/); + assert.match(executedCommands[1], /--remove-label needs review/); + }); + + test("replaces the complete label set on an existing PR", async () => { + const executedCommands: string[] = []; + const shell = createMockShell(executedCommands, [ + { + contains: "gh pr view https://github.com/acme/repo/pull/9 --json number,url", + stdout: JSON.stringify({ number: 9, url: "https://github.com/acme/repo/pull/9" }), + }, + { + contains: "gh repo view --json nameWithOwner", + stdout: JSON.stringify({ nameWithOwner: "acme/repo" }), + }, + { + contains: "/repos/acme/repo/issues/9/labels --input -", + stdout: JSON.stringify([]), + }, + ]); + + const tool = createPrSyncTool(shell); + const output = await tool.execute({ + refUrl: "https://github.com/acme/repo/pull/9", + replaceLabels: [], + }, createToolContextForDirectory("/tmp/repo")); + + const result = JSON.parse(output); + assert.equal(result.action, "updated"); + assert.match(executedCommands[2], /--method PUT/); + assert.match(executedCommands[2], /"labels":\[\]/); + }); + + test("rejects replacing and incrementally changing labels together", async () => { + const tool = createPrSyncTool(createMockShell([], [])); + + await assert.rejects( + tool.execute({ + refUrl: "https://github.com/acme/repo/pull/9", + labels: ["enhancement"], + replaceLabels: ["ready"], + }, createToolContextForDirectory("/tmp/repo")), + /replaceLabels cannot be combined with labels or removeLabels/, + ); + }); + test("submits structured review comments through pr_sync", async () => { const executedCommands: string[] = []; const shell = createMockShell(executedCommands, [ diff --git a/packages/core/tools/pr-sync.ts b/packages/core/tools/pr-sync.ts index 00cd4c0..03ef02c 100644 --- a/packages/core/tools/pr-sync.ts +++ b/packages/core/tools/pr-sync.ts @@ -35,6 +35,9 @@ type PrSyncArgs = { description?: string; base?: string; head?: string; + labels?: string[]; + removeLabels?: string[]; + replaceLabels?: string[]; assignees?: string[]; checklists?: Array<{ name: string; @@ -95,10 +98,19 @@ function hasMetadataUpdate(args: PrSyncArgs, body?: string) { args.title?.trim() || body || args.base?.trim() || + collectLabels(args.labels).length > 0 || + collectLabels(args.removeLabels).length > 0 || + args.replaceLabels !== undefined || collectAssignees(args.assignees).length > 0, ); } +function collectLabels(labels?: string[]): string[] { + return (labels ?? []) + .filter((label) => label.trim()) + .map((label) => label.trim()); +} + function collectAssignees(assignees?: string[]): string[] { return (assignees ?? []) .filter((assignee) => assignee.trim()) @@ -324,7 +336,14 @@ async function updatePullRequest( $: Shell, worktree: string, refUrl: string, - args: { title?: string; body?: string; base?: string; assignees?: string[] }, + args: { + title?: string; + body?: string; + base?: string; + labels?: string[]; + removeLabels?: string[]; + assignees?: string[]; + }, ) { const updateArgs: string[] = []; if (args.title?.trim()) { @@ -336,6 +355,12 @@ async function updatePullRequest( if (args.base?.trim()) { updateArgs.push("--base", args.base.trim()); } + for (const label of collectLabels(args.labels)) { + updateArgs.push("--add-label", label); + } + for (const label of collectLabels(args.removeLabels)) { + updateArgs.push("--remove-label", label); + } for (const assignee of collectAssignees(args.assignees)) { updateArgs.push("--add-assignee", assignee); } @@ -356,6 +381,25 @@ async function updatePullRequest( return true; } +async function replacePullRequestLabels( + $: Shell, + worktree: string, + owner: string, + repo: string, + prNumber: number, + labels: string[], +) { + const payload = JSON.stringify({ labels: collectLabels(labels) }); + const proc = await $`echo ${payload} | gh api --method PUT /repos/${owner}/${repo}/issues/${prNumber}/labels --input -` + .cwd(worktree) + .quiet() + .nothrow(); + + if (proc.exitCode !== 0) { + throw new Error(proc.stderr.toString() || "Failed to replace PR labels"); + } +} + function summarizeActions(actions: string[]) { return actions.join("_and_"); } @@ -389,6 +433,21 @@ export function createPrSyncTool($: Shell) { optional: true, description: "Head branch to open the PR from when creating a new pull request", }, + labels: { + type: "string[]", + optional: true, + description: "Labels to add to the PR", + }, + removeLabels: { + type: "string[]", + optional: true, + description: "Labels to remove from an existing PR", + }, + replaceLabels: { + type: "string[]", + optional: true, + description: "Exact label set for the PR; an empty array clears all labels", + }, assignees: { type: "string[]", optional: true, @@ -436,6 +495,17 @@ export function createPrSyncTool($: Shell) { const metadataUpdate = hasMetadataUpdate(args, body); const existingPrActions = requiresExistingPullRequest(args, review); + if ( + args.replaceLabels !== undefined && + (args.labels !== undefined || args.removeLabels !== undefined) + ) { + throw new Error("pr_sync replaceLabels cannot be combined with labels or removeLabels"); + } + + if (!args.refUrl?.trim() && collectLabels(args.removeLabels).length > 0) { + throw new Error("pr_sync removeLabels requires refUrl"); + } + if (!args.refUrl?.trim() && existingPrActions && metadataUpdate) { throw new Error("pr_sync requires refUrl when combining PR updates with review, comment, or reply actions"); } @@ -460,6 +530,9 @@ export function createPrSyncTool($: Shell) { for (const assignee of collectAssignees(args.assignees)) { createArgs.push("--assignee", assignee); } + for (const label of collectLabels(args.replaceLabels ?? args.labels)) { + createArgs.push("--label", label); + } if (args.draft) { createArgs.push("--draft"); } @@ -494,9 +567,22 @@ export function createPrSyncTool($: Shell) { title: args.title, body, base: args.base, + labels: args.labels, + removeLabels: args.removeLabels, assignees: args.assignees, }); - if (updated) { + if (args.replaceLabels !== undefined) { + const { owner, repoName } = await getRepoContext(); + await replacePullRequestLabels( + $, + ctx.worktree, + owner, + repoName, + target.number, + args.replaceLabels, + ); + } + if (updated || args.replaceLabels !== undefined) { actions.push("updated"); } } @@ -537,7 +623,7 @@ export function createPrSyncTool($: Shell) { if (actions.length === 0) { throw new Error( - "pr_sync requires title, body, description, checklist content, review, commentBody, or replies", + "pr_sync requires title, body, description, checklist content, label changes, assignees, review, commentBody, or replies", ); } diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index 32932ca..d9c3f8a 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -240,6 +240,9 @@ const opencodeToolCreators: Record = { description: tool.schema.string().describe("Short PR description rendered above checklist sections").optional(), base: tool.schema.string().describe("Base branch to merge into").optional(), head: tool.schema.string().describe("Head branch to use when creating a PR").optional(), + labels: tool.schema.array(tool.schema.string()).describe("Labels to add to the PR").optional(), + removeLabels: tool.schema.array(tool.schema.string()).describe("Labels to remove from an existing PR").optional(), + replaceLabels: tool.schema.array(tool.schema.string()).describe("Exact label set for the PR; an empty array clears all labels").optional(), assignees: tool.schema.array(tool.schema.string()).describe("Assignees to apply to the PR").optional(), checklists: tool.schema.array(tool.schema.object({ name: tool.schema.string().describe("Checklist section name"), diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index e9fedc7..2c539d9 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -455,12 +455,15 @@ describe("createOpenCodeTools", () => { }); }); - test("exposes ticket assignees and comments, and PR assignees", async () => { + test("exposes ticket assignees and comments, and PR labels and assignees", async () => { await withTempHome(async () => { const tools = await createOpenCodeTools(createMockClient() as never, process.cwd()); const prSyncArgs = (tools.kompass_pr_sync as any).args; const ticketSyncArgs = (tools.kompass_ticket_sync as any).args; + assert.ok(prSyncArgs.labels); + assert.ok(prSyncArgs.removeLabels); + assert.ok(prSyncArgs.replaceLabels); assert.ok(prSyncArgs.assignees); assert.ok(ticketSyncArgs.assignees); assert.ok(ticketSyncArgs.comments); diff --git a/packages/web/src/content/docs/docs/reference/tools/pr-sync.mdx b/packages/web/src/content/docs/docs/reference/tools/pr-sync.mdx index 3ea5a95..826a208 100644 --- a/packages/web/src/content/docs/docs/reference/tools/pr-sync.mdx +++ b/packages/web/src/content/docs/docs/reference/tools/pr-sync.mdx @@ -14,6 +14,9 @@ Use one tool surface for PR creation, metadata updates, comments, replies, and f - `description` - `base` - `head` +- `labels` +- `removeLabels` +- `replaceLabels` - `assignees` - `checklists` - `draft`