From e09423b6f63b19091145874d78b10f2709d3c356 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:24:10 +0200 Subject: [PATCH] chore(miner): migrate batch 4.5 foundational lib modules to TypeScript Converts 8 packages/loopover-miner/lib/** modules to real TypeScript (in-place .ts -> .js/.d.ts emit, import paths unchanged): deny-hooks, policy-doc-cache, pr-outcome, attempt-log, coding-task-spec, stack-detection, ci-poller, self-review-context. Refs #7313 --- packages/loopover-miner/lib/attempt-log.d.ts | 72 +- packages/loopover-miner/lib/attempt-log.js | 261 +++---- packages/loopover-miner/lib/attempt-log.ts | 260 +++++++ packages/loopover-miner/lib/ci-poller.d.ts | 46 +- packages/loopover-miner/lib/ci-poller.js | 376 +++++----- packages/loopover-miner/lib/ci-poller.ts | 286 ++++++++ .../loopover-miner/lib/coding-task-spec.d.ts | 110 +-- .../loopover-miner/lib/coding-task-spec.js | 295 +++----- .../loopover-miner/lib/coding-task-spec.ts | 282 ++++++++ packages/loopover-miner/lib/deny-hooks.d.ts | 30 +- packages/loopover-miner/lib/deny-hooks.js | 4 +- packages/loopover-miner/lib/deny-hooks.ts | 6 + .../loopover-miner/lib/policy-doc-cache.d.ts | 37 +- .../loopover-miner/lib/policy-doc-cache.js | 91 ++- .../loopover-miner/lib/policy-doc-cache.ts | 106 +++ packages/loopover-miner/lib/pr-outcome.d.ts | 80 ++- packages/loopover-miner/lib/pr-outcome.js | 112 +-- packages/loopover-miner/lib/pr-outcome.ts | 130 ++++ .../lib/self-review-context.d.ts | 110 +-- .../loopover-miner/lib/self-review-context.js | 652 +++++++++--------- .../loopover-miner/lib/self-review-context.ts | 536 ++++++++++++++ .../loopover-miner/lib/stack-detection.d.ts | 54 +- .../loopover-miner/lib/stack-detection.js | 386 +++++------ .../loopover-miner/lib/stack-detection.ts | 295 ++++++++ test/unit/miner-attempt-log.test.ts | 35 + test/unit/miner-ci-poller.test.ts | 135 ++++ .../miner-coding-task-spec-path-guard.test.ts | 54 ++ test/unit/miner-self-review-context.test.ts | 217 ++++++ test/unit/miner-stack-detection.test.ts | 4 + 29 files changed, 3625 insertions(+), 1437 deletions(-) create mode 100644 packages/loopover-miner/lib/attempt-log.ts create mode 100644 packages/loopover-miner/lib/ci-poller.ts create mode 100644 packages/loopover-miner/lib/coding-task-spec.ts create mode 100644 packages/loopover-miner/lib/deny-hooks.ts create mode 100644 packages/loopover-miner/lib/policy-doc-cache.ts create mode 100644 packages/loopover-miner/lib/pr-outcome.ts create mode 100644 packages/loopover-miner/lib/self-review-context.ts create mode 100644 packages/loopover-miner/lib/stack-detection.ts create mode 100644 test/unit/miner-coding-task-spec-path-guard.test.ts diff --git a/packages/loopover-miner/lib/attempt-log.d.ts b/packages/loopover-miner/lib/attempt-log.d.ts index 9f6528803e..1d5354d12e 100644 --- a/packages/loopover-miner/lib/attempt-log.d.ts +++ b/packages/loopover-miner/lib/attempt-log.d.ts @@ -1,45 +1,41 @@ import type { AttemptLogEvent } from "@loopover/engine"; - export type AttemptLogEntry = { - id: number; - seq: number; - eventType: string; - attemptId: string; - actionClass: string; - mode: string; - reason: string; - payload: Record; - /** Coding-agent provider name, when the event set one (#5185). Null for every event type that predates this - * field. */ - provider: string | null; - /** Real dollar cost, when the event set one (#5185). Null (not 0) when absent -- never fabricated. */ - costUsd: number | null; - /** Real token count, when some future driver reports one (#5185). Always null today -- no driver reports real - * token usage yet (#5395). */ - tokensUsed: number | null; - createdAt: string; + id: number; + seq: number; + eventType: string; + attemptId: string; + actionClass: string; + mode: string; + reason: string; + payload: Record; + /** Coding-agent provider name, when the event set one (#5185). Null for every event type that predates this + * field. */ + provider: string | null; + /** Real dollar cost, when the event set one (#5185). Null (not 0) when absent -- never fabricated. */ + costUsd: number | null; + /** Real token count, when some future driver reports one (#5185). Always null today -- no driver reports real + * token usage yet (#5395). */ + tokensUsed: number | null; + createdAt: string; }; - export type ReadAttemptLogEventsFilter = { - attemptId?: string | null; + attemptId?: string | null; }; - export type AttemptLog = { - dbPath: string; - appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; - readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; - exportAttemptLogJsonl(attemptId: string): string; - close(): void; + dbPath: string; + appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; + readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; + exportAttemptLogJsonl(attemptId: string): string; + close(): void; }; - -export function resolveAttemptLogDbPath(env?: Record): string; - -export function initAttemptLog(dbPath?: string): AttemptLog; - -export function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; - -export function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; - -export function exportAttemptLogJsonl(attemptId: string): string; - -export function closeDefaultAttemptLog(): void; +export declare function resolveAttemptLogDbPath(env?: Record): string; +/** + * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter + * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC + * order. (#4294) + */ +export declare function initAttemptLog(dbPath?: string): AttemptLog; +export declare function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; +export declare function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; +export declare function exportAttemptLogJsonl(attemptId: string): string; +export declare function closeDefaultAttemptLog(): void; diff --git a/packages/loopover-miner/lib/attempt-log.js b/packages/loopover-miner/lib/attempt-log.js index b16be599aa..e5ca9f7f8b 100644 --- a/packages/loopover-miner/lib/attempt-log.js +++ b/packages/loopover-miner/lib/attempt-log.js @@ -1,111 +1,99 @@ import { formatAttemptLogJsonl, normalizeAttemptLogEvent } from "@loopover/engine"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; - -// Append-only driver attempt log (#4294): a structured, attempt-scoped event trace for every CodingAgentDriver run -// (started, tool/edit, succeeded/failed/aborted). IMMUTABILITY INVARIANT: INSERT + SELECT only — rows are never -// rewritten or removed after append. -// -// Why a sibling store instead of extending event-ledger.js: event-ledger is the general miner-loop audit trail -// (discovered_issue, plan_built, pr_prepared, …) keyed by repo scope with a growing free-form type vocabulary. -// Attempt events are keyed by attempt_id, validated against the engine's fixed ATTEMPT_LOG_EVENT_TYPES, and are -// exported per attempt as JSONL — mixing both into one table would couple unrelated lifecycles and complicate the -// per-attempt dump path. This module mirrors governor-ledger.js: engine holds pure normalization, miner holds SQLite. - const defaultDbFileName = "attempt-log.sqlite3"; let defaultAttemptLog = null; - export function resolveAttemptLogDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_ATTEMPT_LOG_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_ATTEMPT_LOG_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); } - /** Read-filter attempt scope: omitted/nullish → unscoped (all events); otherwise a non-empty attempt id. */ function normalizeReadAttemptIdFilter(attemptId) { - if (attemptId === undefined || attemptId === null) return undefined; - if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); - const trimmed = attemptId.trim(); - if (!trimmed) throw new Error("invalid_attempt_id"); - return trimmed; + if (attemptId === undefined || attemptId === null) + return undefined; + if (typeof attemptId !== "string") + throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) + throw new Error("invalid_attempt_id"); + return trimmed; } - /** Export requires an explicit attempt id — JSONL dumps are always per attempt. */ function normalizeRequiredAttemptId(attemptId) { - const normalized = normalizeReadAttemptIdFilter(attemptId); - if (normalized === undefined) throw new Error("invalid_attempt_id"); - return normalized; + const normalized = normalizeReadAttemptIdFilter(attemptId); + if (normalized === undefined) + throw new Error("invalid_attempt_id"); + return normalized; } - function rowToEntry(row) { - let payload; - try { - payload = JSON.parse(row.payload_json); - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - throw new Error("corrupted_attempt_log_row"); + let payload; + try { + const parsed = JSON.parse(row.payload_json); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("corrupted_attempt_log_row"); + } + payload = parsed; } - } catch { - throw new Error("corrupted_attempt_log_row"); - } - return { - id: row.id, - seq: row.seq, - eventType: row.event_type, - attemptId: row.attempt_id, - actionClass: row.action_class, - mode: row.mode, - reason: row.reason, - payload, - provider: row.provider, - costUsd: row.cost_usd, - tokensUsed: row.tokens_used, - createdAt: row.created_at, - }; + catch { + throw new Error("corrupted_attempt_log_row"); + } + return { + id: row.id, + seq: row.seq, + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payload, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + createdAt: row.created_at, + }; } - +// `event_type`/`mode` are cast to their literal-union types: every row was written through +// appendAttemptLogEvent's own normalizeAttemptLogEvent call, which already validates both against the engine's +// fixed vocabulary, so a row read back from this table always carries a recognized value. function rowToNormalized(row) { - return { - eventType: row.event_type, - attemptId: row.attempt_id, - actionClass: row.action_class, - mode: row.mode, - reason: row.reason, - payloadJson: row.payload_json, - provider: row.provider, - costUsd: row.cost_usd, - tokensUsed: row.tokens_used, - }; + return { + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payloadJson: row.payload_json, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + }; } - // Add the provider/cost_usd/tokens_used columns (#5185) to an on-disk file created before they existed. `CREATE // TABLE IF NOT EXISTS` above is a no-op against an already-existing table, so a pre-#5185 file needs this // explicit ALTER -- guarded by a per-column presence check (same technique as governor-state.js's own // ensurePauseColumns) so a file missing only one of the three still gets exactly what it's missing. function ensureOutcomeColumns(db) { - const existingColumns = new Set( - db.prepare("PRAGMA table_info(attempt_log_events)").all().map((column) => column.name), - ); - if (!existingColumns.has("provider")) { - db.exec("ALTER TABLE attempt_log_events ADD COLUMN provider TEXT"); - } - if (!existingColumns.has("cost_usd")) { - db.exec("ALTER TABLE attempt_log_events ADD COLUMN cost_usd REAL"); - } - if (!existingColumns.has("tokens_used")) { - db.exec("ALTER TABLE attempt_log_events ADD COLUMN tokens_used INTEGER"); - } + const existingColumns = new Set(db.prepare("PRAGMA table_info(attempt_log_events)").all().map((column) => column.name)); + if (!existingColumns.has("provider")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN provider TEXT"); + } + if (!existingColumns.has("cost_usd")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN cost_usd REAL"); + } + if (!existingColumns.has("tokens_used")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN tokens_used INTEGER"); + } } - /** * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC * order. (#4294) */ export function initAttemptLog(dbPath = resolveAttemptLogDbPath()) { - const resolvedPath = normalizeDbPath(dbPath); - const db = openLocalStoreDb(resolvedPath); - db.exec(` + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` CREATE TABLE IF NOT EXISTS attempt_log_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, seq INTEGER NOT NULL UNIQUE, @@ -118,90 +106,69 @@ export function initAttemptLog(dbPath = resolveAttemptLogDbPath()) { created_at TEXT NOT NULL ) `); - ensureOutcomeColumns(db); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)", - ); - - const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); - const appendStatement = db.prepare(` + ensureOutcomeColumns(db); + db.exec("CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)"); + const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); + const appendStatement = db.prepare(` INSERT INTO attempt_log_events ( seq, attempt_id, event_type, action_class, mode, reason, payload_json, provider, cost_usd, tokens_used, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); - const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); - const readByAttemptStatement = db.prepare( - "SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC", - ); - - return { - dbPath: resolvedPath, - appendAttemptLogEvent(event) { - const normalized = normalizeAttemptLogEvent(event); - const createdAt = new Date().toISOString(); - db.exec("BEGIN IMMEDIATE"); - try { - const { nextSeq } = nextSeqStatement.get(); - const result = appendStatement.run( - nextSeq, - normalized.attemptId, - normalized.eventType, - normalized.actionClass, - normalized.mode, - normalized.reason, - normalized.payloadJson, - normalized.provider, - normalized.costUsd, - normalized.tokensUsed, - createdAt, - ); - const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid))); - db.exec("COMMIT"); - return entry; - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - }, - readAttemptLogEvents(filter = {}) { - const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); - const rows = - attemptId === undefined ? readAllStatement.all() : readByAttemptStatement.all(attemptId); - return rows.map(rowToEntry); - }, - exportAttemptLogJsonl(attemptId) { - const scopedAttemptId = normalizeRequiredAttemptId(attemptId); - const rows = readByAttemptStatement.all(scopedAttemptId); - return formatAttemptLogJsonl(rows.map(rowToNormalized)); - }, - close() { - db.close(); - }, - }; + const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); + const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); + const readByAttemptStatement = db.prepare("SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC"); + return { + dbPath: resolvedPath, + appendAttemptLogEvent(event) { + const normalized = normalizeAttemptLogEvent(event); + const createdAt = new Date().toISOString(); + db.exec("BEGIN IMMEDIATE"); + try { + const { nextSeq } = nextSeqStatement.get(); + const result = appendStatement.run(nextSeq, normalized.attemptId, normalized.eventType, normalized.actionClass, normalized.mode, normalized.reason, normalized.payloadJson, normalized.provider, normalized.costUsd, normalized.tokensUsed, createdAt); + const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid))); + db.exec("COMMIT"); + return entry; + } + catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + readAttemptLogEvents(filter = {}) { + const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); + const rows = attemptId === undefined ? readAllStatement.all() : readByAttemptStatement.all(attemptId); + return rows.map(rowToEntry); + }, + exportAttemptLogJsonl(attemptId) { + const scopedAttemptId = normalizeRequiredAttemptId(attemptId); + const rows = readByAttemptStatement.all(scopedAttemptId); + return formatAttemptLogJsonl(rows.map(rowToNormalized)); + }, + close() { + db.close(); + }, + }; } - function getDefaultAttemptLog() { - defaultAttemptLog ??= initAttemptLog(); - return defaultAttemptLog; + defaultAttemptLog ??= initAttemptLog(); + return defaultAttemptLog; } - export function appendAttemptLogEvent(event) { - return getDefaultAttemptLog().appendAttemptLogEvent(event); + return getDefaultAttemptLog().appendAttemptLogEvent(event); } - export function readAttemptLogEvents(filter) { - return getDefaultAttemptLog().readAttemptLogEvents(filter); + return getDefaultAttemptLog().readAttemptLogEvents(filter); } - export function exportAttemptLogJsonl(attemptId) { - return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); + return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); } - export function closeDefaultAttemptLog() { - if (!defaultAttemptLog) return; - defaultAttemptLog.close(); - defaultAttemptLog = null; + if (!defaultAttemptLog) + return; + defaultAttemptLog.close(); + defaultAttemptLog = null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXR0ZW1wdC1sb2cuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhdHRlbXB0LWxvZy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUscUJBQXFCLEVBQUUsd0JBQXdCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUduRixPQUFPLEVBQUUseUJBQXlCLEVBQUUsZ0JBQWdCLEVBQUUsdUJBQXVCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQTRDeEcsTUFBTSxpQkFBaUIsR0FBRyxxQkFBcUIsQ0FBQztBQUNoRCxJQUFJLGlCQUFpQixHQUFzQixJQUFJLENBQUM7QUFFaEQsTUFBTSxVQUFVLHVCQUF1QixDQUFDLE1BQTBDLE9BQU8sQ0FBQyxHQUFHO0lBQzNGLE9BQU8sdUJBQXVCLENBQUMsaUJBQWlCLEVBQUUsK0JBQStCLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDMUYsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWM7SUFDckMsT0FBTyx5QkFBeUIsQ0FBQyxNQUFNLEVBQUUsdUJBQXVCLEVBQUUsRUFBRSw2QkFBNkIsQ0FBQyxDQUFDO0FBQ3JHLENBQUM7QUFFRCw0R0FBNEc7QUFDNUcsU0FBUyw0QkFBNEIsQ0FBQyxTQUFvQztJQUN4RSxJQUFJLFNBQVMsS0FBSyxTQUFTLElBQUksU0FBUyxLQUFLLElBQUk7UUFBRSxPQUFPLFNBQVMsQ0FBQztJQUNwRSxJQUFJLE9BQU8sU0FBUyxLQUFLLFFBQVE7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLG9CQUFvQixDQUFDLENBQUM7SUFDekUsTUFBTSxPQUFPLEdBQUcsU0FBUyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQ2pDLElBQUksQ0FBQyxPQUFPO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxvQkFBb0IsQ0FBQyxDQUFDO0lBQ3BELE9BQU8sT0FBTyxDQUFDO0FBQ2pCLENBQUM7QUFFRCxtRkFBbUY7QUFDbkYsU0FBUywwQkFBMEIsQ0FBQyxTQUFpQjtJQUNuRCxNQUFNLFVB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/attempt-log.ts b/packages/loopover-miner/lib/attempt-log.ts new file mode 100644 index 0000000000..11dde07246 --- /dev/null +++ b/packages/loopover-miner/lib/attempt-log.ts @@ -0,0 +1,260 @@ +import { formatAttemptLogJsonl, normalizeAttemptLogEvent } from "@loopover/engine"; +import type { AttemptLogEvent, NormalizedAttemptLogEvent } from "@loopover/engine"; +import type { DatabaseSync } from "node:sqlite"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; + +// Append-only driver attempt log (#4294): a structured, attempt-scoped event trace for every CodingAgentDriver run +// (started, tool/edit, succeeded/failed/aborted). IMMUTABILITY INVARIANT: INSERT + SELECT only — rows are never +// rewritten or removed after append. +// +// Why a sibling store instead of extending event-ledger.js: event-ledger is the general miner-loop audit trail +// (discovered_issue, plan_built, pr_prepared, …) keyed by repo scope with a growing free-form type vocabulary. +// Attempt events are keyed by attempt_id, validated against the engine's fixed ATTEMPT_LOG_EVENT_TYPES, and are +// exported per attempt as JSONL — mixing both into one table would couple unrelated lifecycles and complicate the +// per-attempt dump path. This module mirrors governor-ledger.js: engine holds pure normalization, miner holds SQLite. + +export type AttemptLogEntry = { + id: number; + seq: number; + eventType: string; + attemptId: string; + actionClass: string; + mode: string; + reason: string; + payload: Record; + /** Coding-agent provider name, when the event set one (#5185). Null for every event type that predates this + * field. */ + provider: string | null; + /** Real dollar cost, when the event set one (#5185). Null (not 0) when absent -- never fabricated. */ + costUsd: number | null; + /** Real token count, when some future driver reports one (#5185). Always null today -- no driver reports real + * token usage yet (#5395). */ + tokensUsed: number | null; + createdAt: string; +}; + +export type ReadAttemptLogEventsFilter = { + attemptId?: string | null; +}; + +export type AttemptLog = { + dbPath: string; + appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; + readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; + exportAttemptLogJsonl(attemptId: string): string; + close(): void; +}; + +const defaultDbFileName = "attempt-log.sqlite3"; +let defaultAttemptLog: AttemptLog | null = null; + +export function resolveAttemptLogDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_ATTEMPT_LOG_DB", env); +} + +function normalizeDbPath(dbPath: string): string { + return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); +} + +/** Read-filter attempt scope: omitted/nullish → unscoped (all events); otherwise a non-empty attempt id. */ +function normalizeReadAttemptIdFilter(attemptId: string | null | undefined): string | undefined { + if (attemptId === undefined || attemptId === null) return undefined; + if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) throw new Error("invalid_attempt_id"); + return trimmed; +} + +/** Export requires an explicit attempt id — JSONL dumps are always per attempt. */ +function normalizeRequiredAttemptId(attemptId: string): string { + const normalized = normalizeReadAttemptIdFilter(attemptId); + if (normalized === undefined) throw new Error("invalid_attempt_id"); + return normalized; +} + +type AttemptLogRow = { + id: number; + seq: number; + event_type: string; + attempt_id: string; + action_class: string; + mode: string; + reason: string; + payload_json: string; + provider: string | null; + cost_usd: number | null; + tokens_used: number | null; + created_at: string; +}; + +function rowToEntry(row: AttemptLogRow): AttemptLogEntry { + let payload: Record; + try { + const parsed: unknown = JSON.parse(row.payload_json); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("corrupted_attempt_log_row"); + } + payload = parsed as Record; + } catch { + throw new Error("corrupted_attempt_log_row"); + } + return { + id: row.id, + seq: row.seq, + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payload, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + createdAt: row.created_at, + }; +} + +// `event_type`/`mode` are cast to their literal-union types: every row was written through +// appendAttemptLogEvent's own normalizeAttemptLogEvent call, which already validates both against the engine's +// fixed vocabulary, so a row read back from this table always carries a recognized value. +function rowToNormalized(row: AttemptLogRow): NormalizedAttemptLogEvent { + return { + eventType: row.event_type as NormalizedAttemptLogEvent["eventType"], + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode as NormalizedAttemptLogEvent["mode"], + reason: row.reason, + payloadJson: row.payload_json, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + }; +} + +// Add the provider/cost_usd/tokens_used columns (#5185) to an on-disk file created before they existed. `CREATE +// TABLE IF NOT EXISTS` above is a no-op against an already-existing table, so a pre-#5185 file needs this +// explicit ALTER -- guarded by a per-column presence check (same technique as governor-state.js's own +// ensurePauseColumns) so a file missing only one of the three still gets exactly what it's missing. +function ensureOutcomeColumns(db: DatabaseSync): void { + const existingColumns = new Set( + db.prepare("PRAGMA table_info(attempt_log_events)").all().map((column) => column.name as string), + ); + if (!existingColumns.has("provider")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN provider TEXT"); + } + if (!existingColumns.has("cost_usd")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN cost_usd REAL"); + } + if (!existingColumns.has("tokens_used")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN tokens_used INTEGER"); + } +} + +/** + * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter + * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC + * order. (#4294) + */ +export function initAttemptLog(dbPath: string = resolveAttemptLogDbPath()): AttemptLog { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS attempt_log_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seq INTEGER NOT NULL UNIQUE, + attempt_id TEXT NOT NULL, + event_type TEXT NOT NULL, + action_class TEXT NOT NULL, + mode TEXT NOT NULL, + reason TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `); + ensureOutcomeColumns(db); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)", + ); + + const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); + const appendStatement = db.prepare(` + INSERT INTO attempt_log_events ( + seq, attempt_id, event_type, action_class, mode, reason, payload_json, provider, cost_usd, tokens_used, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); + const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); + const readByAttemptStatement = db.prepare( + "SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC", + ); + + return { + dbPath: resolvedPath, + appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry { + const normalized = normalizeAttemptLogEvent(event); + const createdAt = new Date().toISOString(); + db.exec("BEGIN IMMEDIATE"); + try { + const { nextSeq } = nextSeqStatement.get() as { nextSeq: number }; + const result = appendStatement.run( + nextSeq, + normalized.attemptId, + normalized.eventType, + normalized.actionClass, + normalized.mode, + normalized.reason, + normalized.payloadJson, + normalized.provider, + normalized.costUsd, + normalized.tokensUsed, + createdAt, + ); + const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid)) as unknown as AttemptLogRow); + db.exec("COMMIT"); + return entry; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + readAttemptLogEvents(filter: ReadAttemptLogEventsFilter = {}): AttemptLogEntry[] { + const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); + const rows = + attemptId === undefined ? readAllStatement.all() : readByAttemptStatement.all(attemptId); + return (rows as unknown as AttemptLogRow[]).map(rowToEntry); + }, + exportAttemptLogJsonl(attemptId: string): string { + const scopedAttemptId = normalizeRequiredAttemptId(attemptId); + const rows = readByAttemptStatement.all(scopedAttemptId) as unknown as AttemptLogRow[]; + return formatAttemptLogJsonl(rows.map(rowToNormalized)); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultAttemptLog(): AttemptLog { + defaultAttemptLog ??= initAttemptLog(); + return defaultAttemptLog; +} + +export function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry { + return getDefaultAttemptLog().appendAttemptLogEvent(event); +} + +export function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[] { + return getDefaultAttemptLog().readAttemptLogEvents(filter); +} + +export function exportAttemptLogJsonl(attemptId: string): string { + return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); +} + +export function closeDefaultAttemptLog(): void { + if (!defaultAttemptLog) return; + defaultAttemptLog.close(); + defaultAttemptLog = null; +} diff --git a/packages/loopover-miner/lib/ci-poller.d.ts b/packages/loopover-miner/lib/ci-poller.d.ts index 08718c4a8f..fe1d647282 100644 --- a/packages/loopover-miner/lib/ci-poller.d.ts +++ b/packages/loopover-miner/lib/ci-poller.d.ts @@ -1,34 +1,26 @@ export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral"; - export type NormalizedCheckRun = { - name: string; - status: string; - conclusion: CheckRunConclusion; - detailsUrl: string | null; - startedAt: string | null; - completedAt: string | null; + name: string; + status: string; + conclusion: CheckRunConclusion; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; }; - export type PollCheckRunsResult = { - conclusion: CheckRunConclusion; - checks: NormalizedCheckRun[]; - headSha: string; - attempts: number; + conclusion: CheckRunConclusion; + checks: NormalizedCheckRun[]; + headSha: string; + attempts: number; }; - export type PollCheckRunsOptions = { - apiBaseUrl?: string; - fetchFn?: typeof fetch; - githubToken?: string; - maxAttempts?: number; - minIntervalMs?: number; - maxIntervalMs?: number; - requestTimeoutMs?: number; - sleepFn?: (delayMs: number) => Promise; + apiBaseUrl?: string; + fetchFn?: typeof fetch; + githubToken?: string; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; + requestTimeoutMs?: number; + sleepFn?: (delayMs: number) => Promise; }; - -export function pollCheckRuns( - repoFullName: string, - prNumber: number, - options?: PollCheckRunsOptions, -): Promise; +export declare function pollCheckRuns(repoFullName: string, prNumber: number, options?: PollCheckRunsOptions): Promise; diff --git a/packages/loopover-miner/lib/ci-poller.js b/packages/loopover-miner/lib/ci-poller.js index 538a50b1de..f5fd9af8ae 100644 --- a/packages/loopover-miner/lib/ci-poller.js +++ b/packages/loopover-miner/lib/ci-poller.js @@ -1,237 +1,221 @@ import { fetchWithRetry } from "./http-retry.js"; - const defaultApiBaseUrl = "https://api.github.com"; const defaultMinIntervalMs = 60_000; const defaultMaxIntervalMs = 5 * 60_000; const defaultMaxAttempts = 1; const defaultRequestTimeoutMs = 10_000; const githubApiVersion = "2022-11-28"; - function normalizeApiBaseUrl(value) { - if (value === undefined) return defaultApiBaseUrl; - if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl; - let parsed; - try { - parsed = new URL(value.trim()); - } catch { - throw new Error("invalid_api_base_url"); - } - if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { - throw new Error("invalid_api_base_url"); - } - parsed.pathname = parsed.pathname.replace(/\/+$/, ""); - parsed.search = ""; - parsed.hash = ""; - return parsed.toString().replace(/\/+$/, ""); -} - + if (value === undefined) + return defaultApiBaseUrl; + if (typeof value !== "string" || !value.trim()) + return defaultApiBaseUrl; + let parsed; + try { + parsed = new URL(value.trim()); + } + catch { + throw new Error("invalid_api_base_url"); + } + if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { + throw new Error("invalid_api_base_url"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +} function normalizePositiveInt(value, fallback, min, max) { - if (!Number.isFinite(value)) return fallback; - return Math.min(max, Math.max(min, Math.floor(value))); + if (!Number.isFinite(value)) + return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); } - function normalizeOptions(options = {}) { - return { - apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), - fetchFn: options.fetchFn ?? fetch, - githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", - maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), - minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), - maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), - requestTimeoutMs: normalizePositiveInt(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), - sleepFn: - options.sleepFn ?? - ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), - }; -} - + return { + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), + fetchFn: options.fetchFn ?? fetch, + githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", + maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), + minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), + maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), + requestTimeoutMs: normalizePositiveInt(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), + sleepFn: options.sleepFn ?? + ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), + }; +} function parseRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner?.trim() || !repo?.trim() || extra !== undefined) { - throw new Error("invalid_repo_full_name"); - } - return { owner: owner.trim(), repo: repo.trim() }; -} - + if (typeof repoFullName !== "string") + throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) { + throw new Error("invalid_repo_full_name"); + } + return { owner: owner.trim(), repo: repo.trim() }; +} function normalizePullNumber(value) { - if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number"); - return value; + if (!Number.isInteger(value) || value <= 0) + throw new Error("invalid_pr_number"); + return value; } - function githubHeaders(githubToken) { - const headers = { - accept: "application/vnd.github+json", - "user-agent": "loopover-miner", - "x-github-api-version": githubApiVersion, - }; - if (githubToken) headers.authorization = `Bearer ${githubToken}`; - return headers; -} - + const headers = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": githubApiVersion, + }; + if (githubToken) + headers.authorization = `Bearer ${githubToken}`; + return headers; +} function repoPath(target, suffix) { - return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; } - function apiUrl(apiBaseUrl, path, query = "") { - return `${apiBaseUrl}${path}${query}`; + return `${apiBaseUrl}${path}${query}`; } - function githubError(response, payload) { - const code = `github_${response.status}`; - const githubMessage = - typeof payload?.message === "string" && payload.message.trim() ? payload.message : null; - const message = githubMessage ? `${code}: ${githubMessage}` : code; - return Object.assign(new Error(message), { code, githubMessage }); + const code = `github_${response.status}`; + const record = payload; + const githubMessage = typeof record?.message === "string" && record.message.trim() ? record.message : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); } - async function githubGetJsonResponse(url, options) { - // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own - // pending-retry loop; the poller's injected sleepFn keeps tests instant. requestTimeoutMs bounds each - // individual attempt (a stalled connection previously hung this call forever -- #miner-github-read-timeouts). - const response = await fetchWithRetry( - options.fetchFn, - url, - { method: "GET", headers: githubHeaders(options.githubToken) }, - { sleepFn: options.sleepFn, timeoutMs: options.requestTimeoutMs }, - ); - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw githubError(response, payload); - } - return { payload, response }; -} - + // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own + // pending-retry loop; the poller's injected sleepFn keeps tests instant. requestTimeoutMs bounds each + // individual attempt (a stalled connection previously hung this call forever -- #miner-github-read-timeouts). + const response = await fetchWithRetry(options.fetchFn, url, { method: "GET", headers: githubHeaders(options.githubToken) }, { sleepFn: options.sleepFn, timeoutMs: options.requestTimeoutMs }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw githubError(response, payload); + } + return { payload, response }; +} async function githubGetJson(url, options) { - const { payload } = await githubGetJsonResponse(url, options); - return payload; + const { payload } = await githubGetJsonResponse(url, options); + return payload; } - function hasNextLink(response) { - return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); + return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); } - function payloadTotalCount(payload) { - const totalCount = Number(payload?.total_count); - return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; + const record = payload; + const totalCount = Number(record?.total_count); + return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; } - function normalizeConclusion(checkRun) { - if (!checkRun || typeof checkRun !== "object") return "pending"; - if (checkRun.status !== "completed") return "pending"; - switch (checkRun.conclusion) { - case "success": - case "skipped": - return "success"; - case "neutral": - return "neutral"; - case "failure": - case "cancelled": - case "timed_out": - case "action_required": - case "stale": - case "startup_failure": - return "failure"; - default: - return "pending"; - } -} - + if (!checkRun || typeof checkRun !== "object") + return "pending"; + const record = checkRun; + if (record.status !== "completed") + return "pending"; + switch (record.conclusion) { + case "success": + case "skipped": + return "success"; + case "neutral": + return "neutral"; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "stale": + case "startup_failure": + return "failure"; + default: + return "pending"; + } +} function normalizeCheckRun(checkRun) { - return { - name: typeof checkRun?.name === "string" ? checkRun.name : "", - status: typeof checkRun?.status === "string" ? checkRun.status : "unknown", - conclusion: normalizeConclusion(checkRun), - detailsUrl: typeof checkRun?.details_url === "string" ? checkRun.details_url : null, - startedAt: typeof checkRun?.started_at === "string" ? checkRun.started_at : null, - completedAt: typeof checkRun?.completed_at === "string" ? checkRun.completed_at : null, - }; -} - + const record = checkRun; + return { + name: typeof record?.name === "string" ? record.name : "", + status: typeof record?.status === "string" ? record.status : "unknown", + conclusion: normalizeConclusion(checkRun), + detailsUrl: typeof record?.details_url === "string" ? record.details_url : null, + startedAt: typeof record?.started_at === "string" ? record.started_at : null, + completedAt: typeof record?.completed_at === "string" ? record.completed_at : null, + }; +} function aggregateConclusion(checks) { - if (checks.length === 0) return "pending"; - if (checks.some((check) => check.conclusion === "failure")) return "failure"; - if (checks.some((check) => check.conclusion === "pending")) return "pending"; - if (checks.every((check) => check.conclusion === "success")) return "success"; - return "neutral"; + if (checks.length === 0) + return "pending"; + if (checks.some((check) => check.conclusion === "failure")) + return "failure"; + if (checks.some((check) => check.conclusion === "pending")) + return "pending"; + if (checks.every((check) => check.conclusion === "success")) + return "success"; + return "neutral"; } - function backoffDelayMs(attemptIndex, options) { - const exponent = Math.min(10, Math.max(0, attemptIndex)); - return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); } - async function fetchHeadSha(target, prNumber, options) { - const payload = await githubGetJson( - apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), - options, - ); - const headSha = payload?.head?.sha; - if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing"); - return headSha; -} - + const payload = await githubGetJson(apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), options); + const record = payload; + const headSha = record?.head?.sha; + if (typeof headSha !== "string" || !headSha) + throw new Error("github_pr_head_sha_missing"); + return headSha; +} async function fetchCheckRuns(target, headSha, options) { - const checks = []; - let page = 1; - let expectedTotalCount = null; - while (true) { - const { payload, response } = await githubGetJsonResponse( - apiUrl( - options.apiBaseUrl, - repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), - `?per_page=100&page=${page}`, - ), - options, - ); - if (!Array.isArray(payload?.check_runs)) { - throw new Error("github_check_runs_malformed"); - } - const pageChecks = payload.check_runs.map(normalizeCheckRun); - checks.push(...pageChecks); - expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; - if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { - return checks; - } - if (pageChecks.length === 0) { - throw new Error("github_check_runs_pagination_incomplete"); + const checks = []; + let page = 1; + let expectedTotalCount = null; + for (;;) { + const { payload, response } = await githubGetJsonResponse(apiUrl(options.apiBaseUrl, repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), `?per_page=100&page=${page}`), options); + const record = payload; + if (!Array.isArray(record?.check_runs)) { + throw new Error("github_check_runs_malformed"); + } + const pageChecks = record.check_runs.map(normalizeCheckRun); + checks.push(...pageChecks); + expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; + if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { + return checks; + } + if (pageChecks.length === 0) { + throw new Error("github_check_runs_pagination_incomplete"); + } + page += 1; } - page += 1; - } } - export async function pollCheckRuns(repoFullName, prNumber, options = {}) { - const target = parseRepoFullName(repoFullName); - const normalizedPrNumber = normalizePullNumber(prNumber); - const normalizedOptions = normalizeOptions(options); - - let latest = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; - for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { - const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); - const checks = await fetchCheckRuns(target, headSha, normalizedOptions); - latest = { - conclusion: aggregateConclusion(checks), - checks, - headSha, - attempts: attempt + 1, - }; - if (latest.conclusion !== "pending") { - const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); - if (currentHeadSha === headSha) { - return latest; - } - latest = { - conclusion: "pending", - checks: [], - headSha: currentHeadSha, - attempts: attempt + 1, - }; - } - if (attempt === normalizedOptions.maxAttempts - 1) { - return latest; + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + let latest = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + const checks = await fetchCheckRuns(target, headSha, normalizedOptions); + latest = { + conclusion: aggregateConclusion(checks), + checks, + headSha, + attempts: attempt + 1, + }; + if (latest.conclusion !== "pending") { + const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + if (currentHeadSha === headSha) { + return latest; + } + latest = { + conclusion: "pending", + checks: [], + headSha: currentHeadSha, + attempts: attempt + 1, + }; + } + if (attempt === normalizedOptions.maxAttempts - 1) { + return latest; + } + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); } - await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); - } - - return latest; + // Unreachable at runtime: normalizeOptions clamps maxAttempts to a minimum of 1 (normalizePositiveInt's own + // `min` argument), so the loop above always returns internally on its final iteration (line 267 or 277). + // Kept only because TypeScript's control-flow analysis can't see that runtime guarantee through the loop. + return latest; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2ktcG9sbGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2ktcG9sbGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQTBDakQsTUFBTSxpQkFBaUIsR0FBRyx3QkFBd0IsQ0FBQztBQUNuRCxNQUFNLG9CQUFvQixHQUFHLE1BQU0sQ0FBQztBQUNwQyxNQUFNLG9CQUFvQixHQUFHLENBQUMsR0FBRyxNQUFNLENBQUM7QUFDeEMsTUFBTSxrQkFBa0IsR0FBRyxDQUFDLENBQUM7QUFDN0IsTUFBTSx1QkFBdUIsR0FBRyxNQUFNLENBQUM7QUFDdkMsTUFBTSxnQkFBZ0IsR0FBRyxZQUFZLENBQUM7QUFFdEMsU0FBUyxtQkFBbUIsQ0FBQyxLQUF5QjtJQUNwRCxJQUFJLEtBQUssS0FBSyxTQUFTO1FBQUUsT0FBTyxpQkFBaUIsQ0FBQztJQUNsRCxJQUFJLE9BQU8sS0FBSyxLQUFLLFFBQVEsSUFBSSxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUU7UUFBRSxPQUFPLGlCQUFpQixDQUFDO0lBQ3pFLElBQUksTUFBVyxDQUFDO0lBQ2hCLElBQUksQ0FBQztRQUNILE1BQU0sR0FBRyxJQUFJLEdBQUcsQ0FBQyxLQUFLLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQztJQUNqQyxDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsTUFBTSxJQUFJLEtBQUssQ0FBQyxzQkFBc0IsQ0FBQyxDQUFDO0lBQzFDLENBQUM7SUFDRCxJQUFJLE1BQU0sQ0FBQyxRQUFRLEtBQUssUUFBUSxJQUFJLE1BQU0sQ0FBQyxRQUFRLEtBQUssZ0JBQWdCLEVBQUUsQ0FBQztRQUN6RSxNQUFNLElBQUksS0FBSyxDQUFDLHNCQUFzQixDQUFDLENBQUM7SUFDMUMsQ0FBQztJQUNELE1BQU0sQ0FBQyxRQUFRLEdBQUcsTUFBTSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFD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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/ci-poller.ts b/packages/loopover-miner/lib/ci-poller.ts new file mode 100644 index 0000000000..a3bf199ac4 --- /dev/null +++ b/packages/loopover-miner/lib/ci-poller.ts @@ -0,0 +1,286 @@ +import { fetchWithRetry } from "./http-retry.js"; + +export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral"; + +export type NormalizedCheckRun = { + name: string; + status: string; + conclusion: CheckRunConclusion; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; +}; + +export type PollCheckRunsResult = { + conclusion: CheckRunConclusion; + checks: NormalizedCheckRun[]; + headSha: string; + attempts: number; +}; + +export type PollCheckRunsOptions = { + apiBaseUrl?: string; + fetchFn?: typeof fetch; + githubToken?: string; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; + requestTimeoutMs?: number; + sleepFn?: (delayMs: number) => Promise; +}; + +type NormalizedPollOptions = { + apiBaseUrl: string; + fetchFn: typeof fetch; + githubToken: string; + maxAttempts: number; + minIntervalMs: number; + maxIntervalMs: number; + requestTimeoutMs: number; + sleepFn: (delayMs: number) => Promise; +}; + +const defaultApiBaseUrl = "https://api.github.com"; +const defaultMinIntervalMs = 60_000; +const defaultMaxIntervalMs = 5 * 60_000; +const defaultMaxAttempts = 1; +const defaultRequestTimeoutMs = 10_000; +const githubApiVersion = "2022-11-28"; + +function normalizeApiBaseUrl(value: string | undefined): string { + if (value === undefined) return defaultApiBaseUrl; + if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl; + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + throw new Error("invalid_api_base_url"); + } + if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { + throw new Error("invalid_api_base_url"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +} + +function normalizePositiveInt(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value as number))); +} + +function normalizeOptions(options: PollCheckRunsOptions = {}): NormalizedPollOptions { + return { + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), + fetchFn: options.fetchFn ?? fetch, + githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", + maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), + minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), + maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), + requestTimeoutMs: normalizePositiveInt(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), + sleepFn: + options.sleepFn ?? + ((delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs))), + }; +} + +function parseRepoFullName(repoFullName: string): { owner: string; repo: string } { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) { + throw new Error("invalid_repo_full_name"); + } + return { owner: owner.trim(), repo: repo.trim() }; +} + +function normalizePullNumber(value: number): number { + if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number"); + return value; +} + +function githubHeaders(githubToken: string): Record { + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": githubApiVersion, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +function repoPath(target: { owner: string; repo: string }, suffix: string): string { + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function apiUrl(apiBaseUrl: string, path: string, query = ""): string { + return `${apiBaseUrl}${path}${query}`; +} + +function githubError(response: Response, payload: unknown): Error & { code: string; githubMessage: string | null } { + const code = `github_${response.status}`; + const record = payload as { message?: unknown } | null; + const githubMessage = + typeof record?.message === "string" && record.message.trim() ? record.message : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); +} + +async function githubGetJsonResponse(url: string, options: NormalizedPollOptions): Promise<{ payload: unknown; response: Response }> { + // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own + // pending-retry loop; the poller's injected sleepFn keeps tests instant. requestTimeoutMs bounds each + // individual attempt (a stalled connection previously hung this call forever -- #miner-github-read-timeouts). + const response = await fetchWithRetry( + options.fetchFn as unknown as (url: unknown, init?: unknown) => Promise, + url, + { method: "GET", headers: githubHeaders(options.githubToken) }, + { sleepFn: options.sleepFn, timeoutMs: options.requestTimeoutMs }, + ); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw githubError(response, payload); + } + return { payload, response }; +} + +async function githubGetJson(url: string, options: NormalizedPollOptions): Promise { + const { payload } = await githubGetJsonResponse(url, options); + return payload; +} + +function hasNextLink(response: Response): boolean { + return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); +} + +function payloadTotalCount(payload: unknown): number | null { + const record = payload as { total_count?: unknown } | null; + const totalCount = Number(record?.total_count); + return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; +} + +function normalizeConclusion(checkRun: unknown): CheckRunConclusion { + if (!checkRun || typeof checkRun !== "object") return "pending"; + const record = checkRun as { status?: unknown; conclusion?: unknown }; + if (record.status !== "completed") return "pending"; + switch (record.conclusion) { + case "success": + case "skipped": + return "success"; + case "neutral": + return "neutral"; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "stale": + case "startup_failure": + return "failure"; + default: + return "pending"; + } +} + +function normalizeCheckRun(checkRun: unknown): NormalizedCheckRun { + const record = checkRun as { name?: unknown; status?: unknown; details_url?: unknown; started_at?: unknown; completed_at?: unknown } | null; + return { + name: typeof record?.name === "string" ? record.name : "", + status: typeof record?.status === "string" ? record.status : "unknown", + conclusion: normalizeConclusion(checkRun), + detailsUrl: typeof record?.details_url === "string" ? record.details_url : null, + startedAt: typeof record?.started_at === "string" ? record.started_at : null, + completedAt: typeof record?.completed_at === "string" ? record.completed_at : null, + }; +} + +function aggregateConclusion(checks: NormalizedCheckRun[]): CheckRunConclusion { + if (checks.length === 0) return "pending"; + if (checks.some((check) => check.conclusion === "failure")) return "failure"; + if (checks.some((check) => check.conclusion === "pending")) return "pending"; + if (checks.every((check) => check.conclusion === "success")) return "success"; + return "neutral"; +} + +function backoffDelayMs(attemptIndex: number, options: NormalizedPollOptions): number { + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); +} + +async function fetchHeadSha(target: { owner: string; repo: string }, prNumber: number, options: NormalizedPollOptions): Promise { + const payload = await githubGetJson( + apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), + options, + ); + const record = payload as { head?: { sha?: unknown } } | null; + const headSha = record?.head?.sha; + if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing"); + return headSha; +} + +async function fetchCheckRuns(target: { owner: string; repo: string }, headSha: string, options: NormalizedPollOptions): Promise { + const checks: NormalizedCheckRun[] = []; + let page = 1; + let expectedTotalCount: number | null = null; + for (;;) { + const { payload, response } = await githubGetJsonResponse( + apiUrl( + options.apiBaseUrl, + repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), + `?per_page=100&page=${page}`, + ), + options, + ); + const record = payload as { check_runs?: unknown } | null; + if (!Array.isArray(record?.check_runs)) { + throw new Error("github_check_runs_malformed"); + } + const pageChecks = record.check_runs.map(normalizeCheckRun); + checks.push(...pageChecks); + expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; + if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { + return checks; + } + if (pageChecks.length === 0) { + throw new Error("github_check_runs_pagination_incomplete"); + } + page += 1; + } +} + +export async function pollCheckRuns(repoFullName: string, prNumber: number, options: PollCheckRunsOptions = {}): Promise { + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + + let latest: PollCheckRunsResult = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + const checks = await fetchCheckRuns(target, headSha, normalizedOptions); + latest = { + conclusion: aggregateConclusion(checks), + checks, + headSha, + attempts: attempt + 1, + }; + if (latest.conclusion !== "pending") { + const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + if (currentHeadSha === headSha) { + return latest; + } + latest = { + conclusion: "pending", + checks: [], + headSha: currentHeadSha, + attempts: attempt + 1, + }; + } + if (attempt === normalizedOptions.maxAttempts - 1) { + return latest; + } + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } + + // Unreachable at runtime: normalizeOptions clamps maxAttempts to a minimum of 1 (normalizePositiveInt's own + // `min` argument), so the loop above always returns internally on its final iteration (line 267 or 277). + // Kept only because TypeScript's control-flow analysis can't see that runtime guarantee through the loop. + return latest; +} diff --git a/packages/loopover-miner/lib/coding-task-spec.d.ts b/packages/loopover-miner/lib/coding-task-spec.d.ts index 3398e8696d..0e797df00c 100644 --- a/packages/loopover-miner/lib/coding-task-spec.d.ts +++ b/packages/loopover-miner/lib/coding-task-spec.d.ts @@ -1,47 +1,75 @@ import type { AcceptanceCriteria, FeasibilityGateResult, FeasibilityVerdict, IssueRecord, PullRequestRecord } from "@loopover/engine"; import type { RepoStackResult } from "./stack-detection.js"; - -export type CodingTaskIssue = { number: number; title: string; body?: string | null | undefined; labels?: string[] | undefined }; - +export type CodingTaskIssue = { + number: number; + title: string; + body?: string | null | undefined; + labels?: string[] | undefined; +}; export type CodingTaskClaimLedger = { - listClaims(filter: { repoFullName: string; status: string }): Array<{ issueNumber: number }>; + listClaims(filter: { + repoFullName: string; + status: string; + }): Array<{ + issueNumber: number; + }>; +}; +export type CodingTaskContext = { + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; +}; +/** + * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in + * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk + * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented + * "ready" default -- see this file's header for why that's honest, not fabricated. + */ +export declare function buildCodingTaskFeasibility(repoFullName: string, issue: CodingTaskIssue, context: CodingTaskContext, claimLedger: CodingTaskClaimLedger): FeasibilityGateResult; +/** + * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. + */ +export declare function buildCodingTaskAcceptanceCriteria(issue: CodingTaskIssue, feasibility: FeasibilityGateResult): AcceptanceCriteria; +/** + * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes + * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is + * expected to abandon the attempt rather than start it, per acceptance-criteria.ts's own documented design. + */ +export declare function writeAcceptanceCriteriaFile(workingDirectory: string, acceptanceCriteria: AcceptanceCriteria): { + written: boolean; + path: string | null; }; - -export type CodingTaskContext = { issues: IssueRecord[]; pullRequests: PullRequestRecord[] }; - -export function buildCodingTaskFeasibility( - repoFullName: string, - issue: CodingTaskIssue, - context: CodingTaskContext, - claimLedger: CodingTaskClaimLedger, -): FeasibilityGateResult; - -export function buildCodingTaskAcceptanceCriteria(issue: CodingTaskIssue, feasibility: FeasibilityGateResult): AcceptanceCriteria; - -export function writeAcceptanceCriteriaFile(workingDirectory: string, acceptanceCriteria: AcceptanceCriteria): { written: boolean; path: string | null }; - export type CodingTaskSpecInput = { - repoFullName: string; - issue: CodingTaskIssue; - context: CodingTaskContext; - claimLedger: CodingTaskClaimLedger; - workingDirectory: string; - /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ - detectRepoStack?: (repoPath: string) => RepoStackResult; + repoFullName: string; + issue: CodingTaskIssue; + context: CodingTaskContext; + claimLedger: CodingTaskClaimLedger; + workingDirectory: string; + /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ + detectRepoStack?: (repoPath: string) => RepoStackResult; +}; +export type CodingTaskSpecResult = { + ready: false; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; +} | { + ready: true; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; + acceptanceCriteriaPath: string; + instructions: string; + title: string; + body: string | undefined; + labels: string[] | undefined; + linkedIssues: number[]; }; - -export type CodingTaskSpecResult = - | { ready: false; verdict: FeasibilityVerdict; feasibility: FeasibilityGateResult } - | { - ready: true; - verdict: FeasibilityVerdict; - feasibility: FeasibilityGateResult; - acceptanceCriteriaPath: string; - instructions: string; - title: string; - body: string | undefined; - labels: string[] | undefined; - linkedIssues: number[]; - }; - -export function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult; +/** + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the + * target-repo stack (#4786) -> instructions. Returns `ready: false` (with the computed feasibility verdict, + * for the caller to report) when the verdict is `raise`/`avoid` -- the caller should abandon the attempt + * rather than proceed with no real acceptance-criteria file on disk. + * + * `detectRepoStack` is injectable so tests can assert both the detected and fail-closed undiscovered stack + * branches without depending on real filesystem probes; omitted falls back to stack-detection.js's real + * `detectRepoStack` (the production default). + */ +export declare function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult; diff --git a/packages/loopover-miner/lib/coding-task-spec.js b/packages/loopover-miner/lib/coding-task-spec.js index 1071548910..63abe95adf 100644 --- a/packages/loopover-miner/lib/coding-task-spec.js +++ b/packages/loopover-miner/lib/coding-task-spec.js @@ -1,71 +1,29 @@ import { closeSync, constants as fsConstants, openSync, realpathSync, writeFileSync } from "node:fs"; import { isAbsolute, join, relative } from "node:path"; -import { - ACCEPTANCE_CRITERIA_FILENAME, - buildAcceptanceCriteria, - buildCollisionReport, - buildFeasibilityVerdict, - buildPromptPacket, - feasibilityInputFromPreStartCheck, - serializeAcceptanceCriteria, - shouldWriteAcceptanceCriteria, -} from "@loopover/engine"; +import { ACCEPTANCE_CRITERIA_FILENAME, buildAcceptanceCriteria, buildCollisionReport, buildFeasibilityVerdict, buildPromptPacket, feasibilityInputFromPreStartCheck, serializeAcceptanceCriteria, shouldWriteAcceptanceCriteria, } from "@loopover/engine"; import { neutralizePromptInjection } from "./prompt-injection-defense.js"; import { detectRepoStack, renderStackSummary } from "./stack-detection.js"; - -// Coding-task-spec builder (#5132, Wave 3.5 follow-up). The second gap discovered alongside #5132's CLI -// wiring: `IterateLoopInput.title`/`instructions`/`acceptanceCriteriaPath` had no builder anywhere in this -// package. `packages/loopover-engine/src/miner/acceptance-criteria.ts` already composes a PromptPacket + -// FeasibilityGateResult into an immutable AcceptanceCriteria document (and deliberately does NOT write it -- -// "actually writing it into the attempt's worktree is the worktree primitive's job", per its own header) -- -// this module is that caller: derives the four inputs from a real target issue + the already-fetched -// SelfReviewContext (#5145), then writes the file for real. -// -// issueStatus is intentionally left undefined when computing feasibility: buildIssueQualityReport (the only -// thing that could supply it) lives only in root src/signals/engine.ts and has never been extracted into -// @loopover/engine (same gap #5145's own header documents for `issueQuality`). This is not a -// fabrication -- feasibilityInputFromPreStartCheck's OWN documented default for a missing -// issueQualityStatus/lifecycle is "ready", the same honest-default precedent already established. -// -// Target-repo stack detection (#4786 / #4785 follow-up): `detectRepoStack` already returned a structured -// language/package-manager/command description, but nothing in the attempt path consumed it -- instructions -// were issue text + an acceptance-criteria path only. This module now appends that real stack summary (and -// any confidently-inferred validation commands) to the coding-agent prompt so the agent validates against -// THIS repository's tooling rather than assuming LoopOver/loopover CI, Codecov, or `npm run test:ci`. -// -// Prompt-injection defense (#4795): a target issue's title/body is a customer repo's own content -- on -// Rent-a-Loop, anyone who can open an issue on that repo can shape text the coding agent later reads as -// part of its own instructions. `neutralizePromptInjection` runs on both fields before they reach either -// the coding agent's instructions (buildInstructions) or the acceptance-criteria document's taskBrief -// (buildTaskBrief) -- the two places raw issue text is embedded into agent-facing prose. This is a -// DIFFERENT concern from prompt-packet.ts's sanitizePromptPacketField (already applied downstream to -// taskBrief via buildPromptPacket): that scrubs economic/identity terms and local paths, not -// manipulation-shaped instructions, so both layers run and neither substitutes for the other. - function buildTaskBrief(issue) { - const title = neutralizePromptInjection(issue.title).text; - const body = neutralizePromptInjection((issue.body ?? "").trim()).text; - return body ? `${title}\n\n${body}` : title; + const title = neutralizePromptInjection(issue.title).text; + const body = neutralizePromptInjection((issue.body ?? "").trim()).text; + return body ? `${title}\n\n${body}` : title; } - function buildConstraints(issue) { - if (!Array.isArray(issue.labels) || issue.labels.length === 0) return ""; - return `Labels on this issue: ${issue.labels.join(", ")}.`; + if (!Array.isArray(issue.labels) || issue.labels.length === 0) + return ""; + return `Labels on this issue: ${issue.labels.join(", ")}.`; } - function buildFeasibilityNotes(feasibility) { - return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); + return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); } - // Only ever resolves to "claimed"/"unclaimed": the claim ledger's own ClaimStatus vocabulary // ("active"|"released"|"expired") has no "solved" concept for FeasibilityClaimStatus's "solved" value to // map from -- that would need real evidence a PR already resolved the issue (e.g. a merged, linked PR), // which this function doesn't have access to. Not fabricated; genuinely undetectable from claim data alone. function resolveClaimStatus(claimLedger, repoFullName, issueNumber) { - const claims = claimLedger.listClaims({ repoFullName, status: "active" }); - return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; + const claims = claimLedger.listClaims({ repoFullName, status: "active" }); + return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; } - // The target issue's own raw cluster risk from buildCollisionReport (newly exported from // @loopover/engine's public barrel) -- "none" when the issue isn't part of any cluster at all. // DELIBERATELY does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists specifically to @@ -75,114 +33,91 @@ function resolveClaimStatus(claimLedger, repoFullName, issueNumber) { // against it (buildCollisionReport's pairwise "shared linked issue" rule, which fires at "high" for exactly // one PR) is a meaningful, real caution signal, not a false positive to filter out. function resolveDuplicateClusterRisk(repoFullName, issues, pullRequests, issueNumber) { - const report = buildCollisionReport(repoFullName, issues, pullRequests); - const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); - return cluster ? cluster.risk : "none"; + const report = buildCollisionReport(repoFullName, issues, pullRequests); + const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); + return cluster ? cluster.risk : "none"; } - /** * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented * "ready" default -- see this file's header for why that's honest, not fabricated. - * - * @param {string} repoFullName - * @param {{ number: number }} issue - * @param {{ issues: Array<{ number: number }>, pullRequests: unknown[] }} context - * @param {{ listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }} claimLedger - * @returns {import("@loopover/engine").FeasibilityGateResult} */ export function buildCodingTaskFeasibility(repoFullName, issue, context, claimLedger) { - const found = context.issues.some((candidate) => candidate.number === issue.number); - const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); - const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); - const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); - return buildFeasibilityVerdict(feasibilityInput); + const found = context.issues.some((candidate) => candidate.number === issue.number); + const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); + const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); + const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); + return buildFeasibilityVerdict(feasibilityInput); } - /** * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. - * - * @param {{ title: string, body?: string | null, labels?: string[] }} issue - * @param {import("@loopover/engine").FeasibilityGateResult} feasibility - * @returns {import("@loopover/engine").AcceptanceCriteria} */ export function buildCodingTaskAcceptanceCriteria(issue, feasibility) { - const promptPacket = buildPromptPacket({ - taskBrief: buildTaskBrief(issue), - constraints: buildConstraints(issue), - feasibilityNotes: buildFeasibilityNotes(feasibility), - retrievalContext: "", - }); - return buildAcceptanceCriteria({ promptPacket, feasibility }); + const promptPacket = buildPromptPacket({ + taskBrief: buildTaskBrief(issue), + constraints: buildConstraints(issue), + feasibilityNotes: buildFeasibilityNotes(feasibility), + retrievalContext: "", + }); + return buildAcceptanceCriteria({ promptPacket, feasibility }); +} +function assertContainedPath(root, path) { + const relativePath = relative(root, path); + if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) + return; + throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); } - /** * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is * expected to abandon the attempt rather than start it, per acceptance-criteria.ts's own documented design. - * - * @param {string} workingDirectory - * @param {import("@loopover/engine").AcceptanceCriteria} acceptanceCriteria - * @returns {{ written: boolean, path: string | null }} */ -function assertContainedPath(root, path) { - const relativePath = relative(root, path); - if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) return; - throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); -} - export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria) { - if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; - const root = realpathSync(workingDirectory); - const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); - assertContainedPath(root, path); - - let fd; - try { - fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); - writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); - } finally { - if (fd !== undefined) closeSync(fd); - } - - return { written: true, path }; + if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) + return { written: false, path: null }; + const root = realpathSync(workingDirectory); + const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); + assertContainedPath(root, path); + let fd; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); + writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + } + finally { + if (fd !== undefined) + closeSync(fd); + } + return { written: true, path }; } - /** * Prompt guidance derived from a real `detectRepoStack` result (#4786). Lists only commands the detector * confidently inferred -- a `null` command stays omitted rather than guessed -- and always tells the agent * not to assume LoopOver/loopover's own CI/coverage conventions. - * - * @param {import("./stack-detection.js").RepoStackResult} stack - * @returns {string} */ function buildValidationGuidance(stack) { - const lines = [ - `Detected target-repo stack: ${renderStackSummary(stack)}`, - "", - "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", - "Do not assume LoopOver/loopover CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", - ]; - if (stack?.detected === true) { - const commands = [ - stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, - stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, - stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, - stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, - ].filter((entry) => entry !== null); - if (commands.length > 0) { - lines.push("", "Run these commands before finishing:", ...commands); - } else { - lines.push( + const lines = [ + `Detected target-repo stack: ${renderStackSummary(stack)}`, "", - "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing.", - ); + "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", + "Do not assume LoopOver/loopover CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", + ]; + if (stack?.detected === true) { + const commands = [ + stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, + stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, + stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, + stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + if (commands.length > 0) { + lines.push("", "Run these commands before finishing:", ...commands); + } + else { + lines.push("", "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing."); + } } - } - return lines.join("\n"); + return lines.join("\n"); } - /** * The coding-agent driver's own prompt text (agent-sdk-driver.ts's header: "forwarded verbatim as the * prompt -- the acceptance-criteria document already lives inside the worktree", so this points to it @@ -192,34 +127,27 @@ function buildValidationGuidance(stack) { * The issue's title/body are neutralized against prompt-injection (#4795) before embedding -- this is the * literal `prompt:` handoff to the coding agent (agent-sdk-driver.ts), so it's the primary place untrusted * repo content could otherwise redirect agent behavior. - * - * @param {{ number: number, title: string, body?: string | null }} issue - * @param {string} acceptanceCriteriaPath - * @param {import("./stack-detection.js").RepoStackResult} stack */ function buildInstructions(issue, acceptanceCriteriaPath, stack) { - const title = neutralizePromptInjection(issue.title); - const body = neutralizePromptInjection((issue.body ?? "").trim()); - if (title.injected || body.injected) { - console.log( - JSON.stringify({ - event: "prompt_injection_neutralized", - issueNumber: issue.number, - fields: [title.injected ? "title" : null, body.injected ? "body" : null].filter(Boolean), - }), - ); - } - return [ - `Resolve the following GitHub issue in this repository: #${issue.number} -- ${title.text}`, - "", - body.text, - "", - `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, - "", - buildValidationGuidance(stack), - ].join("\n"); + const title = neutralizePromptInjection(issue.title); + const body = neutralizePromptInjection((issue.body ?? "").trim()); + if (title.injected || body.injected) { + console.log(JSON.stringify({ + event: "prompt_injection_neutralized", + issueNumber: issue.number, + fields: [title.injected ? "title" : null, body.injected ? "body" : null].filter(Boolean), + })); + } + return [ + `Resolve the following GitHub issue in this repository: #${issue.number} -- ${title.text}`, + "", + body.text, + "", + `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, + "", + buildValidationGuidance(stack), + ].join("\n"); } - /** * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the * target-repo stack (#4786) -> instructions. Returns `ready: false` (with the computed feasibility verdict, @@ -229,40 +157,29 @@ function buildInstructions(issue, acceptanceCriteriaPath, stack) { * `detectRepoStack` is injectable so tests can assert both the detected and fail-closed undiscovered stack * branches without depending on real filesystem probes; omitted falls back to stack-detection.js's real * `detectRepoStack` (the production default). - * - * @param {{ - * repoFullName: string, issue: { number: number, title: string, body?: string | null, labels?: string[] }, - * context: { issues: Array<{ number: number }>, pullRequests: unknown[] }, - * claimLedger: { listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }, - * workingDirectory: string, - * detectRepoStack?: (repoPath: string) => import("./stack-detection.js").RepoStackResult, - * }} input - * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} */ export function buildCodingTaskSpec(input) { - const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); - const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); - const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); - - if (!writeResult.written) { - return { ready: false, verdict: feasibility.verdict, feasibility }; - } - - // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from - // loopover conventions. Fail-closed `{ detected: false }` results still reach the prompt (via - // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. - const detect = input.detectRepoStack ?? detectRepoStack; - const stack = detect(input.workingDirectory); - - return { - ready: true, - verdict: feasibility.verdict, - feasibility, - acceptanceCriteriaPath: writeResult.path, - instructions: buildInstructions(input.issue, writeResult.path, stack), - title: input.issue.title, - body: input.issue.body ?? undefined, - labels: input.issue.labels, - linkedIssues: [input.issue.number], - }; + const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); + const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); + const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); + if (!writeResult.written) { + return { ready: false, verdict: feasibility.verdict, feasibility }; + } + // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from + // loopover conventions. Fail-closed `{ detected: false }` results still reach the prompt (via + // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. + const detect = input.detectRepoStack ?? detectRepoStack; + const stack = detect(input.workingDirectory); + return { + ready: true, + verdict: feasibility.verdict, + feasibility, + acceptanceCriteriaPath: writeResult.path, + instructions: buildInstructions(input.issue, writeResult.path, stack), + title: input.issue.title, + body: input.issue.body ?? undefined, + labels: input.issue.labels, + linkedIssues: [input.issue.number], + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29kaW5nLXRhc2stc3BlYy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNvZGluZy10YXNrLXNwZWMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFNBQVMsRUFBRSxTQUFTLElBQUksV0FBVyxFQUFFLFFBQVEsRUFBRSxZQUFZLEVBQUUsYUFBYSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ3JHLE9BQU8sRUFBRSxVQUFVLEVBQUUsSUFBSSxFQUFFLFFBQVEsRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUN2RCxPQUFPLEVBQ0wsNEJBQTRCLEVBQzVCLHVCQUF1QixFQUN2QixvQkFBb0IsRUFDcEIsdUJBQXVCLEVBQ3ZCLGlCQUFpQixFQUNqQixpQ0FBaUMsRUFDakMsMkJBQTJCLEVBQzNCLDZCQUE2QixHQUM5QixNQUFNLGtCQUFrQixDQUFDO0FBRTFCLE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLCtCQUErQixDQUFDO0FBQzFFLE9BQU8sRUFBRSxlQUFlLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQXdDM0UsU0FBUyxjQUFjLENBQUMsS0FBc0I7SUFDNUMsTUFBTSxLQUFLLEdBQUcseUJBQXlCLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQztJQUMxRCxNQUFNLElBQUksR0FBRyx5QkFBeUIsQ0FBQyxDQUFDLEtBQUssQ0FBQyxJQUFJLElBQUksRUFBRSxDQUFDLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxJQUFJLENBQUM7SUFDdkUsT0FBTyxJQUFJLENBQUMsQ0FBQyxDQUFDLEdBQUcsS0FBSyxPQUFPLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUM7QUFDOUMsQ0FBQztBQUVELFNBQVMsZ0JBQWdCLENBQUMsS0FBc0I7SUFDOUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDLE1BQU0sQ0FBQyxJQUFJLEtBQUssQ0FBQyxNQUFNLENB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/coding-task-spec.ts b/packages/loopover-miner/lib/coding-task-spec.ts new file mode 100644 index 0000000000..a6b3ca284a --- /dev/null +++ b/packages/loopover-miner/lib/coding-task-spec.ts @@ -0,0 +1,282 @@ +import { closeSync, constants as fsConstants, openSync, realpathSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; +import { + ACCEPTANCE_CRITERIA_FILENAME, + buildAcceptanceCriteria, + buildCollisionReport, + buildFeasibilityVerdict, + buildPromptPacket, + feasibilityInputFromPreStartCheck, + serializeAcceptanceCriteria, + shouldWriteAcceptanceCriteria, +} from "@loopover/engine"; +import type { AcceptanceCriteria, FeasibilityGateResult, FeasibilityVerdict, IssueRecord, PullRequestRecord } from "@loopover/engine"; +import { neutralizePromptInjection } from "./prompt-injection-defense.js"; +import { detectRepoStack, renderStackSummary } from "./stack-detection.js"; +import type { RepoStackResult } from "./stack-detection.js"; + +// Coding-task-spec builder (#5132, Wave 3.5 follow-up). The second gap discovered alongside #5132's CLI +// wiring: `IterateLoopInput.title`/`instructions`/`acceptanceCriteriaPath` had no builder anywhere in this +// package. `packages/loopover-engine/src/miner/acceptance-criteria.ts` already composes a PromptPacket + +// FeasibilityGateResult into an immutable AcceptanceCriteria document (and deliberately does NOT write it -- +// "actually writing it into the attempt's worktree is the worktree primitive's job", per its own header) -- +// this module is that caller: derives the four inputs from a real target issue + the already-fetched +// SelfReviewContext (#5145), then writes the file for real. +// +// issueStatus is intentionally left undefined when computing feasibility: buildIssueQualityReport (the only +// thing that could supply it) lives only in root src/signals/engine.ts and has never been extracted into +// @loopover/engine (same gap #5145's own header documents for `issueQuality`). This is not a +// fabrication -- feasibilityInputFromPreStartCheck's OWN documented default for a missing +// issueQualityStatus/lifecycle is "ready", the same honest-default precedent already established. +// +// Target-repo stack detection (#4786 / #4785 follow-up): `detectRepoStack` already returned a structured +// language/package-manager/command description, but nothing in the attempt path consumed it -- instructions +// were issue text + an acceptance-criteria path only. This module now appends that real stack summary (and +// any confidently-inferred validation commands) to the coding-agent prompt so the agent validates against +// THIS repository's tooling rather than assuming LoopOver/loopover CI, Codecov, or `npm run test:ci`. +// +// Prompt-injection defense (#4795): a target issue's title/body is a customer repo's own content -- on +// Rent-a-Loop, anyone who can open an issue on that repo can shape text the coding agent later reads as +// part of its own instructions. `neutralizePromptInjection` runs on both fields before they reach either +// the coding agent's instructions (buildInstructions) or the acceptance-criteria document's taskBrief +// (buildTaskBrief) -- the two places raw issue text is embedded into agent-facing prose. This is a +// DIFFERENT concern from prompt-packet.ts's sanitizePromptPacketField (already applied downstream to +// taskBrief via buildPromptPacket): that scrubs economic/identity terms and local paths, not +// manipulation-shaped instructions, so both layers run and neither substitutes for the other. + +export type CodingTaskIssue = { number: number; title: string; body?: string | null | undefined; labels?: string[] | undefined }; + +export type CodingTaskClaimLedger = { + listClaims(filter: { repoFullName: string; status: string }): Array<{ issueNumber: number }>; +}; + +export type CodingTaskContext = { issues: IssueRecord[]; pullRequests: PullRequestRecord[] }; + +function buildTaskBrief(issue: CodingTaskIssue): string { + const title = neutralizePromptInjection(issue.title).text; + const body = neutralizePromptInjection((issue.body ?? "").trim()).text; + return body ? `${title}\n\n${body}` : title; +} + +function buildConstraints(issue: CodingTaskIssue): string { + if (!Array.isArray(issue.labels) || issue.labels.length === 0) return ""; + return `Labels on this issue: ${issue.labels.join(", ")}.`; +} + +function buildFeasibilityNotes(feasibility: FeasibilityGateResult): string { + return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); +} + +// Only ever resolves to "claimed"/"unclaimed": the claim ledger's own ClaimStatus vocabulary +// ("active"|"released"|"expired") has no "solved" concept for FeasibilityClaimStatus's "solved" value to +// map from -- that would need real evidence a PR already resolved the issue (e.g. a merged, linked PR), +// which this function doesn't have access to. Not fabricated; genuinely undetectable from claim data alone. +function resolveClaimStatus(claimLedger: CodingTaskClaimLedger, repoFullName: string, issueNumber: number): "claimed" | "unclaimed" { + const claims = claimLedger.listClaims({ repoFullName, status: "active" }); + return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; +} + +// The target issue's own raw cluster risk from buildCollisionReport (newly exported from +// @loopover/engine's public barrel) -- "none" when the issue isn't part of any cluster at all. +// DELIBERATELY does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists specifically to +// stop inDuplicateCluster (self-review, "does MY OWN just-created submission look redundant") from firing on +// the ordinary case of one existing PR already legitimately closing the issue. Feasibility asks a different +// question -- "should I even START working on this issue" -- where an issue already having ANY open PR +// against it (buildCollisionReport's pairwise "shared linked issue" rule, which fires at "high" for exactly +// one PR) is a meaningful, real caution signal, not a false positive to filter out. +function resolveDuplicateClusterRisk( + repoFullName: string, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + issueNumber: number, +): "none" | "low" | "medium" | "high" { + const report = buildCollisionReport(repoFullName, issues, pullRequests); + const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); + return cluster ? cluster.risk : "none"; +} + +/** + * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in + * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk + * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented + * "ready" default -- see this file's header for why that's honest, not fabricated. + */ +export function buildCodingTaskFeasibility( + repoFullName: string, + issue: CodingTaskIssue, + context: CodingTaskContext, + claimLedger: CodingTaskClaimLedger, +): FeasibilityGateResult { + const found = context.issues.some((candidate) => candidate.number === issue.number); + const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); + const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); + const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); + return buildFeasibilityVerdict(feasibilityInput); +} + +/** + * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. + */ +export function buildCodingTaskAcceptanceCriteria(issue: CodingTaskIssue, feasibility: FeasibilityGateResult): AcceptanceCriteria { + const promptPacket = buildPromptPacket({ + taskBrief: buildTaskBrief(issue), + constraints: buildConstraints(issue), + feasibilityNotes: buildFeasibilityNotes(feasibility), + retrievalContext: "", + }); + return buildAcceptanceCriteria({ promptPacket, feasibility }); +} + +function assertContainedPath(root: string, path: string): void { + const relativePath = relative(root, path); + if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) return; + throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); +} + +/** + * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes + * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is + * expected to abandon the attempt rather than start it, per acceptance-criteria.ts's own documented design. + */ +export function writeAcceptanceCriteriaFile(workingDirectory: string, acceptanceCriteria: AcceptanceCriteria): { written: boolean; path: string | null } { + if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; + const root = realpathSync(workingDirectory); + const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); + assertContainedPath(root, path); + + let fd: number | undefined; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); + writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + } finally { + if (fd !== undefined) closeSync(fd); + } + + return { written: true, path }; +} + +/** + * Prompt guidance derived from a real `detectRepoStack` result (#4786). Lists only commands the detector + * confidently inferred -- a `null` command stays omitted rather than guessed -- and always tells the agent + * not to assume LoopOver/loopover's own CI/coverage conventions. + */ +function buildValidationGuidance(stack: RepoStackResult): string { + const lines = [ + `Detected target-repo stack: ${renderStackSummary(stack)}`, + "", + "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", + "Do not assume LoopOver/loopover CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", + ]; + if (stack?.detected === true) { + const commands = [ + stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, + stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, + stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, + stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, + ].filter((entry): entry is string => entry !== null); + if (commands.length > 0) { + lines.push("", "Run these commands before finishing:", ...commands); + } else { + lines.push( + "", + "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing.", + ); + } + } + return lines.join("\n"); +} + +/** + * The coding-agent driver's own prompt text (agent-sdk-driver.ts's header: "forwarded verbatim as the + * prompt -- the acceptance-criteria document already lives inside the worktree", so this points to it + * rather than repeating its content). Also carries the target repo's detected stack + validation commands + * (#4786) so the agent does not default to loopover-specific CI assumptions. + * + * The issue's title/body are neutralized against prompt-injection (#4795) before embedding -- this is the + * literal `prompt:` handoff to the coding agent (agent-sdk-driver.ts), so it's the primary place untrusted + * repo content could otherwise redirect agent behavior. + */ +function buildInstructions(issue: { number: number; title: string; body?: string | null | undefined }, acceptanceCriteriaPath: string, stack: RepoStackResult): string { + const title = neutralizePromptInjection(issue.title); + const body = neutralizePromptInjection((issue.body ?? "").trim()); + if (title.injected || body.injected) { + console.log( + JSON.stringify({ + event: "prompt_injection_neutralized", + issueNumber: issue.number, + fields: [title.injected ? "title" : null, body.injected ? "body" : null].filter(Boolean), + }), + ); + } + return [ + `Resolve the following GitHub issue in this repository: #${issue.number} -- ${title.text}`, + "", + body.text, + "", + `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, + "", + buildValidationGuidance(stack), + ].join("\n"); +} + +export type CodingTaskSpecInput = { + repoFullName: string; + issue: CodingTaskIssue; + context: CodingTaskContext; + claimLedger: CodingTaskClaimLedger; + workingDirectory: string; + /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ + detectRepoStack?: (repoPath: string) => RepoStackResult; +}; + +export type CodingTaskSpecResult = + | { ready: false; verdict: FeasibilityVerdict; feasibility: FeasibilityGateResult } + | { + ready: true; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; + acceptanceCriteriaPath: string; + instructions: string; + title: string; + body: string | undefined; + labels: string[] | undefined; + linkedIssues: number[]; + }; + +/** + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the + * target-repo stack (#4786) -> instructions. Returns `ready: false` (with the computed feasibility verdict, + * for the caller to report) when the verdict is `raise`/`avoid` -- the caller should abandon the attempt + * rather than proceed with no real acceptance-criteria file on disk. + * + * `detectRepoStack` is injectable so tests can assert both the detected and fail-closed undiscovered stack + * branches without depending on real filesystem probes; omitted falls back to stack-detection.js's real + * `detectRepoStack` (the production default). + */ +export function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult { + const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); + const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); + const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); + + if (!writeResult.written) { + return { ready: false, verdict: feasibility.verdict, feasibility }; + } + + // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from + // loopover conventions. Fail-closed `{ detected: false }` results still reach the prompt (via + // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. + const detect = input.detectRepoStack ?? detectRepoStack; + const stack = detect(input.workingDirectory); + + return { + ready: true, + verdict: feasibility.verdict, + feasibility, + acceptanceCriteriaPath: writeResult.path as string, + instructions: buildInstructions(input.issue, writeResult.path as string, stack), + title: input.issue.title, + body: input.issue.body ?? undefined, + labels: input.issue.labels, + linkedIssues: [input.issue.number], + }; +} diff --git a/packages/loopover-miner/lib/deny-hooks.d.ts b/packages/loopover-miner/lib/deny-hooks.d.ts index 402304a2bb..d0dcc55bad 100644 --- a/packages/loopover-miner/lib/deny-hooks.d.ts +++ b/packages/loopover-miner/lib/deny-hooks.d.ts @@ -1,28 +1,2 @@ -export type DenyRule = { - /** Tool-name glob (`*` = any within a segment, `**` across segments) or an exact tool name. */ - matcher: string; - /** Optional glob tested against every path-shaped string in the tool-call input. */ - pathPattern?: string; - /** Optional substrings that must ALL appear in one string-shaped input field (e.g. a shell command). */ - inputIncludesAll?: string[]; - /** Optional pattern that must match a whole whitespace-separated token (quotes stripped) of one - * string-shaped input field — for flag-shaped needles where a substring test would false-positive - * on an unrelated longer flag (e.g. `-f` vs. `--follow-tags`). */ - inputTokenPattern?: RegExp; - /** Human-readable reason surfaced when this rule blocks a call. */ - reason: string; -}; - -export type DenyVerdict = { - allowed: boolean; - blockedBy?: DenyRule; -}; - -export type ProposedToolCall = { - name: string; - input: Record; -}; - -export const DEFAULT_DENY_RULES: DenyRule[]; - -export function evaluateDenyHooks(toolCall: ProposedToolCall, rules?: DenyRule[]): DenyVerdict; +export { DEFAULT_DENY_RULES, evaluateDenyHooks } from "@loopover/engine"; +export type { DenyRule, DenyVerdict, ProposedToolCall } from "@loopover/engine"; diff --git a/packages/loopover-miner/lib/deny-hooks.js b/packages/loopover-miner/lib/deny-hooks.js index af2875c48e..2311a87510 100644 --- a/packages/loopover-miner/lib/deny-hooks.js +++ b/packages/loopover-miner/lib/deny-hooks.js @@ -1,6 +1,6 @@ // PreToolUse-style deny-hook primitives (#2295). Now a thin re-export of the engine's pure, deterministic deny // evaluator: the whole implementation moved into `@loopover/engine` (packages/loopover-engine/src/miner/ // deny-hooks.ts) by #5667 so the review stack and the miner share one copy. No behavior change — the evaluator is -// pure (no IO, no globals, no Date/random). See deny-hooks.d.ts for the type contract (DenyRule/DenyVerdict/ -// ProposedToolCall), which still declares the same shapes the engine module now implements. +// pure (no IO, no globals, no Date/random). export { DEFAULT_DENY_RULES, evaluateDenyHooks } from "@loopover/engine"; +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGVueS1ob29rcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImRlbnktaG9va3MudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsK0dBQStHO0FBQy9HLHlHQUF5RztBQUN6RyxrSEFBa0g7QUFDbEgsNENBQTRDO0FBQzVDLE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxpQkFBaUIsRUFBRSxNQUFNLGtCQUFrQixDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/deny-hooks.ts b/packages/loopover-miner/lib/deny-hooks.ts new file mode 100644 index 0000000000..a42c9a270d --- /dev/null +++ b/packages/loopover-miner/lib/deny-hooks.ts @@ -0,0 +1,6 @@ +// PreToolUse-style deny-hook primitives (#2295). Now a thin re-export of the engine's pure, deterministic deny +// evaluator: the whole implementation moved into `@loopover/engine` (packages/loopover-engine/src/miner/ +// deny-hooks.ts) by #5667 so the review stack and the miner share one copy. No behavior change — the evaluator is +// pure (no IO, no globals, no Date/random). +export { DEFAULT_DENY_RULES, evaluateDenyHooks } from "@loopover/engine"; +export type { DenyRule, DenyVerdict, ProposedToolCall } from "@loopover/engine"; diff --git a/packages/loopover-miner/lib/policy-doc-cache.d.ts b/packages/loopover-miner/lib/policy-doc-cache.d.ts index 4cd5a5e5c6..a90dddb97a 100644 --- a/packages/loopover-miner/lib/policy-doc-cache.d.ts +++ b/packages/loopover-miner/lib/policy-doc-cache.d.ts @@ -1,25 +1,28 @@ export type PolicyDocCacheEntry = { - etag: string; - content: string; + etag: string; + content: string; }; - export type PolicyDocCacheWrite = { - url: string; - etag: string; - content: string; - updatedAt: string; + url: string; + etag: string; + content: string; + updatedAt: string; }; - export type PolicyDocCacheStore = { - dbPath: string; - get(url: string): PolicyDocCacheEntry | null; - put(url: string, etag: string, content: string): PolicyDocCacheWrite; - close(): void; + dbPath: string; + get(url: string): PolicyDocCacheEntry | null; + put(url: string, etag: string, content: string): PolicyDocCacheWrite; + close(): void; }; - /** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ export type PolicyDocCache = Pick; - -export function resolvePolicyDocCacheDbPath(env?: Record): string; - -export function initPolicyDocCacheStore(dbPath?: string): PolicyDocCacheStore; +export declare function resolvePolicyDocCacheDbPath(env?: Record): string; +/** + * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4842) + * + * Opened through the #7175 SqliteDriver seam (`openLocalStoreAdapter`): CRUD goes through `driver.query`, + * while schema creation/migrations still use the underlying DatabaseSync until those helpers are migrated. + * Public API stays synchronous so callers need no async cascade in this part-1 slice. + */ +export declare function initPolicyDocCacheStore(dbPath?: string): PolicyDocCacheStore; diff --git a/packages/loopover-miner/lib/policy-doc-cache.js b/packages/loopover-miner/lib/policy-doc-cache.js index 5cfd206c78..d4be4af4c8 100644 --- a/packages/loopover-miner/lib/policy-doc-cache.js +++ b/packages/loopover-miner/lib/policy-doc-cache.js @@ -1,32 +1,20 @@ import { normalizeLocalStoreDbPath, openLocalStoreAdapter, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; - -// Local ETag cache for discovery's small policy-doc fetches (#4842). `discover` refetches each target repo's -// AI-USAGE.md/CONTRIBUTING.md on every run even though they rarely change, spending rate-limit budget on static -// content; this store lets opportunity-fanout.js revalidate with a conditional GET (If-None-Match) instead, and -// GitHub answers an unchanged doc with a 304 that costs no primary rate-limit budget. A 304 is a GitHub-confirmed -// unchanged body -- the cached content is only ever served AFTER a same-run revalidation, never blindly -- so this -// can never surface a stale policy that would wrongly permit autonomous work on an opted-out repo. Same 100% -// local/client-side discipline (mirrors run-state.js and the other stores this package owns via local-store.js): -// the file lives only on this machine and is never uploaded, synced, or phoned home with. - const defaultDbFileName = "policy-doc-cache.sqlite3"; - export function resolvePolicyDocCacheDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_DOC_CACHE_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_DOC_CACHE_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path"); } - function normalizeUrl(url) { - if (typeof url !== "string") throw new Error("invalid_policy_doc_url"); - const trimmed = url.trim(); - if (!trimmed) throw new Error("invalid_policy_doc_url"); - return trimmed; + if (typeof url !== "string") + throw new Error("invalid_policy_doc_url"); + const trimmed = url.trim(); + if (!trimmed) + throw new Error("invalid_policy_doc_url"); + return trimmed; } - /** * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this * module never uploads, syncs, or phones home with its contents. (#4842) @@ -36,9 +24,9 @@ function normalizeUrl(url) { * Public API stays synchronous so callers need no async cascade in this part-1 slice. */ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) { - const resolvedPath = normalizeDbPath(dbPath); - const { db, driver } = openLocalStoreAdapter(resolvedPath); - db.exec(` + const resolvedPath = normalizeDbPath(dbPath); + const { db, driver } = openLocalStoreAdapter(resolvedPath); + db.exec(` CREATE TABLE IF NOT EXISTS policy_doc_cache ( url TEXT PRIMARY KEY, etag TEXT NOT NULL, @@ -46,11 +34,10 @@ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) updated_at TEXT NOT NULL ) `); - // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). - applySchemaMigrations(db, []); - - const getSql = "SELECT etag, content FROM policy_doc_cache WHERE url = ?"; - const putSql = ` + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + const getSql = "SELECT etag, content FROM policy_doc_cache WHERE url = ?"; + const putSql = ` INSERT INTO policy_doc_cache (url, etag, content, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET @@ -58,27 +45,29 @@ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) content = excluded.content, updated_at = excluded.updated_at `; - - return { - dbPath: resolvedPath, - /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns - * are `TEXT NOT NULL`, so a present row always carries string values. */ - get(url) { - const { rows } = driver.query(getSql, [normalizeUrl(url)]); - const row = rows[0]; - return row ? { etag: row.etag, content: row.content } : null; - }, - /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */ - put(url, etag, content) { - const normalizedUrl = normalizeUrl(url); - if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_doc_etag"); - if (typeof content !== "string") throw new Error("invalid_policy_doc_content"); - const updatedAt = new Date().toISOString(); - driver.query(putSql, [normalizedUrl, etag, content, updatedAt]); - return { url: normalizedUrl, etag, content, updatedAt }; - }, - close() { - db.close(); - }, - }; + return { + dbPath: resolvedPath, + /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns + * are `TEXT NOT NULL`, so a present row always carries string values. */ + get(url) { + const { rows } = driver.query(getSql, [normalizeUrl(url)]); + const row = rows[0]; + return row ? { etag: row.etag, content: row.content } : null; + }, + /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */ + put(url, etag, content) { + const normalizedUrl = normalizeUrl(url); + if (typeof etag !== "string" || !etag.trim()) + throw new Error("invalid_policy_doc_etag"); + if (typeof content !== "string") + throw new Error("invalid_policy_doc_content"); + const updatedAt = new Date().toISOString(); + driver.query(putSql, [normalizedUrl, etag, content, updatedAt]); + return { url: normalizedUrl, etag, content, updatedAt }; + }, + close() { + db.close(); + }, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9saWN5LWRvYy1jYWNoZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBvbGljeS1kb2MtY2FjaGUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLHlCQUF5QixFQUFFLHFCQUFxQixFQUFFLHVCQUF1QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDN0csT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFpQzVELE1BQU0saUJBQWlCLEdBQUcsMEJBQTBCLENBQUM7QUFFckQsTUFBTSxVQUFVLDJCQUEyQixDQUFDLE1BQTBDLE9BQU8sQ0FBQyxHQUFHO0lBQy9GLE9BQU8sdUJBQXVCLENBQUMsaUJBQWlCLEVBQUUsb0NBQW9DLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDL0YsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWM7SUFDckMsT0FBTyx5QkFBeUIsQ0FBQyxNQUFNLEVBQUUsMkJBQTJCLEVBQUUsRUFBRSxrQ0FBa0MsQ0FBQyxDQUFDO0FBQzlHLENBQUM7QUFFRCxTQUFTLFlBQVksQ0FBQyxHQUFXO0lBQy9CLElBQUksT0FBTyxHQUFHLEtBQUssUUFBUTtRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsd0JBQXdCLENBQUMsQ0FBQztJQUN2RSxNQUFNLE9BQU8sR0FBRyxHQUFHLENBQUMsSUFBSSxFQUFFLENBQUM7SUFDM0IsSUFBSSxDQUFDLE9BQU87UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLHdCQUF3QixDQUFDLENBQUM7SUFDeEQsT0FBTyxPQUFPLENBQUM7QUFDakIsQ0FBQztBQUVEOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLFVBQVUsdUJBQXVCLENBQUMsU0FBaUIsMkJBQTJCLEVBQUU7SUFDcEYsTUFBTSxZQUFZLEdBQUcsZUFBZSxDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBQzdDLE1BQU0sRUFBRSxFQUFFLEVBQUUsTUFBTSxFQUFFLEdBQUcscUJBQXFCLENBQUMsWUFBWSxDQUFDLENBQUM7SUFDM0QsRUFBRSxDQUFDLElBQUksQ0FBQzs7Ozs7OztHQU9QLENBQUMsQ0FBQztJQUNILHlHQUF5RztJQUN6RyxxQkFBcUIsQ0FBQyxFQUFFLEVBQUUsRUFBRSxDQUFDLENBQUM7SUFFOUIsTUFBTSxNQUFNLEdBQUcsMERBQTBELENBQUM7SUFDMUUsTUFBTSxNQUFNLEdBQUc7Ozs7Ozs7R0FPZCxDQUFDO0lBRUYsT0FBTztRQUNMLE1BQU0sRUFBRSxZQUFZO1FBQ3BCO2tGQUMwRTtRQUMxRSxHQUFHLENBQUMsR0FBVztZQUNiLE1BQU0sRUFBRSxJQUFJLEVBQUUsR0FBRyxNQUFNLENBQUMsS0FBSyxDQUFDLE1BQU0sRUFBRSxDQUFDLFlBQVksQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLENBQUM7WUFDM0QsTUFBTSxHQUFHLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO1lBQ3BCLE9BQU8sR0FBRyxDQUFDLENBQUMsQ0FBQyxFQUFFLElBQUksRUFBRSxHQUFHLENBQUMsSUFBYyxFQUFFLE9BQU8sRUFBRSxHQUFHLENBQUMsT0FBaUIsRUFBRSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUM7UUFDbkYsQ0FBQztRQUNELDZGQUE2RjtRQUM3RixHQUFHLENBQUMsR0FBVyxFQUFFLElBQVksRUFBRSxPQUFlO1lBQzVDLE1BQU0sYUFBYSxHQUFHLFlBQVksQ0FBQyxHQUFHLENBQUMsQ0FBQztZQUN4QyxJQUFJLE9BQU8sSUFBSSxLQUFLLFFBQVEsSUFBSSxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQUU7Z0JBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyx5QkFBeUIsQ0FBQyxDQUFDO1lBQ3pGLElBQUksT0FBTyxPQUFPLEtBQUssUUFBUTtnQkFBRSxNQUFNLElBQUksS0FBSyxDQUFDLDRCQUE0QixDQUFDLENBQUM7WUFDL0UsTUFBTSxTQUFTLEdBQUcsSUFBSSxJQUFJLEVBQUUsQ0FBQyxXQUFXLEVBQUUsQ0FBQztZQUMzQyxNQUFNLENBQUMsS0FBSyxDQUFDLE1BQU0sRUFBRSxDQUFDLGFBQWEsRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLFNBQVMsQ0FBQyxDQUFDLENBQUM7WUFDaEUsT0FBTyxFQUFFLEdBQUcsRUFBRSxhQUFhLEVBQUUsSUFBSSxFQUFFLE9BQU8sRUFBRSxTQUFTLEVBQUUsQ0FBQztRQUMxRCxDQUFDO1FBQ0QsS0FBSztZQUNILEVBQUUsQ0FBQyxLQUFLLEVBQUUsQ0FBQztRQUNiLENBQUM7S0FDRixDQUFDO0FBQ0osQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-miner/lib/policy-doc-cache.ts b/packages/loopover-miner/lib/policy-doc-cache.ts new file mode 100644 index 0000000000..8b5d15d888 --- /dev/null +++ b/packages/loopover-miner/lib/policy-doc-cache.ts @@ -0,0 +1,106 @@ +import { normalizeLocalStoreDbPath, openLocalStoreAdapter, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +// Local ETag cache for discovery's small policy-doc fetches (#4842). `discover` refetches each target repo's +// AI-USAGE.md/CONTRIBUTING.md on every run even though they rarely change, spending rate-limit budget on static +// content; this store lets opportunity-fanout.js revalidate with a conditional GET (If-None-Match) instead, and +// GitHub answers an unchanged doc with a 304 that costs no primary rate-limit budget. A 304 is a GitHub-confirmed +// unchanged body -- the cached content is only ever served AFTER a same-run revalidation, never blindly -- so this +// can never surface a stale policy that would wrongly permit autonomous work on an opted-out repo. Same 100% +// local/client-side discipline (mirrors run-state.js and the other stores this package owns via local-store.js): +// the file lives only on this machine and is never uploaded, synced, or phoned home with. + +export type PolicyDocCacheEntry = { + etag: string; + content: string; +}; + +export type PolicyDocCacheWrite = { + url: string; + etag: string; + content: string; + updatedAt: string; +}; + +export type PolicyDocCacheStore = { + dbPath: string; + get(url: string): PolicyDocCacheEntry | null; + put(url: string, etag: string, content: string): PolicyDocCacheWrite; + close(): void; +}; + +/** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ +export type PolicyDocCache = Pick; + +const defaultDbFileName = "policy-doc-cache.sqlite3"; + +export function resolvePolicyDocCacheDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_DOC_CACHE_DB", env); +} + +function normalizeDbPath(dbPath: string): string { + return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path"); +} + +function normalizeUrl(url: string): string { + if (typeof url !== "string") throw new Error("invalid_policy_doc_url"); + const trimmed = url.trim(); + if (!trimmed) throw new Error("invalid_policy_doc_url"); + return trimmed; +} + +/** + * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4842) + * + * Opened through the #7175 SqliteDriver seam (`openLocalStoreAdapter`): CRUD goes through `driver.query`, + * while schema creation/migrations still use the underlying DatabaseSync until those helpers are migrated. + * Public API stays synchronous so callers need no async cascade in this part-1 slice. + */ +export function initPolicyDocCacheStore(dbPath: string = resolvePolicyDocCacheDbPath()): PolicyDocCacheStore { + const resolvedPath = normalizeDbPath(dbPath); + const { db, driver } = openLocalStoreAdapter(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS policy_doc_cache ( + url TEXT PRIMARY KEY, + etag TEXT NOT NULL, + content TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + + const getSql = "SELECT etag, content FROM policy_doc_cache WHERE url = ?"; + const putSql = ` + INSERT INTO policy_doc_cache (url, etag, content, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(url) DO UPDATE SET + etag = excluded.etag, + content = excluded.content, + updated_at = excluded.updated_at + `; + + return { + dbPath: resolvedPath, + /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns + * are `TEXT NOT NULL`, so a present row always carries string values. */ + get(url: string): PolicyDocCacheEntry | null { + const { rows } = driver.query(getSql, [normalizeUrl(url)]); + const row = rows[0]; + return row ? { etag: row.etag as string, content: row.content as string } : null; + }, + /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */ + put(url: string, etag: string, content: string): PolicyDocCacheWrite { + const normalizedUrl = normalizeUrl(url); + if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_doc_etag"); + if (typeof content !== "string") throw new Error("invalid_policy_doc_content"); + const updatedAt = new Date().toISOString(); + driver.query(putSql, [normalizedUrl, etag, content, updatedAt]); + return { url: normalizedUrl, etag, content, updatedAt }; + }, + close() { + db.close(); + }, + }; +} diff --git a/packages/loopover-miner/lib/pr-outcome.d.ts b/packages/loopover-miner/lib/pr-outcome.d.ts index 4fe170faae..9c5bd3657a 100644 --- a/packages/loopover-miner/lib/pr-outcome.d.ts +++ b/packages/loopover-miner/lib/pr-outcome.d.ts @@ -1,41 +1,59 @@ import type { AppendEventInput, LedgerEntry } from "./event-ledger.js"; - -export const MINER_PR_OUTCOME_EVENT: "pr_outcome"; -export const MINER_PR_OUTCOME_DECISIONS: readonly ["merged", "closed"]; - +/** Event-ledger vocabulary for a miner-local PR outcome. */ +export declare const MINER_PR_OUTCOME_EVENT = "pr_outcome"; export type MinerPrOutcomeDecision = "merged" | "closed"; - +/** The terminal decisions a miner records for one of its own PRs. */ +export declare const MINER_PR_OUTCOME_DECISIONS: readonly MinerPrOutcomeDecision[]; export interface NormalizedPrOutcomePayload { - prNumber: number; - decision: MinerPrOutcomeDecision; - closedAt: string | null; - reason: string | null; + prNumber: number; + decision: MinerPrOutcomeDecision; + closedAt: string | null; + reason: string | null; } - export interface PrOutcomeInput { - repoFullName?: unknown; - prNumber?: unknown; - decision?: unknown; - closedAt?: unknown; - reason?: unknown; + repoFullName?: unknown; + prNumber?: unknown; + decision?: unknown; + closedAt?: unknown; + reason?: unknown; } - export interface RecordPrOutcomeOptions { - /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the - * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. Reuses the - * real EventLedger#appendEvent signature so a genuine EventLedger (not just a same-shaped stub) type-checks. */ - eventLedger?: { appendEvent(event: AppendEventInput): LedgerEntry }; + /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the + * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. */ + eventLedger?: { + appendEvent(event: AppendEventInput): LedgerEntry; + }; } - export interface PrOutcomeLedgerReader { - readEvents(filter?: { since?: number; repoFullName?: string }): unknown[]; + readEvents(filter?: { + since?: number; + repoFullName?: string; + }): unknown[]; } - -export function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null; - -export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options?: RecordPrOutcomeOptions): unknown; - -export function readPrOutcomes( - eventLedger: PrOutcomeLedgerReader, - filter?: { since?: number; repoFullName?: string }, -): Map; +/** + * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's + * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry + * a reason bucket drawn from {@link REJECTION_REASONS} (shared with the rejection-state-machine sibling); a `merged` + * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). + */ +export declare function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null; +/** + * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's + * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one + * {@link MINER_PR_OUTCOME_EVENT} scoped to the repo and returns the appended entry. Fail-soft on a malformed + * snapshot: a missing repo or an invalid payload returns `null` rather than throwing (an unusable ledger is the + * only hard error, since that is a programmer wiring mistake). + */ +export declare function recordPrOutcomeSnapshot(input: PrOutcomeInput, options?: RecordPrOutcomeOptions): LedgerEntry | null; +/** + * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors + * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces + * the pure result — a later event for the same repo/PR supersedes an earlier one. Returns a `Map` keyed by + * `repoFullName:prNumber`. + */ +export declare function readPrOutcomes(eventLedger: PrOutcomeLedgerReader, filter?: { + since?: number; + repoFullName?: string; +}): Map; diff --git a/packages/loopover-miner/lib/pr-outcome.js b/packages/loopover-miner/lib/pr-outcome.js index dce82bec6c..d66789e2d7 100644 --- a/packages/loopover-miner/lib/pr-outcome.js +++ b/packages/loopover-miner/lib/pr-outcome.js @@ -8,25 +8,21 @@ // loopover SERVER recording ground truth for every contributor. THIS is a laptop-mode miner's local record of // its own PRs (it may have no webhook relay at all): same concept name, different codebase layer, no shared code. // The distinct `MINER_PR_OUTCOME_EVENT` local constant keeps the two from being conflated. - import { REJECTION_REASONS } from "./rejection-templates.js"; - /** Event-ledger vocabulary for a miner-local PR outcome. */ export const MINER_PR_OUTCOME_EVENT = "pr_outcome"; - /** The terminal decisions a miner records for one of its own PRs. */ export const MINER_PR_OUTCOME_DECISIONS = Object.freeze(["merged", "closed"]); - const decisionSet = new Set(MINER_PR_OUTCOME_DECISIONS); const reasonSet = new Set(REJECTION_REASONS); - function optionalString(value) { - if (value === undefined || value === null) return null; - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed || null; + if (value === undefined || value === null) + return null; + if (typeof value !== "string") + return null; + const trimmed = value.trim(); + return trimmed || null; } - /** * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry @@ -34,20 +30,23 @@ function optionalString(value) { * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). */ export function normalizePrOutcomePayload(payload) { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - if (!Number.isInteger(payload.prNumber) || payload.prNumber <= 0) return null; - const decision = optionalString(payload.decision); - if (!decision || !decisionSet.has(decision)) return null; - const reasonRaw = optionalString(payload.reason); - const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; - return { - prNumber: payload.prNumber, - decision, - closedAt: optionalString(payload.closedAt), - reason, - }; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const record = payload; + if (!Number.isInteger(record.prNumber) || record.prNumber <= 0) + return null; + const decision = optionalString(record.decision); + if (!decision || !decisionSet.has(decision)) + return null; + const reasonRaw = optionalString(record.reason); + const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; + return { + prNumber: record.prNumber, + decision: decision, + closedAt: optionalString(record.closedAt), + reason, + }; } - /** * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one @@ -56,20 +55,22 @@ export function normalizePrOutcomePayload(payload) { * only hard error, since that is a programmer wiring mistake). */ export function recordPrOutcomeSnapshot(input, options = {}) { - const eventLedger = options.eventLedger; - if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); - const repoFullName = typeof input?.repoFullName === "string" ? input.repoFullName.trim() : ""; - if (!repoFullName) return null; - const payload = normalizePrOutcomePayload({ - prNumber: input?.prNumber, - decision: input?.decision, - closedAt: input?.closedAt, - reason: input?.reason, - }); - if (!payload) return null; - return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") + throw new Error("invalid_event_ledger"); + const repoFullName = typeof input?.repoFullName === "string" ? input.repoFullName.trim() : ""; + if (!repoFullName) + return null; + const payload = normalizePrOutcomePayload({ + prNumber: input?.prNumber, + decision: input?.decision, + closedAt: input?.closedAt, + reason: input?.reason, + }); + if (!payload) + return null; + return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload: payload }); } - /** * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces @@ -77,21 +78,26 @@ export function recordPrOutcomeSnapshot(input, options = {}) { * `repoFullName:prNumber`. */ export function readPrOutcomes(eventLedger, filter = {}) { - const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; - const latest = new Map(); - for (const event of Array.isArray(events) ? events : []) { - if (event?.type !== MINER_PR_OUTCOME_EVENT) continue; - if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue; - const normalized = normalizePrOutcomePayload(event.payload); - if (!normalized) continue; - // Re-key on every event so Map iteration order tracks most-recently-UPDATED last, not first-seen (#7222). A - // bare Map.set() on an existing key updates the value but leaves the key frozen at its original position, so a - // later outcome for the same PR (e.g. closed-without-merge, then reopened + merged) stayed at its old slot -- - // breaking recency-ordered consumers like loop-reentry.js's countConsecutiveDisengagements. Deleting first - // moves the freshly-updated entry to the end, matching this reducer's own "a later event supersedes" contract. - const key = `${event.repoFullName}:${normalized.prNumber}`; - latest.delete(key); - latest.set(key, { ...normalized, repoFullName: event.repoFullName }); - } - return latest; + const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; + const latest = new Map(); + for (const event of Array.isArray(events) ? events : []) { + const record = event; + if (record?.type !== MINER_PR_OUTCOME_EVENT) + continue; + if (typeof record.repoFullName !== "string" || !record.repoFullName.trim()) + continue; + const normalized = normalizePrOutcomePayload(record.payload); + if (!normalized) + continue; + // Re-key on every event so Map iteration order tracks most-recently-UPDATED last, not first-seen (#7222). A + // bare Map.set() on an existing key updates the value but leaves the key frozen at its original position, so a + // later outcome for the same PR (e.g. closed-without-merge, then reopened + merged) stayed at its old slot -- + // breaking recency-ordered consumers like loop-reentry.js's countConsecutiveDisengagements. Deleting first + // moves the freshly-updated entry to the end, matching this reducer's own "a later event supersedes" contract. + const key = `${record.repoFullName}:${normalized.prNumber}`; + latest.delete(key); + latest.set(key, { ...normalized, repoFullName: record.repoFullName }); + } + return latest; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHItb3V0Y29tZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInByLW91dGNvbWUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsaUhBQWlIO0FBQ2pILDBHQUEwRztBQUMxRyxpSEFBaUg7QUFDakgsOEJBQThCO0FBQzlCLEVBQUU7QUFDRiw2R0FBNkc7QUFDN0csK0dBQStHO0FBQy9HLDhHQUE4RztBQUM5RyxrSEFBa0g7QUFDbEgsMkZBQTJGO0FBRTNGLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBRzdELDREQUE0RDtBQUM1RCxNQUFNLENBQUMsTUFBTSxzQkFBc0IsR0FBRyxZQUFZLENBQUM7QUFJbkQscUVBQXFFO0FBQ3JFLE1BQU0sQ0FBQyxNQUFNLDBCQUEwQixHQUFzQyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUMsUUFBUSxFQUFFLFFBQVEsQ0FBQyxDQUFDLENBQUM7QUEyQmpILE1BQU0sV0FBVyxHQUFnQixJQUFJLEdBQUcsQ0FBQywwQkFBMEIsQ0FBQyxDQUFDO0FBQ3JFLE1BQU0sU0FBUyxHQUFnQixJQUFJLEdBQUcsQ0FBQyxpQkFBaUIsQ0FBQyxDQUFDO0FBRTFELFNBQVMsY0FBYyxDQUFDLEtBQWM7SUFDcEMsSUFBSSxLQUFLLEtBQUssU0FBUyxJQUFJLEtBQUssS0FBSyxJQUFJO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDdkQsSUFBSSxPQUFPLEtBQUssS0FBSyxRQUFRO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDM0MsTUFBTSxPQUFPLEdBQUcsS0FBSyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQzdCLE9BQU8sT0FBTyxJQUFJLElBQUksQ0FBQztBQUN6QixDQUFDO0FBRUQ7Ozs7O0dBS0c7QUFDSCxNQUFNLFVBQVUseUJBQXlCLENBQUMsT0FBZ0I7SUFDeEQsSUFBSSxDQUFDLE9BQU8sSUFBSSxPQUFPLE9BQU8sS0FBSyxRQUFRLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxPQUFPLENBQUM7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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/pr-outcome.ts b/packages/loopover-miner/lib/pr-outcome.ts new file mode 100644 index 0000000000..be01709f2c --- /dev/null +++ b/packages/loopover-miner/lib/pr-outcome.ts @@ -0,0 +1,130 @@ +// Miner-local PR-outcome record (#4274). The miner's OWN local record of the outcomes of its OWN PRs — merged or +// closed — written to the miner's local SQLite via the generic append-only event-ledger.js, mirroring how +// manage-status.js layers a specific typed event (MANAGE_PR_UPDATE_EVENT + a payload normalizer + a thin writer) +// on top of that same ledger. +// +// DISTINCT from the server-side `pr_outcome` concept: src/review/outcomes-wire.ts's `recordPrOutcome` writes +// `pr_outcome` rows to the HOSTED backend's D1 audit tables from the GitHub App's webhook stream — that is the +// loopover SERVER recording ground truth for every contributor. THIS is a laptop-mode miner's local record of +// its own PRs (it may have no webhook relay at all): same concept name, different codebase layer, no shared code. +// The distinct `MINER_PR_OUTCOME_EVENT` local constant keeps the two from being conflated. + +import { REJECTION_REASONS } from "./rejection-templates.js"; +import type { AppendEventInput, LedgerEntry } from "./event-ledger.js"; + +/** Event-ledger vocabulary for a miner-local PR outcome. */ +export const MINER_PR_OUTCOME_EVENT = "pr_outcome"; + +export type MinerPrOutcomeDecision = "merged" | "closed"; + +/** The terminal decisions a miner records for one of its own PRs. */ +export const MINER_PR_OUTCOME_DECISIONS: readonly MinerPrOutcomeDecision[] = Object.freeze(["merged", "closed"]); + +export interface NormalizedPrOutcomePayload { + prNumber: number; + decision: MinerPrOutcomeDecision; + closedAt: string | null; + reason: string | null; +} + +export interface PrOutcomeInput { + repoFullName?: unknown; + prNumber?: unknown; + decision?: unknown; + closedAt?: unknown; + reason?: unknown; +} + +export interface RecordPrOutcomeOptions { + /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the + * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. */ + eventLedger?: { appendEvent(event: AppendEventInput): LedgerEntry }; +} + +export interface PrOutcomeLedgerReader { + readEvents(filter?: { since?: number; repoFullName?: string }): unknown[]; +} + +const decisionSet: Set = new Set(MINER_PR_OUTCOME_DECISIONS); +const reasonSet: Set = new Set(REJECTION_REASONS); + +function optionalString(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +/** + * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's + * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry + * a reason bucket drawn from {@link REJECTION_REASONS} (shared with the rejection-state-machine sibling); a `merged` + * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). + */ +export function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + if (!Number.isInteger(record.prNumber) || (record.prNumber as number) <= 0) return null; + const decision = optionalString(record.decision); + if (!decision || !decisionSet.has(decision)) return null; + const reasonRaw = optionalString(record.reason); + const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; + return { + prNumber: record.prNumber as number, + decision: decision as MinerPrOutcomeDecision, + closedAt: optionalString(record.closedAt), + reason, + }; +} + +/** + * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's + * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one + * {@link MINER_PR_OUTCOME_EVENT} scoped to the repo and returns the appended entry. Fail-soft on a malformed + * snapshot: a missing repo or an invalid payload returns `null` rather than throwing (an unusable ledger is the + * only hard error, since that is a programmer wiring mistake). + */ +export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options: RecordPrOutcomeOptions = {}): LedgerEntry | null { + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + const repoFullName = typeof input?.repoFullName === "string" ? input.repoFullName.trim() : ""; + if (!repoFullName) return null; + const payload = normalizePrOutcomePayload({ + prNumber: input?.prNumber, + decision: input?.decision, + closedAt: input?.closedAt, + reason: input?.reason, + }); + if (!payload) return null; + return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload: payload as unknown as Record }); +} + +/** + * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors + * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces + * the pure result — a later event for the same repo/PR supersedes an earlier one. Returns a `Map` keyed by + * `repoFullName:prNumber`. + */ +export function readPrOutcomes( + eventLedger: PrOutcomeLedgerReader, + filter: { since?: number; repoFullName?: string } = {}, +): Map { + const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; + const latest = new Map(); + for (const event of Array.isArray(events) ? events : []) { + const record = event as { type?: unknown; repoFullName?: unknown; payload?: unknown } | null; + if (record?.type !== MINER_PR_OUTCOME_EVENT) continue; + if (typeof record.repoFullName !== "string" || !record.repoFullName.trim()) continue; + const normalized = normalizePrOutcomePayload(record.payload); + if (!normalized) continue; + // Re-key on every event so Map iteration order tracks most-recently-UPDATED last, not first-seen (#7222). A + // bare Map.set() on an existing key updates the value but leaves the key frozen at its original position, so a + // later outcome for the same PR (e.g. closed-without-merge, then reopened + merged) stayed at its old slot -- + // breaking recency-ordered consumers like loop-reentry.js's countConsecutiveDisengagements. Deleting first + // moves the freshly-updated entry to the end, matching this reducer's own "a later event supersedes" contract. + const key = `${record.repoFullName}:${normalized.prNumber}`; + latest.delete(key); + latest.set(key, { ...normalized, repoFullName: record.repoFullName }); + } + return latest; +} diff --git a/packages/loopover-miner/lib/self-review-context.d.ts b/packages/loopover-miner/lib/self-review-context.d.ts index 3fdcd3bcdc..2ae6828ad6 100644 --- a/packages/loopover-miner/lib/self-review-context.d.ts +++ b/packages/loopover-miner/lib/self-review-context.d.ts @@ -1,58 +1,68 @@ -import type { SelfReviewContext, FocusManifest } from "@loopover/engine"; - -// `bounties` is always omitted (see this file's own header comment for why), so the result is -// SelfReviewContext minus that optional field rather than the full type. `issueQuality` is populated (#6057). +import type { FocusManifest, SelfReviewContext } from "@loopover/engine"; export type SelfReviewContextResult = Omit; - -// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a -// plain GET init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored -// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and -// stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. -export type SelfReviewContextFetch = ( - url: string, - init?: { method?: string; headers?: Record; signal?: AbortSignal }, -) => Promise<{ - ok: boolean; - status: number; - json: () => Promise; - text: () => Promise; +export type SelfReviewContextFetch = (url: string, init?: { + method?: string; + headers?: Record; + signal?: AbortSignal; +}) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; + headers?: { + get(name: string): string | null; + }; + body?: { + getReader(): { + read(): Promise<{ + done: boolean; + value?: Uint8Array; + }>; + cancel(): Promise; + releaseLock(): void; + }; + } | null; }>; - export type LiveGateThresholdFields = { - confidence_floor: number | null; - scope_cap_files: number | null; - scope_cap_lines: number | null; + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; }; - export type LoopoverBackendSessionAuth = { - apiUrl?: string; - sessionToken: string; + apiUrl?: string; + sessionToken: string; }; - export type FetchSelfReviewContextOptions = { - githubToken?: string; - contributorLogin?: string; - linkedIssues?: number[]; - apiBaseUrl?: string; - rawContentBaseUrl?: string; - gittensorApiBase?: string; - fetchImpl?: SelfReviewContextFetch; - perPage?: number; - maxPages?: number; - requestTimeoutMs?: number; - /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ - liveGateProbeTimeoutMs?: number; - /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ - loopoverAuth?: LoopoverBackendSessionAuth | null; - /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ - env?: NodeJS.ProcessEnv; + githubToken?: string; + contributorLogin?: string; + linkedIssues?: number[]; + apiBaseUrl?: string; + rawContentBaseUrl?: string; + gittensorApiBase?: string; + fetchImpl?: SelfReviewContextFetch; + perPage?: number; + maxPages?: number; + requestTimeoutMs?: number; + /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ + liveGateProbeTimeoutMs?: number; + /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ + loopoverAuth?: LoopoverBackendSessionAuth | null; + /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ + env?: Record; }; - -export function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null; - -export function applyLiveGateThresholdsToManifest( - manifest: FocusManifest, - fields: LiveGateThresholdFields | null, -): FocusManifest; - -export function fetchSelfReviewContext(repoFullName: string, options?: FetchSelfReviewContextOptions): Promise; +/** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ +export declare function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null; +/** + * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). + * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). + * - scope_cap_files / scope_cap_lines → prefer live sizeMaxFiles / sizeMaxLines when present. + * Other gate fields are left untouched. + */ +export declare function applyLiveGateThresholdsToManifest(manifest: FocusManifest, fields: LiveGateThresholdFields | null): FocusManifest; +/** + * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed + * construction produces. See this file's header for the one field (bounties) deliberately left undefined + * and why; issueQuality is populated from the live GitHub snapshot. Optionally overlays ORB live gate + * thresholds onto the static `.loopover.yml` reconstruction (#6487). + */ +export declare function fetchSelfReviewContext(repoFullName: string, options?: FetchSelfReviewContextOptions): Promise; diff --git a/packages/loopover-miner/lib/self-review-context.js b/packages/loopover-miner/lib/self-review-context.js index bc5682ef54..eac31ec8a0 100644 --- a/packages/loopover-miner/lib/self-review-context.js +++ b/packages/loopover-miner/lib/self-review-context.js @@ -1,30 +1,5 @@ -import { - buildCollisionReport, - buildIssueQualityReport, - MAX_FOCUS_MANIFEST_BYTES, - parseFocusManifestContent, -} from "@loopover/engine"; +import { buildCollisionReport, buildIssueQualityReport, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent, } from "@loopover/engine"; import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; - -// Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass -// (packages/loopover-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's -// own DB-backed construction produces (src/db/repositories.ts's toRepositoryRecord/toIssueRecord/ -// toPullRequestRecord) -- just built fresh from live GitHub data instead of a DB round-trip, since the miner -// has no database. One of SelfReviewContext's eight fields is DELIBERATELY left undefined, not stubbed: -// -// - `bounties`: bounty data is not GitHub-native in this codebase -- it comes from an external "Gitt" -// system that PUSHES data into the live gate's own internal ingest route (src/api/routes.ts). There is -// no public endpoint the miner could legitimately pull from instead. -// -// `issueQuality` is populated via buildIssueQualityReport (exported from @loopover/engine as a package-local -// twin of the host engine helper — see #6057). Bounty rows and recent-merged PR history are passed as empty -// arrays because this fetcher does not yet pull either source. `bounties` remains omitted for the reason above. -// -// #6487: after the static `.loopover.yml` reconstruction, optionally probe ORB's live-gate-thresholds endpoint -// (same loopover-mcp session posture as resolveGitHubToken). On success, overlay confidence_floor / -// scope_cap_files / scope_cap_lines onto the parsed manifest gate; on 403/timeout/404/no-session, keep the -// static reconstruction unchanged. Fully-standalone (ORB-absent) paths stay byte-identical. - const GITHUB_API_VERSION = "2022-11-28"; const DEFAULT_API_BASE_URL = "https://api.github.com"; const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; @@ -34,78 +9,78 @@ const DEFAULT_MAX_PAGES = 10; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; /** Short ORB probe budget (#6487) — must never make discover/gate-prediction meaningfully slower when ORB is absent. */ const DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS = 400; - // Mirrors src/signals/focus-manifest-loader.ts's MANIFEST_FILE_CANDIDATES exactly -- first candidate that // resolves wins, same as the live gate's own lookup order. const MANIFEST_FILE_CANDIDATES = [".loopover.yml", ".github/loopover.yml", ".loopover.json", ".github/loopover.json"]; - function parseRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") return null; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner || !repo || extra !== undefined) return null; - return { owner, repo }; + if (typeof repoFullName !== "string") + return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + return { owner, repo }; } - +// `githubToken` is always a real string here: this function is private and its sole caller (githubGetJson, +// via resolved.githubToken) already comes from normalizeOptions' own `options.githubToken ?? env.GITHUB_TOKEN +// ?? ""` fallback chain, which always resolves to a string. function githubHeaders(githubToken) { - const headers = { - accept: "application/vnd.github+json", - "user-agent": "loopover-miner", - "x-github-api-version": GITHUB_API_VERSION, - }; - const token = typeof githubToken === "string" ? githubToken.trim() : ""; - if (token) headers.authorization = `Bearer ${token}`; - return headers; + const headers = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + if (githubToken) + headers.authorization = `Bearer ${githubToken}`; + return headers; } - +// resolveLoopoverBackendSession types `env` as the ambient (Cloudflare-Workers-augmented) `NodeJS.ProcessEnv`, +// stricter than this file's own `Record` -- github-token-resolution.js isn't +// converted to TypeScript yet, and any object matching this shape genuinely satisfies it at runtime either way. +const resolveBackendSession = resolveLoopoverBackendSession; function normalizeOptions(options = {}) { - const env = options.env ?? process.env; - // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. - const loopoverAuth = - options.loopoverAuth === null - ? null - : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken - ? { - apiUrl: - typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() - ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") - : (resolveLoopoverBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), - sessionToken: options.loopoverAuth.sessionToken, - } - : resolveLoopoverBackendSession(env); - return { - githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", - apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, - rawContentBaseUrl: - typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, - gittensorApiBase: - typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE, - fetchImpl: options.fetchImpl ?? fetch, - perPage: Number.isInteger(options.perPage) && options.perPage > 0 ? options.perPage : DEFAULT_PER_PAGE, - maxPages: Number.isInteger(options.maxPages) && options.maxPages > 0 ? options.maxPages : DEFAULT_MAX_PAGES, - contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", - linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [], - requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS, - liveGateProbeTimeoutMs: - Number.isInteger(options.liveGateProbeTimeoutMs) && options.liveGateProbeTimeoutMs > 0 - ? options.liveGateProbeTimeoutMs - : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, - loopoverAuth, - }; + const env = options.env ?? process.env; + // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. + const loopoverAuth = options.loopoverAuth === null + ? null + : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken + ? { + apiUrl: typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() + ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") + : (resolveBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), + sessionToken: options.loopoverAuth.sessionToken, + } + : resolveBackendSession(env); + return { + githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", + apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, + rawContentBaseUrl: typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, + gittensorApiBase: typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE, + fetchImpl: options.fetchImpl ?? fetch, + perPage: Number.isInteger(options.perPage) && options.perPage > 0 ? options.perPage : DEFAULT_PER_PAGE, + maxPages: Number.isInteger(options.maxPages) && options.maxPages > 0 ? options.maxPages : DEFAULT_MAX_PAGES, + contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", + linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [], + requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS, + liveGateProbeTimeoutMs: Number.isInteger(options.liveGateProbeTimeoutMs) && options.liveGateProbeTimeoutMs > 0 + ? options.liveGateProbeTimeoutMs + : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, + loopoverAuth, + }; } - /** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ export function parseLiveGateThresholdFields(payload) { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - const confidence_floor = - typeof payload.confidence_floor === "number" && payload.confidence_floor >= 0 && payload.confidence_floor <= 1 - ? payload.confidence_floor - : null; - const scope_cap_files = typeof payload.scope_cap_files === "number" && payload.scope_cap_files > 0 ? payload.scope_cap_files : null; - const scope_cap_lines = typeof payload.scope_cap_lines === "number" && payload.scope_cap_lines > 0 ? payload.scope_cap_lines : null; - if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) return null; - return { confidence_floor, scope_cap_files, scope_cap_lines }; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const record = payload; + const confidence_floor = typeof record.confidence_floor === "number" && record.confidence_floor >= 0 && record.confidence_floor <= 1 + ? record.confidence_floor + : null; + const scope_cap_files = typeof record.scope_cap_files === "number" && record.scope_cap_files > 0 ? record.scope_cap_files : null; + const scope_cap_lines = typeof record.scope_cap_lines === "number" && record.scope_cap_lines > 0 ? record.scope_cap_lines : null; + if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) + return null; + return { confidence_floor, scope_cap_files, scope_cap_lines }; } - /** * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). @@ -113,276 +88,278 @@ export function parseLiveGateThresholdFields(payload) { * Other gate fields are left untouched. */ export function applyLiveGateThresholdsToManifest(manifest, fields) { - if (!manifest || !fields) return manifest; - const gate = { ...manifest.gate }; - if (typeof fields.confidence_floor === "number") { - const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); - if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { - gate.readinessMinScore = floorScore; + if (!manifest || !fields) + return manifest; + const gate = { ...manifest.gate }; + if (typeof fields.confidence_floor === "number") { + const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); + if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { + gate.readinessMinScore = floorScore; + } } - } - if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { - gate.sizeMaxFiles = fields.scope_cap_files; - } - if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { - gate.sizeMaxLines = fields.scope_cap_lines; - } - return { ...manifest, gate }; + if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { + gate.sizeMaxFiles = fields.scope_cap_files; + } + if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { + gate.sizeMaxLines = fields.scope_cap_lines; + } + return { ...manifest, gate }; } - async function probeLiveGateThresholds(target, resolved) { - const auth = resolved.loopoverAuth; - if (!auth?.sessionToken) return null; - const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; - try { - const response = await fetchWithTimeout( - resolved.fetchImpl, - url, - { - method: "GET", - headers: { - authorization: `Bearer ${auth.sessionToken}`, - accept: "application/json", - "user-agent": "loopover-miner", - }, - }, - resolved.liveGateProbeTimeoutMs, - ); - if (!response.ok) return null; - const payload = await response.json().catch(() => null); - return parseLiveGateThresholdFields(payload); - } catch { - return null; - } + const auth = resolved.loopoverAuth; + if (!auth?.sessionToken) + return null; + const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { + method: "GET", + headers: { + authorization: `Bearer ${auth.sessionToken}`, + accept: "application/json", + "user-agent": "loopover-miner", + }, + }, resolved.liveGateProbeTimeoutMs); + if (!response.ok) + return null; + const payload = await response.json().catch(() => null); + return parseLiveGateThresholdFields(payload); + } + catch { + return null; + } } - // A fresh AbortSignal.timeout() per call, so a stalled connection can't hang context construction forever // (#miner-github-read-timeouts) -- shared by this file's three independent fetch call sites (GitHub REST, raw // manifest content, the Gittensor contributor lookup). async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) { - return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } - async function githubGetJson(url, resolved) { - const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: githubHeaders(resolved.githubToken) }, resolved.requestTimeoutMs); - const payload = await response.json().catch(() => null); - return { response, payload }; + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: githubHeaders(resolved.githubToken) }, resolved.requestTimeoutMs); + const payload = await response.json().catch(() => null); + return { response, payload }; } - async function fetchPaginated(pathWithQuery, resolved) { - const results = []; - for (let page = 1; page <= resolved.maxPages; page += 1) { - const separator = pathWithQuery.includes("?") ? "&" : "?"; - const url = `${resolved.apiBaseUrl}${pathWithQuery}${separator}per_page=${resolved.perPage}&page=${page}`; - const { response, payload } = await githubGetJson(url, resolved); - if (!response.ok || !Array.isArray(payload)) break; - results.push(...payload); - if (payload.length < resolved.perPage) break; - } - return results; + const results = []; + for (let page = 1; page <= resolved.maxPages; page += 1) { + const separator = pathWithQuery.includes("?") ? "&" : "?"; + const url = `${resolved.apiBaseUrl}${pathWithQuery}${separator}per_page=${resolved.perPage}&page=${page}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !Array.isArray(payload)) + break; + results.push(...payload); + if (payload.length < resolved.perPage) + break; + } + return results; } - // Mirrors src/db/repositories.ts's toRepositoryRecord + upsertRepositoryFromGitHub's field mapping. The // miner has no App installation/DB, so installationId/isInstalled/isRegistered/registryConfig are honest // "unregistered" defaults, not values pulled from GitHub -- GitHub's own repo payload carries none of them. async function fetchRepositoryRecord(target, resolved) { - const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; - const { response, payload } = await githubGetJson(url, resolved); - if (!response.ok || !payload || typeof payload !== "object") return null; - return { - fullName: `${target.owner}/${target.repo}`, - owner: payload.owner?.login ?? target.owner, - name: payload.name ?? target.repo, - installationId: undefined, - isInstalled: false, - isRegistered: false, - isPrivate: payload.private ?? false, - htmlUrl: payload.html_url ?? null, - defaultBranch: payload.default_branch ?? null, - registryConfig: null, - }; + const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !payload || typeof payload !== "object") + return null; + const record = payload; + return { + fullName: `${target.owner}/${target.repo}`, + owner: record.owner?.login ?? target.owner, + name: record.name ?? target.repo, + installationId: undefined, + isInstalled: false, + isRegistered: false, + isPrivate: record.private ?? false, + htmlUrl: record.html_url ?? null, + defaultBranch: record.default_branch ?? null, + registryConfig: null, + }; } - // Mirrors src/db/repositories.ts's extractLinkedPrNumbers: a real link needs a CLOSING KEYWORD, not a bare // mention (#6769). Without the keyword prefix, an incidental "similar to what we saw in PR #501" in an issue // body counted as a linked PR, so the issue-quality report read the issue as "already references a PR" and the // miner skipped an available issue (the host's own #issue-body-pr-mention-pollution fix, never ported here). const LINKED_PR_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:PR|pull request)\s+#(\d+)\b/gi; function extractLinkedPrNumbers(body) { - const numbers = []; - for (const match of body.matchAll(LINKED_PR_PATTERN)) { - const number = Number(match[1]); - if (Number.isInteger(number) && number > 0) numbers.push(number); - } - return numbers; + const numbers = []; + for (const match of body.matchAll(LINKED_PR_PATTERN)) { + const number = Number(match[1]); + if (Number.isInteger(number) && number > 0) + numbers.push(number); + } + return numbers; } - // Mirrors src/db/repositories.ts's extractLinkedIssueNumbers: GitHub's own closing-keyword vocabulary, only // counting a fully-qualified owner/repo#N reference when it targets the SAME repo being fetched. const LINKED_ISSUE_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi; function extractLinkedIssueNumbers(body, repoFullName) { - // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count. - const withoutCodeSpans = body.replace(/`[^`]*`/g, ""); - const numbers = []; - const normalizedRepo = repoFullName.toLowerCase(); - for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) { - const qualifiedRepo = match[1]; - if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) continue; - const number = Number(match[2]); - if (Number.isInteger(number) && number > 0) numbers.push(number); - } - return numbers; + // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count. + const withoutCodeSpans = body.replace(/`[^`]*`/g, ""); + const numbers = []; + const normalizedRepo = repoFullName.toLowerCase(); + for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) { + const qualifiedRepo = match[1]; + if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) + continue; + const number = Number(match[2]); + if (Number.isInteger(number) && number > 0) + numbers.push(number); + } + return numbers; } - function labelNames(labels) { - if (!Array.isArray(labels)) return []; - return labels.flatMap((label) => (label && typeof label === "object" && typeof label.name === "string" ? [label.name] : [])); + if (!Array.isArray(labels)) + return []; + return labels.flatMap((label) => (label && typeof label === "object" && typeof label.name === "string" ? [label.name] : [])); } - // Mirrors src/db/repositories.ts's toIssueRecord, populated straight from the live payload (createdAt/ // updatedAt/closedAt come from the DB-row read path there only as a caching artifact, not a semantic // transform -- the live REST fields are the real source). function toIssueRecord(repoFullName, issue) { - const body = issue.body ?? ""; - return { - repoFullName, - number: issue.number, - title: issue.title, - state: issue.state, - authorLogin: issue.user?.login ?? null, - authorAssociation: issue.author_association ?? null, - htmlUrl: issue.html_url ?? null, - body, - createdAt: issue.created_at ?? null, - updatedAt: issue.updated_at ?? null, - closedAt: issue.closed_at ?? null, - labels: labelNames(issue.labels), - linkedPrs: extractLinkedPrNumbers(body), - }; + const body = issue.body ?? ""; + const user = issue.user; + return { + repoFullName, + number: issue.number, + title: issue.title, + state: issue.state, + authorLogin: user?.login ?? null, + authorAssociation: issue.author_association ?? null, + htmlUrl: issue.html_url ?? null, + body, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + closedAt: issue.closed_at ?? null, + labels: labelNames(issue.labels), + linkedPrs: extractLinkedPrNumbers(body), + }; } - async function fetchOpenIssueRecords(target, resolved) { - const payloads = await fetchPaginated( - `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues?state=open&sort=created&direction=asc`, - resolved, - ); - // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch. - return payloads.filter((issue) => issue && typeof issue === "object" && !issue.pull_request).map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue)); + const payloads = await fetchPaginated(`/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues?state=open&sort=created&direction=asc`, resolved); + // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch. + return payloads + .filter((issue) => Boolean(issue) && typeof issue === "object" && !issue.pull_request) + .map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue)); } - function mergeableBooleanState(mergeable) { - if (mergeable === true) return "clean"; - if (mergeable === false) return "dirty"; - return null; + if (mergeable === true) + return "clean"; + if (mergeable === false) + return "dirty"; + return null; } - // Mirrors src/db/repositories.ts's toPullRequestRecord. Only the fields SelfReviewContext/buildCollisionReport // actually consume are populated with real precision; merge/RC3 gate-plumbing fields the live gate's fuller // PullRequestRecord carries (mergeAttemptCount, approvedHeadSha, ...) don't exist on the engine package's // leaner mirror type and aren't meaningful for a miner attempt anyway. function toPullRequestRecord(repoFullName, pr) { - const body = pr.body ?? ""; - return { - repoFullName, - number: pr.number, - title: pr.title, - state: pr.state, - authorLogin: pr.user?.login ?? null, - authorAssociation: pr.author_association ?? null, - headSha: pr.head?.sha ?? null, - headRef: pr.head?.ref ?? null, - baseRef: pr.base?.ref ?? null, - htmlUrl: pr.html_url ?? null, - mergedAt: pr.merged_at ?? null, - isDraft: pr.draft ?? null, - mergeableState: pr.mergeable_state ?? mergeableBooleanState(pr.mergeable), - reviewDecision: null, - body, - createdAt: pr.created_at ?? null, - updatedAt: pr.updated_at ?? null, - closedAt: pr.closed_at ?? null, - labels: labelNames(pr.labels), - linkedIssues: extractLinkedIssueNumbers(body, repoFullName), - }; + const body = pr.body ?? ""; + const user = pr.user; + const head = pr.head; + const base = pr.base; + return { + repoFullName, + number: pr.number, + title: pr.title, + state: pr.state, + authorLogin: user?.login ?? null, + authorAssociation: pr.author_association ?? null, + headSha: head?.sha ?? null, + headRef: head?.ref ?? null, + baseRef: base?.ref ?? null, + htmlUrl: pr.html_url ?? null, + mergedAt: pr.merged_at ?? null, + isDraft: pr.draft ?? null, + mergeableState: pr.mergeable_state ?? mergeableBooleanState(pr.mergeable), + reviewDecision: null, + body, + createdAt: pr.created_at ?? null, + updatedAt: pr.updated_at ?? null, + closedAt: pr.closed_at ?? null, + labels: labelNames(pr.labels), + linkedIssues: extractLinkedIssueNumbers(body, repoFullName), + }; } - async function fetchOpenPullRequestRecords(target, resolved) { - const payloads = await fetchPaginated( - `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls?state=open&sort=created&direction=asc`, - resolved, - ); - return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr)); + const payloads = await fetchPaginated(`/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls?state=open&sort=created&direction=asc`, resolved); + return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr)); } - // Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read: // first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory. async function readBoundedManifestResponseText(response) { - const contentLength = response.headers?.get?.("content-length") ?? null; - if (contentLength !== null) { - const parsedLength = Number.parseInt(contentLength, 10); - if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null; - } - if (!response.body?.getReader) { - const text = await response.text(); - if (typeof text !== "string") return null; - return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let totalBytes = 0; - let text = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.byteLength; - if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) { - await reader.cancel(); - return null; - } - text += decoder.decode(value, { stream: true }); + const contentLength = response.headers?.get?.("content-length") ?? null; + if (contentLength !== null) { + const parsedLength = Number.parseInt(contentLength, 10); + if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) + return null; + } + if (!response.body?.getReader) { + const text = await response.text(); + if (typeof text !== "string") + return null; + return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) + break; + totalBytes += value?.byteLength ?? 0; + if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) { + await reader.cancel(); + return null; + } + if (value) + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } + finally { + reader.releaseLock(); } - text += decoder.decode(); - return text; - } finally { - reader.releaseLock(); - } } - async function fetchManifestContent(target, resolved) { - for (const path of MANIFEST_FILE_CANDIDATES) { - const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; - try { - const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } }, resolved.requestTimeoutMs); - if (response.ok) { - const text = await readBoundedManifestResponseText(response); - if (typeof text === "string") return text; - } - } catch { - // Try the next candidate path. + for (const path of MANIFEST_FILE_CANDIDATES) { + const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } }, resolved.requestTimeoutMs); + if (response.ok) { + const text = await readBoundedManifestResponseText(response); + if (typeof text === "string") + return text; + } + } + catch { + // Try the next candidate path. + } } - } - return null; + return null; } - // Mirrors src/gittensor/api.ts's fetchGittensorContributorSnapshot/fetchOfficialGittensorMiner: a public, // unauthenticated GET against the Gittensor API (not GitHub) -- confirmed only when a real entry with a // matching GitHub login is found; any transport/parse failure fails closed to "not confirmed", never throws. async function fetchConfirmedContributor(login, resolved) { - if (!login) return false; - try { - const response = await fetchWithTimeout(resolved.fetchImpl, `${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } }, resolved.requestTimeoutMs); - if (!response.ok) return false; - const payload = await response.json().catch(() => null); - if (!Array.isArray(payload)) return false; - const normalizedLogin = login.toLowerCase(); - return payload.some((miner) => typeof miner?.githubUsername === "string" && miner.githubUsername.toLowerCase() === normalizedLogin); - } catch { - return false; - } + if (!login) + return false; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, `${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } }, resolved.requestTimeoutMs); + if (!response.ok) + return false; + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) + return false; + const normalizedLogin = login.toLowerCase(); + return payload.some((miner) => typeof miner?.githubUsername === "string" && miner.githubUsername.toLowerCase() === normalizedLogin); + } + catch { + return false; + } } - // Per self-review-adapter.ts's own doc comment: the caller computes inDuplicateCluster "the same way the // live gate's collision report would" -- adapted from src/signals/engine.ts's real // isPullRequestInDuplicateCluster (root src/, not extracted to the engine package), which requires >= 2 @@ -394,63 +371,48 @@ async function fetchConfirmedContributor(login, resolved) { // not-yet-existing PR number, since the miner's own submission doesn't exist as a real PullRequestRecord yet. // Takes a prebuilt CollisionReport so issueQuality and inDuplicateCluster share one collision pass. function computeInDuplicateCluster(collisionReport, targetIssueNumbers) { - if (targetIssueNumbers.length === 0) return false; - return collisionReport.clusters.some( - (cluster) => - cluster.risk === "high" && - cluster.items.filter((item) => item.type === "pull_request").length >= 2 && - cluster.items.some((item) => item.type === "issue" && targetIssueNumbers.includes(item.number)), - ); + if (targetIssueNumbers.length === 0) + return false; + return collisionReport.clusters.some((cluster) => cluster.risk === "high" && + cluster.items.filter((item) => item.type === "pull_request").length >= 2 && + cluster.items.some((item) => item.type === "issue" && targetIssueNumbers.includes(item.number))); } - /** * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed * construction produces. See this file's header for the one field (bounties) deliberately left undefined * and why; issueQuality is populated from the live GitHub snapshot. Optionally overlays ORB live gate * thresholds onto the static `.loopover.yml` reconstruction (#6487). - * - * @param {string} repoFullName - * @param {{ - * githubToken?: string, contributorLogin?: string, linkedIssues?: number[], - * apiBaseUrl?: string, rawContentBaseUrl?: string, gittensorApiBase?: string, - * fetchImpl?: typeof fetch, perPage?: number, maxPages?: number, requestTimeoutMs?: number, - * liveGateProbeTimeoutMs?: number, - * loopoverAuth?: { apiUrl?: string, sessionToken: string } | null, - * env?: NodeJS.ProcessEnv, - * }} [options] - * @returns {Promise} */ export async function fetchSelfReviewContext(repoFullName, options = {}) { - const target = parseRepoFullName(repoFullName); - if (!target) throw new Error("invalid_repo_full_name"); - const resolved = normalizeOptions(options); - - const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ - fetchRepositoryRecord(target, resolved), - fetchOpenIssueRecords(target, resolved), - fetchOpenPullRequestRecords(target, resolved), - fetchManifestContent(target, resolved), - fetchConfirmedContributor(resolved.contributorLogin, resolved), - probeLiveGateThresholds(target, resolved), - ]); - - const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); - const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); - // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): - // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged - // because this fetcher has no external bounty source and does not yet pull merge history. - const fullName = `${target.owner}/${target.repo}`; - const collisions = buildCollisionReport(fullName, issues, pullRequests); - const inDuplicateCluster = computeInDuplicateCluster(collisions, resolved.linkedIssues); - const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, []); - - return { - manifest, - repo, - issues, - pullRequests, - confirmedContributor, - inDuplicateCluster, - issueQuality, - }; + const target = parseRepoFullName(repoFullName); + if (!target) + throw new Error("invalid_repo_full_name"); + const resolved = normalizeOptions(options); + const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ + fetchRepositoryRecord(target, resolved), + fetchOpenIssueRecords(target, resolved), + fetchOpenPullRequestRecords(target, resolved), + fetchManifestContent(target, resolved), + fetchConfirmedContributor(resolved.contributorLogin, resolved), + probeLiveGateThresholds(target, resolved), + ]); + const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); + const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); + // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): + // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged + // because this fetcher has no external bounty source and does not yet pull merge history. + const fullName = `${target.owner}/${target.repo}`; + const collisions = buildCollisionReport(fullName, issues, pullRequests); + const inDuplicateCluster = computeInDuplicateCluster(collisions, resolved.linkedIssues); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, []); + return { + manifest, + repo, + issues, + pullRequests, + confirmedContributor, + inDuplicateCluster, + issueQuality, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VsZi1yZXZpZXctY29udGV4dC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInNlbGYtcmV2aWV3LWNvbnRleHQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUNMLG9CQUFvQixFQUNwQix1QkFBdUIsRUFDdkIsd0JBQXdCLEVBQ3hCLHlCQUF5QixHQUMxQixNQUFNLGtCQUFrQixDQUFDO0FBRTFCLE9BQU8sRUFBRSw2QkFBNkIsRUFBRSxNQUFNLDhCQUE4QixDQUFDO0FBeUY3RSxNQUFNLGtCQUFrQixHQUFHLFlBQVksQ0FBQztBQUN4QyxNQUFNLG9CQUFvQixHQUFHLHdCQUF3QixDQUFDO0FBQ3RELE1BQU0sNEJBQTRCLEdBQUcsbUNBQW1DLENBQUM7QUFDekUsTUFBTSwwQkFBMEIsR0FBRywwQkFBMEIsQ0FBQztBQUM5RCxNQUFNLGdCQUFnQixHQUFHLEdBQUcsQ0FBQztBQUM3QixNQUFNLGlCQUFpQixHQUFHLEVBQUUsQ0FBQztBQUM3QixNQUFNLDBCQUEwQixHQUFHLE1BQU0sQ0FBQztBQUMxQyx3SEFBd0g7QUFDeEgsTUFBTSxrQ0FBa0MsR0FBRyxHQUFHLENBQUM7QUFFL0MsMEdBQTBHO0FBQzFHLDJEQUEyRDtBQUMzRCxNQUFNLHdCQUF3QixHQUFHLENBQUMsZUFBZSxFQUFFLHNCQUFzQixFQUFFLGdCQUFnQixFQUFFLHVCQUF1QixDQUFDLENBQUM7QUFFdEgsU0FBUyxpQkFBaUIsQ0FBQyxZQUFvQjtJQUM3QyxJQUFJLE9BQU8sWUFBWSxLQUFLLFFBQVE7UUFBRSxPQUFPLElBQUksQ0FBQztJQUNsRCxNQUFNLENBQUMsS0FBSyxFQUFFLElBQUksRUFBRSxLQUFLLENBQUMsR0FBRyxZQUFZLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBQyxDQUFDO0lBQ3JELElBQUksQ0FBQyxLQUFLLElBQUksQ0FBQyxJQUFJLElBQUksS0FBSyxLQUFLLFNBQVM7UUFBRSxPQUFPLElBQUksQ0FBQztJQUN4RCxPQUFPLEVBQUUsS0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/self-review-context.ts b/packages/loopover-miner/lib/self-review-context.ts new file mode 100644 index 0000000000..2c44eea1e6 --- /dev/null +++ b/packages/loopover-miner/lib/self-review-context.ts @@ -0,0 +1,536 @@ +import { + buildCollisionReport, + buildIssueQualityReport, + MAX_FOCUS_MANIFEST_BYTES, + parseFocusManifestContent, +} from "@loopover/engine"; +import type { FocusManifest, IssueRecord, PullRequestRecord, RepositoryRecord, SelfReviewContext } from "@loopover/engine"; +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; + +// Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass +// (packages/loopover-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's +// own DB-backed construction produces (src/db/repositories.ts's toRepositoryRecord/toIssueRecord/ +// toPullRequestRecord) -- just built fresh from live GitHub data instead of a DB round-trip, since the miner +// has no database. One of SelfReviewContext's eight fields is DELIBERATELY left undefined, not stubbed: +// +// - `bounties`: bounty data is not GitHub-native in this codebase -- it comes from an external "Gitt" +// system that PUSHES data into the live gate's own internal ingest route (src/api/routes.ts). There is +// no public endpoint the miner could legitimately pull from instead. +// +// `issueQuality` is populated via buildIssueQualityReport (exported from @loopover/engine as a package-local +// twin of the host engine helper — see #6057). Bounty rows and recent-merged PR history are passed as empty +// arrays because this fetcher does not yet pull either source. `bounties` remains omitted for the reason above. +// +// #6487: after the static `.loopover.yml` reconstruction, optionally probe ORB's live-gate-thresholds endpoint +// (same loopover-mcp session posture as resolveGitHubToken). On success, overlay confidence_floor / +// scope_cap_files / scope_cap_lines onto the parsed manifest gate; on 403/timeout/404/no-session, keep the +// static reconstruction unchanged. Fully-standalone (ORB-absent) paths stay byte-identical. + +// `bounties` is always omitted (see this file's own header comment for why), so the result is +// SelfReviewContext minus that optional field rather than the full type. `issueQuality` is populated (#6057). +export type SelfReviewContextResult = Omit; + +// A narrower shape than `typeof fetch` on purpose (same rationale as live-issue-snapshot.js's own +// LiveIssueSnapshotFetch), but a bit richer than the shared narrow shape other modules use: this module's +// bounded manifest reader also needs `headers.get()` (content-length bound) and a streaming `body.getReader()` +// (bounded read without loading an oversized response fully into memory first) -- both OPTIONAL here so a +// caller that never supplies them (the plain-JSON GitHub/Gittensor call sites) still satisfies it. +export type SelfReviewContextFetch = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; + headers?: { get(name: string): string | null }; + body?: { getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }>; cancel(): Promise; releaseLock(): void } } | null; +}>; + +type SelfReviewContextResponse = Awaited>; + +export type LiveGateThresholdFields = { + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; +}; + +export type LoopoverBackendSessionAuth = { + apiUrl?: string; + sessionToken: string; +}; + +export type FetchSelfReviewContextOptions = { + githubToken?: string; + contributorLogin?: string; + linkedIssues?: number[]; + apiBaseUrl?: string; + rawContentBaseUrl?: string; + gittensorApiBase?: string; + fetchImpl?: SelfReviewContextFetch; + perPage?: number; + maxPages?: number; + requestTimeoutMs?: number; + /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ + liveGateProbeTimeoutMs?: number; + /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ + loopoverAuth?: LoopoverBackendSessionAuth | null; + /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ + env?: Record; +}; + +type ResolvedOptions = { + githubToken: string; + apiBaseUrl: string; + rawContentBaseUrl: string; + gittensorApiBase: string; + fetchImpl: SelfReviewContextFetch; + perPage: number; + maxPages: number; + contributorLogin: string; + linkedIssues: number[]; + requestTimeoutMs: number; + liveGateProbeTimeoutMs: number; + loopoverAuth: LoopoverBackendSessionAuth | null; +}; + +const GITHUB_API_VERSION = "2022-11-28"; +const DEFAULT_API_BASE_URL = "https://api.github.com"; +const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; +const DEFAULT_GITTENSOR_API_BASE = "https://api.gittensor.io"; +const DEFAULT_PER_PAGE = 100; +const DEFAULT_MAX_PAGES = 10; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +/** Short ORB probe budget (#6487) — must never make discover/gate-prediction meaningfully slower when ORB is absent. */ +const DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS = 400; + +// Mirrors src/signals/focus-manifest-loader.ts's MANIFEST_FILE_CANDIDATES exactly -- first candidate that +// resolves wins, same as the live gate's own lookup order. +const MANIFEST_FILE_CANDIDATES = [".loopover.yml", ".github/loopover.yml", ".loopover.json", ".github/loopover.json"]; + +function parseRepoFullName(repoFullName: string): { owner: string; repo: string } | null { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +// `githubToken` is always a real string here: this function is private and its sole caller (githubGetJson, +// via resolved.githubToken) already comes from normalizeOptions' own `options.githubToken ?? env.GITHUB_TOKEN +// ?? ""` fallback chain, which always resolves to a string. +function githubHeaders(githubToken: string): Record { + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +// resolveLoopoverBackendSession types `env` as the ambient (Cloudflare-Workers-augmented) `NodeJS.ProcessEnv`, +// stricter than this file's own `Record` -- github-token-resolution.js isn't +// converted to TypeScript yet, and any object matching this shape genuinely satisfies it at runtime either way. +const resolveBackendSession = resolveLoopoverBackendSession as ( + env?: Record, +) => LoopoverBackendSessionAuth | null; + +function normalizeOptions(options: FetchSelfReviewContextOptions = {}): ResolvedOptions { + const env = options.env ?? process.env; + // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. + const loopoverAuth: LoopoverBackendSessionAuth | null = + options.loopoverAuth === null + ? null + : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken + ? { + apiUrl: + typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() + ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") + : (resolveBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), + sessionToken: options.loopoverAuth.sessionToken, + } + : resolveBackendSession(env); + return { + githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", + apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, + rawContentBaseUrl: + typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, + gittensorApiBase: + typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE, + fetchImpl: options.fetchImpl ?? (fetch as unknown as SelfReviewContextFetch), + perPage: Number.isInteger(options.perPage) && (options.perPage as number) > 0 ? (options.perPage as number) : DEFAULT_PER_PAGE, + maxPages: Number.isInteger(options.maxPages) && (options.maxPages as number) > 0 ? (options.maxPages as number) : DEFAULT_MAX_PAGES, + contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", + linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [], + requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && (options.requestTimeoutMs as number) > 0 ? (options.requestTimeoutMs as number) : DEFAULT_REQUEST_TIMEOUT_MS, + liveGateProbeTimeoutMs: + Number.isInteger(options.liveGateProbeTimeoutMs) && (options.liveGateProbeTimeoutMs as number) > 0 + ? (options.liveGateProbeTimeoutMs as number) + : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, + loopoverAuth, + }; +} + +/** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ +export function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as { confidence_floor?: unknown; scope_cap_files?: unknown; scope_cap_lines?: unknown }; + const confidence_floor = + typeof record.confidence_floor === "number" && record.confidence_floor >= 0 && record.confidence_floor <= 1 + ? record.confidence_floor + : null; + const scope_cap_files = typeof record.scope_cap_files === "number" && record.scope_cap_files > 0 ? record.scope_cap_files : null; + const scope_cap_lines = typeof record.scope_cap_lines === "number" && record.scope_cap_lines > 0 ? record.scope_cap_lines : null; + if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) return null; + return { confidence_floor, scope_cap_files, scope_cap_lines }; +} + +/** + * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). + * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). + * - scope_cap_files / scope_cap_lines → prefer live sizeMaxFiles / sizeMaxLines when present. + * Other gate fields are left untouched. + */ +export function applyLiveGateThresholdsToManifest(manifest: FocusManifest, fields: LiveGateThresholdFields | null): FocusManifest { + if (!manifest || !fields) return manifest; + const gate = { ...manifest.gate }; + if (typeof fields.confidence_floor === "number") { + const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); + if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { + gate.readinessMinScore = floorScore; + } + } + if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { + gate.sizeMaxFiles = fields.scope_cap_files; + } + if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { + gate.sizeMaxLines = fields.scope_cap_lines; + } + return { ...manifest, gate }; +} + +async function probeLiveGateThresholds(target: { owner: string; repo: string }, resolved: ResolvedOptions): Promise { + const auth = resolved.loopoverAuth; + if (!auth?.sessionToken) return null; + const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; + try { + const response = await fetchWithTimeout( + resolved.fetchImpl, + url, + { + method: "GET", + headers: { + authorization: `Bearer ${auth.sessionToken}`, + accept: "application/json", + "user-agent": "loopover-miner", + }, + }, + resolved.liveGateProbeTimeoutMs, + ); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + return parseLiveGateThresholdFields(payload); + } catch { + return null; + } +} + +// A fresh AbortSignal.timeout() per call, so a stalled connection can't hang context construction forever +// (#miner-github-read-timeouts) -- shared by this file's three independent fetch call sites (GitHub REST, raw +// manifest content, the Gittensor contributor lookup). +async function fetchWithTimeout( + fetchImpl: SelfReviewContextFetch, + url: string, + init: { method?: string; headers?: Record }, + timeoutMs: number, +): Promise { + return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); +} + +async function githubGetJson(url: string, resolved: ResolvedOptions): Promise<{ response: SelfReviewContextResponse; payload: unknown }> { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: githubHeaders(resolved.githubToken) }, resolved.requestTimeoutMs); + const payload = await response.json().catch(() => null); + return { response, payload }; +} + +async function fetchPaginated(pathWithQuery: string, resolved: ResolvedOptions): Promise { + const results: unknown[] = []; + for (let page = 1; page <= resolved.maxPages; page += 1) { + const separator = pathWithQuery.includes("?") ? "&" : "?"; + const url = `${resolved.apiBaseUrl}${pathWithQuery}${separator}per_page=${resolved.perPage}&page=${page}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !Array.isArray(payload)) break; + results.push(...payload); + if (payload.length < resolved.perPage) break; + } + return results; +} + +// Mirrors src/db/repositories.ts's toRepositoryRecord + upsertRepositoryFromGitHub's field mapping. The +// miner has no App installation/DB, so installationId/isInstalled/isRegistered/registryConfig are honest +// "unregistered" defaults, not values pulled from GitHub -- GitHub's own repo payload carries none of them. +async function fetchRepositoryRecord(target: { owner: string; repo: string }, resolved: ResolvedOptions): Promise { + const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !payload || typeof payload !== "object") return null; + const record = payload as { owner?: { login?: unknown }; name?: unknown; private?: unknown; html_url?: unknown; default_branch?: unknown }; + return { + fullName: `${target.owner}/${target.repo}`, + owner: (record.owner?.login as string | undefined) ?? target.owner, + name: (record.name as string | undefined) ?? target.repo, + installationId: undefined, + isInstalled: false, + isRegistered: false, + isPrivate: (record.private as boolean | undefined) ?? false, + htmlUrl: (record.html_url as string | undefined) ?? null, + defaultBranch: (record.default_branch as string | undefined) ?? null, + registryConfig: null, + }; +} + +// Mirrors src/db/repositories.ts's extractLinkedPrNumbers: a real link needs a CLOSING KEYWORD, not a bare +// mention (#6769). Without the keyword prefix, an incidental "similar to what we saw in PR #501" in an issue +// body counted as a linked PR, so the issue-quality report read the issue as "already references a PR" and the +// miner skipped an available issue (the host's own #issue-body-pr-mention-pollution fix, never ported here). +const LINKED_PR_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:PR|pull request)\s+#(\d+)\b/gi; +function extractLinkedPrNumbers(body: string): number[] { + const numbers: number[] = []; + for (const match of body.matchAll(LINKED_PR_PATTERN)) { + const number = Number(match[1]); + if (Number.isInteger(number) && number > 0) numbers.push(number); + } + return numbers; +} + +// Mirrors src/db/repositories.ts's extractLinkedIssueNumbers: GitHub's own closing-keyword vocabulary, only +// counting a fully-qualified owner/repo#N reference when it targets the SAME repo being fetched. +const LINKED_ISSUE_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi; +function extractLinkedIssueNumbers(body: string, repoFullName: string): number[] { + // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count. + const withoutCodeSpans = body.replace(/`[^`]*`/g, ""); + const numbers: number[] = []; + const normalizedRepo = repoFullName.toLowerCase(); + for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) { + const qualifiedRepo = match[1]; + if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) continue; + const number = Number(match[2]); + if (Number.isInteger(number) && number > 0) numbers.push(number); + } + return numbers; +} + +function labelNames(labels: unknown): string[] { + if (!Array.isArray(labels)) return []; + return labels.flatMap((label) => (label && typeof label === "object" && typeof (label as { name?: unknown }).name === "string" ? [(label as { name: string }).name] : [])); +} + +// Mirrors src/db/repositories.ts's toIssueRecord, populated straight from the live payload (createdAt/ +// updatedAt/closedAt come from the DB-row read path there only as a caching artifact, not a semantic +// transform -- the live REST fields are the real source). +function toIssueRecord(repoFullName: string, issue: Record): IssueRecord { + const body = (issue.body as string | undefined) ?? ""; + const user = issue.user as { login?: unknown } | undefined; + return { + repoFullName, + number: issue.number as number, + title: issue.title as string, + state: issue.state as string, + authorLogin: (user?.login as string | undefined) ?? null, + authorAssociation: (issue.author_association as string | undefined) ?? null, + htmlUrl: (issue.html_url as string | undefined) ?? null, + body, + createdAt: (issue.created_at as string | undefined) ?? null, + updatedAt: (issue.updated_at as string | undefined) ?? null, + closedAt: (issue.closed_at as string | undefined) ?? null, + labels: labelNames(issue.labels), + linkedPrs: extractLinkedPrNumbers(body), + }; +} + +async function fetchOpenIssueRecords(target: { owner: string; repo: string }, resolved: ResolvedOptions): Promise { + const payloads = await fetchPaginated( + `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues?state=open&sort=created&direction=asc`, + resolved, + ); + // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch. + return payloads + .filter((issue): issue is Record => Boolean(issue) && typeof issue === "object" && !(issue as { pull_request?: unknown }).pull_request) + .map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue)); +} + +function mergeableBooleanState(mergeable: unknown): string | null { + if (mergeable === true) return "clean"; + if (mergeable === false) return "dirty"; + return null; +} + +// Mirrors src/db/repositories.ts's toPullRequestRecord. Only the fields SelfReviewContext/buildCollisionReport +// actually consume are populated with real precision; merge/RC3 gate-plumbing fields the live gate's fuller +// PullRequestRecord carries (mergeAttemptCount, approvedHeadSha, ...) don't exist on the engine package's +// leaner mirror type and aren't meaningful for a miner attempt anyway. +function toPullRequestRecord(repoFullName: string, pr: Record): PullRequestRecord { + const body = (pr.body as string | undefined) ?? ""; + const user = pr.user as { login?: unknown } | undefined; + const head = pr.head as { sha?: unknown; ref?: unknown } | undefined; + const base = pr.base as { ref?: unknown } | undefined; + return { + repoFullName, + number: pr.number as number, + title: pr.title as string, + state: pr.state as string, + authorLogin: (user?.login as string | undefined) ?? null, + authorAssociation: (pr.author_association as string | undefined) ?? null, + headSha: (head?.sha as string | undefined) ?? null, + headRef: (head?.ref as string | undefined) ?? null, + baseRef: (base?.ref as string | undefined) ?? null, + htmlUrl: (pr.html_url as string | undefined) ?? null, + mergedAt: (pr.merged_at as string | undefined) ?? null, + isDraft: (pr.draft as boolean | undefined) ?? null, + mergeableState: (pr.mergeable_state as string | undefined) ?? mergeableBooleanState(pr.mergeable), + reviewDecision: null, + body, + createdAt: (pr.created_at as string | undefined) ?? null, + updatedAt: (pr.updated_at as string | undefined) ?? null, + closedAt: (pr.closed_at as string | undefined) ?? null, + labels: labelNames(pr.labels), + linkedIssues: extractLinkedIssueNumbers(body, repoFullName), + }; +} + +async function fetchOpenPullRequestRecords(target: { owner: string; repo: string }, resolved: ResolvedOptions): Promise { + const payloads = await fetchPaginated( + `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls?state=open&sort=created&direction=asc`, + resolved, + ); + return (payloads as Record[]).map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr)); +} + +// Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read: +// first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory. +async function readBoundedManifestResponseText(response: SelfReviewContextResponse): Promise { + const contentLength = response.headers?.get?.("content-length") ?? null; + if (contentLength !== null) { + const parsedLength = Number.parseInt(contentLength, 10); + if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null; + } + if (!response.body?.getReader) { + const text = await response.text(); + if (typeof text !== "string") return null; + return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value?.byteLength ?? 0; + if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) { + await reader.cancel(); + return null; + } + if (value) text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchManifestContent(target: { owner: string; repo: string }, resolved: ResolvedOptions): Promise { + for (const path of MANIFEST_FILE_CANDIDATES) { + const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } }, resolved.requestTimeoutMs); + if (response.ok) { + const text = await readBoundedManifestResponseText(response); + if (typeof text === "string") return text; + } + } catch { + // Try the next candidate path. + } + } + return null; +} + +// Mirrors src/gittensor/api.ts's fetchGittensorContributorSnapshot/fetchOfficialGittensorMiner: a public, +// unauthenticated GET against the Gittensor API (not GitHub) -- confirmed only when a real entry with a +// matching GitHub login is found; any transport/parse failure fails closed to "not confirmed", never throws. +async function fetchConfirmedContributor(login: string, resolved: ResolvedOptions): Promise { + if (!login) return false; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, `${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } }, resolved.requestTimeoutMs); + if (!response.ok) return false; + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) return false; + const normalizedLogin = login.toLowerCase(); + return payload.some((miner) => typeof (miner as { githubUsername?: unknown })?.githubUsername === "string" && (miner as { githubUsername: string }).githubUsername.toLowerCase() === normalizedLogin); + } catch { + return false; + } +} + +// Per self-review-adapter.ts's own doc comment: the caller computes inDuplicateCluster "the same way the +// live gate's collision report would" -- adapted from src/signals/engine.ts's real +// isPullRequestInDuplicateCluster (root src/, not extracted to the engine package), which requires >= 2 +// PULL REQUEST items in a high-risk cluster, not just any high-risk cluster containing the target. That +// threshold matters: buildCollisionReport's own pairwise "shared linked issue" rule already marks an +// issue+its-one-legitimately-closing-PR pair as a HIGH-risk cluster (confirmed empirically) -- without the +// >= 2 threshold, inDuplicateCluster would fire on the completely normal case of "one PR already closes +// this issue," not genuine overlapping/duplicate work. Checks the target ISSUE's presence instead of a +// not-yet-existing PR number, since the miner's own submission doesn't exist as a real PullRequestRecord yet. +// Takes a prebuilt CollisionReport so issueQuality and inDuplicateCluster share one collision pass. +function computeInDuplicateCluster(collisionReport: ReturnType, targetIssueNumbers: number[]): boolean { + if (targetIssueNumbers.length === 0) return false; + return collisionReport.clusters.some( + (cluster) => + cluster.risk === "high" && + cluster.items.filter((item) => item.type === "pull_request").length >= 2 && + cluster.items.some((item) => item.type === "issue" && targetIssueNumbers.includes(item.number)), + ); +} + +/** + * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed + * construction produces. See this file's header for the one field (bounties) deliberately left undefined + * and why; issueQuality is populated from the live GitHub snapshot. Optionally overlays ORB live gate + * thresholds onto the static `.loopover.yml` reconstruction (#6487). + */ +export async function fetchSelfReviewContext(repoFullName: string, options: FetchSelfReviewContextOptions = {}): Promise { + const target = parseRepoFullName(repoFullName); + if (!target) throw new Error("invalid_repo_full_name"); + const resolved = normalizeOptions(options); + + const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ + fetchRepositoryRecord(target, resolved), + fetchOpenIssueRecords(target, resolved), + fetchOpenPullRequestRecords(target, resolved), + fetchManifestContent(target, resolved), + fetchConfirmedContributor(resolved.contributorLogin, resolved), + probeLiveGateThresholds(target, resolved), + ]); + + const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); + const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); + // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): + // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged + // because this fetcher has no external bounty source and does not yet pull merge history. + const fullName = `${target.owner}/${target.repo}`; + const collisions = buildCollisionReport(fullName, issues, pullRequests); + const inDuplicateCluster = computeInDuplicateCluster(collisions, resolved.linkedIssues); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, []); + + return { + manifest, + repo, + issues, + pullRequests, + confirmedContributor, + inDuplicateCluster, + issueQuality, + }; +} diff --git a/packages/loopover-miner/lib/stack-detection.d.ts b/packages/loopover-miner/lib/stack-detection.d.ts index e69b216fae..ba6133911b 100644 --- a/packages/loopover-miner/lib/stack-detection.d.ts +++ b/packages/loopover-miner/lib/stack-detection.d.ts @@ -1,41 +1,37 @@ -/** Stack auto-detection (#4785). `detectRepoStack` inspects an already-cloned repo's manifest / lockfile / config - * files and returns a structured stack description, or an explicit fail-closed result when the stack can't be - * confidently identified (no guessing). */ - /** Which manifest (and lockfile, when present) drove the detection. */ export type StackEvidence = { - manifest: string; - lockfile: string | null; + manifest: string; + lockfile: string | null; }; - /** A confidently-detected stack. Command fields are `null` when the command can't be inferred without guessing. */ export type DetectedRepoStack = { - detected: true; - language: string; - packageManager: string | null; - buildCommand: string | null; - testCommand: string | null; - lintCommand: string | null; - formatCommand: string | null; - evidence: StackEvidence; + detected: true; + language: string; + packageManager: string | null; + buildCommand: string | null; + testCommand: string | null; + lintCommand: string | null; + formatCommand: string | null; + evidence: StackEvidence; }; - /** A repo whose stack could not be confidently identified. */ export type UndetectedRepoStack = { - detected: false; - reason: string; + detected: false; + reason: string; }; - export type RepoStackResult = DetectedRepoStack | UndetectedRepoStack; - export type DetectRepoStackOptions = { - existsSync?: (path: string) => boolean; - readFileSync?: (path: string, encoding: "utf8") => string; + existsSync?: (path: string) => boolean; + readFileSync?: (path: string, encoding: "utf8") => string; }; - -/** Manifests, in the precedence order detection tries them (first match wins). */ -export const RECOGNIZED_MANIFESTS: readonly string[]; - -export function detectRepoStack(repoPath: string, options?: DetectRepoStackOptions): RepoStackResult; - -export function renderStackSummary(stack: RepoStackResult): string; +/** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with + * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ +export declare const RECOGNIZED_MANIFESTS: readonly string[]; +/** + * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the + * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no + * recognized manifest is present. Never throws. + */ +export declare function detectRepoStack(repoPath: string, options?: DetectRepoStackOptions): RepoStackResult; +/** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ +export declare function renderStackSummary(stack: RepoStackResult | null | undefined): string; diff --git a/packages/loopover-miner/lib/stack-detection.js b/packages/loopover-miner/lib/stack-detection.js index ab2830afad..2a0fc2087d 100644 --- a/packages/loopover-miner/lib/stack-detection.js +++ b/packages/loopover-miner/lib/stack-detection.js @@ -7,242 +7,242 @@ * can't be confidently identified returns an explicit `{ detected: false, reason }` instead of guessing. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; - /** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ export const RECOGNIZED_MANIFESTS = Object.freeze([ - "package.json", - "pyproject.toml", - "setup.py", - "setup.cfg", - "requirements.txt", - "Pipfile", - "Cargo.toml", - "go.mod", - "pom.xml", - "build.gradle", - "build.gradle.kts", + "package.json", + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle", + "build.gradle.kts", ]); - -const NO_MANIFEST_REASON = - "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; - +const NO_MANIFEST_REASON = "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; const NODE_PACKAGE_MANAGERS = Object.freeze(["npm", "yarn", "pnpm", "bun"]); const NODE_LOCKFILES = Object.freeze([ - ["pnpm-lock.yaml", "pnpm"], - ["yarn.lock", "yarn"], - ["bun.lockb", "bun"], - ["package-lock.json", "npm"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], ]); - /** Build a never-throwing accessor over the cloned repo. `exists` and `read` both swallow fs errors so the detector * treats an EACCES/ENOENT/binary file as simply "absent" instead of crashing the attempt. */ function makeAccess(repoPath, options) { - const existsImpl = options.existsSync ?? existsSync; - const readImpl = options.readFileSync ?? readFileSync; - const exists = (relativePath) => { + const existsImpl = options.existsSync ?? existsSync; + const readImpl = options.readFileSync ?? readFileSync; + const exists = (relativePath) => { + try { + return existsImpl(join(repoPath, relativePath)) === true; + } + catch { + return false; + } + }; + const read = (relativePath) => { + try { + if (!exists(relativePath)) + return null; + const content = readImpl(join(repoPath, relativePath), "utf8"); + return typeof content === "string" ? content : null; + } + catch { + return null; + } + }; + return { exists, read }; +} +function parseJson(text) { + if (typeof text !== "string") + return null; try { - return existsImpl(join(repoPath, relativePath)) === true; - } catch { - return false; + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : null; } - }; - const read = (relativePath) => { - try { - if (!exists(relativePath)) return null; - const content = readImpl(join(repoPath, relativePath), "utf8"); - return typeof content === "string" ? content : null; - } catch { - return null; + catch { + return null; } - }; - return { exists, read }; } - -function parseJson(text) { - if (typeof text !== "string") return null; - try { - const parsed = JSON.parse(text); - return parsed && typeof parsed === "object" ? parsed : null; - } catch { - return null; - } -} - /** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */ function pickScript(scripts, exactName, pattern) { - const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); - if (names.includes(exactName)) return exactName; - return names.find((name) => pattern.test(name)) ?? null; + const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); + if (names.includes(exactName)) + return exactName; + return names.find((name) => pattern.test(name)) ?? null; } - function nodeLockfile(exists) { - const match = NODE_LOCKFILES.find(([file]) => exists(file)); - return match ? match[0] : null; + const match = NODE_LOCKFILES.find(([file]) => exists(file)); + return match ? match[0] : null; } - function nodePackageManager(pkg, lockfile) { - const corepack = - typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : ""; - if (NODE_PACKAGE_MANAGERS.includes(corepack)) return corepack; - const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); - // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). - return byLock ? byLock[1] : "npm"; + const corepack = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : ""; + if (NODE_PACKAGE_MANAGERS.includes(corepack)) + return corepack; + const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); + // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). + return byLock ? byLock[1] : "npm"; } - function hasTypescriptDependency(pkg) { - const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }; - return typeof deps.typescript === "string"; + const dependencies = (pkg?.dependencies ?? {}); + const devDependencies = (pkg?.devDependencies ?? {}); + const deps = { ...dependencies, ...devDependencies }; + return typeof deps.typescript === "string"; } - function detectNode({ exists, read }) { - if (!exists("package.json")) return null; - const pkg = parseJson(read("package.json")); - const scripts = - pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {}; - const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; - const lockfile = nodeLockfile(exists); - const packageManager = nodePackageManager(pkg, lockfile); - - const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); - const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); - const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); - const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); - - return { - language, - packageManager, - buildCommand: buildName ? `${packageManager} run ${buildName}` : null, - // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. - testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, - lintCommand: lintName ? `${packageManager} run ${lintName}` : null, - formatCommand: formatName ? `${packageManager} run ${formatName}` : null, - evidence: { manifest: "package.json", lockfile }, - }; + if (!exists("package.json")) + return null; + const pkg = parseJson(read("package.json")); + const scripts = pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {}; + const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; + const lockfile = nodeLockfile(exists); + const packageManager = nodePackageManager(pkg, lockfile); + const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); + const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); + const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); + const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); + return { + language, + packageManager, + buildCommand: buildName ? `${packageManager} run ${buildName}` : null, + // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. + testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, + lintCommand: lintName ? `${packageManager} run ${lintName}` : null, + formatCommand: formatName ? `${packageManager} run ${formatName}` : null, + evidence: { manifest: "package.json", lockfile }, + }; } - function detectPython({ exists, read }) { - const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); - if (manifest === undefined) return null; - const pyproject = read("pyproject.toml") ?? ""; - - let packageManager; - let lockfile = null; - if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { - packageManager = "poetry"; - lockfile = exists("poetry.lock") ? "poetry.lock" : null; - } else if (exists("uv.lock")) { - packageManager = "uv"; - lockfile = "uv.lock"; - } else if (exists("Pipfile") || exists("Pipfile.lock")) { - packageManager = "pipenv"; - lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; - } else { - packageManager = "pip"; - } - - // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). - const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); - const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); - - return { - language: "python", - packageManager, - buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, - testCommand: hasPytest ? "pytest" : null, - lintCommand: hasRuff ? "ruff check ." : null, - formatCommand: hasRuff ? "ruff format ." : null, - evidence: { manifest, lockfile }, - }; + const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); + if (manifest === undefined) + return null; + const pyproject = read("pyproject.toml") ?? ""; + let packageManager; + let lockfile = null; + if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { + packageManager = "poetry"; + lockfile = exists("poetry.lock") ? "poetry.lock" : null; + } + else if (exists("uv.lock")) { + packageManager = "uv"; + lockfile = "uv.lock"; + } + else if (exists("Pipfile") || exists("Pipfile.lock")) { + packageManager = "pipenv"; + lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; + } + else { + packageManager = "pip"; + } + // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). + const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); + const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); + return { + language: "python", + packageManager, + buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, + testCommand: hasPytest ? "pytest" : null, + lintCommand: hasRuff ? "ruff check ." : null, + formatCommand: hasRuff ? "ruff format ." : null, + evidence: { manifest, lockfile }, + }; } - function detectRust({ exists }) { - if (!exists("Cargo.toml")) return null; - return { - language: "rust", - packageManager: "cargo", - buildCommand: "cargo build", - testCommand: "cargo test", - lintCommand: "cargo clippy", - formatCommand: "cargo fmt", - evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, - }; + if (!exists("Cargo.toml")) + return null; + return { + language: "rust", + packageManager: "cargo", + buildCommand: "cargo build", + testCommand: "cargo test", + lintCommand: "cargo clippy", + formatCommand: "cargo fmt", + evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, + }; } - function detectGo({ exists }) { - if (!exists("go.mod")) return null; - const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); - return { - language: "go", - packageManager: "go", - buildCommand: "go build ./...", - testCommand: "go test ./...", - lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", - formatCommand: "gofmt -l .", - evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, - }; + if (!exists("go.mod")) + return null; + const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); + return { + language: "go", + packageManager: "go", + buildCommand: "go build ./...", + testCommand: "go test ./...", + lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", + formatCommand: "gofmt -l .", + evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, + }; } - function detectMaven({ exists }) { - if (!exists("pom.xml")) return null; - return { - language: "java", - packageManager: "maven", - buildCommand: "mvn -B package", - testCommand: "mvn -B test", - lintCommand: null, - formatCommand: null, - evidence: { manifest: "pom.xml", lockfile: null }, - }; + if (!exists("pom.xml")) + return null; + return { + language: "java", + packageManager: "maven", + buildCommand: "mvn -B package", + testCommand: "mvn -B test", + lintCommand: null, + formatCommand: null, + evidence: { manifest: "pom.xml", lockfile: null }, + }; } - function detectGradle({ exists }) { - const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; - if (manifest === null) return null; - const runner = exists("gradlew") ? "./gradlew" : "gradle"; - return { - language: "java", - packageManager: "gradle", - buildCommand: `${runner} build`, - testCommand: `${runner} test`, - lintCommand: null, - formatCommand: null, - evidence: { manifest, lockfile: null }, - }; + const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; + if (manifest === null) + return null; + const runner = exists("gradlew") ? "./gradlew" : "gradle"; + return { + language: "java", + packageManager: "gradle", + buildCommand: `${runner} build`, + testCommand: `${runner} test`, + lintCommand: null, + formatCommand: null, + evidence: { manifest, lockfile: null }, + }; } - -const DETECTORS = Object.freeze([detectNode, detectPython, detectRust, detectGo, detectMaven, detectGradle]); - +const DETECTORS = Object.freeze([ + detectNode, + detectPython, + detectRust, + detectGo, + detectMaven, + detectGradle, +]); /** * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no * recognized manifest is present. Never throws. */ export function detectRepoStack(repoPath, options = {}) { - if (typeof repoPath !== "string" || !repoPath.trim()) { - return { detected: false, reason: "A repository path is required to detect the stack." }; - } - const access = makeAccess(repoPath, options); - for (const detector of DETECTORS) { - const detected = detector(access); - if (detected !== null) { - return { detected: true, ...detected }; + if (typeof repoPath !== "string" || !repoPath.trim()) { + return { detected: false, reason: "A repository path is required to detect the stack." }; + } + const access = makeAccess(repoPath, options); + for (const detector of DETECTORS) { + const detected = detector(access); + if (detected !== null) { + return { detected: true, ...detected }; + } } - } - return { detected: false, reason: NO_MANIFEST_REASON }; + return { detected: false, reason: NO_MANIFEST_REASON }; } - /** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ export function renderStackSummary(stack) { - if (!stack || stack.detected !== true) { - return `stack not detected: ${stack?.reason ?? "unknown reason"}`; - } - const commands = [ - stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, - stack.testCommand ? `test=\`${stack.testCommand}\`` : null, - stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, - stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, - ].filter((entry) => entry !== null); - const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; - return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; + if (!stack || stack.detected !== true) { + return `stack not detected: ${stack?.reason ?? "unknown reason"}`; + } + const commands = [ + stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, + stack.testCommand ? `test=\`${stack.testCommand}\`` : null, + stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, + stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; + return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RhY2stZGV0ZWN0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsic3RhY2stZGV0ZWN0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Ozs7NEdBTTRHO0FBQzVHLE9BQU8sRUFBRSxVQUFVLEVBQUUsWUFBWSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ25ELE9BQU8sRUFBRSxJQUFJLEVBQUUsTUFBTSxXQUFXLENBQUM7QUF3Q2pDO3dGQUN3RjtBQUN4RixNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBc0IsTUFBTSxDQUFDLE1BQU0sQ0FBQztJQUNuRSxjQUFjO0lBQ2QsZ0JBQWdCO0lBQ2hCLFVBQVU7SUFDVixXQUFXO0lBQ1gsa0JBQWtCO0lBQ2xCLFNBQVM7SUFDVCxZQUFZO0lBQ1osUUFBUTtJQUNSLFNBQVM7SUFDVCxjQUFjO0lBQ2Qsa0JBQWtCO0NBQ25CLENBQUMsQ0FBQztBQUVILE1BQU0sa0JBQWtCLEdBQ3RCLGtKQUFrSixDQUFDO0FBRXJKLE1BQU0scUJBQXFCLEdBQUcsTUFBTSxDQUFDLE1BQU0sQ0FBQyxDQUFDLEtBQUssRUFBRSxNQUFNLEVBQUUsTUFBTSxFQUFFLEtBQUssQ0FBQyxDQUFDLENBQUM7QUFDNUUsTUFBTSxjQUFjLEdBQWdDLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDaEUsQ0FBQyxnQkFBZ0IsRUFBRSxNQUFNLENBQUM7SUFDMUIsQ0FBQyxXQUFXLEVBQUUsTUFBTSxDQUFDO0lBQ3JCLENBQUMsV0FBVyxFQUFFLEtBQUssQ0FBQztJQUNwQixDQUFDLG1CQUFtQixFQUFFLEtBQUssQ0FBQztDQUM3QixDQUFDLENBQUM7QUFFSDs2RkFDNkY7QUFDN0YsU0FBUyxVQUFVLENBQUMsUUFBZ0IsRUFBRSxPQUErQjtJQUNuRSxNQUFNLFVBQVUsR0FBRyxPQUFPLENBQUMsVUFBVSxJQUFJLFVBQVUsQ0FBQztJQUNwRCxNQUFNLFFBQVEsR0FBRyxPQUFPLENBQUMsWUFBWSxJQUFJLFlBQVksQ0FBQztJQUN0RCxNQUFNLE1BQU0sR0FBRyxDQUFDLFlBQW9CLEVBQVcsRUFBRTtRQUMvQyxJQUFJLENBQUM7WUFDSCxPQUFPLFVBQVUsQ0FBQyxJQUFJLENBQUMsUUFBUSxFQUFFLFlBQVksQ0FBQyxDQUFDLEtBQUssSUFBSSxDQUFDO1FBQzNELENBQUM7UUFBQyxNQUFNLENBQUM7WUFDUCxPQUFPLEtBQUssQ0FBQztRQUNmLENBQUM7SUFDSCxDQUFDLENBQUM7SUFDRixNQUFNLElBQUksR0FBRyxDQUFDLFlBQW9CLEVBQWlCLEVBQUU7UUFDbkQsSUFBSSxDQUFDO1lBQ0gsSUFBSSxDQUFDLE1BQU0sQ0FBQyxZQUFZLENBQUM7Z0JBQUUsT0FBTyxJQUFJLENBQUM7WUFDdkMsTUFBTSxPQUFPLEdBQUcsUUFBUSxDQUFDLElBQUksQ0FBQyxRQUFRLEVBQUUsWUFBWSxDQUFDLEVBQUUsTUFBTSxDQUFDLENBQUM7WUFDL0QsT0FBTyxPQUFPLE9BQU8sS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO1FBQ3RELENBQUM7UUFBQyxNQUFNLENBQUM7WUFDUCxPQUFPLElBQUksQ0FBQztRQUNkLENBQUM7SUFDSCxDQUFDLENBQUM7SUFDRixPQUFPLEVBQUUsTUFBTSxFQUFFLElBQUksRUFBRSxDQUFDO0FBQzFCLENBQUM7QUFFRCxTQUFTLFNBQVMsQ0FBQyxJQUFtQjtJQUNwQyxJQUFJLE9BQU8sSUFBSSxLQUFLLFFBQVE7UUFBRSxPQUFPLElBQUksQ0FBQztJQUMxQyxJQUFJLENBQUM7UUFDSCxNQUFNLE1BQU0sR0FBWSxJQUFJLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxDQUFDO1FBQ3pDLE9BQU8sTUFBTSxJQUFJLE9BQU8sTUFBTSxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUUsTUFBa0MsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0lBQzNGLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxPQUFPLElBQUksQ0FBQztJQUNkLENBQUM7QUFDSCxDQUFDO0FBRUQsK0dBQStHO0FBQy9HLFNBQVMsVUFBVSxDQUFDLE9BQWdDLEVBQUUsU0FBaUIsRUFBRSxPQUFlO0lBQ3RGLE1BQU0sS0FBSyxHQUFHLE1BQU0sQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQyxPQUFPLE9BQU8sQ0FBQyxJQUFJLENBQUMsS0FBSyxRQUFRLENBQUMsQ0FBQztJQUN2RixJQUFJLEtBQUssQ0FBQyxRQUFRLENBQUMsU0FBUyxDQUFDO1FBQUUsT0FBTyxTQUFTLENBQUM7SUFDaEQsT0FBTyxLQUFLLENBQUMsSUFBSSxDQUFDLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQyxPQUFPLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDLElBQUksSUFBSSxDQUFDO0FBQzFELENBQUM7QUFFRCxTQUFTLFlBQVksQ0FBQyxNQUF3QjtJQUM1QyxNQUFNLEtBQUssR0FBRyxjQUFjLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsRUFBRSxFQUFFLENBQUMsTUFBTSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUM7SUFDNUQsT0FBTyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0FBQ2pDLENBQUM7QUFFRCxTQUFTLGtCQUFrQixDQUFDLEdBQW1DLEVBQUUsUUFBdUI7SUFDdEYsTUFBTSxRQUFRLEdBQ1osT0FBTyxHQUFHLEVBQUUsY0FBYyxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDLGNBQWMsQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFFLENBQUMsSUFBSSxFQUFFLENBQUMsV0FBVyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztJQUN4RyxJQUFJLHFCQUFxQixDQUFDLFFBQVEsQ0FBQyxRQUFRLENBQUM7UUFBRSxPQUFPLFFBQVEsQ0FBQztJQUM5RCxNQUFNLE1BQU0sR0FBRyxjQUFjLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsRUFBRSxFQUFFLENBQUMsSUFBSSxLQUFLLFFBQVEsQ0FBQyxDQUFDO0lBQ2xFLCtHQUErRztJQUMvRyxPQUFPLE1BQU0sQ0FBQyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUM7QUFDcEMsQ0FBQztBQUVELFNBQVMsdUJBQXVCLENBQUMsR0FBbUM7SUFDbEUsTUFBTSxZQUFZLEdBQUcsQ0FBQyxHQUFHLEVBQUUsWUFBWSxJQUFJLEVBQUUsQ0FBNEIsQ0FBQztJQUMxRSxNQUFNLGVBQWUsR0FBRyxDQUFDLEdBQUcsRUFBRSxlQUFlLElBQUksRUFBRSxDQUE0QixDQUFDO0lBQ2hGLE1BQU0sSUFBSSxHQUFHLEVBQUUsR0FBRyxZQUFZLEVBQUUsR0FBRyxlQUFlLEVBQUUsQ0FBQztJQUNyRCxPQUFPLE9BQU8sSUFBSSxDQUFDLFVBQVUsS0FBSyxRQUFRLENBQUM7QUFDN0MsQ0FBQztBQUVELFNBQVMsVUFBVSxDQUFDLEVBQUUsTUFBTSxFQUFFLElBQUksRUFBVTtJQUMxQyxJQUFJLENBQUMsTUFBTSxDQUFDLGNBQWMsQ0FBQztRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQ3pDLE1BQU0sR0FBRyxHQUFHLFNBQVMsQ0FBQyxJQUFJLENBQUMsY0FBYyxDQUFDLENBQUMsQ0FBQztJQUM1QyxNQUFNLE9BQU8sR0FDWCxHQUFHLElBQUksT0FBTyxHQUFHLENBQUMsT0FBTyxLQUFLLFFBQVEsSUFBSSxHQUFHLENBQUMsT0FBTyxJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxDQUFFLEdBQUcsQ0FBQyxPQUFtQyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7SUFDdkksTUFBTSxRQUFRLEdBQUcsTUFBTSxDQUFDLGVBQWUsQ0FBQyxJQUFJLHVCQUF1QixDQUFDLEdBQUcsQ0FBQyxDQUFDLENBQUMsQ0FBQyxZQUFZLENBQUMsQ0FBQyxDQUFDLFlBQVksQ0FBQztJQUN2RyxNQUFNLFFBQVEsR0FBRyxZQUFZLENBQUMsTUFBTSxDQUFDLENBQUM7SUFDdEMsTUFBTSxjQUFjLEdBQUcsa0JBQWtCLENBQUMsR0FBRyxFQUFFLFFBQVEsQ0FBQyxDQUFDO0lBRXpELE1BQU0sU0FBUyxHQUFHLFVBQVUsQ0FBQyxPQUFPLEVBQUUsT0FBTyxFQUFFLCtCQUErQixDQUFDLENBQUM7SUFDaEYsTUFBTSxRQUFRLEdBQUcsVUFBVSxDQUFDLE9BQU8sRUFBRSxNQUFNLEVBQUUsaUJBQWlCLENBQUMsQ0FBQztJQUNoRSxNQUFNLFFBQVEsR0FBRyxVQUFVLENBQUMsT0FBTyxFQUFFLE1BQU0sRUFBRSxpQkFBaUIsQ0FBQyxDQUFDO0lBQ2hFLE1BQU0sVUFBVSxHQUFHLFVBQVUsQ0FBQyxPQUFPLEVBQUUsUUFBUSxFQUFFLHlCQUF5QixDQUFDLENBQUM7SUFFNUUsT0FBTztRQUNMLFFBQVE7UUFDUixjQUFjO1FBQ2QsWUFBWSxFQUFFLFNBQVMsQ0FBQyxDQUFDLENBQUMsR0FBRyxjQUFjLFFBQVEsU0FBUyxFQUFFLENBQUMsQ0FBQyxDQUFDLElBQUk7UUFDckUsdUdBQXVHO1FBQ3ZHLFdBQVcsRUFBRSxRQUFRLENBQUMsQ0FBQyxDQUFDLENBQUMsUUFBUSxLQUFLLE1BQU0sQ0FBQyxDQUFDLENBQUMsR0FBRyxjQUFjLE9BQU8sQ0FBQyxDQUFDLENBQUMsR0FBRyxjQUFjLFFBQVEsUUFBUSxFQUFFLENBQUMsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUNySCxXQUFXLEVBQUUsUUFBUSxDQUFDLENBQUMsQ0FBQyxHQUFHLGNBQWMsUUFBUSxRQUFRLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUNsRSxhQUFhLEVBQUUsVUFBVSxDQUFDLENBQUMsQ0FBQyxHQUFHLGNBQWMsUUFBUSxVQUFVLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUN4RSxRQUFRLEVBQUUsRUFBRSxRQUFRLEVBQUUsY0FBYyxFQUFFLFFBQVEsRUFBRTtLQUNqRCxDQUFDO0FBQ0osQ0FBQztBQUVELFNBQVMsWUFBWSxDQUFDLEVBQUUsTUFBTSxFQUFFLElBQUksRUFBVTtJQUM1QyxNQUFNLFFBQVEsR0FBRyxDQUFDLGdCQUFnQixFQUFFLFVBQVUsRUFBRSxXQUFXLEVBQUUsa0JBQWtCLEVBQUUsU0FBUyxDQUFDLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBQ3pHLElBQUksUUFBUSxLQUFLLFNBQVM7UUFBRSxPQUFPLElBQUksQ0FBQztJQUN4QyxNQUFNLFNBQVMsR0FBRyxJQUFJLENBQUMsZ0JBQWdCLENBQUMsSUFBSSxFQUFFLENBQUM7SUFFL0MsSUFBSSxjQUFzQixDQUFDO0lBQzNCLElBQUksUUFBUSxHQUFrQixJQUFJLENBQUM7SUFDbkMsSUFBSSxNQUFNLENBQUMsYUFBYSxDQUFDLElBQUksa0JBQWtCLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxFQUFFLENBQUM7UUFDaEUsY0FBYyxHQUFHLFFBQVEsQ0FBQztRQUMxQixRQUFRLEdBQUcsTUFBTSxDQUFDLGFBQWEsQ0FBQyxDQUFDLENBQUMsQ0FBQyxhQUFhLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQztJQUMxRCxDQUFDO1NBQU0sSUFBSSxNQUFNLENBQUMsU0FBUyxDQUFDLEVBQUUsQ0FBQztRQUM3QixjQUFjLEdBQUcsSUFBSSxDQUFDO1FBQ3RCLFFBQVEsR0FBRyxTQUFTLENBQUM7SUFDdkIsQ0FBQztTQUFNLElBQUksTUFBTSxDQUFDLFNBQVMsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxjQUFjLENBQUMsRUFBRSxDQUFDO1FBQ3ZELGNBQWMsR0FBRyxRQUFRLENBQUM7UUFDMUIsUUFBUSxHQUFHLE1BQU0sQ0FBQyxjQUFjLENBQUMsQ0FBQyxDQUFDLENBQUMsY0FBYyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUM7SUFDNUQsQ0FBQztTQUFNLENBQUM7UUFDTixjQUFjLEdBQUcsS0FBSyxDQUFDO0lBQ3pCLENBQUM7SUFFRCw4R0FBOEc7SUFDOUcsTUFBTSxPQUFPLEdBQUcsTUFBTSxDQUFDLFdBQVcsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxZQUFZLENBQUMsSUFBSSxnQkFBZ0IsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLENBQUM7SUFDaEcsTUFBTSxTQUFTLEdBQUcsTUFBTSxDQUFDLFlBQVksQ0FBQyxJQUFJLE1BQU0sQ0FBQyxTQUFTLENBQUMsSUFBSSxrQkFBa0IsQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLENBQUM7SUFFbEcsT0FBTztRQUNMLFFBQVEsRUFBRSxRQUFRO1FBQ2xCLGNBQWM7UUFDZCxZQUFZLEVBQUUsa0JBQWtCLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLGNBQWMsS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLGNBQWMsQ0FBQyxDQUFDLENBQUMsaUJBQWlCLENBQUMsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUM1SCxXQUFXLEVBQUUsU0FBUyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLElBQUk7UUFDeEMsV0FBVyxFQUFFLE9BQU8sQ0FBQyxDQUFDLENBQUMsY0FBYyxDQUFDLENBQUMsQ0FBQyxJQUFJO1FBQzVDLGFBQWEsRUFBRSxPQUFPLENBQUMsQ0FBQyxDQUFDLGVBQWUsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUMvQyxRQUFRLEVBQUUsRUFBRSxRQUFRLEVBQUUsUUFBUSxFQUFFO0tBQ2pDLENBQUM7QUFDSixDQUFDO0FBRUQsU0FBUyxVQUFVLENBQUMsRUFBRSxNQUFNLEVBQVU7SUFDcEMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxZQUFZLENBQUM7UUFBRSxPQUFPLElBQUksQ0FBQztJQUN2QyxPQUFPO1FBQ0wsUUFBUSxFQUFFLE1BQU07UUFDaEIsY0FBYyxFQUFFLE9BQU87UUFDdkIsWUFBWSxFQUFFLGFBQWE7UUFDM0IsV0FBVyxFQUFFLFlBQVk7UUFDekIsV0FBVyxFQUFFLGNBQWM7UUFDM0IsYUFBYSxFQUFFLFdBQVc7UUFDMUIsUUFBUSxFQUFFLEVBQUUsUUFBUSxFQUFFLFlBQVksRUFBRSxRQUFRLEVBQUUsTUFBTSxDQUFDLFlBQVksQ0FBQyxDQUFDLENBQUMsQ0FBQyxZQUFZLENBQUMsQ0FBQyxDQUFDLElBQUksRUFBRTtLQUMzRixDQUFDO0FBQ0osQ0FBQztBQUVELFNBQVMsUUFBUSxDQUFDLEVBQUUsTUFBTSxFQUFVO0lBQ2xDLElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDbkMsTUFBTSxXQUFXLEdBQUcsTUFBTSxDQUFDLGVBQWUsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxnQkFBZ0IsQ0FBQyxJQUFJLE1BQU0sQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDO0lBQ3BHLE9BQU87UUFDTCxRQUFRLEVBQUUsSUFBSTtRQUNkLGNBQWMsRUFBRSxJQUFJO1FBQ3BCLFlBQVksRUFBRSxnQkFBZ0I7UUFDOUIsV0FBVyxFQUFFLGVBQWU7UUFDNUIsV0FBVyxFQUFFLFdBQVcsQ0FBQyxDQUFDLENBQUMsbUJBQW1CLENBQUMsQ0FBQyxDQUFDLGNBQWM7UUFDL0QsYUFBYSxFQUFFLFlBQVk7UUFDM0IsUUFBUSxFQUFFLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsTUFBTSxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLElBQUksRUFBRTtLQUMvRSxDQUFDO0FBQ0osQ0FBQztBQUVELFNBQVMsV0FBVyxDQUFDLEVBQUUsTUFBTSxFQUFVO0lBQ3JDLElBQUksQ0FBQyxNQUFNLENBQUMsU0FBUyxDQUFDO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDcEMsT0FBTztRQUNMLFFBQVEsRUFBRSxNQUFNO1FBQ2hCLGNBQWMsRUFBRSxPQUFPO1FBQ3ZCLFlBQVksRUFBRSxnQkFBZ0I7UUFDOUIsV0FBVyxFQUFFLGFBQWE7UUFDMUIsV0FBVyxFQUFFLElBQUk7UUFDakIsYUFBYSxFQUFFLElBQUk7UUFDbkIsUUFBUSxFQUFFLEVBQUUsUUFBUSxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsSUFBSSxFQUFFO0tBQ2xELENBQUM7QUFDSixDQUFDO0FBRUQsU0FBUyxZQUFZLENBQUMsRUFBRSxNQUFNLEVBQVU7SUFDdEMsTUFBTSxRQUFRLEdBQUcsTUFBTSxDQUFDLGNBQWMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxjQUFjLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxrQkFBa0IsQ0FBQyxDQUFDLENBQUMsQ0FBQyxrQkFBa0IsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0lBQ2xILElBQUksUUFBUSxLQUFLLElBQUk7UUFBRSxPQUFPLElBQUksQ0FBQztJQUNuQyxNQUFNLE1BQU0sR0FBRyxNQUFNLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDO0lBQzFELE9BQU87UUFDTCxRQUFRLEVBQUUsTUFBTTtRQUNoQixjQUFjLEVBQUUsUUFBUTtRQUN4QixZQUFZLEVBQUUsR0FBRyxNQUFNLFFBQVE7UUFDL0IsV0FBVyxFQUFFLEdBQUcsTUFBTSxPQUFPO1FBQzdCLFdBQVcsRUFBRSxJQUFJO1FBQ2pCLGFBQWEsRUFBRSxJQUFJO1FBQ25CLFFBQVEsRUFBRSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsSUFBSSxFQUFFO0tBQ3ZDLENBQUM7QUFDSixDQUFDO0FBRUQsTUFBTSxTQUFTLEdBQWdFLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDM0YsVUFBVTtJQUNWLFlBQVk7SUFDWixVQUFVO0lBQ1YsUUFBUTtJQUNSLFdBQVc7SUFDWCxZQUFZO0NBQ2IsQ0FBQyxDQUFDO0FBRUg7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQUMsUUFBZ0IsRUFBRSxVQUFrQyxFQUFFO0lBQ3BGLElBQUksT0FBTyxRQUFRLEtBQUssUUFBUSxJQUFJLENBQUMsUUFBUSxDQUFDLElBQUksRUFBRSxFQUFFLENBQUM7UUFDckQsT0FBTyxFQUFFLFFBQVEsRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLG9EQUFvRCxFQUFFLENBQUM7SUFDM0YsQ0FBQztJQUNELE1BQU0sTUFBTSxHQUFHLFVBQVUsQ0FBQyxRQUFRLEVBQUUsT0FBTyxDQUFDLENBQUM7SUFDN0MsS0FBSyxNQUFNLFFBQVEsSUFBSSxTQUFTLEVBQUUsQ0FBQztRQUNqQyxNQUFNLFFBQVEsR0FBRyxRQUFRLENBQUMsTUFBTSxDQUFDLENBQUM7UUFDbEMsSUFBSSxRQUFRLEtBQUssSUFBSSxFQUFFLENBQUM7WUFDdEIsT0FBTyxFQUFFLFFBQVEsRUFBRSxJQUFJLEVBQUUsR0FBRyxRQUFRLEVBQUUsQ0FBQztRQUN6QyxDQUFDO0lBQ0gsQ0FBQztJQUNELE9BQU8sRUFBRSxRQUFRLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxrQkFBa0IsRUFBRSxDQUFDO0FBQ3pELENBQUM7QUFFRCwyR0FBMkc7QUFDM0csTUFBTSxVQUFVLGtCQUFrQixDQUFDLEtBQXlDO0lBQzFFLElBQUksQ0FBQyxLQUFLLElBQUksS0FBSyxDQUFDLFFBQVEsS0FBSyxJQUFJLEVBQUUsQ0FBQztRQUN0QyxPQUFPLHVCQUF1QixLQUFLLEVBQUUsTUFBTSxJQUFJLGdCQUFnQixFQUFFLENBQUM7SUFDcEUsQ0FBQztJQUNELE1BQU0sUUFBUSxHQUFHO1FBQ2YsS0FBSyxDQUFDLFlBQVksQ0FBQyxDQUFDLENBQUMsV0FBVyxLQUFLLENBQUMsWUFBWSxJQUFJLENBQUMsQ0FBQyxDQUFDLElBQUk7UUFDN0QsS0FBSyxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsVUFBVSxLQUFLLENBQUMsV0FBVyxJQUFJLENBQUMsQ0FBQyxDQUFDLElBQUk7UUFDMUQsS0FBSyxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsVUFBVSxLQUFLLENBQUMsV0FBVyxJQUFJLENBQUMsQ0FBQyxDQUFDLElBQUk7UUFDMUQsS0FBSyxDQUFDLGFBQWEsQ0FBQyxDQUFDLENBQUMsWUFBWSxLQUFLLENBQUMsYUFBYSxJQUFJLENBQUMsQ0FBQyxDQUFDLElBQUk7S0FDakUsQ0FBQyxNQUFNLENBQUMsQ0FBQyxLQUFLLEVBQW1CLEVBQUUsQ0FBQyxLQUFLLEtBQUssSUFBSSxDQUFDLENBQUM7SUFDckQsTUFBTSxNQUFNLEdBQUcsUUFBUSxDQUFDLE1BQU0sR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFDLEtBQUssUUFBUSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQyxvQ0FBb0MsQ0FBQztJQUN4RyxPQUFPLEdBQUcsS0FBSyxDQUFDLFFBQVEsUUFBUSxLQUFLLENBQUMsY0FBYyxJQUFJLFNBQVMsR0FBRyxNQUFNLEVBQUUsQ0FBQztBQUMvRSxDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/stack-detection.ts b/packages/loopover-miner/lib/stack-detection.ts new file mode 100644 index 0000000000..a8c7132845 --- /dev/null +++ b/packages/loopover-miner/lib/stack-detection.ts @@ -0,0 +1,295 @@ +/** Stack auto-detection (#4785): inspect an already-cloned target repo's manifest / lockfile / config files and + * infer a structured description of its stack — language, package manager, and the build / test / lint / format + * commands — before any code-generation step runs. Like `miner-goal-spec.js` this reads the ALREADY-CLONED repo on + * disk (attempt-worktree.js's prepareAttemptWorktree runs first), so the injected `existsSync` / `readFileSync` + * always receive the FULL joined path, mirroring node:fs. It is pure and NEVER throws: an unreadable/unparseable + * file degrades to "no evidence" rather than crashing, and — per the acceptance criteria — a repo whose stack + * can't be confidently identified returns an explicit `{ detected: false, reason }` instead of guessing. */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Which manifest (and lockfile, when present) drove the detection. */ +export type StackEvidence = { + manifest: string; + lockfile: string | null; +}; + +/** A confidently-detected stack. Command fields are `null` when the command can't be inferred without guessing. */ +export type DetectedRepoStack = { + detected: true; + language: string; + packageManager: string | null; + buildCommand: string | null; + testCommand: string | null; + lintCommand: string | null; + formatCommand: string | null; + evidence: StackEvidence; +}; + +/** A repo whose stack could not be confidently identified. */ +export type UndetectedRepoStack = { + detected: false; + reason: string; +}; + +export type RepoStackResult = DetectedRepoStack | UndetectedRepoStack; + +export type DetectRepoStackOptions = { + existsSync?: (path: string) => boolean; + readFileSync?: (path: string, encoding: "utf8") => string; +}; + +type Access = { + exists: (relativePath: string) => boolean; + read: (relativePath: string) => string | null; +}; + +type DetectedStackFields = Omit; + +/** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with + * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ +export const RECOGNIZED_MANIFESTS: readonly string[] = Object.freeze([ + "package.json", + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle", + "build.gradle.kts", +]); + +const NO_MANIFEST_REASON = + "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; + +const NODE_PACKAGE_MANAGERS = Object.freeze(["npm", "yarn", "pnpm", "bun"]); +const NODE_LOCKFILES: readonly [string, string][] = Object.freeze([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], +]); + +/** Build a never-throwing accessor over the cloned repo. `exists` and `read` both swallow fs errors so the detector + * treats an EACCES/ENOENT/binary file as simply "absent" instead of crashing the attempt. */ +function makeAccess(repoPath: string, options: DetectRepoStackOptions): Access { + const existsImpl = options.existsSync ?? existsSync; + const readImpl = options.readFileSync ?? readFileSync; + const exists = (relativePath: string): boolean => { + try { + return existsImpl(join(repoPath, relativePath)) === true; + } catch { + return false; + } + }; + const read = (relativePath: string): string | null => { + try { + if (!exists(relativePath)) return null; + const content = readImpl(join(repoPath, relativePath), "utf8"); + return typeof content === "string" ? content : null; + } catch { + return null; + } + }; + return { exists, read }; +} + +function parseJson(text: string | null): Record | null { + if (typeof text !== "string") return null; + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */ +function pickScript(scripts: Record, exactName: string, pattern: RegExp): string | null { + const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); + if (names.includes(exactName)) return exactName; + return names.find((name) => pattern.test(name)) ?? null; +} + +function nodeLockfile(exists: Access["exists"]): string | null { + const match = NODE_LOCKFILES.find(([file]) => exists(file)); + return match ? match[0] : null; +} + +function nodePackageManager(pkg: Record | null, lockfile: string | null): string { + const corepack = + typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0]!.trim().toLowerCase() : ""; + if (NODE_PACKAGE_MANAGERS.includes(corepack)) return corepack; + const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); + // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). + return byLock ? byLock[1] : "npm"; +} + +function hasTypescriptDependency(pkg: Record | null): boolean { + const dependencies = (pkg?.dependencies ?? {}) as Record; + const devDependencies = (pkg?.devDependencies ?? {}) as Record; + const deps = { ...dependencies, ...devDependencies }; + return typeof deps.typescript === "string"; +} + +function detectNode({ exists, read }: Access): DetectedStackFields | null { + if (!exists("package.json")) return null; + const pkg = parseJson(read("package.json")); + const scripts = + pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? (pkg.scripts as Record) : {}; + const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; + const lockfile = nodeLockfile(exists); + const packageManager = nodePackageManager(pkg, lockfile); + + const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); + const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); + const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); + const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); + + return { + language, + packageManager, + buildCommand: buildName ? `${packageManager} run ${buildName}` : null, + // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. + testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, + lintCommand: lintName ? `${packageManager} run ${lintName}` : null, + formatCommand: formatName ? `${packageManager} run ${formatName}` : null, + evidence: { manifest: "package.json", lockfile }, + }; +} + +function detectPython({ exists, read }: Access): DetectedStackFields | null { + const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); + if (manifest === undefined) return null; + const pyproject = read("pyproject.toml") ?? ""; + + let packageManager: string; + let lockfile: string | null = null; + if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { + packageManager = "poetry"; + lockfile = exists("poetry.lock") ? "poetry.lock" : null; + } else if (exists("uv.lock")) { + packageManager = "uv"; + lockfile = "uv.lock"; + } else if (exists("Pipfile") || exists("Pipfile.lock")) { + packageManager = "pipenv"; + lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; + } else { + packageManager = "pip"; + } + + // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). + const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); + const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); + + return { + language: "python", + packageManager, + buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, + testCommand: hasPytest ? "pytest" : null, + lintCommand: hasRuff ? "ruff check ." : null, + formatCommand: hasRuff ? "ruff format ." : null, + evidence: { manifest, lockfile }, + }; +} + +function detectRust({ exists }: Access): DetectedStackFields | null { + if (!exists("Cargo.toml")) return null; + return { + language: "rust", + packageManager: "cargo", + buildCommand: "cargo build", + testCommand: "cargo test", + lintCommand: "cargo clippy", + formatCommand: "cargo fmt", + evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, + }; +} + +function detectGo({ exists }: Access): DetectedStackFields | null { + if (!exists("go.mod")) return null; + const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); + return { + language: "go", + packageManager: "go", + buildCommand: "go build ./...", + testCommand: "go test ./...", + lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", + formatCommand: "gofmt -l .", + evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, + }; +} + +function detectMaven({ exists }: Access): DetectedStackFields | null { + if (!exists("pom.xml")) return null; + return { + language: "java", + packageManager: "maven", + buildCommand: "mvn -B package", + testCommand: "mvn -B test", + lintCommand: null, + formatCommand: null, + evidence: { manifest: "pom.xml", lockfile: null }, + }; +} + +function detectGradle({ exists }: Access): DetectedStackFields | null { + const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; + if (manifest === null) return null; + const runner = exists("gradlew") ? "./gradlew" : "gradle"; + return { + language: "java", + packageManager: "gradle", + buildCommand: `${runner} build`, + testCommand: `${runner} test`, + lintCommand: null, + formatCommand: null, + evidence: { manifest, lockfile: null }, + }; +} + +const DETECTORS: readonly ((access: Access) => DetectedStackFields | null)[] = Object.freeze([ + detectNode, + detectPython, + detectRust, + detectGo, + detectMaven, + detectGradle, +]); + +/** + * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the + * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no + * recognized manifest is present. Never throws. + */ +export function detectRepoStack(repoPath: string, options: DetectRepoStackOptions = {}): RepoStackResult { + if (typeof repoPath !== "string" || !repoPath.trim()) { + return { detected: false, reason: "A repository path is required to detect the stack." }; + } + const access = makeAccess(repoPath, options); + for (const detector of DETECTORS) { + const detected = detector(access); + if (detected !== null) { + return { detected: true, ...detected }; + } + } + return { detected: false, reason: NO_MANIFEST_REASON }; +} + +/** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ +export function renderStackSummary(stack: RepoStackResult | null | undefined): string { + if (!stack || stack.detected !== true) { + return `stack not detected: ${stack?.reason ?? "unknown reason"}`; + } + const commands = [ + stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, + stack.testCommand ? `test=\`${stack.testCommand}\`` : null, + stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, + stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, + ].filter((entry): entry is string => entry !== null); + const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; + return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; +} diff --git a/test/unit/miner-attempt-log.test.ts b/test/unit/miner-attempt-log.test.ts index 7e43c2c35c..cdd81fe595 100644 --- a/test/unit/miner-attempt-log.test.ts +++ b/test/unit/miner-attempt-log.test.ts @@ -174,6 +174,13 @@ describe("loopover-miner attempt log (#4294)", () => { expect(() => log.exportAttemptLogJsonl(" ")).toThrow(/invalid_attempt_id/); }); + it("rejects an undefined attemptId from an untyped export caller instead of exporting everything", () => { + const log = tempAttemptLog(); + expect(() => log.exportAttemptLogJsonl(undefined as unknown as string)).toThrow( + /invalid_attempt_id/, + ); + }); + it("rejects a payload JSON would not round-trip verbatim, and accepts a nested JSON-safe one", () => { const log = tempAttemptLog(); expect(() => @@ -210,6 +217,34 @@ describe("loopover-miner attempt log (#4294)", () => { expect(() => log.readAttemptLogEvents()).toThrow("corrupted_attempt_log_row"); }); + it("rejects a payload blob that is valid JSON but not an object, distinct from unparseable JSON", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); + const raw = new DatabaseSync(log.dbPath); + raw.prepare("UPDATE attempt_log_events SET payload_json = ? WHERE id = 1").run("[1,2,3]"); + raw.close(); + expect(() => log.readAttemptLogEvents()).toThrow("corrupted_attempt_log_row"); + }); + + it("REGRESSION: rolls the transaction back if the insert throws, leaving prior rows unchanged", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); + + // Drops a column out from under the already-prepared INSERT statement, forcing node:sqlite to throw INSIDE + // appendAttemptLogEvent's try block (after BEGIN IMMEDIATE) -- exercising the ROLLBACK path (#4294's + // append-only invariant must not leave a stranded transaction or a partial row on a mid-insert failure). + const raw = new DatabaseSync(log.dbPath); + raw.exec("ALTER TABLE attempt_log_events DROP COLUMN mode"); + raw.close(); + + expect(() => log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent })).toThrow(); + + // Rolled back: still just the one row from before the schema was broken, not a partial second insert. + const raw2 = new DatabaseSync(log.dbPath); + expect(raw2.prepare("SELECT COUNT(*) AS count FROM attempt_log_events").get()).toEqual({ count: 1 }); + raw2.close(); + }); + it("uses the default singleton helpers and closes cleanly", () => { const root = mkdtempSync(join(tmpdir(), "loopover-miner-attempt-log-default-")); roots.push(root); diff --git a/test/unit/miner-ci-poller.test.ts b/test/unit/miner-ci-poller.test.ts index ba83895174..d2cf712cb8 100644 --- a/test/unit/miner-ci-poller.test.ts +++ b/test/unit/miner-ci-poller.test.ts @@ -432,4 +432,139 @@ describe("miner CI check-run poller (#2323)", () => { expect(timeoutSpy.mock.calls.every(([ms]) => ms === 2500)).toBe(true); timeoutSpy.mockRestore(); }); + + it("treats a whitespace-only apiBaseUrl the same as omitted, defaulting to the standard GitHub API host", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "https://api.github.com/repos/acme/widgets/pulls/42") return prResponse("head-sha"); + if (url === "https://api.github.com/repos/acme/widgets/commits/head-sha/check-runs?per_page=100&page=1") { + return checksResponse([checkRun("validate", "completed", "success")]); + } + return jsonResponse({}, { status: 404 }); + }); + + await expect( + pollCheckRuns("acme/widgets", 42, { apiBaseUrl: " ", fetchFn }), + ).resolves.toMatchObject({ conclusion: "success" }); + }); + + it("falls back to the real global fetch and a real timer-based backoff when both are omitted", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/99")) return prResponse("head-sha"); + if (url.endsWith("/repos/acme/widgets/commits/head-sha/check-runs?per_page=100&page=1")) { + const checkRunAttempts = fetchFn.mock.calls.filter(([request]) => String(request).includes("/check-runs")).length; + return checkRunAttempts >= 2 + ? checksResponse([checkRun("validate", "completed", "success")]) + : checksResponse([checkRun("validate", "queued")]); + } + return jsonResponse({}, { status: 404 }); + }); + vi.stubGlobal("fetch", fetchFn); + + try { + const result = await pollCheckRuns("acme/widgets", 99, { + apiBaseUrl: API, + maxAttempts: 2, + minIntervalMs: 1, + maxIntervalMs: 1, + }); + expect(result).toMatchObject({ conclusion: "success", attempts: 2 }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("treats an unrecognized completed conclusion as pending (defensive default for a future GitHub value)", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce(checksResponse([checkRun("validate", "completed", "some_future_conclusion")])); + + await expect( + pollCheckRuns("acme/widgets", 20, { apiBaseUrl: API, fetchFn, maxAttempts: 1 }), + ).resolves.toMatchObject({ + conclusion: "pending", + checks: [{ name: "validate", conclusion: "pending" }], + }); + }); + + it("treats a non-object check-run entry as a safely-defaulted pending check instead of crashing", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse()) + .mockResolvedValueOnce(jsonResponse({ total_count: 1, check_runs: [null] })); + + await expect( + pollCheckRuns("acme/widgets", 21, { apiBaseUrl: API, fetchFn, maxAttempts: 1 }), + ).resolves.toMatchObject({ + conclusion: "pending", + checks: [ + { + name: "", + status: "unknown", + conclusion: "pending", + detailsUrl: null, + startedAt: null, + completedAt: null, + }, + ], + }); + }); + + it("ignores a missing or invalid total_count and paginates by the link header alone", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/22")) return prResponse("link-only-sha"); + if (url.endsWith("/repos/acme/widgets/commits/link-only-sha/check-runs?per_page=100&page=1")) { + return jsonResponse( + { total_count: -1, check_runs: [checkRun("first", "completed", "success")] }, + { + headers: { + link: `<${API}/repos/acme/widgets/commits/link-only-sha/check-runs?per_page=100&page=2>; rel="next"`, + }, + }, + ); + } + if (url.endsWith("/repos/acme/widgets/commits/link-only-sha/check-runs?per_page=100&page=2")) { + return jsonResponse({ check_runs: [checkRun("second", "completed", "success")] }); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await pollCheckRuns("acme/widgets", 22, { apiBaseUrl: API, fetchFn, maxAttempts: 1 }); + expect(result.checks.map((check) => check.name)).toEqual(["first", "second"]); + }); + + it("surfaces a GitHub error using only the status code when the payload carries no usable message", async () => { + const fetchFn = vi.fn(async () => jsonResponse({}, { status: 401 })); + + await expect( + pollCheckRuns("acme/widgets", 23, { apiBaseUrl: API, fetchFn, maxAttempts: 1 }), + ).rejects.toMatchObject({ code: "github_401", message: "github_401", githubMessage: null }); + }); + + it("throws when a page unexpectedly returns zero check runs despite pagination still being expected", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/repos/acme/widgets/pulls/24")) return prResponse("incomplete-sha"); + if (url.endsWith("/repos/acme/widgets/commits/incomplete-sha/check-runs?per_page=100&page=1")) { + return jsonResponse({ total_count: 5, check_runs: [] }); + } + return jsonResponse({}, { status: 404 }); + }); + + await expect( + pollCheckRuns("acme/widgets", 24, { apiBaseUrl: API, fetchFn, maxAttempts: 1 }), + ).rejects.toThrow("github_check_runs_pagination_incomplete"); + }); + + it("rejects a non-string repoFullName from an untyped caller instead of crashing on .split", async () => { + const fetchFn = vi.fn(); + + await expect( + pollCheckRuns(42 as unknown as string, 1, { apiBaseUrl: API, fetchFn }), + ).rejects.toThrow("invalid_repo_full_name"); + expect(fetchFn).not.toHaveBeenCalled(); + }); }); diff --git a/test/unit/miner-coding-task-spec-path-guard.test.ts b/test/unit/miner-coding-task-spec-path-guard.test.ts new file mode 100644 index 0000000000..8cf10d4542 --- /dev/null +++ b/test/unit/miner-coding-task-spec-path-guard.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// REGRESSION coverage for assertContainedPath's throw arm in coding-task-spec.ts (#5132): under normal +// operation ACCEPTANCE_CRITERIA_FILENAME is a fixed, safe basename ("acceptance-criteria.json"), so +// join(root, ACCEPTANCE_CRITERIA_FILENAME) can never actually escape root -- miner-coding-task-spec.test.ts +// exercises every other branch of this file but can't reach this one through that fixed constant. This file +// mocks @loopover/engine's ACCEPTANCE_CRITERIA_FILENAME export to a path-traversal value to prove the +// defense-in-depth guard actually fires if that upstream constant were ever a non-basename (e.g. a future +// @loopover/engine regression), rather than deleting the check as unreachable dead code. +vi.mock("@loopover/engine", async () => { + const actual = await import("../../packages/loopover-engine/src/index"); + return { ...actual, ACCEPTANCE_CRITERIA_FILENAME: "../escape.json" }; +}); + +import { writeAcceptanceCriteriaFile } from "../../packages/loopover-miner/lib/coding-task-spec.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempDir() { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-coding-task-spec-path-guard-")); + roots.push(root); + return realpathSync(root); +} + +function goAcceptanceCriteria() { + return { + version: 1, + verdict: "go" as const, + writable: true, + taskBrief: "brief", + constraints: "", + feasibilityNotes: "", + retrievalContext: "", + feasibilitySummary: "", + avoidReasons: [], + raiseReasons: [], + }; +} + +describe("coding-task-spec assertContainedPath (#5132)", () => { + it("REGRESSION: refuses to write outside the worktree when the acceptance-criteria filename escapes root", () => { + const workingDirectory = tempDir(); + expect(() => writeAcceptanceCriteriaFile(workingDirectory, goAcceptanceCriteria())).toThrow( + /Refusing to write acceptance criteria outside the worktree/, + ); + }); +}); diff --git a/test/unit/miner-self-review-context.test.ts b/test/unit/miner-self-review-context.test.ts index 3ae49eece4..5fe75a8fb0 100644 --- a/test/unit/miner-self-review-context.test.ts +++ b/test/unit/miner-self-review-context.test.ts @@ -126,6 +126,213 @@ describe("fetchSelfReviewContext (#5145)", () => { await expect(fetchSelfReviewContext("not-a-repo")).rejects.toThrow("invalid_repo_full_name"); }); + it("rejects a non-string repoFullName, without calling fetch", async () => { + const fetchImpl = vi.fn(); + await expect(fetchSelfReviewContext(42 as unknown as string, { fetchImpl: fetchImpl as never })).rejects.toThrow("invalid_repo_full_name"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("falls back to every default option (apiBaseUrl/rawContentBaseUrl/gittensorApiBase/perPage/maxPages/contributorLogin/requestTimeoutMs/liveGateProbeTimeoutMs) when omitted", async () => { + const fetchImpl = routedFetch({ + "/repos/acme/widgets/issues": () => jsonResponse([]), + "/repos/acme/widgets/pulls": () => jsonResponse([]), + "/repos/acme/widgets": () => jsonResponse(REPO_PAYLOAD), + "raw.githubusercontent.com": () => jsonResponse(null, 404), + "api.gittensor.io/miners": () => jsonResponse([]), + }); + + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: null, + }); + expect(result.repo?.fullName).toBe("acme/widgets"); + expect(result.issues).toEqual([]); + expect(result.pullRequests).toEqual([]); + }); + + it("uses a custom apiBaseUrl/rawContentBaseUrl/gittensorApiBase when provided", async () => { + const requests: string[] = []; + const fetchImpl = async (url: string) => { + requests.push(url); + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.example.internal")) return jsonResponse(null, 404); + if (url.includes("gittensor.example.internal")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + apiBaseUrl: "https://ghe.example.internal/api/v3", + rawContentBaseUrl: "https://raw.example.internal", + gittensorApiBase: "https://gittensor.example.internal", + contributorLogin: "miner-bot", + loopoverAuth: null, + }); + expect(requests.some((url) => url.startsWith("https://ghe.example.internal/api/v3/"))).toBe(true); + expect(requests.some((url) => url.startsWith("https://raw.example.internal/"))).toBe(true); + expect(requests.some((url) => url.startsWith("https://gittensor.example.internal/"))).toBe(true); + }); + + it("defaults fetchImpl to the real global fetch when omitted", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 404) as unknown as Response); + try { + const result = await fetchSelfReviewContext("acme/widgets", { loopoverAuth: null }); + expect(fetchSpy).toHaveBeenCalled(); + expect(result.repo).toBeNull(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("treats whitespace-only apiBaseUrl/rawContentBaseUrl/gittensorApiBase as absent and falls back to the default", async () => { + const requests: string[] = []; + const fetchImpl = async (url: string) => { + requests.push(url); + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.githubusercontent.com")) return jsonResponse(null, 404); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + apiBaseUrl: " ", + rawContentBaseUrl: " ", + gittensorApiBase: " ", + contributorLogin: "miner-bot", + loopoverAuth: null, + }); + expect(requests.some((url) => url.startsWith("https://api.github.com/"))).toBe(true); + expect(requests.some((url) => url.startsWith("https://raw.githubusercontent.com/"))).toBe(true); + expect(requests.some((url) => url.startsWith("https://api.gittensor.io/"))).toBe(true); + }); + + it("resolves the ORB probe apiUrl from the loopover-mcp session when loopoverAuth omits its own apiUrl", async () => { + const requests: string[] = []; + const fetchImpl = async (url: string) => { + requests.push(url); + if (url.includes("/live-gate-thresholds")) return jsonResponse({ confidence_floor: 0.9, scope_cap_files: null, scope_cap_lines: null }); + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.githubusercontent.com")) return jsonResponse(null, 404); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + // No env session on disk here, so resolveLoopoverBackendSession(env) returns null -- exercising the + // hardcoded "https://api.loopover.ai" fallback alongside options.loopoverAuth.apiUrl being omitted. + await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: { sessionToken: "mcp-test-token" }, + env: { LOOPOVER_CONFIG_DIR: "/nonexistent-loopover-config-dir" }, + }); + expect(requests.some((url) => url.startsWith("https://api.loopover.ai/v1/repos/acme/widgets/live-gate-thresholds"))).toBe(true); + }); + + it("stops paginating when maxPages is not a positive integer (falls back to the default page cap)", async () => { + let pageCount = 0; + const fetchImpl = async (url: string) => { + if (url.includes("/repos/acme/widgets/issues")) { + pageCount += 1; + return jsonResponse([issuePayload({ number: pageCount })]); + } + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.githubusercontent.com")) return jsonResponse(null, 404); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + maxPages: 0, + perPage: 1, + loopoverAuth: null, + }); + // Each 1-issue page exactly fills perPage:1, so it's never a "short" page -- pagination keeps going until + // the invalid maxPages:0 falls back to the real default (10), proven by getting exactly 10 issues back + // rather than looping forever or stopping at 0. + expect(result.issues).toHaveLength(10); + }); + + it("minimal repo payload: falls back to the target owner/repo, false, and null for every optional GitHub field", async () => { + const fetchImpl = routedFetch({ + "/repos/acme/widgets/issues": () => jsonResponse([]), + "/repos/acme/widgets/pulls": () => jsonResponse([]), + "/repos/acme/widgets": () => jsonResponse({}), + "raw.githubusercontent.com": () => jsonResponse(null, 404), + "api.gittensor.io/miners": () => jsonResponse([]), + }); + + const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, loopoverAuth: null }); + expect(result.repo).toEqual({ + fullName: "acme/widgets", + owner: "acme", + name: "widgets", + installationId: undefined, + isInstalled: false, + isRegistered: false, + isPrivate: false, + htmlUrl: null, + defaultBranch: null, + registryConfig: null, + }); + }); + + it("minimal issue/PR payloads: falls back to '', null, and [] for every optional field, without a labels array", async () => { + const fetchImpl = routedFetch({ + "/repos/acme/widgets/issues": () => jsonResponse([{ number: 7, title: "Bare issue", state: "open" }]), + "/repos/acme/widgets/pulls": () => jsonResponse([{ number: 42, title: "Bare PR", state: "open" }]), + "/repos/acme/widgets": () => jsonResponse(REPO_PAYLOAD), + "raw.githubusercontent.com": () => jsonResponse(null, 404), + "api.gittensor.io/miners": () => jsonResponse([]), + }); + + const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, loopoverAuth: null }); + expect(result.issues[0]).toEqual({ + repoFullName: "acme/widgets", + number: 7, + title: "Bare issue", + state: "open", + authorLogin: null, + authorAssociation: null, + htmlUrl: null, + body: "", + createdAt: null, + updatedAt: null, + closedAt: null, + labels: [], + linkedPrs: [], + }); + expect(result.pullRequests[0]).toEqual({ + repoFullName: "acme/widgets", + number: 42, + title: "Bare PR", + state: "open", + authorLogin: null, + authorAssociation: null, + headSha: null, + headRef: null, + baseRef: null, + htmlUrl: null, + mergedAt: null, + isDraft: null, + mergeableState: null, + reviewDecision: null, + body: "", + createdAt: null, + updatedAt: null, + closedAt: null, + labels: [], + linkedIssues: [], + }); + }); + it("builds a full context from live GitHub data: repo, issues, pull requests, manifest, contributor, duplicate cluster", async () => { // The PR title is deliberately unrelated to the issue title -- buildCollisionReport's pairwise term- // overlap clustering would otherwise ALSO cluster this pair (e.g. two titles both mentioning "retry"/ @@ -647,6 +854,16 @@ describe("live gate thresholds probe (#6487)", () => { scope_cap_lines: null, }); expect(notLoosened.gate.readinessMinScore).toBe(70); + + // No fields/manifest at all: applyLiveGateThresholdsToManifest returns the input manifest unchanged. + expect(applyLiveGateThresholdsToManifest(base, null)).toBe(base); + expect(applyLiveGateThresholdsToManifest(null as never, { confidence_floor: 0.9, scope_cap_files: null, scope_cap_lines: null })).toBeNull(); + + // No readiness gate configured at all (readinessMinScore is null, not a number): the confidence_floor + // overlay has nothing to compare against, so it leaves readinessMinScore untouched (still null). + const noReadinessGate = parseFocusManifestContent(null, "repo_file"); + const stillNull = applyLiveGateThresholdsToManifest(noReadinessGate, { confidence_floor: 0.9, scope_cap_files: null, scope_cap_lines: null }); + expect(stillNull.gate.readinessMinScore).toBeNull(); }); it("uses live ORB thresholds when the probe returns 200", async () => { diff --git a/test/unit/miner-stack-detection.test.ts b/test/unit/miner-stack-detection.test.ts index dd4d005eaa..38a86d8281 100644 --- a/test/unit/miner-stack-detection.test.ts +++ b/test/unit/miner-stack-detection.test.ts @@ -132,6 +132,10 @@ describe("detectRepoStack — Node (#4785)", () => { expect(detect({ "package.json": null })).toMatchObject({ detected: true, language: "javascript" }); }); + it("degrades safely on a package.json that is valid JSON but not an object (e.g. a bare null)", () => { + expect(detect({ "package.json": "null" })).toMatchObject({ detected: true, language: "javascript", buildCommand: null }); + }); + it("treats a non-string readFileSync result as no content", () => { const result = detectRepoStack(ROOT, { existsSync: (path: string) => path === join(ROOT, "package.json"),