Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/gittensory-miner/lib/manage-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/run-state-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type ParsedStateGetArgs =
| {
repoFullName: string;
json: boolean;
apiBaseUrl: string | undefined;
}
| { error: string };

Expand All @@ -11,6 +12,7 @@ export type ParsedStateSetArgs =
state: "idle" | "discovering" | "planning" | "preparing";
dryRun: boolean;
json: boolean;
apiBaseUrl: string | undefined;
}
| { error: string };

Expand Down
32 changes: 26 additions & 6 deletions packages/gittensory-miner/lib/run-state-cli.js
Original file line number Diff line number Diff line change
@@ -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 <owner/repo> [--json]";
const STATE_GET_USAGE = "Usage: gittensory-miner state get <owner/repo> [--api-base-url <url>] [--json]";
const STATE_SET_USAGE =
"Usage: gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--dry-run] [--json]";
"Usage: gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--api-base-url <url>] [--dry-run] [--json]";

const allowedRunStates = new Set(RUN_STATES);

Expand All @@ -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) {
Expand All @@ -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}` };
}
Expand All @@ -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) {
Expand All @@ -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}` };
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 6 additions & 4 deletions packages/gittensory-miner/lib/run-state.d.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
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;
};

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;
};
Expand All @@ -26,9 +28,9 @@ export function resolveRunStateDbPath(env?: Record<string, string | undefined>):

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[];

Expand Down
77 changes: 59 additions & 18 deletions packages/gittensory-miner/lib/run-state.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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() {
Expand Down
38 changes: 37 additions & 1 deletion test/unit/miner-cli-run-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading