Skip to content

Commit 43281f8

Browse files
committed
feat(miner): add a pr-outcomes CLI for the hosted contributor outcome history
Closes #7658. Adds 'loopover-miner pr-outcomes --miner-login <login> [--limit <n>] [--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.
1 parent 050f8cb commit 43281f8

5 files changed

Lines changed: 367 additions & 8 deletions

File tree

packages/loopover-miner/bin/loopover-miner.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ if (cliArgs[0] === "loop") {
228228
process.exit(exitCode);
229229
}
230230

231-
const exitCode = runCli(cliArgs, { packageName });
231+
/* 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. */
232+
const exitCode = await runCli(cliArgs, { packageName });
232233
await awaitOpportunisticUpdateCheck(updateCheck);
233234
process.exit(exitCode);

packages/loopover-miner/lib/cli.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { argsWantJson, reportCliFailure } from "./cli-error.js";
2+
import { runPrOutcomesCli, type RunPrOutcomesOptions } from "./pr-outcomes-cli.js";
23

34
export function printVersion(input: { packageName: string; packageVersion: string }): void {
45
console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`);
@@ -52,6 +53,7 @@ export function printHelp(input: { packageName: string }): void {
5253
" loopover-miner governor status [--json] Show whether the governor is paused",
5354
" loopover-miner governor metrics Print governor rate-limit/cap-usage counters in Prometheus text format",
5455
" loopover-miner calibration [--json] Report predicted-vs-realized gate accuracy",
56+
" loopover-miner pr-outcomes --miner-login <login> [--limit <n>] [--json] Show your own hosted post-merge PR outcomes",
5557
" loopover-miner feasibility <claimStatus> <duplicateClusterRisk> <issueStatus> [--not-found] [--json]",
5658
" loopover-miner idea-feasibility <claimStatus> <duplicateClusterRisk> [--not-resolvable] [--hint <text>]... [--json]",
5759
" Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)",
@@ -74,8 +76,15 @@ export function printHelp(input: { packageName: string }): void {
7476
);
7577
}
7678

77-
export function runCli(cliArgs: string[], input: { packageName: string }): number {
79+
export async function runCli(cliArgs: string[], input: { packageName: string }, options: RunPrOutcomesOptions = {}): Promise<number> {
7880
const command = cliArgs[0] ?? "";
81+
// `pr-outcomes` (#7658) dispatches HERE, in the foundation CLI module, rather than growing another branch in
82+
// bin/loopover-miner.ts: the bin dispatcher is subprocess-only-tested and genuinely Codecov-graded (see the
83+
// packages/loopover-miner/bin note in vitest.config.ts's coverage.include), so a command dispatched in this
84+
// in-process-tested module keeps its whole path measurable instead of adding permanently-uncovered bin lines.
85+
if (command === "pr-outcomes") {
86+
return runPrOutcomesCli(cliArgs.slice(1), options);
87+
}
7988
const message = `Unknown command: ${command}. Run ${input.packageName} --help.`;
8089
return reportCliFailure(argsWantJson(cliArgs), message, 1);
8190
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
}

test/unit/miner-cli.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,30 +65,31 @@ describe("loopover-miner CLI helpers", () => {
6565
expect(text).toContain("loopover-miner --help");
6666
expect(text).toContain("loopover-miner version");
6767
expect(text).toContain("loopover-miner metrics");
68+
expect(text).toContain("loopover-miner pr-outcomes --miner-login <login> [--limit <n>] [--json]");
6869
expect(text).toContain("loopover-miner migrate [--json]");
6970
expect(text).toContain("loopover-miner ledger metrics");
7071
expect(text).toContain("loopover-miner queue dashboard [--json]");
7172
expect(text).toContain("--no-update-check");
7273
});
7374

74-
it("returns exit code 1 for unknown commands", () => {
75+
it("returns exit code 1 for unknown commands", async () => {
7576
const error = vi
7677
.spyOn(console, "error")
7778
.mockImplementation(() => undefined);
78-
expect(
79+
await expect(
7980
runCli(["mystery"], { packageName: "@loopover/miner" }),
80-
).toBe(1);
81+
).resolves.toBe(1);
8182
expect(error).toHaveBeenCalledWith(
8283
"Unknown command: mystery. Run @loopover/miner --help.",
8384
);
8485
});
8586

86-
it("emits JSON for unknown commands when --json is set (#4836)", () => {
87+
it("emits JSON for unknown commands when --json is set (#4836)", async () => {
8788
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
8889
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
89-
expect(
90+
await expect(
9091
runCli(["mystery", "--json"], { packageName: "@loopover/miner" }),
91-
).toBe(1);
92+
).resolves.toBe(1);
9293
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
9394
ok: false,
9495
error: "Unknown command: mystery. Run @loopover/miner --help.",

0 commit comments

Comments
 (0)