| 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 RepoRef, type Values } from "../cli.js"; |
| 4 | import { ApiError } from "../api.js"; |
| 5 | import { currentBranch, gitCapture } 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 | 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). */ |
| 146 | const 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 | */ |
| 152 | async 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 | |
| 164 | async 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 | |
| 170 | const 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 | |
| 199 | function 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 | |
| 204 | const 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 | |
| 238 | const DONE = ["success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"]; |
| 239 | const FAILED = ["failure", "cancelled", "timed_out", "action_required"]; |
| 240 | const 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. */ |
| 242 | const FIRST_CHECK_WAIT_MS = 120_000; |
| 243 | |
| 244 | const 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 | |
| 300 | const 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 | |
| 328 | const 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 | |
| 414 | const 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 | |
| 434 | const 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 | |
| 452 | export const prGroup: Group = { name: "pr", summary: "Work with pull requests", commands: [create, listCmd, view, diff, checks, review, merge, ready, comment] }; |