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
10 changes: 10 additions & 0 deletions packages/loopover-miner/lib/deny-hook-synthesis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import type { DenyRuleProposal, SynthesisConfig } from "@loopover/engine";
import { DEFAULT_FORGE_CONFIG } from "./forge-config.js";
import type { DenyRule } from "./deny-hooks.js";
import { DENY_HOOK_SYNTHESIS_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js";

// Re-export the pure synthesis helpers from the engine so this module's public API is unchanged after #5667
// moved derivation/audit into @loopover/engine. Only the SQLite store below (and its forge/db-path helpers) is
Expand Down Expand Up @@ -59,6 +60,8 @@ export type DenyHookSynthesisStore = {
repoFullName: string,
options?: { includeDefaults?: boolean; apiBaseUrl?: string },
): DenyRule[];
/** Delete every proposal row for one repo across ALL forge hosts (#8009); returns the number of rows removed. */
purgeByRepo(repoFullName: string): number;
close(): void;
};

Expand Down Expand Up @@ -248,6 +251,13 @@ export function initDenyHookSynthesisStore(dbPath: string = resolveDenyHookSynth
approvedProposals: proposals,
} as Parameters<typeof resolveEffectiveDenyRules>[0]);
},
/** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what
* `loopover-miner purge` invokes. Filters on `repo_full_name` alone (the spec's own doc covers why), so —
* unlike every other method here, which scopes to one forge — the sweep clears the repo's proposals under
* every `api_base_url` they were recorded against, mirroring governor-state's purgeByRepo. */
purgeByRepo(repoFullName) {
return purgeStoreByRepo(db, DENY_HOOK_SYNTHESIS_PURGE_SPEC, normalizeRepoFullName(repoFullName));
},
close() {
db.close();
},
Expand Down
28 changes: 25 additions & 3 deletions packages/loopover-miner/lib/purge-cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// `loopover-miner purge` (#5564, #6599): an explicit, operator-invoked right-to-be-forgotten path across the local
// ledgers. Deletes every row for one repo from the stores that have a real `repoColumn` (claim-ledger,
// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache, and
// governor-state's two repo-scoped tables — #7091), via each store's own `purgeByRepo` method (which reuses
// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state, contribution-profile-cache,
// governor-state's two repo-scoped tables — #7091 — plus policy-verdict-cache — #6987 — and ranked-candidates,
// replay-snapshot, and deny-hook-synthesis — #8009), via each store's own `purgeByRepo` method (which reuses
// `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`).
// `attempt-log.js` is deliberately reported as not-purgeable rather than silently skipped or approximated: its
// payload is a free-form `Record<string, unknown>` with no dedicated repo column, so a precise per-repo match
Expand Down Expand Up @@ -30,6 +31,12 @@ import { openGovernorState, resolveGovernorStateDbPath } from "./governor-state.
import type { GovernorState } from "./governor-state.js";
import { initPolicyVerdictCacheStore, resolvePolicyVerdictCacheDbPath } from "./policy-verdict-cache.js";
import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
import { initRankedCandidatesStore, resolveRankedCandidatesDbPath } from "./ranked-candidates.js";
import type { RankedCandidatesStore } from "./ranked-candidates.js";
import { openReplaySnapshotStore, resolveReplaySnapshotDbPath } from "./replay-snapshot.js";
import type { ReplaySnapshotStore } from "./replay-snapshot.js";
import { initDenyHookSynthesisStore, resolveDenyHookSynthesisDbPath } from "./deny-hook-synthesis.js";
import type { DenyHookSynthesisStore } from "./deny-hook-synthesis.js";
import { resolveAttemptLogDbPath } from "./attempt-log.js";
import {
CLAIM_LEDGER_PURGE_SPEC,
Expand All @@ -42,6 +49,9 @@ import {
GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC,
GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC,
POLICY_VERDICT_CACHE_PURGE_SPEC,
RANKED_CANDIDATES_PURGE_SPEC,
REPLAY_SNAPSHOT_PURGE_SPEC,
DENY_HOOK_SYNTHESIS_PURGE_SPEC,
countStoreByRepo,
describeError,
} from "./store-maintenance.js";
Expand All @@ -66,7 +76,10 @@ type PurgeOpenerKey =
| "initRunStateStore"
| "initContributionProfileCache"
| "openGovernorState"
| "initPolicyVerdictCacheStore";
| "initPolicyVerdictCacheStore"
| "initRankedCandidatesStore"
| "openReplaySnapshotStore"
| "initDenyHookSynthesisStore";

export type PurgeCliOptions = {
openClaimLedger?: () => ClaimLedger;
Expand All @@ -78,6 +91,9 @@ export type PurgeCliOptions = {
initContributionProfileCache?: () => ContributionProfileCache;
openGovernorState?: () => GovernorState;
initPolicyVerdictCacheStore?: () => PolicyVerdictCacheStore;
initRankedCandidatesStore?: () => RankedCandidatesStore;
openReplaySnapshotStore?: () => ReplaySnapshotStore;
initDenyHookSynthesisStore?: () => DenyHookSynthesisStore;
resolveDbPaths?: Record<string, () => string>;
};

Expand All @@ -102,6 +118,12 @@ const REAL_PURGE_TARGETS: PurgeTarget[] = [
// single handle (never reopening the file), and its dry-run count sums both via `specs` (#7091).
{ name: "governor-state", optionKey: "openGovernorState", opener: openGovernorState, resolveDbPath: resolveGovernorStateDbPath, specs: [GOVERNOR_REPUTATION_HISTORY_PURGE_SPEC, GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC] },
{ name: "policy-verdict-cache", optionKey: "initPolicyVerdictCacheStore", opener: initPolicyVerdictCacheStore, resolveDbPath: resolvePolicyVerdictCacheDbPath, spec: POLICY_VERDICT_CACHE_PURGE_SPEC },
// Three more repo-scoped stores the earlier sweeps missed (#8009). deny-hook-synthesis's dry-run count works
// on both pre- and post-forge-scope files: its live table is `deny_rule_proposals` either way, and the purge
// filters on `repo_full_name` alone (all forge hosts), per its spec's own doc in store-maintenance.js.
{ name: "ranked-candidates", optionKey: "initRankedCandidatesStore", opener: initRankedCandidatesStore, resolveDbPath: resolveRankedCandidatesDbPath, spec: RANKED_CANDIDATES_PURGE_SPEC },
{ name: "replay-snapshot", optionKey: "openReplaySnapshotStore", opener: openReplaySnapshotStore, resolveDbPath: resolveReplaySnapshotDbPath, spec: REPLAY_SNAPSHOT_PURGE_SPEC },
{ name: "deny-hook-synthesis", optionKey: "initDenyHookSynthesisStore", opener: initDenyHookSynthesisStore, resolveDbPath: resolveDenyHookSynthesisDbPath, spec: DENY_HOOK_SYNTHESIS_PURGE_SPEC },
];

export type ParsedPurgeArgs = { json: boolean; dryRun: boolean; repoFullName: string } | { error: string };
Expand Down
25 changes: 21 additions & 4 deletions packages/loopover-miner/lib/ranked-candidates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SQLOutputValue } from "node:sqlite";
import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
import { applySchemaMigrations } from "./schema-version.js";
import { RANKED_CANDIDATES_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js";

// Last-discover-run ranked-candidates snapshot (#4859 prerequisite): `discover-cli.js`'s runDiscover already
// computes the FULL per-issue ranking breakdown (rankScore/laneFit/freshness/potential/feasibility/dupRisk, via
Expand Down Expand Up @@ -54,6 +55,8 @@ export type RankedCandidatesStore = {
dbPath: string;
saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult;
listRankedCandidates(): RankedCandidateRow[];
/** Delete every snapshot row for one repo (#8009); returns the number of rows removed. */
purgeByRepo(repoFullName: string): number;
close(): void;
};

Expand Down Expand Up @@ -101,17 +104,25 @@ function normalizeFiniteRankDimension(value: unknown, fallback: number): number
return Number.isFinite(value) ? (value as number) : fallback;
}

/** Guard an owner/repo value to the canonical `owner/repo` shape. Shared by the candidate write path and
* purgeByRepo (#8009), each throwing its own error name — a rejected candidate and a rejected purge target are
* different operator mistakes. */
function normalizeRepoFullName(value: unknown, error: string): string {
const repoFullName = typeof value === "string" ? value.trim() : "";
const [owner, repo, extra] = repoFullName.split("/");
if (!owner || !repo || extra !== undefined) throw new Error(error);
return `${owner}/${repo}`;
}

function normalizeCandidate(candidate: RankedCandidateInput): NormalizedRankedCandidate {
if (!candidate || typeof candidate !== "object") throw new Error("invalid_ranked_candidate");
const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : "";
const [owner, repo, extra] = repoFullName.split("/");
if (!owner || !repo || extra !== undefined) throw new Error("invalid_ranked_candidate");
const repoFullName = normalizeRepoFullName(candidate.repoFullName, "invalid_ranked_candidate");
const issueNumber = candidate.issueNumber;
if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error("invalid_ranked_candidate");
const rankScore = Number(candidate.rankScore);
if (!Number.isFinite(rankScore)) throw new Error("invalid_ranked_candidate");
return {
repoFullName: `${owner}/${repo}`,
repoFullName,
issueNumber,
title: typeof candidate.title === "string" ? candidate.title : "",
htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null,
Expand Down Expand Up @@ -225,6 +236,12 @@ export function initRankedCandidatesStore(dbPath: string = resolveRankedCandidat
listRankedCandidates() {
return listStatement.all().map((row) => rowToCandidate(asRankedCandidateDbRow(row)));
},
/** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what
* `loopover-miner purge` invokes. Reuses store-maintenance.js's identifier-guarded purgeStoreByRepo,
* exactly like the other repo-scoped stores. */
purgeByRepo(repoFullName) {
return purgeStoreByRepo(db, RANKED_CANDIDATES_PURGE_SPEC, normalizeRepoFullName(repoFullName, "invalid_repo_full_name"));
},
close() {
db.close();
},
Expand Down
11 changes: 11 additions & 0 deletions packages/loopover-miner/lib/replay-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { join } from "node:path";
import { removeWorktree } from "@loopover/engine";
import type { WorktreeExecFn, WorktreeRemoveResult } from "@loopover/engine";
import { openLocalStoreAdapter, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js";
import { REPLAY_SNAPSHOT_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js";

// Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports:
// (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive
Expand Down Expand Up @@ -51,6 +52,8 @@ export type ReplaySnapshotStore = {
dbPath: string;
getSnapshot(repoFullName: string, commitSha: string): ReplaySnapshot | null;
saveSnapshot(snapshot: Omit<ReplaySnapshot, "exportedAt">): ReplaySnapshot;
/** Delete every cached snapshot row for one repo (#8009); returns the number of rows removed. */
purgeByRepo(repoFullName: string): number;
close(): void;
};

Expand Down Expand Up @@ -272,6 +275,14 @@ export function openReplaySnapshotStore(dbPath: string = resolveReplaySnapshotDb
dbPath: resolvedPath,
getSnapshot,
saveSnapshot,
/** Explicit, operator-invoked right-to-be-forgotten purge (#8009) — never runs automatically; this is what
* `loopover-miner purge` invokes. Reuses store-maintenance.js's identifier-guarded purgeStoreByRepo against
* the raw handle (the #7175 driver seam covers this store's own CRUD, not the shared maintenance helpers),
* exactly like the other repo-scoped stores. Removes only DB rows — exported worktrees are transient files
* the snapshot merely references, cleaned up by removeReplaySnapshotWorktree in their own lifecycle. */
purgeByRepo(repoFullName: string): number {
return purgeStoreByRepo(db, REPLAY_SNAPSHOT_PURGE_SPEC, normalizeRepoFullName(repoFullName));
},
close() {
db.close();
},
Expand Down
12 changes: 12 additions & 0 deletions packages/loopover-miner/lib/store-maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export const GOVERNOR_OWN_SUBMISSIONS_PURGE_SPEC: LedgerPurgeSpec = { table: "go
* column, exactly like `attempt-log.js`). */
export const POLICY_VERDICT_CACHE_PURGE_SPEC: LedgerPurgeSpec = { table: "policy_verdict_cache", repoColumn: "repo_scope" };

/** Three more repo-scoped stores the #5564/#7091/#6987 sweeps missed (#8009), same `repoColumn` shape and same
* internal-constant-only discipline. ranked-candidates is a wholesale-replaced snapshot, but its rows persist
* between discover runs; replay_snapshots embeds commit SHAs and README content. deny-hook-synthesis's live
* table is always `deny_rule_proposals` (`deny_rule_proposals_v2` exists only transiently mid-rebuild inside
* its forge-scope migration, never at rest, so one spec covers both pre- and post-migration files), and — like
* `governor_reputation_history` above — it is purged on `repo_full_name` alone (its key is composite with
* `api_base_url`), so a right-to-be-forgotten sweep clears the repo across every forge host it was recorded
* against, not just the default one. */
export const RANKED_CANDIDATES_PURGE_SPEC: LedgerPurgeSpec = { table: "miner_ranked_candidates", repoColumn: "repo_full_name" };
export const REPLAY_SNAPSHOT_PURGE_SPEC: LedgerPurgeSpec = { table: "replay_snapshots", repoColumn: "repo_full_name" };
export const DENY_HOOK_SYNTHESIS_PURGE_SPEC: LedgerPurgeSpec = { table: "deny_rule_proposals", repoColumn: "repo_full_name" };

export type StoreIntegrityResult = { name: string; ok: boolean; detail: string };
export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number };

Expand Down
21 changes: 21 additions & 0 deletions test/unit/miner-deny-hook-synthesis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,27 @@ describe("initDenyHookSynthesisStore() (#4522)", () => {
expect(() => store.setProposalStatus("acme/widgets", "path:abc", "bogus")).toThrow("invalid_proposal_status");
});

it("purgeByRepo sweeps the repo's proposals under EVERY forge host and leaves other repos intact (#8009)", () => {
const store = tempStore();
const history = [
{ blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] },
{ blockerCodes: ["guardrail_hold"], changedPaths: ["CHANGELOG.md"] },
];
store.refreshProposals("acme/widgets", history, {}, "https://api.github.com");
store.refreshProposals("acme/widgets", history, {}, "https://gitlab.example/api");
store.refreshProposals("acme/other", history);

// Filters on repo_full_name alone: one proposal per forge host = 2 rows removed.
expect(store.purgeByRepo("acme/widgets")).toBe(2);
expect(store.listProposals("acme/widgets", "https://api.github.com")).toEqual([]);
expect(store.listProposals("acme/widgets", "https://gitlab.example/api")).toEqual([]);
expect(store.listProposals("acme/other")).toHaveLength(1);
});

it("purgeByRepo returns 0 when the repo has no proposals (#8009)", () => {
expect(tempStore().purgeByRepo("acme/widgets")).toBe(0);
});

it("migrates an existing pre-#5563 file, backfilling api_base_url and preserving every row", () => {
const dir = mkdtempSync(join(tmpdir(), "miner-deny-hook-synthesis-legacy-"));
tempDirs.push(dir);
Expand Down
1 change: 1 addition & 0 deletions test/unit/miner-discover-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,7 @@ describe("runDiscover (#4247)", () => {
dbPath: ":memory:",
saveRankedCandidates,
listRankedCandidates: () => [],
purgeByRepo: () => 0,
close: () => undefined,
}),
fetchCandidateIssuesWithSummary,
Expand Down
Loading