/** * `pr` — open, merge or update one pull request through the broker. * * `pr ` runs as the catalogued gh action when the catalog has one (`pr:view`, * `pr:close`, …); this file is the flag-only lifecycle for the actions gh cannot express. * * The credential never leaves the broker. The broker mints a token narrowed to `pull_requests: write` * plus read access to the branches it names, performs the action itself, and answers with the number, * the URL and the state: the agent gets the lifecycle of a pull request without ever holding * something that could push to it. * * Exit codes: 0 done, 1 refused, 3 broker unreachable. */ import { readFileSync } from 'node:fs'; import { ANSWER_TIMEOUT_MS, channelDir, findGitDir, readOpener, removeResult, waitForResult, writeRequest, } from 'gcb-cli'; import { requestOverSocket, resolveSocketPath } from '../helper.ts'; import { baseBranchFor, qualifiedBranch } from './remotes.ts'; import { fail, parseArgs, say } from 'gcb-cli'; /** Longer than a credential request: a token is minted, then the action is performed. */ const PR_TIMEOUT_MS = 60_000; /** Usage text for `pr`. */ export const PR_USAGE = `Usage: gcb pr [options] Open, merge, update or inspect a pull request through the broker, without the credential ever reaching this side. A subcommand the gh action catalog has (pr view, pr close, …) runs as that action instead; gcb gh --help lists them. The flags below are the flag-only lifecycle gh cannot express. Opening (the default) --head The branch holding the change [required] May be :, e.g. origin:feat/x, to name a fork --base The branch to merge into (default: main) A : here names the same repository as --repo --title The title [required] --body The body, inline --body-file The body, read from a file — a pull request body is usually long --draft Open it as a draft --via-host Ask a host-side opener to create it, as you, instead of as the app (run 'gcb host-opener' on the host to provide one) Acting on one --number The pull request number [required] --proxy Use this proxy for this request --no-proxy Reach GitHub directly for this request --status Report its state, mergeability and the runs for its commit --comment Post an anchored review comment (also needs --file and --line); for a plain comment use: gcb gh pr comment --body ... --file Anchor that comment to a line of this file, repository-relative --line The line to anchor to, in the new file unless --side says otherwise --side Which side of the diff --line counts on (default: right) --threads List its review threads: their ids, whether they are resolved, and the comments --reply-to Reply inside the thread that comment id belongs to, with --body/--body-file --edit Replace the body of that review comment, with --body/--body-file (an id --threads prints; no --number is needed) --resolve Mark that review thread resolved (the id --threads prints) --merge Merge it (branch protection still applies) --method merge, squash (default) or rebase With --number and none of the above, the title, body or base is updated. Common --socket Broker socket (default: $GIT_BROKER_SOCKET or the socket file) --host Host as configured, e.g. github.com (default: github.com) --repo Repository the pull request belongs to [required] -h, --help Show this help `; /** * Read the action out of the flags. * * @param args - the parsed arguments. * @returns `merge`, `update` or `open`. */ function actionOf(args: { has(name: string): boolean; value(name: string): string | undefined }) { // `--resolve`, `--reply-to` and `--edit` take a value, and a bare one is parsed as a boolean flag with // no value at all. Selecting the action from the flag's *presence* is what turns that into the refusal // below — reading only `value` let a bare `--edit` fall through to `update`, which patched the pull // request itself with the body meant for the comment. const present = (name: string): boolean => args.has(name) || args.value(name) !== undefined; if (present('resolve')) return 'resolve' as const; if (present('reply-to')) return 'reply' as const; if (present('edit')) return 'edit' as const; if (args.has('threads')) return 'threads' as const; if (args.has('comment')) return 'comment' as const; if (args.has('status')) return 'status' as const; if (args.has('merge')) return 'merge' as const; if (args.value('number') !== undefined) return 'update' as const; return 'open' as const; } /** * Run the `pr` command. * * @param argv - arguments after the command name. * @returns the process exit code: 0 done, 1 refused, 3 unreachable. */ export async function runPr(argv: readonly string[]): Promise { const args = parseArgs(argv, { booleans: ['help', 'draft', 'merge', 'status', 'comment', 'threads', 'via-host', 'no-proxy'], }); if (args.has('help') || args.has('h')) { say(PR_USAGE); return 0; } // Before --repo: the point of the message is to name the command that does it now. if (args.has('close') || args.value('close') !== undefined) { fail('--close moved to: gcb gh pr close --repo owner/name'); } // Before --repo too: a bare `gcb pr --comment` should hear what the flag needs, not that --repo // is missing. An anchored comment (--file and --line) is still this command's to post. if (args.has('comment') && args.value('file') === undefined && args.value('line') === undefined) { fail('--comment only posts an anchored review comment; pass --file and --line'); } if (args.has('no-proxy') && args.value('proxy') !== undefined) { fail('--proxy and --no-proxy are mutually exclusive'); } if (args.has('proxy') && args.value('proxy') === undefined) fail('--proxy needs a value'); const proxy = args.has('no-proxy') ? null : args.value('proxy'); const socketPath = resolveSocketPath(args.value('socket'), process.env); const host = args.value('host') ?? 'github.com'; const repo = args.value('repo') ?? fail('--repo is required (e.g. owner/name)'); const action = actionOf(args); if (args.has('via-host') && action !== 'open') { fail('--via-host only creates a pull request, so it does not go with ' + JSON.stringify(action)); } // `--method` takes a value, so `has` — which is about boolean flags — is false for it however it is given. if (args.value('method') !== undefined && action !== 'merge') { fail('--method chooses how to merge, so it only goes with --merge'); } const viaHost = action === 'open' && args.has('via-host'); if (!repo) fail('--repo is required (e.g. owner/name), also with --via-host: the opener passes it on'); const number = args.value('number'); // `--head origin:feat/x` and `--base upstream:main` name a configured remote instead of a repository // typed by hand: it is the name this checkout already pushes to, and a typo lists the ones that exist. // The base is a branch of the repository --repo names, so the owner a remote resolves to is a check // rather than part of the value: sending `owner:branch` as a base names a branch that cannot exist. const baseBranch = (): string | undefined => { const raw = args.value('base'); if (raw === undefined) return undefined; try { return baseBranchFor(raw, repo, process.cwd()); } catch (error) { fail((error as Error).message); } }; const qualified = (value: string | undefined): string | undefined => { if (value === undefined) return undefined; try { return qualifiedBranch(value, process.cwd()); } catch (error) { fail((error as Error).message); } }; const head = qualified(args.value('head')); const base = baseBranch(); const title = args.value('title'); const bodyFile = args.value('body-file'); let body = args.value('body'); if (bodyFile !== undefined) { try { body = readFileSync(bodyFile, 'utf8'); } catch (error) { fail(`cannot read ${bodyFile}: ${(error as Error).message}`); } } if (action === 'open') { if (!head) fail('--head is required to open a pull request'); if (!title) fail('--title is required to open a pull request'); } else if (action !== 'resolve' && action !== 'edit' && number === undefined) { const verb = action === 'status' ? 'inspect' : action; fail(`--number is required to ${verb} a pull request`); } // `--resolve` and `--edit` name their target by id and need no pull request number, but one given // anyway still has to be a number. if (number !== undefined && (!Number.isInteger(Number(number)) || Number(number) <= 0)) { fail(`--number must be a positive integer, got ${JSON.stringify(number)}`); } if (action === 'update' && title === undefined && body === undefined && base === undefined) { fail('an update has to change something: pass --title, --body/--body-file or --base'); } if ((action === 'comment' || action === 'reply' || action === 'edit') && (body === undefined || body.trim() === '')) { fail('a comment has to say something: pass --body or --body-file'); } const filePath = args.value('file'); const lineArg = args.value('line'); const side = (args.value('side') ?? 'right').toLowerCase(); if ((filePath === undefined) !== (lineArg === undefined)) { fail('an anchored comment needs both --file and --line, or neither'); } if (filePath !== undefined) { if (action !== 'comment') fail('--file and --line anchor a comment, so they only go with --comment'); if (!Number.isInteger(Number(lineArg)) || Number(lineArg) <= 0) { fail(`--line must be a positive integer, got ${JSON.stringify(lineArg)}`); } if (side !== 'left' && side !== 'right') { fail(`--side must be left or right, got ${JSON.stringify(args.value('side'))}`); } } const replyTo = args.value('reply-to'); if (action === 'reply' && (replyTo === undefined || !Number.isInteger(Number(replyTo)) || Number(replyTo) <= 0)) { fail(`--reply-to needs the id of the comment being answered, got ${JSON.stringify(replyTo)}`); } const editId = args.value('edit'); if (action === 'edit' && (editId === undefined || !Number.isInteger(Number(editId)) || Number(editId) <= 0)) { fail(`--edit needs the id of the review comment being changed, got ${JSON.stringify(editId)}`); } const resolveId = args.value('resolve'); if (action === 'resolve' && (resolveId === undefined || resolveId.trim() === '')) { fail(`--resolve needs the id of the thread being resolved, got ${JSON.stringify(resolveId)}`); } if (viaHost) { if (head === undefined || title === undefined) fail('--via-host needs --head and --title'); const gitDir = findGitDir(process.cwd()); if (gitDir === undefined) fail('--via-host needs a git repository: run it from the checkout'); const dir = channelDir(gitDir); if (readOpener(dir) === undefined) { fail( `no host opener is watching ${dir}\n` + ' start one on the host: gcb host-opener --repo \n' + ' or drop --via-host to open it as the app instead', ); } // writeRequest refuses a request the opener would have to refuse: a title past its limit, a body past // its limit, a head equal to its base. It throws, and a stack trace tells the caller none of that. let id: string; try { id = writeRequest(dir, { repo, head, base: base ?? 'main', title, body: body ?? '', draft: args.has('draft'), proxy, session: 'pr', pid: process.pid, }); } catch (error) { fail((error as Error).message); } const answer = await waitForResult(dir, id, ANSWER_TIMEOUT_MS); removeResult(dir, id); if (answer === undefined) { fail( `the host opener did not answer ${id} within ${Math.round(ANSWER_TIMEOUT_MS / 1000)}s\n` + ' it may still be working: the request is not cancelled, and the opener removes it only once\n' + ' it has created the pull request or failed. Check the pull request list before asking again.', ); } if (answer.error !== undefined) fail(`the host opener could not open it: ${answer.error}`); say(`opened #${answer.number ?? '?'} (as you, through the host opener): ${answer.url ?? '(no url)'}`); return 0; } if (socketPath === undefined) { fail('no broker socket: pass --socket, set GIT_BROKER_SOCKET, or run `gcb setup`'); } let response; try { response = await requestOverSocket( socketPath, { v: 1, op: 'pull-request', host, path: repo, action, ...(number === undefined || action === 'edit' ? {} : { number: Number(number) }), ...(action === 'reply' ? { commentId: Number(replyTo) } : {}), ...(action === 'edit' ? { commentId: Number(editId) } : {}), ...(action === 'resolve' ? { threadId: args.value('resolve') } : {}), ...(head === undefined ? {} : { head }), ...(base === undefined ? {} : { base }), ...(title === undefined ? {} : { title }), ...(body === undefined ? {} : { body }), ...(filePath === undefined ? {} : { filePath, line: Number(lineArg), side }), ...(action === 'merge' ? { method: args.value('method') ?? 'squash' } : {}), draft: args.has('draft'), session: 'pr', pid: process.pid, proxy, }, PR_TIMEOUT_MS, ); } catch (error) { process.stderr.write(`unreachable: ${(error as Error).message}\n`); return 3; } if (!response.ok) { process.stderr.write(`refused [${response.code ?? 'unknown'}]: ${response.reason ?? 'no reason given'}\n`); return 1; } if (action === 'status' || action === 'threads' || action === 'resolve') { say(response.prStatus ?? 'no report'); return 0; } const done = action === 'open' ? 'opened' : action === 'merge' ? 'merged' : action === 'comment' ? 'commented on' : action === 'reply' ? 'replied in' : action === 'edit' ? 'edited a review comment on' : 'updated'; const state = response.prState === undefined ? '' : ` (${response.prState})`; say(`${done} #${response.prNumber ?? '?'}${state}: ${response.prUrl ?? '(no url)'}`); return 0; }