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.

313 lines · 13.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 Values } from "../cli.js";
4import { currentBranch } from "../git.js";
5import { CliError, ExitCode, readAll } from "../io.js";
6import { ago, json, statusText, symbol, table, truncate } from "../output.js";
7
8export interface IssueJson {
9 number: number;
10 title: string;
11 body: string;
12 state: "open" | "closed";
13 state_reason: string | null;
14 user: { login: string; type: string };
15 labels: Array<{ name: string }>;
16 assignees: Array<{ login: string }>;
17 comments: number;
18 created_at: string;
19 updated_at: string;
20 closed_at: string | null;
21 html_url: string;
22 pull_request?: { url: string };
23 head?: { ref: string; sha: string };
24 base?: { ref: string };
25 draft?: boolean;
26 merged?: boolean;
27 merged_at?: string | null;
28 merged_by?: { login: string } | null;
29 intent?: string | null;
30}
31
32interface PullDetail extends IssueJson {
33 mergeable: boolean;
34 mergeable_state: string;
35 merge_blockers: string[];
36 approvals: number;
37 required_approvals: number;
38 checks: Array<{ name: string; state: string }>;
39}
40
41export interface CommentJson {
42 id: string;
43 body: string;
44 user: { login: string };
45 created_at: string;
46}
47
48/** -b/--body, else --body-file (or "-" for stdin), else piped stdin when `stdinFallback`. */
49export async function bodyFrom(ctx: Ctx, o: Values, stdinFallback = false): Promise<string | undefined> {
50 const b = str(o, "body");
51 if (b !== undefined) return b;
52 const f = str(o, "body-file");
53 if (f !== undefined) {
54 if (f === "-") return readAll(ctx.io.stdin);
55 return readFile(path.resolve(ctx.io.cwd, f), "utf8");
56 }
57 if (stdinFallback && !ctx.io.stdin.isTTY) return readAll(ctx.io.stdin);
58 return undefined;
59}
60
61export const bodyOpts = {
62 body: { type: "string" as const, short: "b", desc: "Body text (Markdown)", arg: "TEXT" },
63 "body-file": { type: "string" as const, desc: "Read the body from a file ('-' for stdin)", arg: "FILE" },
64};
65
66export function prState(c: Ctx["c"], p: IssueJson): string {
67 if (p.merged) return c.magenta("merged");
68 if (p.state === "closed") return c.red("closed");
69 if (p.draft) return c.dim("draft");
70 return c.green("open");
71}
72
73const create: Command = {
74 name: "pr create",
75 summary: "Open a pull request",
76 options: {
77 title: { type: "string", short: "t", desc: "Title (required)", arg: "TEXT" },
78 ...bodyOpts,
79 head: { type: "string", short: "H", desc: "Branch with your changes (default: current branch)", arg: "BRANCH" },
80 base: { type: "string", short: "B", desc: "Branch to merge into (default: the repository's default branch)", arg: "BRANCH" },
81 draft: { type: "boolean", short: "d", desc: "Open as a draft" },
82 intent: { type: "string", desc: "What this change is for, for reviewers (agents should always set it)", arg: "TEXT" },
83 reviewer: { type: "string", short: "r", multiple: true, desc: "Request a review from this login", arg: "LOGIN" },
84 },
85 repo: true,
86 json: true,
87 maxArgs: 0,
88 async run(ctx, o) {
89 const title = str(o, "title");
90 if (!title) throw new CliError("--title is required", 2);
91 const repo = await ctx.repo();
92 const head = str(o, "head") ?? (await currentBranch(ctx.io));
93 const reviewers = list(o, "reviewer");
94 const payload = {
95 title,
96 body: await bodyFrom(ctx, o),
97 head,
98 base: str(o, "base"),
99 draft: o.draft === true ? true : undefined,
100 intent: str(o, "intent"),
101 reviewers: reviewers.length ? reviewers : undefined,
102 };
103 const pr = await (await ctx.authedApi()).post<IssueJson>(`${repoPath(repo)}/pulls`, payload);
104 if (ctx.json) return json(ctx.io, pr);
105 ctx.out(`${ctx.c.green("✓")} Opened #${pr.number} ${pr.title} (${pr.head?.ref} → ${pr.base?.ref})`);
106 ctx.out(pr.html_url);
107 },
108};
109
110const listCmd: Command = {
111 name: "pr list",
112 summary: "List pull requests",
113 options: {
114 state: { type: "string", short: "s", desc: "open, closed, merged or all (default: open)", arg: "STATE" },
115 limit: { type: "string", short: "L", desc: "Maximum number to show (default 30)", arg: "N" },
116 author: { type: "string", short: "A", desc: "Only pull requests opened by this login", arg: "LOGIN" },
117 },
118 repo: true,
119 json: true,
120 maxArgs: 0,
121 async run(ctx, o) {
122 const repo = await ctx.repo();
123 const state = str(o, "state") ?? "open";
124 if (!["open", "closed", "merged", "all"].includes(state)) throw new CliError("--state must be open, closed, merged or all", 2);
125 const limit = str(o, "limit") ? positiveInt(str(o, "limit"), "--limit") : 30;
126 const api = await ctx.api();
127 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") });
128 if (state === "merged") prs = prs.filter((p) => p.merged);
129 prs = prs.slice(0, limit);
130 if (ctx.json) return json(ctx.io, prs);
131 if (!prs.length) return ctx.err(`No ${state === "all" ? "" : `${state} `}pull requests in ${repo.owner}/${repo.name}.`);
132 const c = ctx.c;
133 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))])));
134 },
135};
136
137async function getPull(ctx: Ctx, n: number) {
138 const repo = await ctx.repo();
139 const api = await ctx.api();
140 return { repo, api, pr: await api.get<PullDetail>(`${repoPath(repo)}/pulls/${n}`) };
141}
142
143const view: Command = {
144 name: "pr view",
145 summary: "Show a pull request",
146 args: "<number>",
147 options: { comments: { type: "boolean", short: "c", desc: "Include the conversation" } },
148 repo: true,
149 json: true,
150 minArgs: 1,
151 maxArgs: 1,
152 async run(ctx, o, [num]) {
153 const n = positiveInt(num, "pull request number");
154 const { pr, api, repo } = await getPull(ctx, n);
155 const comments = o.comments ? await api.get<CommentJson[]>(`${repoPath(repo)}/issues/${n}/comments`) : undefined;
156 if (ctx.json) return json(ctx.io, comments ? { ...pr, comment_list: comments } : pr);
157 const c = ctx.c;
158 ctx.out(`${c.bold(pr.title)} ${c.dim(`#${pr.number}`)}`);
159 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)}`);
160 if (pr.intent) ctx.out(`${c.dim("intent:")} ${pr.intent}`);
161 if (pr.state === "open") {
162 ctx.out(`${c.dim("mergeable:")} ${statusText(c, pr.mergeable_state)} · approvals ${pr.approvals}/${pr.required_approvals}`);
163 for (const b of pr.merge_blockers ?? []) ctx.out(` ${c.red("•")} ${b}`);
164 }
165 if (pr.checks?.length) ctx.out(`${c.dim("checks:")} ${pr.checks.map((k) => `${checkSymbol(ctx, k.state)} ${k.name}`).join(" ")}`);
166 if (pr.body?.trim()) ctx.out(`\n${pr.body.trim()}\n`);
167 for (const cm of comments ?? []) ctx.out(`\n${c.bold(cm.user.login)} ${c.dim(ago(cm.created_at))}\n${cm.body.trim()}`);
168 ctx.out(c.dim(pr.html_url));
169 },
170};
171
172function checkSymbol(ctx: Ctx, state: string): string {
173 const done = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale"];
174 return done.includes(state) ? symbol(ctx.c, "completed", state) : symbol(ctx.c, state);
175}
176
177const diff: Command = {
178 name: "pr diff",
179 summary: "Show a pull request's changes as a unified diff",
180 args: "<number>",
181 options: { "name-only": { type: "boolean", desc: "Only list changed file names" } },
182 repo: true,
183 minArgs: 1,
184 maxArgs: 1,
185 async run(ctx, o, [num]) {
186 const repo = await ctx.repo();
187 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`);
188 if (o["name-only"]) {
189 ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join(""));
190 return;
191 }
192 const c = ctx.c;
193 for (const f of files) {
194 const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`;
195 if (!c.enabled) {
196 ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`);
197 continue;
198 }
199 for (const line of patch.replace(/\n$/, "").split("\n")) {
200 if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line));
201 else if (line.startsWith("@@")) ctx.out(c.cyan(line));
202 else if (line.startsWith("+")) ctx.out(c.green(line));
203 else if (line.startsWith("-")) ctx.out(c.red(line));
204 else ctx.out(line);
205 }
206 }
207 },
208};
209
210const checks: Command = {
211 name: "pr checks",
212 summary: "Show check status for a pull request",
213 description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.",
214 args: "<number>",
215 repo: true,
216 json: true,
217 minArgs: 1,
218 maxArgs: 1,
219 async run(ctx, _o, [num]) {
220 const { pr } = await getPull(ctx, positiveInt(num, "pull request number"));
221 const list = pr.checks ?? [];
222 if (ctx.json) json(ctx.io, list);
223 else if (!list.length) ctx.err("No checks reported for this pull request.");
224 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)])));
225 const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state));
226 const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state));
227 throw new ExitCode(failed ? 1 : pending ? 8 : 0);
228 },
229};
230
231const review: Command = {
232 name: "pr review",
233 summary: "Approve, request changes or comment on a pull request",
234 args: "<number>",
235 options: {
236 approve: { type: "boolean", short: "a", desc: "Approve" },
237 "request-changes": { type: "boolean", short: "r", desc: "Request changes" },
238 comment: { type: "boolean", short: "c", desc: "Comment without approving" },
239 ...bodyOpts,
240 },
241 repo: true,
242 json: true,
243 minArgs: 1,
244 maxArgs: 1,
245 async run(ctx, o, [num]) {
246 const n = positiveInt(num, "pull request number");
247 const which = exactlyOne(o, ["approve", "request-changes", "comment"]);
248 const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT";
249 const body = await bodyFrom(ctx, o);
250 if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2);
251 const repo = await ctx.repo();
252 const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body });
253 if (ctx.json) return json(ctx.io, r);
254 const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed";
255 ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`);
256 },
257};
258
259const merge: Command = {
260 name: "pr merge",
261 summary: "Merge a pull request",
262 args: "<number>",
263 options: {
264 merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" },
265 squash: { type: "boolean", short: "s", desc: "Squash into one commit" },
266 rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" },
267 "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" },
268 subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" },
269 ...bodyOpts,
270 "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" },
271 },
272 repo: true,
273 json: true,
274 minArgs: 1,
275 maxArgs: 1,
276 async run(ctx, o, [num]) {
277 const n = positiveInt(num, "pull request number");
278 const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge");
279 const repo = await ctx.repo();
280 const payload = {
281 merge_method: method,
282 delete_branch: o["delete-branch"] === true ? true : undefined,
283 commit_title: str(o, "subject"),
284 commit_message: await bodyFrom(ctx, o),
285 sha: str(o, "match-head-commit"),
286 };
287 const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload);
288 if (ctx.json) return json(ctx.io, r);
289 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" : ""}`);
290 },
291};
292
293const comment: Command = {
294 name: "pr comment",
295 summary: "Comment on a pull request",
296 args: "<number>",
297 options: bodyOpts,
298 repo: true,
299 json: true,
300 minArgs: 1,
301 maxArgs: 1,
302 async run(ctx, o, [num]) {
303 const n = positiveInt(num, "pull request number");
304 const body = await bodyFrom(ctx, o, true);
305 if (!body?.trim()) throw new CliError("--body is required", 2);
306 const repo = await ctx.repo();
307 const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body });
308 if (ctx.json) return json(ctx.io, r);
309 ctx.out(`${ctx.c.green("✓")} Commented on #${n}`);
310 },
311};
312
313export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, comment] };