| 1 | import { readFile } from "node:fs/promises"; |
| 2 | import path from "node:path"; |
| 3 | import { exactlyOne, list, positiveInt, repoPath, str, type Command, type Ctx, type Group, type Values } from "../cli.js"; |
| 4 | import { ApiError } from "../api.js"; |
| 5 | import { currentBranch } from "../git.js"; |
| 6 | import { CliError, ExitCode, readAll } from "../io.js"; |
| 7 | import { ago, json, statusText, symbol, table, truncate } from "../output.js"; |
| 8 | |
| 9 | export 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 | |
| 33 | interface 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 | |
| 42 | export 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`. */ |
| 50 | export 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 | |
| 62 | export 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 | |
| 67 | export 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 | |
| 74 | const 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 | |
| 111 | const 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 | |
| 138 | async 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 | |
| 144 | const 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 | |
| 173 | function 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 | |
| 178 | const 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 | |
| 211 | const 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 | |
| 232 | const 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 | |
| 260 | const 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 | auto: { type: "boolean", desc: "Turn on auto-merge: merge (or join the merge queue) once every requirement is met" }, |
| 274 | "disable-auto": { type: "boolean", desc: "Turn off auto-merge (and take the pull request out of the merge queue if it is in it)" }, |
| 275 | }, |
| 276 | repo: true, |
| 277 | json: true, |
| 278 | minArgs: 1, |
| 279 | maxArgs: 1, |
| 280 | async run(ctx, o, [num]) { |
| 281 | const n = positiveInt(num, "pull request number"); |
| 282 | const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); |
| 283 | const repo = await ctx.repo(); |
| 284 | const api = await ctx.authedApi(); |
| 285 | const explicit = ["merge", "squash", "rebase"].some((k) => o[k] === true); |
| 286 | if (o.auto === true && o["disable-auto"] === true) throw new CliError("use only one of --auto, --disable-auto", 2); |
| 287 | if (o.auto === true && o.admin === true) throw new CliError("--admin merges now; it can't be combined with --auto", 2); |
| 288 | if (o["disable-auto"] === true) { |
| 289 | // Like `gh pr merge --disable-auto`: turn auto-merge off. Diffinery should not land it by |
| 290 | // itself afterwards, so a pull request auto-merge (or someone) already queued leaves the queue too. |
| 291 | await api.delete(`${repoPath(repo)}/pulls/${n}/auto-merge`); |
| 292 | let dequeued = true; |
| 293 | try { |
| 294 | await api.delete(`${repoPath(repo)}/pulls/${n}/merge-queue`); |
| 295 | } catch (e) { |
| 296 | if (!(e instanceof ApiError) || e.status !== 404) throw e; |
| 297 | dequeued = false; |
| 298 | } |
| 299 | if (ctx.json) return json(ctx.io, { auto_merge: null, dequeued }); |
| 300 | return ctx.out(`${ctx.c.green("✓")} Turned off auto-merge for #${n}${dequeued ? " and removed it from the merge queue" : ""}`); |
| 301 | } |
| 302 | if (o.auto === true) { |
| 303 | // Like `gh pr merge --auto`: PUT /pulls/{n}/auto-merge. Without a method flag the server picks |
| 304 | // the merge queue's method, squash where linear history is required, else a merge commit. |
| 305 | 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`, { |
| 306 | merge_method: explicit ? method : undefined, |
| 307 | delete_branch: o["delete-branch"] === true ? true : undefined, |
| 308 | commit_title: str(o, "subject"), |
| 309 | commit_message: await bodyFrom(ctx, o), |
| 310 | sha: str(o, "match-head-commit"), |
| 311 | }); |
| 312 | if (ctx.json) return json(ctx.io, r); |
| 313 | if (r.state === "merged") return ctx.out(`${ctx.c.green("✓")} Merged #${n} ${ctx.c.dim(String(r.merge_commit_sha ?? "").slice(0, 12))}`); |
| 314 | if (r.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue`); |
| 315 | 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`); |
| 316 | } |
| 317 | const payload = { |
| 318 | merge_method: method, |
| 319 | delete_branch: o["delete-branch"] === true ? true : undefined, |
| 320 | commit_title: str(o, "subject"), |
| 321 | commit_message: await bodyFrom(ctx, o), |
| 322 | sha: str(o, "match-head-commit"), |
| 323 | bypass_merge_queue: o.admin === true ? true : undefined, |
| 324 | bypass_rules: o.admin === true ? true : undefined, |
| 325 | }; |
| 326 | let r: { merged: boolean; sha: string; method: string }; |
| 327 | try { |
| 328 | r = await api.put(`${repoPath(repo)}/pulls/${n}/merge`, payload); |
| 329 | } catch (e) { |
| 330 | // Like `gh pr merge`: a base branch with a merge queue gets the pull request queued instead. |
| 331 | if (!(e instanceof ApiError) || e.status !== 405 || !/merge queue/.test(e.message) || o.admin === true) throw e; |
| 332 | 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`, { |
| 333 | merge_method: explicit ? method : undefined, |
| 334 | delete_branch: payload.delete_branch, |
| 335 | }); |
| 336 | if (ctx.json) return json(ctx.io, q); |
| 337 | if (q.state === "queued") return ctx.out(`${ctx.c.green("✓")} Added #${n} to the merge queue (position ${q.entry?.position ?? "?"})`); |
| 338 | 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))}`); |
| 339 | throw new CliError(`#${n} left the merge queue: ${q.reason ?? "see the pull request"}`); |
| 340 | } |
| 341 | if (ctx.json) return json(ctx.io, r); |
| 342 | 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" : ""}`); |
| 343 | }, |
| 344 | }; |
| 345 | |
| 346 | const comment: Command = { |
| 347 | name: "pr comment", |
| 348 | summary: "Comment on a pull request", |
| 349 | args: "<number>", |
| 350 | options: bodyOpts, |
| 351 | repo: true, |
| 352 | json: true, |
| 353 | minArgs: 1, |
| 354 | maxArgs: 1, |
| 355 | async run(ctx, o, [num]) { |
| 356 | const n = positiveInt(num, "pull request number"); |
| 357 | const body = await bodyFrom(ctx, o, true); |
| 358 | if (!body?.trim()) throw new CliError("--body is required", 2); |
| 359 | const repo = await ctx.repo(); |
| 360 | const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); |
| 361 | if (ctx.json) return json(ctx.io, r); |
| 362 | ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); |
| 363 | }, |
| 364 | }; |
| 365 | |
| 366 | const ready: Command = { |
| 367 | name: "pr ready", |
| 368 | summary: "Mark a draft pull request ready for review", |
| 369 | args: "<number>", |
| 370 | options: { undo: { type: "boolean", desc: "Convert the pull request back to a draft" } }, |
| 371 | repo: true, |
| 372 | json: true, |
| 373 | minArgs: 1, |
| 374 | maxArgs: 1, |
| 375 | async run(ctx, o, [num]) { |
| 376 | const n = positiveInt(num, "pull request number"); |
| 377 | const repo = await ctx.repo(); |
| 378 | const pr = await (await ctx.authedApi()).patch<IssueJson>(`${repoPath(repo)}/pulls/${n}`, { draft: o.undo === true }); |
| 379 | if (ctx.json) return json(ctx.io, pr); |
| 380 | ctx.out(`${ctx.c.green("✓")} ${o.undo === true ? `Converted #${n} to a draft` : `Marked #${n} ready for review`}`); |
| 381 | }, |
| 382 | }; |
| 383 | |
| 384 | export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, ready, comment] }; |