Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

462 lines · 22.1 KBRaw History
1import { readFile } from "node:fs/promises";
2import path from "node:path";
3import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type RepoRef, type Values } from "../cli.js";
4import { ApiError } from "../api.js";
5import { currentBranch, gitCapture } from "../git.js";
6import { CliError, ExitCode, readAll } from "../io.js";
7import { ago, json, statusText, symbol, table, truncate } from "../output.js";
8
9export interface IssueJson {
10 number: number;
11 title: string;
12 body: string;
13 state: "open" | "closed";
14 state_reason: string | null;
15 user: { login: string; type: string };
16 labels: Array<{ name: string }>;
17 assignees: Array<{ login: string }>;
18 comments: number;
19 created_at: string;
20 updated_at: string;
21 closed_at: string | null;
22 html_url: string;
23 pull_request?: { url: string };
24 head?: { ref: string; sha: string };
25 base?: { ref: string };
26 draft?: boolean;
27 merged?: boolean;
28 merged_at?: string | null;
29 merged_by?: { login: string } | null;
30 intent?: string | null;
31}
32
33interface PullDetail extends IssueJson {
34 mergeable: boolean;
35 mergeable_state: string;
36 merge_blockers: string[];
37 approvals: number;
38 required_approvals: number;
39 checks: Array<{ name: string; state: string }>;
40 auto_merge?: { merge_method: string } | null;
41 /** Only for agents and workflow tokens: what stops Diffinery landing it through auto-merge. */
42 auto_merge_blockers?: string[];
43}
44
45export interface CommentJson {
46 id: string;
47 body: string;
48 user: { login: string };
49 created_at: string;
50}
51
52/** -b/--body, else --body-file (or "-" for stdin), else piped stdin when `stdinFallback`. */
53export async function bodyFrom(ctx: Ctx, o: Values, stdinFallback = false): Promise<string | undefined> {
54 const b = str(o, "body");
55 if (b !== undefined) return b;
56 const f = str(o, "body-file");
57 if (f !== undefined) {
58 if (f === "-") return readAll(ctx.io.stdin);
59 return readFile(path.resolve(ctx.io.cwd, f), "utf8");
60 }
61 if (stdinFallback && !ctx.io.stdin.isTTY) return readAll(ctx.io.stdin);
62 return undefined;
63}
64
65export const bodyOpts = {
66 body: { type: "string" as const, short: "b", desc: "Body text (Markdown)", arg: "TEXT" },
67 "body-file": { type: "string" as const, desc: "Read the body from a file ('-' for stdin)", arg: "FILE" },
68};
69
70export function prState(c: Ctx["c"], p: IssueJson): string {
71 if (p.merged) return c.magenta("merged");
72 if (p.state === "closed") return c.red("closed");
73 if (p.draft) return c.dim("draft");
74 return c.green("open");
75}
76
77const create: Command = {
78 name: "pr create",
79 summary: "Open a pull request",
80 options: {
81 title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" },
82 ...bodyOpts,
83 head: { type: "string", short: "H", desc: "Branch with your changes (default: current branch)", arg: "BRANCH" },
84 base: { type: "string", short: "B", desc: "Branch to merge into (default: the repository's default branch)", arg: "BRANCH" },
85 draft: { type: "boolean", short: "d", desc: "Open as a draft" },
86 intent: { type: "string", desc: "What this change is for, for reviewers (agents should always set it)", arg: "TEXT" },
87 reviewer: { type: "string", short: "r", multiple: true, desc: "Request a review from this login", arg: "LOGIN" },
88 },
89 repo: true,
90 json: true,
91 maxArgs: 0,
92 async run(ctx, o) {
93 const title = str(o, "title");
94 if (!title) throw new CliError("--title is required", 2);
95 const repo = await ctx.repo();
96 const head = str(o, "head") ?? (await currentBranch(ctx.io));
97 const reviewers = list(o, "reviewer");
98 const payload = {
99 title,
100 body: await bodyFrom(ctx, o),
101 head,
102 base: str(o, "base"),
103 draft: o.draft === true ? true : undefined,
104 intent: str(o, "intent"),
105 reviewers: reviewers.length ? reviewers : undefined,
106 };
107 const pr = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/pulls`, payload);
108 if (ctx.json) return json(ctx.io, pr);
109 ctx.out(`${ctx.c.green("✓")} Opened #${pr.number} ${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`);
110 ctx.out(pr.html_url);
111 },
112};
113
114const listCmd: Command = {
115 name: "pr list",
116 summary: "List pull requests",
117 options: {
118 state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" },
119 limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" },
120 author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" },
121 head: { type: "string", short: "H", desc: "Only pull requests from this branch", arg: "BRANCH" },
122 },
123 repo: true,
124 json: true,
125 maxArgs: 0,
126 async run(ctx, o) {
127 const repo = await ctx.repo();
128 const state = str(o, "state") ?? "open";
129 if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2);
130 const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30;
131 const head = str(o, "head");
132 const api = await ctx.api();
133 let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, {
134 state: state === "merged" ? "closed" : state,
135 per_page: Math.min(100, state === "merged" ? 100 : limit),
136 creator: str(o, "author"),
137 head: head ? headQuery(repo, head) : undefined,
138 });
139 if (state === "merged") prs = prs.filter((p) => p.merged);
140 prs = prs.slice(0, limit);
141 if (ctx.json) return json(ctx.io, prs);
142 if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests ${head ? `from ${head} ` : ""}in ${repo.owner}/${repo.name}.`);
143 const c = ctx.c;
144 ctx.io.stdout.write(table(prs.map((p) => [c.bold(`#${p.number}`), truncate(p.title, 60), c.cyan(`${p.head?.ref ?? ""}`), prState(c, p), c.dim(p.user.login), c.dim(ago(p.updated_at))])));
145 },
146};
147
148/** GitHub's `head` filter form, OWNER:BRANCH (Diffinery also accepts a bare branch). */
149const headQuery = (repo: RepoRef, branch: string) => (branch.includes(":") ? branch : `${repo.owner}:${branch}`);
150
151/**
152 * The pull request number argument; without one, like gh, the open pull request whose head is
153 * the current git branch.
154 */
155async function prNumber(ctx: Ctx, num: string | undefined): Promise<number> {
156 if (num !== undefined) return positiveInt(num, "pull request number");
157 const r = await gitCapture(ctx.io, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
158 const branch = r.code === 0 ? r.stdout.trim() : "";
159 if (!branch) throw new CliError("no pull request number given, and there is no current branch to find one for (detached HEAD or not a git repository); pass a pull request number");
160 const repo = await ctx.repo();
161 const prs = await (await ctx.api()).get<IssueJson[]>(`${repoPath(repo)}/pulls`, { head: headQuery(repo, branch), state: "open" });
162 const pr = prs.find((p) => p.head?.ref === branch);
163 if (!pr) throw new CliError(`no open pull request for the current branch (${branch}) in ${repo.owner}/${repo.name}; pass a pull request number`);
164 return pr.number;
165}
166
167async function getPull(ctx: Ctx, n: number) {
168 const repo = await ctx.repo();
169 const api = await ctx.api();
170 return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) };
171}
172
173const view: Command = {
174 name: "pr view",
175 summary: "Show a pull request",
176 description: "Without a number, shows the open pull request for the current branch.",
177 args: "[<number>]",
178 options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } },
179 repo: true,
180 json: true,
181 maxArgs: 1,
182 async run(ctx, o, [num]) {
183 const n = await prNumber(ctx, num);
184 const { pr, api, repo } = await getPull(ctx, n);
185 const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined;
186 if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr);
187 const c = ctx.c;
188 ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`);
189 ctx.out(`${prState(c, pr)} · ${pr.user.login} wants to merge ${c.cyan(pr.head?.ref ?? "?")} into ${c.cyan(pr.base?.ref ?? "?")} · ${ago(pr.created_at)}`);
190 if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`);
191 if (pr.state === "open") {
192 ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`);
193 for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`);
194 if (pr.auto_merge) {
195 // For an agent, merge_blockers can name an approval its auto-merge doesn't need; say what is really left.
196 const left = pr.auto_merge_blockers;
197 const when = !left ? "it merges by itself once every requirement is met" : left.length ? "it merges by itself once these are done:" : "nothing is left, it merges now";
198 ctx.out(`${c.dim("auto-merge:")} on (${pr.auto_merge.merge_method}), ${when}`);
199 for (const b of left ?? []) ctx.out(` ${c.yellow("•")} ${b}`);
200 }
201 }
202 if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`);
203 if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`);
204 for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`);
205 ctx.out(c.dim(pr.html_url));
206 },
207};
208
209function checkSymbol(ctx: Ctx, state: string): string {
210 const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"];
211 return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state);
212}
213
214const diff: Command = {
215 name: "pr diff",
216 summary: "Show a pull request's changes as a unified diff",
217 description: "Without a number, shows the open pull request for the current branch.",
218 args: "[<number>]",
219 options: { "name-only": { type: "boolean", desc: "Only list changed file names" } },
220 repo: true,
221 maxArgs: 1,
222 async run(ctx, o, [num]) {
223 const n = await prNumber(ctx, num);
224 const repo = await ctx.repo();
225 const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${n}/files`);
226 if (o["name-only"]) {
227 ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
228 return;
229 }
230 const c = ctx.c;
231 for (const f of files) {
232 const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`;
233 if (!c.enabled) {
234 ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`);
235 continue;
236 }
237 for (const line of patch.replace(/\n$/, "").split("\n")) {
238 if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line));
239 else if (line.startsWith("@@")) ctx.out(c.cyan(line));
240 else if (line.startsWith("+")) ctx.out(c.green(line));
241 else if (line.startsWith("-")) ctx.out(c.red(line));
242 else ctx.out(line);
243 }
244 }
245 },
246};
247
248const DONE = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"];
249const FAILED = ["failure", "cancelled", "timed_out", "action_required"];
250const PENDING = ["queued", "in_progress", "waiting", "pending", "requested"];
251/** Right after a push, a pull request has no checks until its runs start: --watch waits this long for the first. */
252const FIRST_CHECK_WAIT_MS = 120_000;
253
254const checks: Command = {
255 name: "pr checks",
256 summary: "Show check status for a pull request",
257 description:
258 "Without a number, shows the open pull request for the current branch. With --watch, polls until no check is pending (waiting up to 2 minutes for the first check to appear, as right after a push), printing the checks whenever they change (on a terminal; otherwise once at the end).\n\nExit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending (never with --watch).",
259 args: "[<number>]",
260 options: {
261 watch: { type: "boolean", desc: "Wait until no check is pending" },
262 interval: { type: "string", short: "i", desc: "Seconds between polls with --watch (default 10)", arg: "SECONDS" },
263 },
264 repo: true,
265 json: true,
266 maxArgs: 1,
267 async run(ctx, o, [num]) {
268 const interval = Math.max(0, Number(str(o, "interval") ?? 10)) * 1000;
269 if (!Number.isFinite(interval)) throw new CliError("--interval must be a number", 2);
270 const n = await prNumber(ctx, num);
271 const repo = await ctx.repo();
272 const api = await ctx.api();
273 const render = (list: PullDetail["checks"]) => table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, DONE.includes(k.state) ? "completed" : k.state, k.state)]));
274 // Like `gh pr checks --watch`: on a terminal, show progress as it happens; when piped, only the result.
275 const live = o.watch === true && !ctx.json && !!ctx.io.stdout.isTTY;
276 let shown: string | undefined;
277 let failures = 0;
278 let waited = 0;
279 let list: PullDetail["checks"];
280 for (;;) {
281 try {
282 list = (await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`)).checks ?? [];
283 failures = 0;
284 } catch (e) {
285 // While watching, ride out transient network/server errors; give up on client errors.
286 const status = (e as { status?: number }).status;
287 if (o.watch !== true || (status && status < 500 && status !== 429) || ++failures > 5) throw e;
288 await ctx.io.sleep(interval);
289 continue;
290 }
291 const waitingForFirst = !list.length && waited < FIRST_CHECK_WAIT_MS;
292 if (o.watch !== true || (!waitingForFirst && !list.some((k) => PENDING.includes(k.state)))) break;
293 const t = render(list);
294 if (live && t && t !== shown) {
295 ctx.io.stdout.write(`${shown === undefined ? "" : "\n"}${t}`);
296 shown = t;
297 }
298 await ctx.io.sleep(interval);
299 waited += Math.max(interval, 1000);
300 }
301 if (ctx.json) json(ctx.io, list);
302 else if (!list.length) ctx.err("No checks reported for this pull request.");
303 else if (render(list) !== shown) ctx.io.stdout.write(`${shown === undefined ? "" : "\n"}${render(list)}`);
304 const failed = list.some((k) => FAILED.includes(k.state));
305 const pending = list.some((k) => PENDING.includes(k.state));
306 throw new ExitCode(failed ? 1 : pending ? 8 : 0);
307 },
308};
309
310const review: Command = {
311 name: "pr review",
312 summary: "Approve, request changes or comment on a pull request",
313 description: "Without a number, reviews the open pull request for the current branch.",
314 args: "[<number>]",
315 options: {
316 approve: { type: "boolean", short: "a", desc: "Approve" },
317 "request-changes": { type: "boolean", short: "r", desc: "Request changes" },
318 comment: { type: "boolean", short: "c", desc: "Comment without approving" },
319 ...bodyOpts,
320 },
321 repo: true,
322 json: true,
323 maxArgs: 1,
324 async run(ctx, o, [num]) {
325 const which = exactlyOne(o, ["approve", "request-changes", "comment"]);
326 const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT";
327 const body = await bodyFrom(ctx, o);
328 if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2);
329 const n = await prNumber(ctx, num);
330 const repo = await ctx.repo();
331 const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body });
332 if (ctx.json) return json(ctx.io, r);
333 const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed";
334 ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`);
335 },
336};
337
338const merge: Command = {
339 name: "pr merge",
340 summary: "Merge a pull request",
341 description: "Without a number, merges the open pull request for the current branch.",
342 args: "[<number>]",
343 options: {
344 merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" },
345 squash: { type: "boolean", short: "s", desc: "Squash into one commit" },
346 rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" },
347 "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" },
348 subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" },
349 ...bodyOpts,
350 "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" },
351 admin: { type: "boolean", desc: "Merge now, bypassing unmet branch rules and any merge queue (repository admins; audited)" },
352 auto: { type: "boolean", desc: "Turn on auto-merge: merge (or join the merge queue) once every requirement is met" },
353 "disable-auto": { type: "boolean", desc: "Turn off auto-merge (and take the pull request out of the merge queue if it is in it)" },
354 },
355 repo: true,
356 json: true,
357 maxArgs: 1,
358 async run(ctx, o, [num]) {
359 const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge");
360 const explicit = ["merge", "squash", "rebase"].some((k) => o[k] === true);
361 if (o.auto === true && o["disable-auto"] === true) throw new CliError("use only one of --auto, --disable-auto", 2);
362 if (o.auto === true && o.admin === true) throw new CliError("--admin merges now; it can't be combined with --auto", 2);
363 const n = await prNumber(ctx, num);
364 const repo = await ctx.repo();
365 const api = await ctx.authedApi();
366 if (o["disable-auto"] === true) {
367 // Like `gh pr merge --disable-auto`: turn auto-merge off. Diffinery should not land it by
368 // itself afterwards, so a pull request auto-merge (or someone) already queued leaves the queue too.
369 await api.delete(`${repoPath(repo)}/pulls/${n}/auto-merge`);
370 let dequeued = true;
371 try {
372 await api.delete(`${repoPath(repo)}/pulls/${n}/merge-queue`);
373 } catch (e) {
374 if (!(e instanceof ApiError) || e.status !== 404) throw e;
375 dequeued = false;
376 }
377 if (ctx.json) return json(ctx.io, { auto_merge: null, dequeued });
378 return ctx.out(`${ctx.c.green("✓")} Turned off auto-merge for #${n}${dequeued ? " and removed it from the merge queue" : ""}`);
379 }
380 if (o.auto === true) {
381 // Like `gh pr merge --auto`: PUT /pulls/{n}/auto-merge. Without a method flag the server picks
382 // the merge queue's method, squash where linear history is required, else a merge commit.
383 const r = await api.put<{ state: "enabled" | "queued" | "merged"; auto_merge: { merge_method: string } | null; merge_commit_sha: string | null }>(`${repoPath(repo)}/pulls/${n}/auto-merge`, {
384 merge_method: explicit ? method : undefined,
385 delete_branch: o["delete-branch"] === true ? true : undefined,
386 commit_title: str(o, "subject"),
387 commit_message: await bodyFrom(ctx, o),
388 sha: str(o, "match-head-commit"),
389 });
390 if (ctx.json) return json(ctx.io, r);
391 if (r.state === "merged") return ctx.out(`${ctx.c.green("✓")} Merged #${n} ${ctx.c.dim(String(r.merge_commit_sha ?? "").slice(0, 12))}`);
392 if (r.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue`);
393 return ctx.out(`${ctx.c.green("✓")} Auto-merge is on for #${n} (${r.auto_merge?.merge_method ?? method}): it merges once every requirement is met`);
394 }
395 const payload = {
396 merge_method: method,
397 delete_branch: o["delete-branch"] === true ? true : undefined,
398 commit_title: str(o, "subject"),
399 commit_message: await bodyFrom(ctx, o),
400 sha: str(o, "match-head-commit"),
401 bypass_merge_queue: o.admin === true ? true : undefined,
402 bypass_rules: o.admin === true ? true : undefined,
403 };
404 let r: { merged: boolean; sha: string; method: string };
405 try {
406 r = await api.put(`${repoPath(repo)}/pulls/${n}/merge`, payload);
407 } catch (e) {
408 // Like `gh pr merge`: a base branch with a merge queue gets the pull request queued instead.
409 if (!(e instanceof ApiError) || e.status !== 405 || !/merge queue/.test(e.message) || o.admin === true) throw e;
410 const q = await api.put<{ state: string; entry: { position: number } | null; merge_commit_sha: string | null; reason: string | null }>(`${repoPath(repo)}/pulls/${n}/merge-queue`, {
411 merge_method: explicit ? method : undefined,
412 delete_branch: payload.delete_branch,
413 });
414 if (ctx.json) return json(ctx.io, q);
415 if (q.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue (position ${q.entry?.position ?? "?"})`);
416 if (q.state === "merged") return ctx.out(`${ctx.c.green("✓")} Merged #${n} through the merge queue ${ctx.c.dim(String(q.merge_commit_sha ?? "").slice(0, 12))}`);
417 throw new CliError(`#${n} left the merge queue: ${q.reason ?? "see the pull request"}`);
418 }
419 if (ctx.json) return json(ctx.io, r);
420 ctx.out(`${ctx.c.green("✓")} Merged #${n} (${r.method ?? method}) ${ctx.c.dim(String(r.sha ?? "").slice(0, 12))}${payload.delete_branch ? " and deleted the branch" : ""}`);
421 },
422};
423
424const comment: Command = {
425 name: "pr comment",
426 summary: "Comment on a pull request",
427 description: "Without a number, comments on the open pull request for the current branch.",
428 args: "[<number>]",
429 options: bodyOpts,
430 repo: true,
431 json: true,
432 maxArgs: 1,
433 async run(ctx, o, [num]) {
434 const body = await bodyFrom(ctx, o, true);
435 if (!body?.trim()) throw new CliError("--body is required", 2);
436 const n = await prNumber(ctx, num);
437 const repo = await ctx.repo();
438 const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body });
439 if (ctx.json) return json(ctx.io, r);
440 ctx.out(`${ctx.c.green("✓")} Commented on #${n}`);
441 },
442};
443
444const ready: Command = {
445 name: "pr ready",
446 summary: "Mark a draft pull request ready for review",
447 description: "Without a number, uses the open pull request for the current branch.",
448 args: "[<number>]",
449 options: { undo: { type: "boolean", desc: "Convert the pull request back to a draft" } },
450 repo: true,
451 json: true,
452 maxArgs: 1,
453 async run(ctx, o, [num]) {
454 const n = await prNumber(ctx, num);
455 const repo = await ctx.repo();
456 const pr = await (await ctx.authedApi()).patch<IssueJson>(`${repoPath(repo)}/pulls/${n}`, { draft: o.undo === true });
457 if (ctx.json) return json(ctx.io, pr);
458 ctx.out(`${ctx.c.green("✓")} ${o.undo === true ? `Converted #${n} to a draft` : `Marked #${n} ready for review`}`);
459 },
460};
461
462export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, ready, comment] };