diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.d.ts b/packages/gittensory-miner/lib/claim-ledger-cli.d.ts index ea89f7b2ab..05b9254760 100644 --- a/packages/gittensory-miner/lib/claim-ledger-cli.d.ts +++ b/packages/gittensory-miner/lib/claim-ledger-cli.d.ts @@ -7,6 +7,7 @@ export type ParsedClaimClaimArgs = note: string | undefined; dryRun: boolean; json: boolean; + apiBaseUrl: string | undefined; } | { error: string }; @@ -16,6 +17,7 @@ export type ParsedClaimReleaseArgs = issueNumber: number; dryRun: boolean; json: boolean; + apiBaseUrl: string | undefined; } | { error: string }; diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.js b/packages/gittensory-miner/lib/claim-ledger-cli.js index ce16350a0f..0aad188880 100644 --- a/packages/gittensory-miner/lib/claim-ledger-cli.js +++ b/packages/gittensory-miner/lib/claim-ledger-cli.js @@ -2,8 +2,9 @@ import { CLAIM_STATUSES, openClaimLedger } from "./claim-ledger.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const CLAIM_CLAIM_USAGE = - "Usage: gittensory-miner claim claim [--note ] [--dry-run] [--json]"; -const CLAIM_RELEASE_USAGE = "Usage: gittensory-miner claim release [--dry-run] [--json]"; + "Usage: gittensory-miner claim claim [--note ] [--api-base-url ] [--dry-run] [--json]"; +const CLAIM_RELEASE_USAGE = + "Usage: gittensory-miner claim release [--api-base-url ] [--dry-run] [--json]"; const CLAIM_LIST_USAGE = "Usage: gittensory-miner claim list [--repo ] [--status active|released|expired] [--json]"; @@ -27,7 +28,7 @@ function parseIssueNumberArg(value, usage) { } export function parseClaimClaimArgs(args) { - const options = { json: false, note: undefined, dryRun: false }; + const options = { json: false, note: undefined, dryRun: false, apiBaseUrl: undefined }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -50,6 +51,17 @@ export function parseClaimClaimArgs(args) { index += 1; continue; } + // #5563: scope the claim 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: CLAIM_CLAIM_USAGE }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -71,14 +83,16 @@ export function parseClaimClaimArgs(args) { note: options.note, dryRun: options.dryRun, json: options.json, + apiBaseUrl: options.apiBaseUrl, }; } export function parseClaimReleaseArgs(args) { - const options = { json: false, dryRun: false }; + const options = { json: false, dryRun: false, apiBaseUrl: undefined }; const positional = []; - for (const token of args) { + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; if (token === "--json") { options.json = true; continue; @@ -87,6 +101,15 @@ export function parseClaimReleaseArgs(args) { options.dryRun = true; continue; } + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: CLAIM_RELEASE_USAGE }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -107,6 +130,7 @@ export function parseClaimReleaseArgs(args) { issueNumber: issue.issueNumber, dryRun: options.dryRun, json: options.json, + apiBaseUrl: options.apiBaseUrl, }; } @@ -216,6 +240,7 @@ export function runClaimClaim(args, options = {}) { parsed.repoFullName, parsed.issueNumber, parsed.note, + parsed.apiBaseUrl, ); if (parsed.json) { console.log(JSON.stringify({ claim }, null, 2)); @@ -247,7 +272,7 @@ export function runClaimRelease(args, options = {}) { try { return withClaimLedger(options, (claimLedger) => { - const claim = claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); + const claim = claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber, parsed.apiBaseUrl); if (!claim) { return reportCliFailure(parsed.json, "claim_not_found"); } diff --git a/packages/gittensory-miner/lib/claim-ledger-expiry.js b/packages/gittensory-miner/lib/claim-ledger-expiry.js index 8866e28fd8..7455340a20 100644 --- a/packages/gittensory-miner/lib/claim-ledger-expiry.js +++ b/packages/gittensory-miner/lib/claim-ledger-expiry.js @@ -32,7 +32,9 @@ export function sweepExpiredClaims(store, nowMs, maxAgeMs = DEFAULT_MAX_CLAIM_AG const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); const transitioned = []; for (const claim of expired) { - const updated = store.expireClaim(claim.repoFullName, claim.issueNumber); + // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. + const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); if (updated) transitioned.push(updated); } return transitioned; diff --git a/packages/gittensory-miner/lib/claim-ledger.d.ts b/packages/gittensory-miner/lib/claim-ledger.d.ts index 3077e70e39..c18d3e5499 100644 --- a/packages/gittensory-miner/lib/claim-ledger.d.ts +++ b/packages/gittensory-miner/lib/claim-ledger.d.ts @@ -2,6 +2,7 @@ export type ClaimStatus = "active" | "released" | "expired"; export type ClaimEntry = { id: number; + apiBaseUrl: string; repoFullName: string; issueNumber: number; claimedAt: string; @@ -13,6 +14,7 @@ export type RecordClaimInput = { repoFullName: string; issueNumber: number; note?: string; + apiBaseUrl?: string; }; export type ListClaimsFilter = { @@ -23,9 +25,9 @@ export type ListClaimsFilter = { export type ClaimLedger = { dbPath: string; recordClaim(claim: RecordClaimInput): ClaimEntry; - claimIssue(repoFullName: string, issueNumber: number, note?: string): ClaimEntry; - releaseClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; - expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; + claimIssue(repoFullName: string, issueNumber: number, note?: string, apiBaseUrl?: string): ClaimEntry; + releaseClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; + expireClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; listClaims(filter?: ListClaimsFilter): ClaimEntry[]; listActiveClaims(repoFullName?: string): ClaimEntry[]; purgeByRepo(repoFullName: string): number; @@ -48,13 +50,13 @@ export function openClaimLedgerReadOnly(dbPath: string): ReadOnlyClaimLedger; export function recordClaim(claim: RecordClaimInput): ClaimEntry; -export function releaseClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; +export function releaseClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; -export function expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; +export function expireClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; export function listClaims(filter?: ListClaimsFilter): ClaimEntry[]; -export function claimIssue(repoFullName: string, issueNumber: number, note?: string): ClaimEntry; +export function claimIssue(repoFullName: string, issueNumber: number, note?: string, apiBaseUrl?: string): ClaimEntry; export function listActiveClaims(repoFullName?: string): ClaimEntry[]; diff --git a/packages/gittensory-miner/lib/claim-ledger.js b/packages/gittensory-miner/lib/claim-ledger.js index f70d555c6d..c9e1a7267f 100644 --- a/packages/gittensory-miner/lib/claim-ledger.js +++ b/packages/gittensory-miner/lib/claim-ledger.js @@ -1,4 +1,5 @@ import { DatabaseSync } from "node:sqlite"; +import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; import { CLAIM_LEDGER_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; @@ -34,6 +35,14 @@ function normalizeIssueNumber(issueNumber) { return issueNumber; } +/** 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(); +} + /** Optional free-text note: omitted/nullish → null; a string is kept as-is; anything else is rejected. */ function normalizeNote(note) { if (note === undefined || note === null) return null; @@ -44,6 +53,7 @@ function normalizeNote(note) { function rowToClaim(row) { return { id: row.id, + apiBaseUrl: row.api_base_url, repoFullName: row.repo_full_name, issueNumber: row.issue_number, claimedAt: row.claimed_at, @@ -52,10 +62,37 @@ function rowToClaim(row) { }; } +// v1 -> v2 (#5563): scope the UNIQUE constraint by (api_base_url, repo_full_name, issue_number) instead of bare +// (repo_full_name, issue_number) -- two different forge hosts serving a same-named repo/issue must not collide +// in this ledger. SQLite cannot ALTER a UNIQUE constraint 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. Runs inside applySchemaMigrations' own transaction, so a mid-rebuild failure +// leaves the file at v1 and retries cleanly on next open. +function addApiBaseUrlScope(db) { + db.exec(` + CREATE TABLE miner_claims_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_base_url TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + claimed_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'expired')), + note TEXT, + UNIQUE (api_base_url, repo_full_name, issue_number) + ) + `); + db.prepare( + `INSERT INTO miner_claims_v2 (id, api_base_url, repo_full_name, issue_number, claimed_at, status, note) + SELECT id, ?, repo_full_name, issue_number, claimed_at, status, note FROM miner_claims`, + ).run(DEFAULT_FORGE_CONFIG.apiBaseUrl); + db.exec("DROP TABLE miner_claims"); + db.exec("ALTER TABLE miner_claims_v2 RENAME TO miner_claims"); +} + /** - * Opens the local claim ledger, creating the table on first use. `UNIQUE(repo_full_name, issue_number)` keeps ONE - * row per claimed issue, and `recordClaim` is a single atomic INSERT…ON CONFLICT statement (no read-then-write), so - * concurrent claims cannot duplicate a row. (#2314) + * Opens the local claim ledger, creating the table on first use. `UNIQUE(api_base_url, repo_full_name, + * issue_number)` keeps ONE row per claimed issue per forge host, and `recordClaim` is a single atomic + * INSERT…ON CONFLICT statement (no read-then-write), so concurrent claims cannot duplicate a row. (#2314, #5563) */ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) { const resolvedPath = normalizeDbPath(dbPath); @@ -74,29 +111,33 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) { UNIQUE (repo_full_name, issue_number) ) `); - // 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]); // Idempotent claim in ONE atomic statement: insert a new active claim, or — only if the existing row is NOT // already active — re-activate it (a released/expired claim can be re-claimed). The `WHERE status <> 'active'` // guard makes re-claiming an already-active issue a true no-op (no row churn), never a duplicate row. const recordStatement = db.prepare(` - INSERT INTO miner_claims (repo_full_name, issue_number, claimed_at, status, note) - VALUES (?, ?, ?, 'active', ?) - ON CONFLICT(repo_full_name, issue_number) DO UPDATE SET + INSERT INTO miner_claims (api_base_url, repo_full_name, issue_number, claimed_at, status, note) + VALUES (?, ?, ?, ?, 'active', ?) + ON CONFLICT(api_base_url, repo_full_name, issue_number) DO UPDATE SET claimed_at = excluded.claimed_at, note = excluded.note, status = 'active' WHERE miner_claims.status <> 'active' `); const getStatement = db.prepare( - "SELECT * FROM miner_claims WHERE repo_full_name = ? AND issue_number = ?", + "SELECT * FROM miner_claims WHERE api_base_url = ? AND repo_full_name = ? AND issue_number = ?", ); + // RETURNING (matching portfolio-queue.js's own claim/release statements) makes the "nothing to release/expire" + // case observable directly from ONE atomic statement, rather than a separate post-UPDATE SELECT whose "row + // went missing" branch would be structurally unreachable (nothing else runs between the UPDATE and a SELECT + // on the same key within one synchronous call). const releaseStatement = db.prepare( - "UPDATE miner_claims SET status = 'released' WHERE repo_full_name = ? AND issue_number = ? AND status = 'active'", + "UPDATE miner_claims SET status = 'released' WHERE api_base_url = ? AND repo_full_name = ? AND issue_number = ? AND status = 'active' RETURNING *", ); const expireStatement = db.prepare( - "UPDATE miner_claims SET status = 'expired' WHERE repo_full_name = ? AND issue_number = ? AND status = 'active'", + "UPDATE miner_claims SET status = 'expired' WHERE api_base_url = ? AND repo_full_name = ? AND issue_number = ? AND status = 'active' RETURNING *", ); const listAllStatement = db.prepare("SELECT * FROM miner_claims ORDER BY id ASC"); const listRepoStatement = db.prepare( @@ -123,27 +164,26 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) { const ledger = { dbPath: resolvedPath, recordClaim(claim) { + const apiBaseUrl = normalizeApiBaseUrl(claim?.apiBaseUrl); const repoFullName = normalizeRepoFullName(claim?.repoFullName); const issueNumber = normalizeIssueNumber(claim?.issueNumber); const note = normalizeNote(claim?.note); const claimedAt = new Date().toISOString(); - recordStatement.run(repoFullName, issueNumber, claimedAt, note); - return rowToClaim(getStatement.get(repoFullName, issueNumber)); + recordStatement.run(apiBaseUrl, repoFullName, issueNumber, claimedAt, note); + return rowToClaim(getStatement.get(apiBaseUrl, repoFullName, issueNumber)); }, - releaseClaim(repoFullName, issueNumber) { + releaseClaim(repoFullName, issueNumber, apiBaseUrl) { + const normalizedForge = normalizeApiBaseUrl(apiBaseUrl); const normalizedRepo = normalizeRepoFullName(repoFullName); const normalizedIssue = normalizeIssueNumber(issueNumber); - const result = releaseStatement.run(normalizedRepo, normalizedIssue); - if (result.changes === 0) return null; - const row = getStatement.get(normalizedRepo, normalizedIssue); + const row = releaseStatement.get(normalizedForge, normalizedRepo, normalizedIssue); return row ? rowToClaim(row) : null; }, - expireClaim(repoFullName, issueNumber) { + expireClaim(repoFullName, issueNumber, apiBaseUrl) { + const normalizedForge = normalizeApiBaseUrl(apiBaseUrl); const normalizedRepo = normalizeRepoFullName(repoFullName); const normalizedIssue = normalizeIssueNumber(issueNumber); - const result = expireStatement.run(normalizedRepo, normalizedIssue); - if (result.changes === 0) return null; - const row = getStatement.get(normalizedRepo, normalizedIssue); + const row = expireStatement.get(normalizedForge, normalizedRepo, normalizedIssue); return row ? rowToClaim(row) : null; }, listClaims(filter = {}) { @@ -162,8 +202,8 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) { } return rows.map(rowToClaim); }, - claimIssue(repoFullName, issueNumber, note) { - return ledger.recordClaim({ repoFullName, issueNumber, note }); + claimIssue(repoFullName, issueNumber, note, apiBaseUrl) { + return ledger.recordClaim({ repoFullName, issueNumber, note, apiBaseUrl }); }, listActiveClaims(repoFullName) { const filter = { status: "active" }; @@ -230,21 +270,21 @@ export function recordClaim(claim) { return getDefaultClaimLedger().recordClaim(claim); } -export function releaseClaim(repoFullName, issueNumber) { - return getDefaultClaimLedger().releaseClaim(repoFullName, issueNumber); +export function releaseClaim(repoFullName, issueNumber, apiBaseUrl) { + return getDefaultClaimLedger().releaseClaim(repoFullName, issueNumber, apiBaseUrl); } -export function expireClaim(repoFullName, issueNumber) { - return getDefaultClaimLedger().expireClaim(repoFullName, issueNumber); +export function expireClaim(repoFullName, issueNumber, apiBaseUrl) { + return getDefaultClaimLedger().expireClaim(repoFullName, issueNumber, apiBaseUrl); } export function listClaims(filter) { return getDefaultClaimLedger().listClaims(filter); } -/** Foundation-phase alias for `recordClaim({ repoFullName, issueNumber, note })`. (#3351) */ -export function claimIssue(repoFullName, issueNumber, note) { - return getDefaultClaimLedger().claimIssue(repoFullName, issueNumber, note); +/** Foundation-phase alias for `recordClaim({ repoFullName, issueNumber, note, apiBaseUrl })`. (#3351) */ +export function claimIssue(repoFullName, issueNumber, note, apiBaseUrl) { + return getDefaultClaimLedger().claimIssue(repoFullName, issueNumber, note, apiBaseUrl); } /** List only `active` claims, optionally scoped to one repo. (#3351) */ diff --git a/test/unit/miner-claim-ledger-cli.test.ts b/test/unit/miner-claim-ledger-cli.test.ts index 024c5eb411..0ad13e4fb6 100644 --- a/test/unit/miner-claim-ledger-cli.test.ts +++ b/test/unit/miner-claim-ledger-cli.test.ts @@ -105,10 +105,36 @@ describe("gittensory-miner claim ledger CLI (#4290)", () => { }); }); + it("parseClaimClaimArgs and parseClaimReleaseArgs accept --api-base-url (#5563)", () => { + expect(parseClaimClaimArgs(["acme/widgets", "42", "--api-base-url", "https://ghe.example.com/api/v3"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 42, + note: undefined, + dryRun: false, + json: false, + apiBaseUrl: "https://ghe.example.com/api/v3", + }); + expect(parseClaimClaimArgs(["acme/widgets", "42", "--api-base-url"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner claim claim"), + }); + + expect(parseClaimReleaseArgs(["acme/widgets", "7", "--api-base-url", "https://ghe.example.com/api/v3"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 7, + dryRun: false, + json: false, + apiBaseUrl: "https://ghe.example.com/api/v3", + }); + expect(parseClaimReleaseArgs(["acme/widgets", "7", "--api-base-url"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner claim release"), + }); + }); + it("renderClaimsTable formats claim rows and empty output", () => { const entries: ClaimEntry[] = [ { id: 1, + apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", issueNumber: 7, status: "active", @@ -157,6 +183,36 @@ describe("gittensory-miner claim ledger CLI (#4290)", () => { }); }); + it("runClaimClaim and runClaimRelease thread --api-base-url through, so two hosts don't collide (#5563)", () => { + const claimLedger = tempClaimLedger(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + runClaimClaim(["acme/widgets", "1", "--api-base-url", "https://api.github.com"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect( + runClaimClaim(["acme/widgets", "1", "--api-base-url", "https://ghe.example.com/api/v3"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect(claimLedger.listClaims({ repoFullName: "acme/widgets" })).toHaveLength(2); + + // Releasing the GHE host's claim must not touch the github.com host's claim. + log.mockClear(); + expect( + runClaimRelease(["acme/widgets", "1", "--api-base-url", "https://ghe.example.com/api/v3", "--json"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + claim: expect.objectContaining({ apiBaseUrl: "https://ghe.example.com/api/v3", status: "released" }), + }); + const active = claimLedger.listClaims({ repoFullName: "acme/widgets", status: "active" }); + expect(active).toEqual([expect.objectContaining({ apiBaseUrl: "https://api.github.com" })]); + }); + it("#4847: --dry-run reports what would happen and returns 0 without opening the claim ledger", () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); const openClaimLedgerSpy = vi.fn(); diff --git a/test/unit/miner-claim-ledger-expiry.test.ts b/test/unit/miner-claim-ledger-expiry.test.ts index b856b49e72..12ca952421 100644 --- a/test/unit/miner-claim-ledger-expiry.test.ts +++ b/test/unit/miner-claim-ledger-expiry.test.ts @@ -26,6 +26,7 @@ function tempLedger() { function claim(overrides: Record = {}) { return { id: 1, + apiBaseUrl: "https://api.github.com", repoFullName: "o/a", issueNumber: 1, claimedAt: "2026-01-01T00:00:00.000Z", @@ -114,4 +115,24 @@ describe("gittensory-miner claim ledger expiry (#2316)", () => { expect(ledger.expireClaim("o/a", 9)).toBeNull(); expect(ledger.expireClaim("o/a", 404)).toBeNull(); }); + + it("REGRESSION: sweepExpiredClaims echoes each claim's own apiBaseUrl, so it can't expire the wrong host's row (#5563)", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + const ledger = tempLedger(); + const maxAgeMs = 7 * 24 * 60 * 60 * 1000; + + // Two forge hosts each claim the SAME repo/issue pair -- only possible post-#5563's scoped uniqueness. The + // GHE claim is recorded first (stale by nowMs); the github.com claim is recorded a week later (still fresh). + ledger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1, apiBaseUrl: "https://ghe.example.com/api/v3" }); + vi.setSystemTime(new Date("2026-06-08T00:00:00.000Z")); + ledger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1, apiBaseUrl: "https://api.github.com" }); + + const nowMs = Date.parse("2026-06-09T00:00:00.000Z"); + const transitioned = sweepExpiredClaims(ledger, nowMs, maxAgeMs); + expect(transitioned).toEqual([expect.objectContaining({ apiBaseUrl: "https://ghe.example.com/api/v3", status: "expired" })]); + + const active = ledger.listClaims({ repoFullName: "acme/widgets", status: "active" }); + expect(active).toEqual([expect.objectContaining({ apiBaseUrl: "https://api.github.com" })]); + }); }); diff --git a/test/unit/miner-claim-ledger.test.ts b/test/unit/miner-claim-ledger.test.ts index 8ba2e11071..a0db7d529d 100644 --- a/test/unit/miner-claim-ledger.test.ts +++ b/test/unit/miner-claim-ledger.test.ts @@ -7,9 +7,13 @@ import { CLAIM_STATUSES, claimIssue, closeDefaultClaimLedger, + expireClaim, listActiveClaims, + listClaims, openClaimLedger, openClaimLedgerReadOnly, + recordClaim, + releaseClaim, resolveClaimLedgerDbPath, } from "../../packages/gittensory-miner/lib/claim-ledger.js"; @@ -178,20 +182,34 @@ describe("gittensory-miner claim ledger (#2314)", () => { expect(listActiveClaims("o/missing")).toEqual([]); }); - it("creates miner_claims with the foundation schema (#3352)", () => { + it("top-level recordClaim, releaseClaim, expireClaim, and listClaims use the default ledger store, forwarding apiBaseUrl (#5563)", () => { + const root = tempRoot(); + vi.stubEnv("GITTENSORY_MINER_CLAIM_LEDGER_DB", join(root, "claim-ledger.sqlite3")); + closeDefaultClaimLedger(); + const ghClaim = recordClaim({ repoFullName: "o/a", issueNumber: 5, apiBaseUrl: "https://api.github.com" }); + const geClaim = recordClaim({ repoFullName: "o/a", issueNumber: 5, apiBaseUrl: "https://ghe.example.com/api/v3" }); + expect(listClaims({ repoFullName: "o/a" })).toEqual([ghClaim, geClaim]); + + expect(releaseClaim("o/a", 5, "https://api.github.com")?.status).toBe("released"); + expect(expireClaim("o/a", 5, "https://ghe.example.com/api/v3")?.status).toBe("expired"); + expect(listClaims({ repoFullName: "o/a", status: "active" })).toEqual([]); + }); + + it("creates miner_claims with the foundation schema, forge-scoped (#3352, #5563)", () => { const ledger = tempLedger(); const db = new DatabaseSync(ledger.dbPath, { readOnly: true }); type TableColumn = { name: string; notnull: number; dflt_value: string | null; pk: number }; const columns = db.prepare("PRAGMA table_info(miner_claims)").all() as TableColumn[]; expect(columns.map((column) => column.name)).toEqual([ "id", + "api_base_url", "repo_full_name", "issue_number", "claimed_at", "status", "note", ]); - for (const name of ["repo_full_name", "issue_number", "claimed_at", "status"]) { + for (const name of ["api_base_url", "repo_full_name", "issue_number", "claimed_at", "status"]) { expect(columns.find((column) => column.name === name)?.notnull).toBe(1); } expect(columns.find((column) => column.name === "status")?.dflt_value).toBe("'active'"); @@ -201,18 +219,105 @@ describe("gittensory-miner claim ledger (#2314)", () => { .filter((index) => index.unique === 1); expect(uniqueIndexes.length).toBeGreaterThan(0); const indexCols = db.prepare(`PRAGMA index_info('${uniqueIndexes[0]!.name}')`).all() as Array<{ name: string }>; - expect(indexCols.map((column) => column.name).sort()).toEqual(["issue_number", "repo_full_name"]); + expect(indexCols.map((column) => column.name).sort()).toEqual(["api_base_url", "issue_number", "repo_full_name"]); db.close(); const writable = new DatabaseSync(ledger.dbPath); expect(() => writable.exec( - "INSERT INTO miner_claims (repo_full_name, issue_number, claimed_at, status) VALUES ('o/a', 1, '2026-01-01T00:00:00.000Z', 'bogus')", + "INSERT INTO miner_claims (api_base_url, repo_full_name, issue_number, claimed_at, status) VALUES ('https://api.github.com', 'o/a', 1, '2026-01-01T00:00:00.000Z', 'bogus')", ), ).toThrow(); writable.close(); }); + describe("forge-scoping (#5563)", () => { + it("defaults apiBaseUrl to the github.com default when omitted", () => { + const ledger = tempLedger(); + const claim = ledger.recordClaim({ repoFullName: "o/a", issueNumber: 1 }); + expect(claim.apiBaseUrl).toBe("https://api.github.com"); + }); + + it("two forge hosts can each hold an active claim on the same owner/repo#issue without colliding", () => { + const ledger = tempLedger(); + const ghClaim = ledger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1, apiBaseUrl: "https://api.github.com" }); + const geClaim = ledger.recordClaim({ + repoFullName: "acme/widgets", + issueNumber: 1, + apiBaseUrl: "https://ghe.example.com/api/v3", + }); + expect(ghClaim.id).not.toBe(geClaim.id); + expect(ledger.listClaims({ repoFullName: "acme/widgets" })).toHaveLength(2); + + // Releasing one host's claim leaves the other host's claim active. + expect(ledger.releaseClaim("acme/widgets", 1, "https://api.github.com")?.status).toBe("released"); + const remaining = ledger.listClaims({ repoFullName: "acme/widgets", status: "active" }); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.apiBaseUrl).toBe("https://ghe.example.com/api/v3"); + }); + + it("expireClaim is scoped by apiBaseUrl too, not just repo+issue", () => { + const ledger = tempLedger(); + ledger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1, apiBaseUrl: "https://api.github.com" }); + ledger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1, apiBaseUrl: "https://ghe.example.com/api/v3" }); + expect(ledger.expireClaim("acme/widgets", 1, "https://ghe.example.com/api/v3")?.apiBaseUrl).toBe( + "https://ghe.example.com/api/v3", + ); + expect(ledger.listClaims({ repoFullName: "acme/widgets", status: "active" })).toHaveLength(1); + expect(ledger.listClaims({ repoFullName: "acme/widgets", status: "expired" })).toHaveLength(1); + }); + + it("rejects a non-string or blank apiBaseUrl", () => { + const ledger = tempLedger(); + expect(() => ledger.recordClaim({ repoFullName: "o/a", issueNumber: 1, apiBaseUrl: " " })).toThrow( + "invalid_api_base_url", + ); + expect(() => ledger.recordClaim({ repoFullName: "o/a", issueNumber: 1, apiBaseUrl: 42 as never })).toThrow( + "invalid_api_base_url", + ); + }); + + it("migrates an existing pre-#5563 file, backfilling api_base_url and preserving every row", () => { + const root = tempRoot(); + const dbPath = join(root, "legacy.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + claimed_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'expired')), + note TEXT, + UNIQUE (repo_full_name, issue_number) + ) + `); + legacy.exec( + "INSERT INTO miner_claims (repo_full_name, issue_number, claimed_at, status, note) VALUES ('acme/widgets', 5, '2026-01-01T00:00:00.000Z', 'active', 'pre-migration')", + ); + legacy.close(); + + const ledger = openClaimLedger(dbPath); + ledgers.push(ledger); + const claims = ledger.listClaims(); + expect(claims).toEqual([ + { + id: 1, + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + issueNumber: 5, + claimedAt: "2026-01-01T00:00:00.000Z", + status: "active", + note: "pre-migration", + }, + ]); + // The old bare (repo_full_name, issue_number) collision is gone: a second host can now claim the same pair. + const geClaim = ledger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 5, apiBaseUrl: "https://ghe.example.com/api/v3" }); + expect(ledger.listClaims({ repoFullName: "acme/widgets" })).toHaveLength(2); + expect(geClaim.apiBaseUrl).toBe("https://ghe.example.com/api/v3"); + }); + }); + describe("purgeByRepo (#5564)", () => { it("deletes every claim for one repo, across all statuses, and leaves other repos untouched", () => { const ledger = tempLedger(); @@ -253,6 +358,7 @@ describe("gittensory-miner claim ledger (#2314)", () => { expect(readOnly.listActiveClaims("acme/widgets")).toEqual([ { id: expect.any(Number), + apiBaseUrl: "https://api.github.com", repoFullName: "acme/widgets", issueNumber: 42, claimedAt: expect.any(String),