From 43281f82af4bc7d648e1176cd0e112c83978826b Mon Sep 17 00:00:00 2001 From: michiot05 <281539540+michiot05@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:27:13 +0200 Subject: [PATCH] feat(miner): add a pr-outcomes CLI for the hosted contributor outcome history Closes #7658. Adds 'loopover-miner pr-outcomes --miner-login [--limit ] [--json]' calling the hosted GET /v1/contributors/:login/pr-outcomes with the existing loopover-mcp session posture (resolveLoopoverBackendSession, #6487), following tenant-cli's structural template (exported parse fn, injectable fetch, fail-loud non-2xx). Dispatch lives in lib/cli.ts's now-async runCli so the whole path stays in-process-coverable (the bin dispatcher is subprocess-only-executed; see vitest.config.ts's coverage note); printHelp documents the command. Tests mock all HTTP per the package's *-cli.test conventions. --- packages/loopover-miner/bin/loopover-miner.ts | 3 +- packages/loopover-miner/lib/cli.ts | 11 +- .../loopover-miner/lib/pr-outcomes-cli.ts | 141 ++++++++++++ test/unit/miner-cli.test.ts | 13 +- test/unit/miner-pr-outcomes-cli.test.ts | 207 ++++++++++++++++++ 5 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 packages/loopover-miner/lib/pr-outcomes-cli.ts create mode 100644 test/unit/miner-pr-outcomes-cli.test.ts diff --git a/packages/loopover-miner/bin/loopover-miner.ts b/packages/loopover-miner/bin/loopover-miner.ts index ddf22f34c0..6694929fe9 100755 --- a/packages/loopover-miner/bin/loopover-miner.ts +++ b/packages/loopover-miner/bin/loopover-miner.ts @@ -228,6 +228,7 @@ if (cliArgs[0] === "loop") { process.exit(exitCode); } -const exitCode = runCli(cliArgs, { packageName }); +/* v8 ignore next -- bin dispatcher lines are subprocess-only executed (see the packages/loopover-miner/bin note in vitest.config.ts's coverage.include); the awaited runCli fallback, including its #7658 pr-outcomes dispatch, is fully unit-covered in-process via lib/cli.ts + lib/pr-outcomes-cli.ts. */ +const exitCode = await runCli(cliArgs, { packageName }); await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/loopover-miner/lib/cli.ts b/packages/loopover-miner/lib/cli.ts index d551903ccb..276a2e9fdb 100644 --- a/packages/loopover-miner/lib/cli.ts +++ b/packages/loopover-miner/lib/cli.ts @@ -1,4 +1,5 @@ import { argsWantJson, reportCliFailure } from "./cli-error.js"; +import { runPrOutcomesCli, type RunPrOutcomesOptions } from "./pr-outcomes-cli.js"; export function printVersion(input: { packageName: string; packageVersion: string }): void { console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); @@ -52,6 +53,7 @@ export function printHelp(input: { packageName: string }): void { " loopover-miner governor status [--json] Show whether the governor is paused", " loopover-miner governor metrics Print governor rate-limit/cap-usage counters in Prometheus text format", " loopover-miner calibration [--json] Report predicted-vs-realized gate accuracy", + " loopover-miner pr-outcomes --miner-login [--limit ] [--json] Show your own hosted post-merge PR outcomes", " loopover-miner feasibility [--not-found] [--json]", " loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]", " Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)", @@ -74,8 +76,15 @@ export function printHelp(input: { packageName: string }): void { ); } -export function runCli(cliArgs: string[], input: { packageName: string }): number { +export async function runCli(cliArgs: string[], input: { packageName: string }, options: RunPrOutcomesOptions = {}): Promise { const command = cliArgs[0] ?? ""; + // `pr-outcomes` (#7658) dispatches HERE, in the foundation CLI module, rather than growing another branch in + // bin/loopover-miner.ts: the bin dispatcher is subprocess-only-tested and genuinely Codecov-graded (see the + // packages/loopover-miner/bin note in vitest.config.ts's coverage.include), so a command dispatched in this + // in-process-tested module keeps its whole path measurable instead of adding permanently-uncovered bin lines. + if (command === "pr-outcomes") { + return runPrOutcomesCli(cliArgs.slice(1), options); + } const message = `Unknown command: ${command}. Run ${input.packageName} --help.`; return reportCliFailure(argsWantJson(cliArgs), message, 1); } diff --git a/packages/loopover-miner/lib/pr-outcomes-cli.ts b/packages/loopover-miner/lib/pr-outcomes-cli.ts new file mode 100644 index 0000000000..3dce5f39fe --- /dev/null +++ b/packages/loopover-miner/lib/pr-outcomes-cli.ts @@ -0,0 +1,141 @@ +// `loopover-miner pr-outcomes --miner-login [--limit ] [--json]` (#7658): read-only report of the +// miner's own hosted post-merge outcome history. Calls the hosted `GET /v1/contributors/:login/pr-outcomes` +// (src/signals/contributor-pr-outcomes.ts — public-safe attribution only, no reward/wallet fields) using the +// same loopover-mcp session + API URL posture the other backend calls in this package use +// (resolveLoopoverBackendSession, #6487). Thin composition layer like tenant-cli.js: argv parsing plus one +// authenticated GET; every failure (no session, unreachable host, non-2xx, malformed body) is reported as a +// non-zero exit with a visible message — reading your own outcome history is a deliberate action whose +// failure the miner must see, so there is deliberately no silent-degrade path. +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; + +const PR_OUTCOMES_USAGE = "Usage: loopover-miner pr-outcomes --miner-login [--limit ] [--json]"; +// Matches the route's own AbortSignal-based read timeouts elsewhere in this package (self-review-context.js). +const PR_OUTCOMES_TIMEOUT_MS = 10_000; + +export type ParsedPrOutcomesArgs = { minerLogin: string; limit: number | null; json: boolean } | { error: string }; + +/** One hosted outcome row, as `GET /v1/contributors/:login/pr-outcomes` returns it. */ +export type PrOutcomeRow = { + repoFullName: string; + pullNumber: number | null; + outcome: string; + attribution: string; + deeplink: string; + recordedAt: string; +}; + +export type PrOutcomesPayload = { + login: string; + count: number; + summary: string; + outcomes: PrOutcomeRow[]; +}; + +// A narrower shape than `typeof fetch` on purpose: this command only ever issues a plain GET with headers and +// a signal, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored — same +// rationale as self-review-context.js's own SelfReviewContextFetch. +export type PrOutcomesFetch = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; json: () => Promise; text: () => Promise }>; + +export type RunPrOutcomesOptions = { + /** Read for the loopover-mcp session/config resolution — defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Injected fetch so tests drive the CLI without a live backend; defaults to the real global fetch. */ + fetchImpl?: PrOutcomesFetch; + /** Injectable session resolver so tests exercise the CLI without a config file on disk. */ + resolveSession?: typeof resolveLoopoverBackendSession; +}; + +/** Parse `pr-outcomes --miner-login [--limit ] [--json]`. Returns the parsed args or `{ error }`. + * `--limit` mirrors the route's own `?limit` validation (an integer between 1 and 100) so a bad value fails + * here with a clear message instead of as an HTTP 400 round-trip. */ +export function parsePrOutcomesArgs(args: string[]): ParsedPrOutcomesArgs { + let minerLogin: string | null = null; + let limit: number | null = null; + let json = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + json = true; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: `--miner-login requires a value. ${PR_OUTCOMES_USAGE}` }; + minerLogin = value; + index += 1; + continue; + } + if (token === "--limit") { + const value = args[index + 1]; + const parsed = Number(value); + if (!value || !Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + return { error: `--limit must be an integer between 1 and 100. ${PR_OUTCOMES_USAGE}` }; + } + limit = parsed; + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}. ${PR_OUTCOMES_USAGE}` }; + return { error: `Unexpected argument: ${token}. ${PR_OUTCOMES_USAGE}` }; + } + if (minerLogin === null) return { error: `--miner-login is required. ${PR_OUTCOMES_USAGE}` }; + return { minerLogin, limit, json }; +} + +/** Render the text view: the payload's own summary line, then one line per outcome (newest first, as the + * route returns them). A `pullNumber` can be null for an older delivery row — rendered as the repo alone. */ +export function renderPrOutcomesText(payload: PrOutcomesPayload): string { + const lines = [payload.summary]; + for (const outcome of payload.outcomes) { + const target = outcome.pullNumber === null ? outcome.repoFullName : `${outcome.repoFullName}#${outcome.pullNumber}`; + lines.push(`- ${target} ${outcome.outcome} ${outcome.recordedAt} ${outcome.deeplink}`); + } + return lines.join("\n"); +} + +/** + * Run `loopover-miner pr-outcomes --miner-login [--limit ] [--json]`. Fetches the miner's own + * hosted post-merge outcomes and prints them (a JSON dump under `--json`, else a text summary). Returns the + * process exit code: 0 on success, 1 on a usage error, 2 on a session/HTTP/network failure. + */ +export async function runPrOutcomesCli(args: string[] = [], options: RunPrOutcomesOptions = {}): Promise { + const parsed = parsePrOutcomesArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error, 1); + } + const resolveSession = options.resolveSession ?? resolveLoopoverBackendSession; + const session = resolveSession(options.env ?? process.env); + if (!session) { + return reportCliFailure( + parsed.json, + "No LoopOver session found — run `loopover-mcp login` first so pr-outcomes can read your own hosted outcome history.", + ); + } + const fetchImpl = options.fetchImpl ?? (fetch as unknown as PrOutcomesFetch); + const query = parsed.limit === null ? "" : `?limit=${parsed.limit}`; + const url = `${session.apiUrl}/v1/contributors/${encodeURIComponent(parsed.minerLogin)}/pr-outcomes${query}`; + try { + const response = await fetchImpl(url, { + method: "GET", + headers: { authorization: `Bearer ${session.sessionToken}`, accept: "application/json" }, + signal: AbortSignal.timeout(PR_OUTCOMES_TIMEOUT_MS), + }); + if (!response.ok) { + const detail = (await response.text()).slice(0, 200); + return reportCliFailure(parsed.json, `pr-outcomes request failed (HTTP ${response.status}): ${detail}`); + } + const payload = (await response.json()) as PrOutcomesPayload; + if (parsed.json) { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log(renderPrOutcomesText(payload)); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index 835868931c..b780786b81 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -65,30 +65,31 @@ describe("loopover-miner CLI helpers", () => { expect(text).toContain("loopover-miner --help"); expect(text).toContain("loopover-miner version"); expect(text).toContain("loopover-miner metrics"); + expect(text).toContain("loopover-miner pr-outcomes --miner-login [--limit ] [--json]"); expect(text).toContain("loopover-miner migrate [--json]"); expect(text).toContain("loopover-miner ledger metrics"); expect(text).toContain("loopover-miner queue dashboard [--json]"); expect(text).toContain("--no-update-check"); }); - it("returns exit code 1 for unknown commands", () => { + it("returns exit code 1 for unknown commands", async () => { const error = vi .spyOn(console, "error") .mockImplementation(() => undefined); - expect( + await expect( runCli(["mystery"], { packageName: "@loopover/miner" }), - ).toBe(1); + ).resolves.toBe(1); expect(error).toHaveBeenCalledWith( "Unknown command: mystery. Run @loopover/miner --help.", ); }); - it("emits JSON for unknown commands when --json is set (#4836)", () => { + it("emits JSON for unknown commands when --json is set (#4836)", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - expect( + await expect( runCli(["mystery", "--json"], { packageName: "@loopover/miner" }), - ).toBe(1); + ).resolves.toBe(1); expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ ok: false, error: "Unknown command: mystery. Run @loopover/miner --help.", diff --git a/test/unit/miner-pr-outcomes-cli.test.ts b/test/unit/miner-pr-outcomes-cli.test.ts new file mode 100644 index 0000000000..dfcb0f4e30 --- /dev/null +++ b/test/unit/miner-pr-outcomes-cli.test.ts @@ -0,0 +1,207 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runCli } from "../../packages/loopover-miner/lib/cli.js"; +import { + parsePrOutcomesArgs, + renderPrOutcomesText, + runPrOutcomesCli, +} from "../../packages/loopover-miner/lib/pr-outcomes-cli.js"; +import type { PrOutcomesFetch, PrOutcomesPayload } from "../../packages/loopover-miner/lib/pr-outcomes-cli.js"; + +// #7658: the miner's own hosted post-merge outcomes over the CLI. Every HTTP interaction is driven through an +// injected fetch (no live network), mirroring tenant-cli's injectable-client test conventions. + +const tempDirs: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const SESSION = { apiUrl: "https://api.loopover.test", sessionToken: "session-token-1" }; + +const PAYLOAD: PrOutcomesPayload = { + login: "octominer", + count: 2, + summary: "LoopOver post-merge outcomes for octominer: 2 merged PR(s).", + outcomes: [ + { + repoFullName: "acme/widgets", + pullNumber: 41, + outcome: "merged", + attribution: "merged into acme/widgets", + deeplink: "https://github.com/acme/widgets/pull/41", + recordedAt: "2026-07-20T00:00:00.000Z", + }, + { + repoFullName: "acme/legacy", + pullNumber: null, + outcome: "merged", + attribution: "merged into acme/legacy", + deeplink: "https://github.com/acme/legacy", + recordedAt: "2026-07-19T00:00:00.000Z", + }, + ], +}; + +function fetchReturning(payload: unknown, status = 200): { fetchImpl: PrOutcomesFetch; requests: Array<{ url: string; init?: unknown }> } { + const requests: Array<{ url: string; init?: unknown }> = []; + const fetchImpl: PrOutcomesFetch = async (url, init) => { + requests.push({ url, init }); + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + text: async () => JSON.stringify(payload), + }; + }; + return { fetchImpl, requests }; +} + +describe("parsePrOutcomesArgs (#7658)", () => { + it("parses login, limit, and json together", () => { + expect(parsePrOutcomesArgs(["--miner-login", "octominer", "--limit", "5", "--json"])).toEqual({ + minerLogin: "octominer", + limit: 5, + json: true, + }); + }); + + it("defaults limit to null and json to false", () => { + expect(parsePrOutcomesArgs(["--miner-login", "octominer"])).toEqual({ minerLogin: "octominer", limit: null, json: false }); + }); + + it("requires --miner-login", () => { + expect(parsePrOutcomesArgs([])).toEqual({ error: expect.stringContaining("--miner-login is required") }); + }); + + it("rejects a --miner-login without a value (including a following flag)", () => { + expect(parsePrOutcomesArgs(["--miner-login"])).toEqual({ error: expect.stringContaining("--miner-login requires a value") }); + expect(parsePrOutcomesArgs(["--miner-login", "--json"])).toEqual({ error: expect.stringContaining("--miner-login requires a value") }); + }); + + it("rejects non-integer, out-of-range, and missing --limit values (mirroring the route's own validation)", () => { + for (const bad of [["--limit"], ["--limit", "0"], ["--limit", "101"], ["--limit", "1.5"], ["--limit", "abc"]]) { + expect(parsePrOutcomesArgs(["--miner-login", "octominer", ...bad])).toEqual({ + error: expect.stringContaining("--limit must be an integer between 1 and 100"), + }); + } + }); + + it("rejects unknown options and unexpected positionals", () => { + expect(parsePrOutcomesArgs(["--miner-login", "octominer", "--bogus"])).toEqual({ error: expect.stringContaining("Unknown option: --bogus") }); + expect(parsePrOutcomesArgs(["--miner-login", "octominer", "stray"])).toEqual({ error: expect.stringContaining("Unexpected argument: stray") }); + }); +}); + +describe("renderPrOutcomesText (#7658)", () => { + it("renders the summary plus one line per outcome, dropping the #pull suffix for a null pullNumber", () => { + const text = renderPrOutcomesText(PAYLOAD); + expect(text).toContain("2 merged PR(s)"); + expect(text).toContain("- acme/widgets#41 merged 2026-07-20T00:00:00.000Z https://github.com/acme/widgets/pull/41"); + expect(text).toContain("- acme/legacy merged 2026-07-19T00:00:00.000Z https://github.com/acme/legacy"); + expect(text).not.toContain("acme/legacy#"); + }); +}); + +describe("runPrOutcomesCli (#7658)", () => { + it("fetches the miner's own outcomes with the session bearer token and prints the text view", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const { fetchImpl, requests } = fetchReturning(PAYLOAD); + const code = await runPrOutcomesCli(["--miner-login", "octominer"], { fetchImpl, resolveSession: () => SESSION }); + expect(code).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toBe("https://api.loopover.test/v1/contributors/octominer/pr-outcomes"); + expect((requests[0]!.init as { headers: Record }).headers).toMatchObject({ + authorization: "Bearer session-token-1", + accept: "application/json", + }); + const text = String(log.mock.calls[0]?.[0]); + expect(text).toContain("2 merged PR(s)"); + expect(text).toContain("- acme/widgets#41 merged"); + expect(text).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i); + }); + + it("forwards --limit as the route's ?limit query and encodes the login", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const { fetchImpl, requests } = fetchReturning({ ...PAYLOAD, outcomes: [] }); + const code = await runPrOutcomesCli(["--miner-login", "octo miner", "--limit", "5"], { fetchImpl, resolveSession: () => SESSION }); + expect(code).toBe(0); + expect(requests[0]!.url).toBe("https://api.loopover.test/v1/contributors/octo%20miner/pr-outcomes?limit=5"); + }); + + it("emits the raw payload under --json", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const { fetchImpl } = fetchReturning(PAYLOAD); + const code = await runPrOutcomesCli(["--miner-login", "octominer", "--json"], { fetchImpl, resolveSession: () => SESSION }); + expect(code).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual(PAYLOAD); + }); + + it("reports a usage error as exit 1 (JSON-shaped under --json)", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect(runPrOutcomesCli(["--json"])).resolves.toBe(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ ok: false }); + await expect(runPrOutcomesCli([])).resolves.toBe(1); + expect(error).toHaveBeenCalledWith(expect.stringContaining("--miner-login is required")); + }); + + it("fails loud with a login hint when no LoopOver session exists on disk (real resolver, empty config dir)", async () => { + const dir = mkdtempSync(join(tmpdir(), "miner-pr-outcomes-cli-")); + tempDirs.push(dir); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const code = await runPrOutcomesCli(["--miner-login", "octominer"], { env: { ...process.env, LOOPOVER_CONFIG_DIR: dir } }); + expect(code).toBe(2); + expect(error).toHaveBeenCalledWith(expect.stringContaining("loopover-mcp login")); + }); + + it("reports a non-2xx response as a failure with the HTTP status and body detail", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { fetchImpl } = fetchReturning({ error: "forbidden_contributor" }, 403); + const code = await runPrOutcomesCli(["--miner-login", "octominer"], { fetchImpl, resolveSession: () => SESSION }); + expect(code).toBe(2); + expect(error).toHaveBeenCalledWith(expect.stringContaining("HTTP 403")); + expect(error).toHaveBeenCalledWith(expect.stringContaining("forbidden_contributor")); + }); + + it("falls back to the global fetch when no fetchImpl is injected (stubbed, no live network)", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, status: 200, json: async () => PAYLOAD, text: async () => "" })), + ); + try { + const code = await runPrOutcomesCli(["--miner-login", "octominer", "--json"], { resolveSession: () => SESSION }); + expect(code).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ login: "octominer" }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("reports a thrown fetch failure (network/timeout) via the shared CLI error shape", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const fetchImpl: PrOutcomesFetch = async () => { + throw new Error("fetch failed: connect ECONNREFUSED"); + }; + const code = await runPrOutcomesCli(["--miner-login", "octominer"], { fetchImpl, resolveSession: () => SESSION }); + expect(code).toBe(2); + expect(error).toHaveBeenCalledWith(expect.stringContaining("ECONNREFUSED")); + }); +}); + +describe("runCli pr-outcomes dispatch (#7658)", () => { + it("dispatches pr-outcomes through the foundation CLI module", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const { fetchImpl, requests } = fetchReturning(PAYLOAD); + const code = await runCli(["pr-outcomes", "--miner-login", "octominer", "--json"], { packageName: "@loopover/miner" }, { + fetchImpl, + resolveSession: () => SESSION, + }); + expect(code).toBe(0); + expect(requests).toHaveLength(1); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ login: "octominer", count: 2 }); + }); +});