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.

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