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.

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