Skip to content
Open
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
74 changes: 73 additions & 1 deletion packages/core/test/pr-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
{
Expand All @@ -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"));

Expand All @@ -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/);
});
Expand Down Expand Up @@ -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 },
Expand All @@ -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, [
Expand Down
92 changes: 89 additions & 3 deletions packages/core/tools/pr-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type PrSyncArgs = {
description?: string;
base?: string;
head?: string;
labels?: string[];
removeLabels?: string[];
replaceLabels?: string[];
assignees?: string[];
checklists?: Array<{
name: string;
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()) {
Expand All @@ -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);
}
Expand All @@ -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_");
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Expand All @@ -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");
}
Expand Down Expand Up @@ -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");
}
}
Expand Down Expand Up @@ -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",
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ const opencodeToolCreators: Record<string, OpenCodeToolCreator> = {
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"),
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/test/tool-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading