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.

357 lines · 15.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 Values } from "../cli.js";
4import { ApiError } from "../api.js";
5import { currentBranch } 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 },
119 repo: true,
120 json: true,
121 maxArgs: 0,
122 async run(ctx, o) {
123 const repo = await ctx.repo();
124 const state = str(o, "state") ?? "open";
125 if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2);
126 const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30;
127 const api = await ctx.api();
128 let prs = await api.get<IssueJson[]>(`${repoPath(repo)}/pulls`, { state: state === "merged" ? "closed" : state, per_page: Math.min(100, state === "merged" ? 100 : limit), creator: str(o, "author") });
129 if (state === "merged") prs = prs.filter((p) => p.merged);
130 prs = prs.slice(0, limit);
131 if (ctx.json) return json(ctx.io, prs);
132 if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests in ${repo.owner}/${repo.name}.`);
133 const c = ctx.c;
134 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))])));
135 },
136};
137
138async function getPull(ctx: Ctx, n: number) {
139 const repo = await ctx.repo();
140 const api = await ctx.api();
141 return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) };
142}
143
144const view: Command = {
145 name: "pr view",
146 summary: "Show a pull request",
147 args: "<number>",
148 options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } },
149 repo: true,
150 json: true,
151 minArgs: 1,
152 maxArgs: 1,
153 async run(ctx, o, [num]) {
154 const n = positiveInt(num, "pull request number");
155 const { pr, api, repo } = await getPull(ctx, n);
156 const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined;
157 if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr);
158 const c = ctx.c;
159 ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`);
160 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)}`);
161 if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`);
162 if (pr.state === "open") {
163 ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`);
164 for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`);
165 }
166 if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`);
167 if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`);
168 for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`);
169 ctx.out(c.dim(pr.html_url));
170 },
171};
172
173function checkSymbol(ctx: Ctx, state: string): string {
174 const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"];
175 return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state);
176}
177
178const diff: Command = {
179 name: "pr diff",
180 summary: "Show a pull request's changes as a unified diff",
181 args: "<number>",
182 options: { "name-only": { type: "boolean", desc: "Only list changed file names" } },
183 repo: true,
184 minArgs: 1,
185 maxArgs: 1,
186 async run(ctx, o, [num]) {
187 const repo = await ctx.repo();
188 const files = await (await ctx.api()).get<Array<{ filename: string; status: string; patch?: string; binary?: boolean }>>(`${repoPath(repo)}/pulls/${positiveInt(num, "pull request number")}/files`);
189 if (o["name-only"]) {
190 ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
191 return;
192 }
193 const c = ctx.c;
194 for (const f of files) {
195 const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`;
196 if (!c.enabled) {
197 ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`);
198 continue;
199 }
200 for (const line of patch.replace(/\n$/, "").split("\n")) {
201 if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line));
202 else if (line.startsWith("@@")) ctx.out(c.cyan(line));
203 else if (line.startsWith("+")) ctx.out(c.green(line));
204 else if (line.startsWith("-")) ctx.out(c.red(line));
205 else ctx.out(line);
206 }
207 }
208 },
209};
210
211const checks: Command = {
212 name: "pr checks",
213 summary: "Show check status for a pull request",
214 description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.",
215 args: "<number>",
216 repo: true,
217 json: true,
218 minArgs: 1,
219 maxArgs: 1,
220 async run(ctx, _o, [num]) {
221 const { pr } = await getPull(ctx, positiveInt(num, "pull request number"));
222 const list = pr.checks ?? [];
223 if (ctx.json) json(ctx.io, list);
224 else if (!list.length) ctx.err("No checks reported for this pull request.");
225 else ctx.io.stdout.write(table(list.map((k) => [checkSymbol(ctx, k.state), k.name, statusText(ctx.c, ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"].includes(k.state) ? "completed" : k.state, k.state)])));
226 const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state));
227 const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state));
228 throw new ExitCode(failed ? 1 : pending ? 8 : 0);
229 },
230};
231
232const review: Command = {
233 name: "pr review",
234 summary: "Approve, request changes or comment on a pull request",
235 args: "<number>",
236 options: {
237 approve: { type: "boolean", short: "a", desc: "Approve" },
238 "request-changes": { type: "boolean", short: "r", desc: "Request changes" },
239 comment: { type: "boolean", short: "c", desc: "Comment without approving" },
240 ...bodyOpts,
241 },
242 repo: true,
243 json: true,
244 minArgs: 1,
245 maxArgs: 1,
246 async run(ctx, o, [num]) {
247 const n = positiveInt(num, "pull request number");
248 const which = exactlyOne(o, ["approve", "request-changes", "comment"]);
249 const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT";
250 const body = await bodyFrom(ctx, o);
251 if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2);
252 const repo = await ctx.repo();
253 const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body });
254 if (ctx.json) return json(ctx.io, r);
255 const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed";
256 ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`);
257 },
258};
259
260const merge: Command = {
261 name: "pr merge",
262 summary: "Merge a pull request",
263 args: "<number>",
264 options: {
265 merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" },
266 squash: { type: "boolean", short: "s", desc: "Squash into one commit" },
267 rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" },
268 "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" },
269 subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" },
270 ...bodyOpts,
271 "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" },
272 admin: { type: "boolean", desc: "Merge now, bypassing unmet branch rules and any merge queue (repository admins; audited)" },
273 "disable-auto": { type: "boolean", desc: "Remove the pull request from the merge queue" },
274 },
275 repo: true,
276 json: true,
277 minArgs: 1,
278 maxArgs: 1,
279 async run(ctx, o, [num]) {
280 const n = positiveInt(num, "pull request number");
281 const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge");
282 const repo = await ctx.repo();
283 const api = await ctx.authedApi();
284 if (o["disable-auto"] === true) {
285 await api.delete(`${repoPath(repo)}/pulls/${n}/merge-queue`);
286 if (ctx.json) return json(ctx.io, { removed: true });
287 return ctx.out(`${ctx.c.green("✓")} Removed #${n} from the merge queue`);
288 }
289 const explicit = ["merge", "squash", "rebase"].some((k) => o[k] === true);
290 const payload = {
291 merge_method: method,
292 delete_branch: o["delete-branch"] === true ? true : undefined,
293 commit_title: str(o, "subject"),
294 commit_message: await bodyFrom(ctx, o),
295 sha: str(o, "match-head-commit"),
296 bypass_merge_queue: o.admin === true ? true : undefined,
297 bypass_rules: o.admin === true ? true : undefined,
298 };
299 let r: { merged: boolean; sha: string; method: string };
300 try {
301 r = await api.put(`${repoPath(repo)}/pulls/${n}/merge`, payload);
302 } catch (e) {
303 // Like `gh pr merge`: a base branch with a merge queue gets the pull request queued instead.
304 if (!(e instanceof ApiError) || e.status !== 405 || !/merge queue/.test(e.message) || o.admin === true) throw e;
305 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`, {
306 merge_method: explicit ? method : undefined,
307 delete_branch: payload.delete_branch,
308 });
309 if (ctx.json) return json(ctx.io, q);
310 if (q.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue (position ${q.entry?.position ?? "?"})`);
311 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))}`);
312 throw new CliError(`#${n} left the merge queue: ${q.reason ?? "see the pull request"}`);
313 }
314 if (ctx.json) return json(ctx.io, r);
315 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" : ""}`);
316 },
317};
318
319const comment: Command = {
320 name: "pr comment",
321 summary: "Comment on a pull request",
322 args: "<number>",
323 options: bodyOpts,
324 repo: true,
325 json: true,
326 minArgs: 1,
327 maxArgs: 1,
328 async run(ctx, o, [num]) {
329 const n = positiveInt(num, "pull request number");
330 const body = await bodyFrom(ctx, o, true);
331 if (!body?.trim()) throw new CliError("--body is required", 2);
332 const repo = await ctx.repo();
333 const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body });
334 if (ctx.json) return json(ctx.io, r);
335 ctx.out(`${ctx.c.green("✓")} Commented on #${n}`);
336 },
337};
338
339const ready: Command = {
340 name: "pr ready",
341 summary: "Mark a draft pull request ready for review",
342 args: "<number>",
343 options: { undo: { type: "boolean", desc: "Convert the pull request back to a draft" } },
344 repo: true,
345 json: true,
346 minArgs: 1,
347 maxArgs: 1,
348 async run(ctx, o, [num]) {
349 const n = positiveInt(num, "pull request number");
350 const repo = await ctx.repo();
351 const pr = await (await ctx.authedApi()).patch<IssueJson>(`${repoPath(repo)}/pulls/${n}`, { draft: o.undo === true });
352 if (ctx.json) return json(ctx.io, pr);
353 ctx.out(`${ctx.c.green("✓")} ${o.undo === true ? `Converted #${n} to a draft` : `Marked #${n} ready for review`}`);
354 },
355};
356
357export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, ready, comment] };