diff --git a/packages/gittensory-miner/lib/manage-status.js b/packages/gittensory-miner/lib/manage-status.js index 1565fae6f8..e3c156dbf2 100644 --- a/packages/gittensory-miner/lib/manage-status.js +++ b/packages/gittensory-miner/lib/manage-status.js @@ -125,6 +125,11 @@ export function collectRunPortfolio(sources) { list.push(row); prsByRepo.set(row.repoFullName, list); } + // NOTE (#5563): keyed by repoFullName alone, not apiBaseUrl -- this dashboard fold predates multi-forge run + // states and produces exactly ONE row per repo name. If the same repo name has a recorded run state on two + // different hosts, only one (the later entry in listRunStates' order) survives here; the other's row is still + // intact in the store, just not surfaced in this particular view. Safe (no data loss, no write), just a display + // limitation -- broadening this fold to be host-aware is a separate, larger dashboard-shape change. const runStateByRepo = new Map(runStateStore.listRunStates().map((entry) => [entry.repoFullName, entry])); const repoFullNames = new Set([...prsByRepo.keys(), ...runStateByRepo.keys()]); diff --git a/packages/gittensory-miner/lib/run-state-cli.d.ts b/packages/gittensory-miner/lib/run-state-cli.d.ts index 0b4ce2b786..eafbe6fc43 100644 --- a/packages/gittensory-miner/lib/run-state-cli.d.ts +++ b/packages/gittensory-miner/lib/run-state-cli.d.ts @@ -2,6 +2,7 @@ export type ParsedStateGetArgs = | { repoFullName: string; json: boolean; + apiBaseUrl: string | undefined; } | { error: string }; @@ -11,6 +12,7 @@ export type ParsedStateSetArgs = state: "idle" | "discovering" | "planning" | "preparing"; dryRun: boolean; json: boolean; + apiBaseUrl: string | undefined; } | { error: string }; diff --git a/packages/gittensory-miner/lib/run-state-cli.js b/packages/gittensory-miner/lib/run-state-cli.js index fa2d8c3ee7..e86b094d12 100644 --- a/packages/gittensory-miner/lib/run-state-cli.js +++ b/packages/gittensory-miner/lib/run-state-cli.js @@ -1,9 +1,9 @@ import { RUN_STATES, getRunState, setRunState } from "./run-state.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; -const STATE_GET_USAGE = "Usage: gittensory-miner state get [--json]"; +const STATE_GET_USAGE = "Usage: gittensory-miner state get [--api-base-url ] [--json]"; const STATE_SET_USAGE = - "Usage: gittensory-miner state set [--dry-run] [--json]"; + "Usage: gittensory-miner state set [--api-base-url ] [--dry-run] [--json]"; const allowedRunStates = new Set(RUN_STATES); @@ -18,7 +18,7 @@ function parseRepoArg(value, usage) { } export function parseStateGetArgs(args) { - const options = { json: false }; + const options = { json: false, apiBaseUrl: undefined }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -27,6 +27,17 @@ export function parseStateGetArgs(args) { options.json = true; continue; } + // #5563: scope the lookup to a non-default forge host, so it doesn't collide with (or get confused for) a + // same-named repo on the default github.com host. + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: STATE_GET_USAGE }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -44,7 +55,7 @@ export function parseStateGetArgs(args) { } export function parseStateSetArgs(args) { - const options = { json: false, dryRun: false }; + const options = { json: false, dryRun: false, apiBaseUrl: undefined }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -58,6 +69,15 @@ export function parseStateSetArgs(args) { options.dryRun = true; continue; } + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: STATE_SET_USAGE }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -86,7 +106,7 @@ export function runStateGet(args) { } try { - const state = getRunState(parsed.repoFullName); + const state = getRunState(parsed.repoFullName, parsed.apiBaseUrl); if (parsed.json) { console.log(JSON.stringify({ repoFullName: parsed.repoFullName, state })); } else { @@ -115,7 +135,7 @@ export function runStateSet(args) { } try { - const write = setRunState(parsed.repoFullName, parsed.state); + const write = setRunState(parsed.repoFullName, parsed.state, parsed.apiBaseUrl); if (parsed.json) { console.log(JSON.stringify(write)); } else { diff --git a/packages/gittensory-miner/lib/run-state.d.ts b/packages/gittensory-miner/lib/run-state.d.ts index 01827e69e8..2446e12142 100644 --- a/packages/gittensory-miner/lib/run-state.d.ts +++ b/packages/gittensory-miner/lib/run-state.d.ts @@ -1,12 +1,14 @@ export type RunState = "idle" | "discovering" | "planning" | "preparing"; export type RunStateWrite = { + apiBaseUrl: string; repoFullName: string; state: RunState; updatedAt: string; }; export type RunStateRow = { + apiBaseUrl: string; repoFullName: string; state: RunState; updatedAt: string; @@ -14,8 +16,8 @@ export type RunStateRow = { export type RunStateStore = { dbPath: string; - getRunState(repoFullName: string): RunState | null; - setRunState(repoFullName: string, state: RunState): RunStateWrite; + getRunState(repoFullName: string, apiBaseUrl?: string): RunState | null; + setRunState(repoFullName: string, state: RunState, apiBaseUrl?: string): RunStateWrite; listRunStates(): RunStateRow[]; close(): void; }; @@ -26,9 +28,9 @@ export function resolveRunStateDbPath(env?: Record): export function initRunStateStore(dbPath?: string): RunStateStore; -export function getRunState(repoFullName: string): RunState | null; +export function getRunState(repoFullName: string, apiBaseUrl?: string): RunState | null; -export function setRunState(repoFullName: string, state: RunState): RunStateWrite; +export function setRunState(repoFullName: string, state: RunState, apiBaseUrl?: string): RunStateWrite; export function listRunStates(): RunStateRow[]; diff --git a/packages/gittensory-miner/lib/run-state.js b/packages/gittensory-miner/lib/run-state.js index f7c890100c..558e506ce7 100644 --- a/packages/gittensory-miner/lib/run-state.js +++ b/packages/gittensory-miner/lib/run-state.js @@ -1,3 +1,4 @@ +import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; @@ -28,9 +29,43 @@ function normalizeRunState(state) { throw new Error("invalid_run_state"); } +/** Optional forge host, scoping rows so two hosts serving the same owner/repo name never collide (#5563). + * Omitted/nullish → the github.com default, so every pre-existing single-forge caller is unaffected. */ +function normalizeApiBaseUrl(apiBaseUrl) { + if (apiBaseUrl === undefined || apiBaseUrl === null) return DEFAULT_FORGE_CONFIG.apiBaseUrl; + if (typeof apiBaseUrl !== "string" || !apiBaseUrl.trim()) throw new Error("invalid_api_base_url"); + return apiBaseUrl.trim(); +} + +// v1 -> v2 (#5563): rebuild the bare `repo_full_name` PRIMARY KEY into a (api_base_url, repo_full_name) composite +// -- two forge hosts serving a same-named owner/repo must not share one "current state" row. SQLite cannot ALTER +// a PRIMARY KEY in place, so this rebuilds the table: create the new shape, copy every existing row with the +// pre-#4784 implicit single-forge default backfilled, drop the old table, rename the new one in. +function addApiBaseUrlScope(db) { + db.exec(` + CREATE TABLE miner_run_state_v2 ( + api_base_url TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('idle', 'discovering', 'planning', 'preparing')), + updated_at TEXT NOT NULL, + PRIMARY KEY (api_base_url, repo_full_name) + ) + `); + // OR IGNORE: a row this store's own read path already treats as unusable garbage (an unrecognized `state`, + // e.g. from a hand-edited or otherwise corrupted file -- getRunState/listRunStates fail closed on it too) + // would violate the CHECK constraint above and abort the whole migration. Skipping it here is consistent with + // that same fail-closed posture, rather than turning one bad row into a permanently unmigratable file. + db.prepare( + `INSERT OR IGNORE INTO miner_run_state_v2 (api_base_url, repo_full_name, state, updated_at) + SELECT ?, repo_full_name, state, updated_at FROM miner_run_state`, + ).run(DEFAULT_FORGE_CONFIG.apiBaseUrl); + db.exec("DROP TABLE miner_run_state"); + db.exec("ALTER TABLE miner_run_state_v2 RENAME TO miner_run_state"); +} + /** * Opens the 100% local/client-side miner run-state store. The database only lives on this machine; - * this module never uploads, syncs, or phones home with its contents. (#2289) + * this module never uploads, syncs, or phones home with its contents. (#2289, #5563) */ export function initRunStateStore(dbPath = resolveRunStateDbPath()) { const resolvedPath = normalizeDbPath(dbPath); @@ -42,42 +77,48 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) { updated_at TEXT NOT NULL ) `); - // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). - applySchemaMigrations(db, []); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations. + applySchemaMigrations(db, [addApiBaseUrlScope]); const getStatement = db.prepare( - "SELECT state FROM miner_run_state WHERE repo_full_name = ?", + "SELECT state FROM miner_run_state WHERE api_base_url = ? AND repo_full_name = ?", ); const setStatement = db.prepare(` - INSERT INTO miner_run_state (repo_full_name, state, updated_at) - VALUES (?, ?, ?) - ON CONFLICT(repo_full_name) DO UPDATE SET + INSERT INTO miner_run_state (api_base_url, repo_full_name, state, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(api_base_url, repo_full_name) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at `); const listStatement = db.prepare( - "SELECT repo_full_name, state, updated_at FROM miner_run_state ORDER BY repo_full_name", + "SELECT api_base_url, repo_full_name, state, updated_at FROM miner_run_state ORDER BY repo_full_name", ); return { dbPath: resolvedPath, - getRunState(repoFullName) { - const row = getStatement.get(normalizeRepoFullName(repoFullName)); + getRunState(repoFullName, apiBaseUrl) { + const row = getStatement.get(normalizeApiBaseUrl(apiBaseUrl), normalizeRepoFullName(repoFullName)); return runStateSet.has(row?.state) ? row.state : null; }, - setRunState(repoFullName, state) { + setRunState(repoFullName, state, apiBaseUrl) { + const normalizedForge = normalizeApiBaseUrl(apiBaseUrl); const normalizedRepo = normalizeRepoFullName(repoFullName); const normalizedState = normalizeRunState(state); const updatedAt = new Date().toISOString(); - setStatement.run(normalizedRepo, normalizedState, updatedAt); - return { repoFullName: normalizedRepo, state: normalizedState, updatedAt }; + setStatement.run(normalizedForge, normalizedRepo, normalizedState, updatedAt); + return { apiBaseUrl: normalizedForge, repoFullName: normalizedRepo, state: normalizedState, updatedAt }; }, /** Every repo with a recorded run state, across the whole store — the per-repo discover/plan/prepare * signal a "run portfolio" view folds alongside managed PR rows (#4279). */ listRunStates() { return listStatement.all() .filter((row) => runStateSet.has(row.state)) - .map((row) => ({ repoFullName: row.repo_full_name, state: row.state, updatedAt: row.updated_at })); + .map((row) => ({ + apiBaseUrl: row.api_base_url, + repoFullName: row.repo_full_name, + state: row.state, + updatedAt: row.updated_at, + })); }, close() { db.close(); @@ -90,12 +131,12 @@ function getDefaultRunStateStore() { return defaultRunStateStore; } -export function getRunState(repoFullName) { - return getDefaultRunStateStore().getRunState(repoFullName); +export function getRunState(repoFullName, apiBaseUrl) { + return getDefaultRunStateStore().getRunState(repoFullName, apiBaseUrl); } -export function setRunState(repoFullName, state) { - return getDefaultRunStateStore().setRunState(repoFullName, state); +export function setRunState(repoFullName, state, apiBaseUrl) { + return getDefaultRunStateStore().setRunState(repoFullName, state, apiBaseUrl); } export function listRunStates() { diff --git a/test/unit/miner-cli-run-state.test.ts b/test/unit/miner-cli-run-state.test.ts index 05ffad8b45..5d8a2704e9 100644 --- a/test/unit/miner-cli-run-state.test.ts +++ b/test/unit/miner-cli-run-state.test.ts @@ -41,14 +41,50 @@ describe("gittensory-miner state CLI", () => { }); }); + it("parseStateGetArgs and parseStateSetArgs accept --api-base-url (#5563)", () => { + expect(parseStateGetArgs(["acme/widgets", "--api-base-url", "https://ghe.example.com/api/v3"])).toEqual({ + repoFullName: "acme/widgets", + json: false, + apiBaseUrl: "https://ghe.example.com/api/v3", + }); + expect(parseStateGetArgs(["acme/widgets", "--api-base-url"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner state get"), + }); + expect(parseStateSetArgs(["acme/widgets", "planning", "--api-base-url", "https://ghe.example.com/api/v3"])).toEqual({ + repoFullName: "acme/widgets", + state: "planning", + dryRun: false, + json: false, + apiBaseUrl: "https://ghe.example.com/api/v3", + }); + expect(parseStateSetArgs(["acme/widgets", "planning", "--api-base-url"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner state set"), + }); + }); + it("runStateGet prints none before any write", () => { getRunState.mockReturnValue(null); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runStateGet(["acme/widgets"])).toBe(0); - expect(getRunState).toHaveBeenCalledWith("acme/widgets"); + expect(getRunState).toHaveBeenCalledWith("acme/widgets", undefined); expect(log).toHaveBeenCalledWith("none"); }); + it("runStateGet and runStateSet thread --api-base-url through to the store (#5563)", () => { + getRunState.mockReturnValue("planning"); + setRunState.mockReturnValue({ + apiBaseUrl: "https://ghe.example.com/api/v3", + repoFullName: "acme/widgets", + state: "planning", + updatedAt: "2026-07-03T00:00:00.000Z", + }); + expect(runStateGet(["acme/widgets", "--api-base-url", "https://ghe.example.com/api/v3"])).toBe(0); + expect(getRunState).toHaveBeenCalledWith("acme/widgets", "https://ghe.example.com/api/v3"); + + expect(runStateSet(["acme/widgets", "planning", "--api-base-url", "https://ghe.example.com/api/v3"])).toBe(0); + expect(setRunState).toHaveBeenCalledWith("acme/widgets", "planning", "https://ghe.example.com/api/v3"); + }); + it("runStateSet persists state and runStateGet returns JSON output", () => { setRunState.mockReturnValue({ repoFullName: "acme/widgets", diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts index 18f8e863ba..fd04a76f78 100644 --- a/test/unit/miner-run-state.test.ts +++ b/test/unit/miner-run-state.test.ts @@ -196,7 +196,12 @@ describe("gittensory-miner run-state store (#2289)", () => { const store = initRunStateStore(dbPath); try { expect(store.listRunStates()).toEqual([ - { repoFullName: "acme/widgets", state: "planning", updatedAt: "2026-07-02T00:00:00.000Z" }, + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + state: "planning", + updatedAt: "2026-07-02T00:00:00.000Z", + }, ]); } finally { store.close(); @@ -212,4 +217,83 @@ describe("gittensory-miner run-state store (#2289)", () => { expect.objectContaining({ repoFullName: "acme/widgets", state: "preparing" }), ]); }); + + it("module-level getRunState forwards apiBaseUrl to the default store (#5563)", () => { + vi.stubEnv("GITTENSORY_MINER_RUN_STATE_DB", join(tempRoot(), "default-get.sqlite3")); + setRunState("acme/widgets", "planning", "https://ghe.example.com/api/v3"); + expect(getRunState("acme/widgets")).toBeNull(); // github.com default: no row there + expect(getRunState("acme/widgets", "https://ghe.example.com/api/v3")).toBe("planning"); + }); + + describe("forge-scoping (#5563)", () => { + it("defaults apiBaseUrl to the github.com default when omitted", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + try { + const write = store.setRunState("o/a", "idle"); + expect(write.apiBaseUrl).toBe("https://api.github.com"); + } finally { + store.close(); + } + }); + + it("two forge hosts can each hold their own current state for the same owner/repo without colliding", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + try { + store.setRunState("acme/widgets", "discovering", "https://api.github.com"); + store.setRunState("acme/widgets", "preparing", "https://ghe.example.com/api/v3"); + expect(store.getRunState("acme/widgets", "https://api.github.com")).toBe("discovering"); + expect(store.getRunState("acme/widgets", "https://ghe.example.com/api/v3")).toBe("preparing"); + expect(store.listRunStates().map((row) => row.apiBaseUrl).sort()).toEqual([ + "https://api.github.com", + "https://ghe.example.com/api/v3", + ]); + } finally { + store.close(); + } + }); + + it("rejects a non-string or blank apiBaseUrl", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + try { + expect(() => store.setRunState("o/a", "idle", " ")).toThrow("invalid_api_base_url"); + expect(() => store.getRunState("o/a", 42 as never)).toThrow("invalid_api_base_url"); + } finally { + store.close(); + } + }); + + it("migrates an existing pre-#5563 file, backfilling api_base_url and preserving every row", () => { + const dbPath = join(tempRoot(), "legacy.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_run_state ( + repo_full_name TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK (state IN ('idle', 'discovering', 'planning', 'preparing')), + updated_at TEXT NOT NULL + ) + `); + legacy.exec( + "INSERT INTO miner_run_state (repo_full_name, state, updated_at) VALUES ('acme/widgets', 'planning', '2026-01-01T00:00:00.000Z')", + ); + legacy.close(); + + const store = initRunStateStore(dbPath); + try { + expect(store.listRunStates()).toEqual([ + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + state: "planning", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + // The old bare repo_full_name PRIMARY KEY collision is gone: a second host can now hold its own state. + const geWrite = store.setRunState("acme/widgets", "preparing", "https://ghe.example.com/api/v3"); + expect(store.listRunStates()).toHaveLength(2); + expect(geWrite.apiBaseUrl).toBe("https://ghe.example.com/api/v3"); + } finally { + store.close(); + } + }); + }); });