From b3deae0c04fb24a498474f46acfe2b6edb87e390 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:11:05 -0700 Subject: [PATCH 1/2] feat(miner-governor): late-binding freshness check before open_pr fires (#3007) Adds checkSubmissionFreshness: a final, read-only check immediately before open_pr spec construction, complementing the claim-time check. Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the live issue/PR snapshot, aborting before the Governor chokepoint is ever invoked for a stale candidate: - claim_superseded: the local claim row is missing or not active - issue_closed: the live issue snapshot shows it closed - already_addressed: another author already has an open or merged PR referencing the issue (the miner's own referencing PRs don't count) - live_state_unavailable: the injected fetch failed or threw -- fails closed, never treated as "no evidence of staleness, so proceed" Logs the abort reason to the event ledger (not every check, matching the issue's own "log the abort reason" wording); the fresh path never writes. This is a standalone, composable unit -- the freshness check, prepareOpenPrSubmission (#2337), and the Governor chokepoint are separate, sequentially-composed steps a future real call site wires together, not nested calls. Closes #3007. --- .../lib/submission-freshness-check.d.ts | 32 +++ .../lib/submission-freshness-check.js | 89 ++++++++ packages/gittensory-miner/package.json | 2 +- .../miner-submission-freshness-check.test.ts | 214 ++++++++++++++++++ 4 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/submission-freshness-check.d.ts create mode 100644 packages/gittensory-miner/lib/submission-freshness-check.js create mode 100644 test/unit/miner-submission-freshness-check.test.ts diff --git a/packages/gittensory-miner/lib/submission-freshness-check.d.ts b/packages/gittensory-miner/lib/submission-freshness-check.d.ts new file mode 100644 index 000000000..725660294 --- /dev/null +++ b/packages/gittensory-miner/lib/submission-freshness-check.d.ts @@ -0,0 +1,32 @@ +export const SUBMISSION_FRESHNESS_ABORT_EVENT: "submission_freshness_abort"; + +export type FreshnessAbortReason = "issue_closed" | "already_addressed" | "claim_superseded" | "live_state_unavailable"; + +export type SubmissionFreshnessCandidate = { + repoFullName: string; + issueNumber: number; + minerLogin: string; +}; + +export type LiveIssueSnapshot = { + state: "open" | "closed"; + referencingPrs: Array<{ number: number; state: "open" | "closed" | "merged"; authorLogin: string }>; +}; + +export type SubmissionFreshnessClaimLedger = { + listClaims(filter: { repoFullName?: string; status?: string }): Array<{ repoFullName: string; issueNumber: number; status: string }>; +}; + +export type SubmissionFreshnessEventLedger = { + appendEvent(event: { type: string; repoFullName?: string; payload: Record }): unknown; +}; + +export type SubmissionFreshnessDeps = { + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: SubmissionFreshnessEventLedger; +}; + +export type SubmissionFreshnessResult = { fresh: true } | { fresh: false; reason: FreshnessAbortReason }; + +export function checkSubmissionFreshness(candidate: SubmissionFreshnessCandidate, deps: SubmissionFreshnessDeps): Promise; diff --git a/packages/gittensory-miner/lib/submission-freshness-check.js b/packages/gittensory-miner/lib/submission-freshness-check.js new file mode 100644 index 000000000..a48bf2e72 --- /dev/null +++ b/packages/gittensory-miner/lib/submission-freshness-check.js @@ -0,0 +1,89 @@ +// Late-binding freshness check before open_pr fires (#3007). A soft-claim made at the start of a long +// create/iterate loop can go stale by the time a candidate reaches submission: the target issue may have been +// closed, already fixed by another author, or the miner's own claim may have been released/expired in the +// interim. This is a FINAL, read-only check immediately before open_pr spec construction -- complementing, not +// replacing, the claim-time check (src/miner/soft-claim.ts) -- so a stale submission never reaches the Governor +// chokepoint (governor-chokepoint.js) as a live write attempt: check freshness first, THEN prepareOpenPrSubmission +// (harness-submission-trigger.js), THEN the Governor. These are separate, sequentially-composed units, not nested +// calls -- a future real call site wires them together in that order. +// +// READ-ONLY BY CONTRACT: never writes anything except its own abort-reason audit event (on staleness only, not +// on every check -- mirrors this issue's own "log the abort reason" wording, not a per-decision audit trail). +// The live-state fetch is an injected dependency so this stays testable without real network I/O and agnostic +// to HOW the caller sources issue/PR state (raw GitHub API, gittensory's own cached MCP data, etc.). +// +// FAIL CLOSED: an unreachable/failed live-state fetch is treated as stale (aborts), never as "no evidence of +// staleness, so proceed" -- mirrors this package's fail-closed convention elsewhere (harness-submission- +// trigger.js's predicted_gate_unavailable/slop_assessment_unavailable, iterate-loop.ts's ambiguous-on-error). +// +// NOT a rejection outcome: staleness is caught BEFORE any PR exists, so it is not the same lifecycle event as +// rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR, +// no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate +// decision in this package -- never throw, never surface anything to the target repo. + +export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort"; + +/** + * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr. + * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the + * live issue/PR snapshot. Fails closed (throws) on a malformed candidate or missing dependency. + * + * @param {{ repoFullName: string, issueNumber: number, minerLogin: string }} candidate + * @param {{ + * claimLedger: { listClaims(filter: { repoFullName?: string, status?: string }): Array<{ repoFullName: string, issueNumber: number, status: string }> }, + * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<{ state: "open"|"closed", referencingPrs: Array<{ number: number, state: "open"|"closed"|"merged", authorLogin: string }> } | null>, + * eventLedger: { appendEvent(event: { type: string, repoFullName?: string, payload: Record }): unknown }, + * }} deps + */ +export async function checkSubmissionFreshness(candidate, deps) { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_freshness_candidate"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) throw new Error("invalid_repo_full_name"); + if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) throw new Error("invalid_issue_number"); + const minerLogin = typeof candidate.minerLogin === "string" ? candidate.minerLogin.trim() : ""; + if (!minerLogin) throw new Error("invalid_miner_login"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_freshness_deps"); + const { claimLedger, fetchLiveIssueSnapshot, eventLedger } = deps; + if (!claimLedger || typeof claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); + if (typeof fetchLiveIssueSnapshot !== "function") throw new Error("invalid_live_state_fetcher"); + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + + const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber); + if (!claim || claim.status !== "active") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded"); + } + + let snapshot; + try { + snapshot = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber); + } catch { + snapshot = null; + } + if (!snapshot || typeof snapshot !== "object") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable"); + } + + if (snapshot.state === "closed") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed"); + } + + const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : []; + const addressedByAnotherAuthor = referencingPrs.some( + (pr) => pr.authorLogin !== minerLogin && (pr.state === "merged" || pr.state === "open"), + ); + if (addressedByAnotherAuthor) { + return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed"); + } + + return { fresh: true }; +} + +function abort(eventLedger, repoFullName, issueNumber, reason) { + eventLedger.appendEvent({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName, + payload: { issueNumber, reason }, + }); + return { fresh: false, reason }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 47d4491cd..92c6ce731 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-submission-freshness-check.test.ts b/test/unit/miner-submission-freshness-check.test.ts new file mode 100644 index 000000000..3df6d560a --- /dev/null +++ b/test/unit/miner-submission-freshness-check.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; + +import { checkSubmissionFreshness, SUBMISSION_FRESHNESS_ABORT_EVENT } from "../../packages/gittensory-miner/lib/submission-freshness-check.js"; + +function stubClaimLedger(claims: Array<{ repoFullName: string; issueNumber: number; status: string }> = []) { + const listClaims = vi.fn((filter: { repoFullName?: string; status?: string }) => + claims.filter((c) => (filter.repoFullName === undefined || c.repoFullName === filter.repoFullName) && (filter.status === undefined || c.status === filter.status)), + ); + return { claimLedger: { listClaims }, listClaims }; +} + +function stubEventLedger() { + const appendEvent = vi.fn((_event: { type: string; repoFullName?: string; payload: Record }) => undefined); + return { eventLedger: { appendEvent }, appendEvent }; +} + +const activeClaim = { repoFullName: "acme/widgets", issueNumber: 42, status: "active" }; + +describe("checkSubmissionFreshness (#3007)", () => { + it("a fresh claim proceeds: active claim, open issue, no other-author referencing PRs", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger, appendEvent } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + expect(appendEvent).not.toHaveBeenCalled(); // only aborts get logged + }); + + it("claim-superseded abort: no claim ledger entry at all for this issue, without ever fetching live state", async () => { + const { claimLedger } = stubClaimLedger([]); // no rows + const { eventLedger, appendEvent } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "claim_superseded" }); + expect(fetchLiveIssueSnapshot).not.toHaveBeenCalled(); // local, free check runs first + expect(appendEvent).toHaveBeenCalledWith({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName: "acme/widgets", + payload: { issueNumber: 42, reason: "claim_superseded" }, + }); + }); + + it("claim-superseded abort: a claim row exists but is released, not active", async () => { + const { claimLedger } = stubClaimLedger([{ repoFullName: "acme/widgets", issueNumber: 42, status: "released" }]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "claim_superseded" }); + expect(fetchLiveIssueSnapshot).not.toHaveBeenCalled(); + }); + + it("issue-closed abort", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger, appendEvent } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "closed" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "issue_closed" }); + expect(appendEvent).toHaveBeenCalledWith({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName: "acme/widgets", + payload: { issueNumber: 42, reason: "issue_closed" }, + }); + }); + + it("already-addressed abort: an OPEN PR from another author already references the issue", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "someone-else" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "already_addressed" }); + }); + + it("already-addressed abort: a MERGED PR from another author already references the issue", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "merged" as const, authorLogin: "someone-else" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "already_addressed" }); + }); + + it("a referencing PR authored by the miner ITSELF does not count as already-addressed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "miner-bot" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("a CLOSED (not merged) referencing PR from another author does not count as already-addressed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "closed" as const, authorLogin: "someone-else" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("live-state-unavailable abort when the fetch returns null", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => null); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "live_state_unavailable" }); + }); + + it("live-state-unavailable abort (fail closed) when the fetch throws, never treated as no-evidence-so-proceed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => { + throw new Error("network down"); + }); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "live_state_unavailable" }); + }); + + it("tolerates a snapshot with no referencingPrs key at all (treated as empty, not a throw)", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const }) as never); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("fails closed on a malformed candidate rather than silently proceeding", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + const deps = { claimLedger, fetchLiveIssueSnapshot, eventLedger }; + + await expect(checkSubmissionFreshness(null as never, deps)).rejects.toThrow("invalid_freshness_candidate"); + await expect(checkSubmissionFreshness({ issueNumber: 42, minerLogin: "m" } as never, deps)).rejects.toThrow("invalid_repo_full_name"); + await expect(checkSubmissionFreshness({ repoFullName: "acme/widgets", minerLogin: "m" } as never, deps)).rejects.toThrow("invalid_issue_number"); + await expect(checkSubmissionFreshness({ repoFullName: "acme/widgets", issueNumber: 0, minerLogin: "m" }, deps)).rejects.toThrow("invalid_issue_number"); + await expect(checkSubmissionFreshness({ repoFullName: "acme/widgets", issueNumber: 42 } as never, deps)).rejects.toThrow("invalid_miner_login"); + }); + + it("fails closed on malformed or missing dependencies", async () => { + const candidate = { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }; + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + await expect(checkSubmissionFreshness(candidate, null as never)).rejects.toThrow("invalid_freshness_deps"); + await expect(checkSubmissionFreshness(candidate, { fetchLiveIssueSnapshot, eventLedger } as never)).rejects.toThrow("invalid_claim_ledger"); + await expect(checkSubmissionFreshness(candidate, { claimLedger, eventLedger } as never)).rejects.toThrow("invalid_live_state_fetcher"); + await expect(checkSubmissionFreshness(candidate, { claimLedger, fetchLiveIssueSnapshot } as never)).rejects.toThrow("invalid_event_ledger"); + }); +}); From b52a53a5d2647422d46efad06de82e973b8282de Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:53:15 -0700 Subject: [PATCH 2/2] fix(miner-governor): compare referencing-PR author logins case-insensitively pr.authorLogin !== minerLogin was a strict comparison, so a miner's own referencing PR authored as e.g. "Miner-Bot" while candidate.minerLogin is configured as "miner-bot" was misclassified as already_addressed by another author -- GitHub logins are case-insensitive for identity, but different API responses can echo back different casing. Normalize both sides before comparing, and guard against a non-string authorLogin (ignored rather than crashing or false-flagging). Found by AI review on the original PR. --- .../lib/submission-freshness-check.js | 6 ++- .../miner-submission-freshness-check.test.ts | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/gittensory-miner/lib/submission-freshness-check.js b/packages/gittensory-miner/lib/submission-freshness-check.js index a48bf2e72..42fc88d9b 100644 --- a/packages/gittensory-miner/lib/submission-freshness-check.js +++ b/packages/gittensory-miner/lib/submission-freshness-check.js @@ -68,9 +68,13 @@ export async function checkSubmissionFreshness(candidate, deps) { return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed"); } + // GitHub logins are case-insensitive for identity purposes (the same account can be echoed back with + // different casing by different API responses), so a strict `!==` would misclassify the miner's own + // referencing PR as "another author" whenever the casing happens to differ -- compare case-normalized. + const minerLoginKey = minerLogin.toLowerCase(); const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : []; const addressedByAnotherAuthor = referencingPrs.some( - (pr) => pr.authorLogin !== minerLogin && (pr.state === "merged" || pr.state === "open"), + (pr) => typeof pr.authorLogin === "string" && pr.authorLogin.trim().toLowerCase() !== minerLoginKey && (pr.state === "merged" || pr.state === "open"), ); if (addressedByAnotherAuthor) { return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed"); diff --git a/test/unit/miner-submission-freshness-check.test.ts b/test/unit/miner-submission-freshness-check.test.ts index 3df6d560a..de8a9b954 100644 --- a/test/unit/miner-submission-freshness-check.test.ts +++ b/test/unit/miner-submission-freshness-check.test.ts @@ -130,6 +130,54 @@ describe("checkSubmissionFreshness (#3007)", () => { expect(result).toEqual({ fresh: true }); }); + it("a referencing PR authored by the miner's OWN login in a different case does not count as already-addressed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "Miner-Bot" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("already-addressed abort still fires for a differently-cased OTHER author (case-insensitivity isn't a blanket bypass)", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "SOMEONE-ELSE" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "already_addressed" }); + }); + + it("a referencing PR with a non-string authorLogin is ignored rather than crashing or false-flagging", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: undefined as unknown as string }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + it("a CLOSED (not merged) referencing PR from another author does not count as already-addressed", async () => { const { claimLedger } = stubClaimLedger([activeClaim]); const { eventLedger } = stubEventLedger();