|
| 1 | +// `loopover-miner pr-outcomes --miner-login <login> [--limit <n>] [--json]` (#7658): read-only report of the |
| 2 | +// miner's own hosted post-merge outcome history. Calls the hosted `GET /v1/contributors/:login/pr-outcomes` |
| 3 | +// (src/signals/contributor-pr-outcomes.ts — public-safe attribution only, no reward/wallet fields) using the |
| 4 | +// same loopover-mcp session + API URL posture the other backend calls in this package use |
| 5 | +// (resolveLoopoverBackendSession, #6487). Thin composition layer like tenant-cli.js: argv parsing plus one |
| 6 | +// authenticated GET; every failure (no session, unreachable host, non-2xx, malformed body) is reported as a |
| 7 | +// non-zero exit with a visible message — reading your own outcome history is a deliberate action whose |
| 8 | +// failure the miner must see, so there is deliberately no silent-degrade path. |
| 9 | +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; |
| 10 | +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; |
| 11 | + |
| 12 | +const PR_OUTCOMES_USAGE = "Usage: loopover-miner pr-outcomes --miner-login <login> [--limit <n>] [--json]"; |
| 13 | +// Matches the route's own AbortSignal-based read timeouts elsewhere in this package (self-review-context.js). |
| 14 | +const PR_OUTCOMES_TIMEOUT_MS = 10_000; |
| 15 | + |
| 16 | +export type ParsedPrOutcomesArgs = { minerLogin: string; limit: number | null; json: boolean } | { error: string }; |
| 17 | + |
| 18 | +/** One hosted outcome row, as `GET /v1/contributors/:login/pr-outcomes` returns it. */ |
| 19 | +export type PrOutcomeRow = { |
| 20 | + repoFullName: string; |
| 21 | + pullNumber: number | null; |
| 22 | + outcome: string; |
| 23 | + attribution: string; |
| 24 | + deeplink: string; |
| 25 | + recordedAt: string; |
| 26 | +}; |
| 27 | + |
| 28 | +export type PrOutcomesPayload = { |
| 29 | + login: string; |
| 30 | + count: number; |
| 31 | + summary: string; |
| 32 | + outcomes: PrOutcomeRow[]; |
| 33 | +}; |
| 34 | + |
| 35 | +// A narrower shape than `typeof fetch` on purpose: this command only ever issues a plain GET with headers and |
| 36 | +// a signal, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored — same |
| 37 | +// rationale as self-review-context.js's own SelfReviewContextFetch. |
| 38 | +export type PrOutcomesFetch = ( |
| 39 | + url: string, |
| 40 | + init?: { method?: string; headers?: Record<string, string>; signal?: AbortSignal }, |
| 41 | +) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown>; text: () => Promise<string> }>; |
| 42 | + |
| 43 | +export type RunPrOutcomesOptions = { |
| 44 | + /** Read for the loopover-mcp session/config resolution — defaults to `process.env`. */ |
| 45 | + env?: NodeJS.ProcessEnv; |
| 46 | + /** Injected fetch so tests drive the CLI without a live backend; defaults to the real global fetch. */ |
| 47 | + fetchImpl?: PrOutcomesFetch; |
| 48 | + /** Injectable session resolver so tests exercise the CLI without a config file on disk. */ |
| 49 | + resolveSession?: typeof resolveLoopoverBackendSession; |
| 50 | +}; |
| 51 | + |
| 52 | +/** Parse `pr-outcomes --miner-login <login> [--limit <n>] [--json]`. Returns the parsed args or `{ error }`. |
| 53 | + * `--limit` mirrors the route's own `?limit` validation (an integer between 1 and 100) so a bad value fails |
| 54 | + * here with a clear message instead of as an HTTP 400 round-trip. */ |
| 55 | +export function parsePrOutcomesArgs(args: string[]): ParsedPrOutcomesArgs { |
| 56 | + let minerLogin: string | null = null; |
| 57 | + let limit: number | null = null; |
| 58 | + let json = false; |
| 59 | + for (let index = 0; index < args.length; index += 1) { |
| 60 | + const token = args[index]!; |
| 61 | + if (token === "--json") { |
| 62 | + json = true; |
| 63 | + continue; |
| 64 | + } |
| 65 | + if (token === "--miner-login") { |
| 66 | + const value = args[index + 1]; |
| 67 | + if (!value || value.startsWith("-")) return { error: `--miner-login requires a value. ${PR_OUTCOMES_USAGE}` }; |
| 68 | + minerLogin = value; |
| 69 | + index += 1; |
| 70 | + continue; |
| 71 | + } |
| 72 | + if (token === "--limit") { |
| 73 | + const value = args[index + 1]; |
| 74 | + const parsed = Number(value); |
| 75 | + if (!value || !Number.isInteger(parsed) || parsed < 1 || parsed > 100) { |
| 76 | + return { error: `--limit must be an integer between 1 and 100. ${PR_OUTCOMES_USAGE}` }; |
| 77 | + } |
| 78 | + limit = parsed; |
| 79 | + index += 1; |
| 80 | + continue; |
| 81 | + } |
| 82 | + if (token.startsWith("-")) return { error: `Unknown option: ${token}. ${PR_OUTCOMES_USAGE}` }; |
| 83 | + return { error: `Unexpected argument: ${token}. ${PR_OUTCOMES_USAGE}` }; |
| 84 | + } |
| 85 | + if (minerLogin === null) return { error: `--miner-login is required. ${PR_OUTCOMES_USAGE}` }; |
| 86 | + return { minerLogin, limit, json }; |
| 87 | +} |
| 88 | + |
| 89 | +/** Render the text view: the payload's own summary line, then one line per outcome (newest first, as the |
| 90 | + * route returns them). A `pullNumber` can be null for an older delivery row — rendered as the repo alone. */ |
| 91 | +export function renderPrOutcomesText(payload: PrOutcomesPayload): string { |
| 92 | + const lines = [payload.summary]; |
| 93 | + for (const outcome of payload.outcomes) { |
| 94 | + const target = outcome.pullNumber === null ? outcome.repoFullName : `${outcome.repoFullName}#${outcome.pullNumber}`; |
| 95 | + lines.push(`- ${target} ${outcome.outcome} ${outcome.recordedAt} ${outcome.deeplink}`); |
| 96 | + } |
| 97 | + return lines.join("\n"); |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Run `loopover-miner pr-outcomes --miner-login <login> [--limit <n>] [--json]`. Fetches the miner's own |
| 102 | + * hosted post-merge outcomes and prints them (a JSON dump under `--json`, else a text summary). Returns the |
| 103 | + * process exit code: 0 on success, 1 on a usage error, 2 on a session/HTTP/network failure. |
| 104 | + */ |
| 105 | +export async function runPrOutcomesCli(args: string[] = [], options: RunPrOutcomesOptions = {}): Promise<number> { |
| 106 | + const parsed = parsePrOutcomesArgs(args); |
| 107 | + if ("error" in parsed) { |
| 108 | + return reportCliFailure(argsWantJson(args), parsed.error, 1); |
| 109 | + } |
| 110 | + const resolveSession = options.resolveSession ?? resolveLoopoverBackendSession; |
| 111 | + const session = resolveSession(options.env ?? process.env); |
| 112 | + if (!session) { |
| 113 | + return reportCliFailure( |
| 114 | + parsed.json, |
| 115 | + "No LoopOver session found — run `loopover-mcp login` first so pr-outcomes can read your own hosted outcome history.", |
| 116 | + ); |
| 117 | + } |
| 118 | + const fetchImpl = options.fetchImpl ?? (fetch as unknown as PrOutcomesFetch); |
| 119 | + const query = parsed.limit === null ? "" : `?limit=${parsed.limit}`; |
| 120 | + const url = `${session.apiUrl}/v1/contributors/${encodeURIComponent(parsed.minerLogin)}/pr-outcomes${query}`; |
| 121 | + try { |
| 122 | + const response = await fetchImpl(url, { |
| 123 | + method: "GET", |
| 124 | + headers: { authorization: `Bearer ${session.sessionToken}`, accept: "application/json" }, |
| 125 | + signal: AbortSignal.timeout(PR_OUTCOMES_TIMEOUT_MS), |
| 126 | + }); |
| 127 | + if (!response.ok) { |
| 128 | + const detail = (await response.text()).slice(0, 200); |
| 129 | + return reportCliFailure(parsed.json, `pr-outcomes request failed (HTTP ${response.status}): ${detail}`); |
| 130 | + } |
| 131 | + const payload = (await response.json()) as PrOutcomesPayload; |
| 132 | + if (parsed.json) { |
| 133 | + console.log(JSON.stringify(payload, null, 2)); |
| 134 | + } else { |
| 135 | + console.log(renderPrOutcomesText(payload)); |
| 136 | + } |
| 137 | + return 0; |
| 138 | + } catch (error) { |
| 139 | + return reportCliFailure(parsed.json, describeCliError(error)); |
| 140 | + } |
| 141 | +} |
0 commit comments