| 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 { currentBranch } from "../git.js"; |
| 5 | import { CliError, ExitCode, readAll } from "../io.js"; |
| 6 | import { ago, json, statusText, symbol, table, truncate } from "../output.js"; |
| 7 | |
| 8 | export 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 | |
| 32 | interface 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 | |
| 41 | export 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`. */ |
| 49 | export 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 | |
| 61 | export 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 | |
| 66 | export 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 | |
| 73 | const 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 | |
| 110 | const 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 | |
| 137 | async 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 | |
| 143 | const 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 | |
| 172 | function 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 | |
| 177 | const 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"]) return ctx.io.stdout.write(files.map((f) => `${f.filename}\n`).join("")); |
| 189 | const c = ctx.c; |
| 190 | for (const f of files) { |
| 191 | const patch = f.patch ?? `diff --git a/${f.filename} b/${f.filename}\n`; |
| 192 | if (!c.enabled) { |
| 193 | ctx.io.stdout.write(patch.endsWith("\n") ? patch : `${patch}\n`); |
| 194 | continue; |
| 195 | } |
| 196 | for (const line of patch.replace(/\n$/, "").split("\n")) { |
| 197 | if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) ctx.out(c.bold(line)); |
| 198 | else if (line.startsWith("@@")) ctx.out(c.cyan(line)); |
| 199 | else if (line.startsWith("+")) ctx.out(c.green(line)); |
| 200 | else if (line.startsWith("-")) ctx.out(c.red(line)); |
| 201 | else ctx.out(line); |
| 202 | } |
| 203 | } |
| 204 | }, |
| 205 | }; |
| 206 | |
| 207 | const checks: Command = { |
| 208 | name: "pr checks", |
| 209 | summary: "Show check status for a pull request", |
| 210 | description: "Exit status: 0 when all checks passed, 1 when any failed, 8 when some are still pending.", |
| 211 | args: "<number>", |
| 212 | repo: true, |
| 213 | json: true, |
| 214 | minArgs: 1, |
| 215 | maxArgs: 1, |
| 216 | async run(ctx, _o, [num]) { |
| 217 | const { pr } = await getPull(ctx, positiveInt(num, "pull request number")); |
| 218 | const list = pr.checks ?? []; |
| 219 | if (ctx.json) json(ctx.io, list); |
| 220 | else if (!list.length) ctx.err("No checks reported for this pull request."); |
| 221 | 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)]))); |
| 222 | const failed = list.some((k) => ["failure", "cancelled", "timed_out", "action_required"].includes(k.state)); |
| 223 | const pending = list.some((k) => ["queued", "in_progress", "waiting", "pending", "requested"].includes(k.state)); |
| 224 | throw new ExitCode(failed ? 1 : pending ? 8 : 0); |
| 225 | }, |
| 226 | }; |
| 227 | |
| 228 | const review: Command = { |
| 229 | name: "pr review", |
| 230 | summary: "Approve, request changes or comment on a pull request", |
| 231 | args: "<number>", |
| 232 | options: { |
| 233 | approve: { type: "boolean", short: "a", desc: "Approve" }, |
| 234 | "request-changes": { type: "boolean", short: "r", desc: "Request changes" }, |
| 235 | comment: { type: "boolean", short: "c", desc: "Comment without approving" }, |
| 236 | ...bodyOpts, |
| 237 | }, |
| 238 | repo: true, |
| 239 | json: true, |
| 240 | minArgs: 1, |
| 241 | maxArgs: 1, |
| 242 | async run(ctx, o, [num]) { |
| 243 | const n = positiveInt(num, "pull request number"); |
| 244 | const which = exactlyOne(o, ["approve", "request-changes", "comment"]); |
| 245 | const event = which === "approve" ? "APPROVE" : which === "request-changes" ? "REQUEST_CHANGES" : "COMMENT"; |
| 246 | const body = await bodyFrom(ctx, o); |
| 247 | if (event !== "APPROVE" && !body) throw new CliError(`--body is required with --${which}`, 2); |
| 248 | const repo = await ctx.repo(); |
| 249 | const r = await (await ctx.authedApi()).post<{ id: string; state: string }>(`${repoPath(repo)}/pulls/${n}/reviews`, { event, body }); |
| 250 | if (ctx.json) return json(ctx.io, r); |
| 251 | const verb = event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed"; |
| 252 | ctx.out(`${ctx.c.green("✓")} ${verb} #${n}`); |
| 253 | }, |
| 254 | }; |
| 255 | |
| 256 | const merge: Command = { |
| 257 | name: "pr merge", |
| 258 | summary: "Merge a pull request", |
| 259 | args: "<number>", |
| 260 | options: { |
| 261 | merge: { type: "boolean", short: "m", desc: "Create a merge commit (default)" }, |
| 262 | squash: { type: "boolean", short: "s", desc: "Squash into one commit" }, |
| 263 | rebase: { type: "boolean", short: "r", desc: "Rebase the commits onto the base branch" }, |
| 264 | "delete-branch": { type: "boolean", short: "d", desc: "Delete the head branch after merging" }, |
| 265 | subject: { type: "string", short: "t", desc: "Commit title", arg: "TEXT" }, |
| 266 | ...bodyOpts, |
| 267 | "match-head-commit": { type: "string", desc: "Only merge if the head is still at this SHA", arg: "SHA" }, |
| 268 | }, |
| 269 | repo: true, |
| 270 | json: true, |
| 271 | minArgs: 1, |
| 272 | maxArgs: 1, |
| 273 | async run(ctx, o, [num]) { |
| 274 | const n = positiveInt(num, "pull request number"); |
| 275 | const method = exactlyOne(o, ["merge", "squash", "rebase"], "merge"); |
| 276 | const repo = await ctx.repo(); |
| 277 | const payload = { |
| 278 | merge_method: method, |
| 279 | delete_branch: o["delete-branch"] === true ? true : undefined, |
| 280 | commit_title: str(o, "subject"), |
| 281 | commit_message: await bodyFrom(ctx, o), |
| 282 | sha: str(o, "match-head-commit"), |
| 283 | }; |
| 284 | const r = await (await ctx.authedApi()).put<{ merged: boolean; sha: string; method: string }>(`${repoPath(repo)}/pulls/${n}/merge`, payload); |
| 285 | if (ctx.json) return json(ctx.io, r); |
| 286 | 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" : ""}`); |
| 287 | }, |
| 288 | }; |
| 289 | |
| 290 | const comment: Command = { |
| 291 | name: "pr comment", |
| 292 | summary: "Comment on a pull request", |
| 293 | args: "<number>", |
| 294 | options: bodyOpts, |
| 295 | repo: true, |
| 296 | json: true, |
| 297 | minArgs: 1, |
| 298 | maxArgs: 1, |
| 299 | async run(ctx, o, [num]) { |
| 300 | const n = positiveInt(num, "pull request number"); |
| 301 | const body = await bodyFrom(ctx, o, true); |
| 302 | if (!body?.trim()) throw new CliError("--body is required", 2); |
| 303 | const repo = await ctx.repo(); |
| 304 | const r = await (await ctx.authedApi()).post<CommentJson>(`${repoPath(repo)}/issues/${n}/comments`, { body }); |
| 305 | if (ctx.json) return json(ctx.io, r); |
| 306 | ctx.out(`${ctx.c.green("✓")} Commented on #${n}`); |
| 307 | }, |
| 308 | }; |
| 309 | |
| 310 | export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, comment] }; |