ghcr-manager 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +5 -5
  3. package/dist/cleanup-summary/_cleanup-summary-markdown.js +5 -4
  4. package/dist/cleanup-summary/_cleanup-summary.d.ts +8 -4
  5. package/dist/cleanup-summary/_cleanup-summary.js +8 -4
  6. package/dist/cleanup-summary/index.d.ts +1 -1
  7. package/dist/cli/_cleanup-command.js +22 -2
  8. package/dist/cli/_tag-selector-resolver.js +2 -2
  9. package/dist/core/_digest-tag.d.ts +2 -0
  10. package/dist/core/_digest-tag.js +12 -0
  11. package/dist/core/_types.d.ts +2 -1
  12. package/dist/core/index.d.ts +2 -1
  13. package/dist/core/index.js +1 -0
  14. package/dist/db/_cleanup-run-writer.js +69 -50
  15. package/dist/db/_db-merge-cleanup-copy.js +128 -80
  16. package/dist/db/_db-merge-scan-copy.js +1 -1
  17. package/dist/db/_manifest-reachability.js +25 -0
  18. package/dist/db/_scan-writer.js +118 -116
  19. package/dist/db/index.d.ts +1 -1
  20. package/dist/db/planner/_planner-direct-target-roots.js +2 -0
  21. package/dist/db/planner/_planner-direct-target-tags.js +5 -0
  22. package/dist/db/planner/_planner-output.d.ts +1 -1
  23. package/dist/db/planner/_planner-output.js +0 -11
  24. package/dist/db/planner/_planner-repository.d.ts +1 -1
  25. package/dist/db/planner/_planner-types.d.ts +12 -18
  26. package/dist/db/planner/index.d.ts +1 -1
  27. package/package.json +1 -1
  28. package/resources/sql/schema/001_schema.sql +20 -0
  29. package/resources/sql/views/003_v_scan_root_manifests.sql +1 -0
  30. package/resources/sql/views/{004_v_digest_derived_tag_relations.sql → 004_v_digest_tag_relations.sql} +7 -8
  31. package/resources/sql/views/005_v_cleanup_root_closure_members.sql +2 -1
  32. package/resources/sql/views/007_v_cleanup_root_decision_readable.sql +67 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.6] - 2026-05-21
11
+
12
+ ### Added
13
+
14
+ - Cleanup audit now persists concrete selected tags in `cleanup_selected_tags`.
15
+ - Cleanup schema docs now include a readable cleanup-decision view plus example SQL queries for audit inspection.
16
+ - GHCR digest-tag helper relations are now modeled explicitly in scan data and manifest reachability.
17
+
18
+ ### Changed
19
+
20
+ - Cleanup summary JSON now exposes derived affected manifests for fully deletable roots.
21
+ - Cleanup Markdown now reads displayed counts from the summary arrays instead of carrying duplicate count fields.
22
+ - Cleanup decision audit fields are now constrained more tightly in SQLite and TypeScript, including `selection_mode`,
23
+ `selection_reason`, and related block reason codes.
24
+ - Digest-tag helper artifacts are now classified on `tags.is_digest_tag` and excluded from normal user-facing tag
25
+ selection and output.
26
+ - Digest-tag helper terminology was simplified across code and SQL surfaces.
27
+ - Schema docs now include a table of contents and collapsible example query blocks for easier GitHub browsing.
28
+
29
+ ### Fixed
30
+
31
+ - Fixed remote action path handling for artifact upload and merge helper actions.
32
+ - Cleanup reachability now follows digest-tag helper edges recursively, matching helper-artifact cascades more closely.
33
+
10
34
  ## [0.9.5] - 2026-05-21
11
35
 
12
36
  ### Changed
package/README.md CHANGED
@@ -33,7 +33,7 @@ jobs:
33
33
 
34
34
  - name: Preview GHCR cleanup
35
35
  id: ghcr-manager
36
- uses: gh-workflow/ghcr-manager@0.9.5
36
+ uses: gh-workflow/ghcr-manager@0.9.6
37
37
  with:
38
38
  command: cleanup
39
39
  token: ${{ github.token }}
@@ -72,7 +72,7 @@ The action supports three commands:
72
72
  ### Preview cleanup
73
73
 
74
74
  ```yaml
75
- - uses: gh-workflow/ghcr-manager@0.9.5
75
+ - uses: gh-workflow/ghcr-manager@0.9.6
76
76
  with:
77
77
  command: cleanup
78
78
  token: ${{ github.token }}
@@ -93,7 +93,7 @@ The action supports three commands:
93
93
  ### Apply cleanup
94
94
 
95
95
  ```yaml
96
- - uses: gh-workflow/ghcr-manager@0.9.5
96
+ - uses: gh-workflow/ghcr-manager@0.9.6
97
97
  with:
98
98
  command: cleanup
99
99
  token: ${{ github.token }}
@@ -112,7 +112,7 @@ Note: the second scan only runs if cleanup actually makes changes.
112
112
  ### Remove selected tags directly
113
113
 
114
114
  ```yaml
115
- - uses: gh-workflow/ghcr-manager@0.9.5
115
+ - uses: gh-workflow/ghcr-manager@0.9.6
116
116
  with:
117
117
  command: untag
118
118
  token: ${{ github.token }}
@@ -128,7 +128,7 @@ Note: the second scan only runs if cleanup actually makes changes.
128
128
  ### Scan one package
129
129
 
130
130
  ```yaml
131
- - uses: gh-workflow/ghcr-manager@0.9.5
131
+ - uses: gh-workflow/ghcr-manager@0.9.6
132
132
  with:
133
133
  command: scan
134
134
  token: ${{ github.token }}
@@ -12,10 +12,11 @@ export function renderCleanupSummaryMarkdown(summary, options) {
12
12
  "| --- | --- |",
13
13
  `| 📦 Package | \`${_escapeInlineCode(`${summary.owner}/${summary.packageName}`)}\` |`,
14
14
  `| ⚙️ Mode | ${summary.dryRun ? "Cleanup dry-run" : "Cleanup"} |`,
15
- `| 🏷️ Matched tags | ${summary.validationSummary.directTargetTagCount} |`,
16
- `| 🗑️ Fully deletable roots | ${summary.validationSummary.fullyDeletableRootCount} |`,
17
- `| 🔗 Untag-only roots | ${summary.validationSummary.untagOnlyRootCount} |`,
18
- `| 🛡️ Blocked roots | ${summary.validationSummary.blockedDeleteRootCount} |`,
15
+ `| 🏷️ Matched tags | ${summary.directTargetTags.length} |`,
16
+ `| 🗑️ Fully deletable roots | ${summary.fullyDeletableRoots.length} |`,
17
+ `| 🔗 Untag-only roots | ${summary.untagOnlyRoots.length} |`,
18
+ `| 🛡️ Blocked roots | ${summary.blockedRoots.length} |`,
19
+ `| 📄 Affected manifests | ${summary.affectedManifests.length} |`,
19
20
  ""
20
21
  ];
21
22
  lines.push(..._renderJsonDetails("⚙️ Cleanup filter", summary.plannerInputs));
@@ -1,4 +1,4 @@
1
- import type { DeletePlan } from "../db/index.js";
1
+ import type { DeletePlan, DeletePlanSelectionMode, DeletePlanSelectionReason } from "../db/index.js";
2
2
  import type { DeleteExecutionSummary } from "../execute/index.js";
3
3
  export interface CleanupSummaryRoot {
4
4
  versionId: number;
@@ -6,8 +6,8 @@ export interface CleanupSummaryRoot {
6
6
  manifestKind?: string;
7
7
  rootTags: string[];
8
8
  matchedTags: string[];
9
- selectionMode: string;
10
- selectionReason: string;
9
+ selectionMode: DeletePlanSelectionMode;
10
+ selectionReason: DeletePlanSelectionReason;
11
11
  validationStatus: "fully-deletable" | "blocked" | "untag-only";
12
12
  validationReasonCode: "untag-only-partial-tag-match" | "fully-deletable-no-retained-overlap" | "blocked-overlap-with-retained-root";
13
13
  validationReason: string;
@@ -16,6 +16,9 @@ export interface CleanupSummaryRoot {
16
16
  overlapDigest?: string;
17
17
  overlapManifestKind?: string;
18
18
  }
19
+ export interface CleanupSummaryAffectedManifest {
20
+ digest: string;
21
+ }
19
22
  export interface CleanupSummary {
20
23
  command: "cleanup";
21
24
  owner: string;
@@ -23,12 +26,12 @@ export interface CleanupSummary {
23
26
  scanCompletedAt: string;
24
27
  dryRun: boolean;
25
28
  plannerInputs: DeletePlan["plannerInputs"];
26
- validationSummary: DeletePlan["validationSummary"];
27
29
  directTargetTags: string[];
28
30
  collateralTags: string[];
29
31
  fullyDeletableRoots: CleanupSummaryRoot[];
30
32
  untagOnlyRoots: CleanupSummaryRoot[];
31
33
  blockedRoots: CleanupSummaryRoot[];
34
+ affectedManifests: CleanupSummaryAffectedManifest[];
32
35
  deletedPackageVersions: DeleteExecutionSummary["deletedPackageVersions"];
33
36
  untaggedTags: DeleteExecutionSummary["untaggedTags"];
34
37
  unsupportedUntagRoots: DeleteExecutionSummary["unsupportedUntagRoots"];
@@ -36,5 +39,6 @@ export interface CleanupSummary {
36
39
  export declare function buildCleanupSummary(plan: DeletePlan, options: {
37
40
  dryRun: boolean;
38
41
  listRootTags: (versionId: number) => string[];
42
+ listAffectedManifestDigests: (rootDigests: string[]) => string[];
39
43
  executionSummary?: DeleteExecutionSummary;
40
44
  }): CleanupSummary;
@@ -1,6 +1,10 @@
1
1
  export function buildCleanupSummary(plan, options) {
2
2
  const directTargetTagSet = new Set(plan.directTargetTags);
3
3
  const roots = plan.rootDecisions.map((decision) => _mapRootDecision(decision, directTargetTagSet, options.listRootTags));
4
+ const fullyDeletableRoots = roots.filter((root) => root.validationStatus === "fully-deletable");
5
+ const blockedRoots = roots.filter((root) => root.validationStatus === "blocked");
6
+ const untagOnlyRoots = roots.filter((root) => root.validationStatus === "untag-only");
7
+ const affectedManifestDigests = options.listAffectedManifestDigests(fullyDeletableRoots.map((root) => root.digest));
4
8
  return {
5
9
  command: "cleanup",
6
10
  owner: plan.owner,
@@ -8,12 +12,12 @@ export function buildCleanupSummary(plan, options) {
8
12
  scanCompletedAt: plan.scanCompletedAt,
9
13
  dryRun: options.dryRun,
10
14
  plannerInputs: plan.plannerInputs,
11
- validationSummary: plan.validationSummary,
12
15
  directTargetTags: plan.directTargetTags,
13
16
  collateralTags: plan.collateralTags,
14
- fullyDeletableRoots: roots.filter((root) => root.validationStatus === "fully-deletable"),
15
- untagOnlyRoots: roots.filter((root) => root.validationStatus === "untag-only"),
16
- blockedRoots: roots.filter((root) => root.validationStatus === "blocked"),
17
+ fullyDeletableRoots,
18
+ untagOnlyRoots,
19
+ blockedRoots,
20
+ affectedManifests: affectedManifestDigests.map((digest) => ({ digest })),
17
21
  deletedPackageVersions: options.executionSummary?.deletedPackageVersions ?? [],
18
22
  untaggedTags: options.executionSummary?.untaggedTags ?? [],
19
23
  unsupportedUntagRoots: options.executionSummary?.unsupportedUntagRoots ?? []
@@ -1,2 +1,2 @@
1
- export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryRoot } from "./_cleanup-summary.js";
1
+ export { buildCleanupSummary, type CleanupSummary, type CleanupSummaryAffectedManifest, type CleanupSummaryRoot } from "./_cleanup-summary.js";
2
2
  export { renderCleanupSummaryMarkdown } from "./_cleanup-summary-markdown.js";
@@ -14,16 +14,18 @@ export async function handleCleanup(args) {
14
14
  try {
15
15
  const repository = new PlannerRepository(database, logger);
16
16
  const cleanupRunWriter = new CleanupRunWriter(database);
17
+ const scanId = repository.getLatestCompletedScanId(inputs.owner, inputs.packageName);
17
18
  logger.debug(`Starting cleanup for ${inputs.owner}/${inputs.packageName}`);
18
19
  const plan = loadDeletePlan(repository, resolveTagSelectors(database, inputs));
19
- cleanupRunWriter.persistCleanupRun(repository.getLatestCompletedScanId(inputs.owner, inputs.packageName), plan, {
20
+ cleanupRunWriter.persistCleanupRun(scanId, plan, {
20
21
  dryRun,
21
22
  cleanupStartedAt: new Date().toISOString()
22
23
  });
23
24
  if (dryRun) {
24
25
  const summary = buildCleanupSummary(plan, {
25
26
  dryRun: true,
26
- listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId)
27
+ listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId),
28
+ listAffectedManifestDigests: (rootDigests) => _listAffectedManifestDigests(database, scanId, rootDigests)
27
29
  });
28
30
  logger.debug(`Completed dry-run cleanup for ${inputs.owner}/${inputs.packageName}`);
29
31
  console.log(JSON.stringify(summary));
@@ -37,6 +39,7 @@ export async function handleCleanup(args) {
37
39
  const summary = buildCleanupSummary(plan, {
38
40
  dryRun: false,
39
41
  listRootTags: (versionId) => _listRootTags(database, inputs.owner, inputs.packageName, versionId),
42
+ listAffectedManifestDigests: (rootDigests) => _listAffectedManifestDigests(database, scanId, rootDigests),
40
43
  executionSummary
41
44
  });
42
45
  logger.debug(`Completed cleanup for ${inputs.owner}/${inputs.packageName}`);
@@ -47,6 +50,22 @@ export async function handleCleanup(args) {
47
50
  database.close();
48
51
  }
49
52
  }
53
+ function _listAffectedManifestDigests(database, scanId, rootDigests) {
54
+ if (rootDigests.length === 0) {
55
+ return [];
56
+ }
57
+ const placeholders = rootDigests.map(() => "?").join(", ");
58
+ const rows = database
59
+ .prepare(`
60
+ SELECT DISTINCT descendant_digest AS digest
61
+ FROM manifest_reachability
62
+ WHERE scan_id = ?
63
+ AND ancestor_digest IN (${placeholders})
64
+ ORDER BY descendant_digest
65
+ `)
66
+ .all(scanId, ...rootDigests);
67
+ return rows.map((row) => row.digest);
68
+ }
50
69
  function _listRootTags(database, owner, packageName, versionId) {
51
70
  const rows = database
52
71
  .prepare(`
@@ -56,6 +75,7 @@ function _listRootTags(database, owner, packageName, versionId) {
56
75
  WHERE latest_scan.owner = ?
57
76
  AND latest_scan.package_name = ?
58
77
  AND tags.version_id = ?
78
+ AND tags.is_digest_tag = 0
59
79
  ORDER BY tags.tag
60
80
  `)
61
81
  .all(owner, packageName, versionId);
@@ -77,7 +77,7 @@ function _listLatestBrokenIndexTags(database, owner, packageName, cutoffTimestam
77
77
  return rows.map((row) => row.tag);
78
78
  }
79
79
  // Some OCI tooling publishes companion artifacts such as signatures or attestations under
80
- // digest-derived tags in the same repository, for example `sha256-<digest>.sig`, while the
80
+ // digest tags in the same repository, for example `sha256-<digest>.sig`, while the
81
81
  // actual relationship is the artifact's subject/referrer link to the parent digest.
82
82
  //
83
83
  // Public references:
@@ -94,7 +94,7 @@ function _listLatestOrphanedTags(database, owner, packageName, cutoffTimestamp)
94
94
  const rows = database
95
95
  .prepare(`
96
96
  SELECT DISTINCT dtr.tag
97
- FROM v_digest_derived_tag_relations dtr
97
+ FROM v_digest_tag_relations dtr
98
98
  INNER JOIN package_versions pv
99
99
  ON pv.scan_id = dtr.scan_id
100
100
  AND pv.version_id = dtr.artifact_version_id
@@ -0,0 +1,2 @@
1
+ export declare function isDigestTag(tag: string): boolean;
2
+ export declare function digestFromDigestTag(tag: string): string | null;
@@ -0,0 +1,12 @@
1
+ export function isDigestTag(tag) {
2
+ return tag.startsWith("sha256-") && tag.length >= 71 && !_containsNonHex(tag.slice(7, 71));
3
+ }
4
+ export function digestFromDigestTag(tag) {
5
+ if (!isDigestTag(tag)) {
6
+ return null;
7
+ }
8
+ return `sha256:${tag.slice(7, 71)}`;
9
+ }
10
+ function _containsNonHex(value) {
11
+ return /[^0-9A-Fa-f]/.test(value);
12
+ }
@@ -1,4 +1,5 @@
1
1
  export type ManifestKind = "image_index" | "image_manifest" | "artifact_manifest" | "attestation_manifest" | "signature_manifest";
2
+ export type ManifestEdgeKind = "image-child" | "referrer" | "digest-tag-referrer";
2
3
  export interface PackageVersionRecord {
3
4
  versionId: number;
4
5
  createdAt: string;
@@ -38,7 +39,7 @@ export interface ManifestDescriptorRecord {
38
39
  export interface ManifestEdgeRecord {
39
40
  parentDigest: string;
40
41
  childDigest: string;
41
- edgeKind: "image-child" | "referrer";
42
+ edgeKind: ManifestEdgeKind;
42
43
  }
43
44
  export interface PackageSnapshot {
44
45
  packageName: string;
@@ -1,4 +1,5 @@
1
- export type { ManifestEdgeRecord, ManifestDescriptorRecord, ManifestKind, ManifestRecord, PackageSnapshot, PackageVersionRecord, TagRecord } from "./_types.js";
1
+ export type { ManifestEdgeKind, ManifestEdgeRecord, ManifestDescriptorRecord, ManifestKind, ManifestRecord, PackageSnapshot, PackageVersionRecord, TagRecord } from "./_types.js";
2
2
  export type { HttpErrorResponse } from "./_http-error.js";
3
3
  export { buildHttpErrorMessage } from "./_http-error.js";
4
4
  export { getOwnerURIComponent } from "./_github-package-owner.js";
5
+ export { digestFromDigestTag, isDigestTag } from "./_digest-tag.js";
@@ -1,2 +1,3 @@
1
1
  export { buildHttpErrorMessage } from "./_http-error.js";
2
2
  export { getOwnerURIComponent } from "./_github-package-owner.js";
3
+ export { digestFromDigestTag, isDigestTag } from "./_digest-tag.js";
@@ -2,72 +2,91 @@ import { randomUUID } from "node:crypto";
2
2
  import { resolveGitHubActionsRunUrl } from "./_github-actions-run-url.js";
3
3
  export class CleanupRunWriter {
4
4
  #database;
5
+ #insertSelectedTagStatement;
6
+ #insertRootDecisionStatement;
7
+ #insertProtectedRootBlockStatement;
8
+ #insertCleanupRunStatement;
5
9
  constructor(database) {
6
10
  this.#database = database;
11
+ this.#insertSelectedTagStatement = this.#database.prepare(`
12
+ INSERT INTO cleanup_selected_tags(
13
+ cleanup_run_id,
14
+ scan_id,
15
+ tag
16
+ )
17
+ VALUES(?, ?, ?)
18
+ `);
19
+ this.#insertRootDecisionStatement = this.#database.prepare(`
20
+ INSERT INTO cleanup_root_decisions(
21
+ cleanup_run_id,
22
+ scan_id,
23
+ digest,
24
+ selection_mode,
25
+ selection_reason,
26
+ validation_status,
27
+ validation_reason_code,
28
+ validation_reason,
29
+ blocking_digest,
30
+ overlap_digest
31
+ )
32
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33
+ `);
34
+ this.#insertProtectedRootBlockStatement = this.#database.prepare(`
35
+ INSERT INTO cleanup_protected_root_blocks(
36
+ cleanup_run_id,
37
+ scan_id,
38
+ protected_digest,
39
+ blocked_digest,
40
+ block_reason_code,
41
+ overlap_digest
42
+ )
43
+ VALUES(?, ?, ?, ?, ?, ?)
44
+ `);
45
+ this.#insertCleanupRunStatement = this.#database.prepare(`
46
+ INSERT INTO cleanup_runs(
47
+ scan_id,
48
+ cleanup_uuid,
49
+ cleanup_started_at,
50
+ github_actions_run_url,
51
+ dry_run,
52
+ planner_inputs_json,
53
+ direct_target_tag_count,
54
+ direct_target_root_count,
55
+ delete_root_candidate_count,
56
+ untag_only_root_count,
57
+ fully_deletable_root_count,
58
+ blocked_delete_root_count,
59
+ protected_root_count
60
+ )
61
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
62
+ `);
7
63
  }
8
64
  persistCleanupRun(scanId, plan, options) {
9
65
  return this.#database.transaction(() => {
10
66
  const cleanupRunId = this.#insertCleanupRun(scanId, plan, options);
67
+ for (const tag of plan.directTargetTags) {
68
+ this.#insertSelectedTagStatement.run(cleanupRunId, scanId, tag);
69
+ }
11
70
  for (const rootDecision of plan.rootDecisions) {
12
- this.#database
13
- .prepare(`
14
- INSERT INTO cleanup_root_decisions(
15
- cleanup_run_id,
16
- scan_id,
17
- digest,
18
- selection_mode,
19
- selection_reason,
20
- validation_status,
21
- validation_reason_code,
22
- validation_reason,
23
- blocking_digest,
24
- overlap_digest
25
- )
26
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
27
- `)
28
- .run(cleanupRunId, scanId, rootDecision.digest, rootDecision.selectionMode, rootDecision.selectionReason, rootDecision.validationStatus, rootDecision.validationReasonCode, rootDecision.validationReason, rootDecision.blockingDigest ?? null, rootDecision.overlapDigest ?? null);
71
+ this.#insertRootDecisionStatement.run(cleanupRunId, scanId, rootDecision.digest, rootDecision.selectionMode, rootDecision.selectionReason, rootDecision.validationStatus, rootDecision.validationReasonCode, rootDecision.validationReason, rootDecision.blockingDigest ?? null, rootDecision.overlapDigest ?? null);
29
72
  }
30
73
  for (const protectedRoot of plan.protectedRoots) {
31
74
  for (const block of protectedRoot.blocks) {
32
- this.#database
33
- .prepare(`
34
- INSERT INTO cleanup_protected_root_blocks(
35
- cleanup_run_id,
36
- scan_id,
37
- protected_digest,
38
- blocked_digest,
39
- block_reason_code,
40
- overlap_digest
41
- )
42
- VALUES(?, ?, ?, ?, ?, ?)
43
- `)
44
- .run(cleanupRunId, scanId, protectedRoot.digest, block.blockedDigest, block.blockReasonCode, block.overlapDigest);
75
+ this.#insertProtectedRootBlockStatement.run(cleanupRunId, scanId, protectedRoot.digest, block.blockedDigest, block.blockReasonCode, block.overlapDigest);
45
76
  }
46
77
  }
47
78
  return cleanupRunId;
48
79
  })();
49
80
  }
50
81
  #insertCleanupRun(scanId, plan, options) {
51
- const result = this.#database
52
- .prepare(`
53
- INSERT INTO cleanup_runs(
54
- scan_id,
55
- cleanup_uuid,
56
- cleanup_started_at,
57
- github_actions_run_url,
58
- dry_run,
59
- planner_inputs_json,
60
- direct_target_tag_count,
61
- direct_target_root_count,
62
- delete_root_candidate_count,
63
- untag_only_root_count,
64
- fully_deletable_root_count,
65
- blocked_delete_root_count,
66
- protected_root_count
67
- )
68
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
69
- `)
70
- .run(scanId, randomUUID(), options.cleanupStartedAt, resolveGitHubActionsRunUrl(), options.dryRun ? 1 : 0, JSON.stringify(plan.plannerInputs), plan.validationSummary.directTargetTagCount, plan.validationSummary.directTargetRootCount, plan.validationSummary.deleteRootCandidateCount, plan.validationSummary.untagOnlyRootCount, plan.validationSummary.fullyDeletableRootCount, plan.validationSummary.blockedDeleteRootCount, plan.validationSummary.protectedRootCount);
82
+ const directTargetTagCount = plan.directTargetTags.length;
83
+ const directTargetRootCount = plan.directTargetRoots.length;
84
+ const deleteRootCandidateCount = plan.directTargetRoots.filter((root) => root.selectionMode === "delete-root").length;
85
+ const untagOnlyRootCount = plan.rootDecisions.filter((decision) => decision.validationStatus === "untag-only").length;
86
+ const fullyDeletableRootCount = plan.fullyDeletableRoots.length;
87
+ const blockedDeleteRootCount = plan.rootDecisions.filter((decision) => decision.validationStatus === "blocked").length;
88
+ const protectedRootCount = plan.protectedRoots.length;
89
+ const result = this.#insertCleanupRunStatement.run(scanId, randomUUID(), options.cleanupStartedAt, resolveGitHubActionsRunUrl(), options.dryRun ? 1 : 0, JSON.stringify(plan.plannerInputs), directTargetTagCount, directTargetRootCount, deleteRootCandidateCount, untagOnlyRootCount, fullyDeletableRootCount, blockedDeleteRootCount, protectedRootCount);
71
90
  return Number(result.lastInsertRowid);
72
91
  }
73
92
  }
@@ -1,7 +1,30 @@
1
1
  export class DbMergeCleanupCopy {
2
2
  #database;
3
+ #insertCleanupRunStatement;
4
+ #copyRootDecisionsStatementByAttachName = new Map();
5
+ #copySelectedTagsStatementByAttachName = new Map();
6
+ #copyProtectedRootBlocksStatementByAttachName = new Map();
7
+ #listCleanupUuidsStatementByTableName = new Map();
3
8
  constructor(database) {
4
9
  this.#database = database;
10
+ this.#insertCleanupRunStatement = this.#database.prepare(`
11
+ INSERT INTO cleanup_runs(
12
+ scan_id,
13
+ cleanup_uuid,
14
+ cleanup_started_at,
15
+ github_actions_run_url,
16
+ dry_run,
17
+ planner_inputs_json,
18
+ direct_target_tag_count,
19
+ direct_target_root_count,
20
+ delete_root_candidate_count,
21
+ untag_only_root_count,
22
+ fully_deletable_root_count,
23
+ blocked_delete_root_count,
24
+ protected_root_count
25
+ )
26
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
27
+ `);
5
28
  }
6
29
  copyCleanupRuns(attachName, sourceScanId, targetScanId, existingCleanupUuids) {
7
30
  const rows = this.#database
@@ -31,92 +54,117 @@ export class DbMergeCleanupCopy {
31
54
  if (knownCleanupUuids.has(row.cleanup_uuid)) {
32
55
  continue;
33
56
  }
34
- const cleanupRunId = Number(this.#database
35
- .prepare(`
36
- INSERT INTO cleanup_runs(
37
- scan_id,
38
- cleanup_uuid,
39
- cleanup_started_at,
40
- github_actions_run_url,
41
- dry_run,
42
- planner_inputs_json,
43
- direct_target_tag_count,
44
- direct_target_root_count,
45
- delete_root_candidate_count,
46
- untag_only_root_count,
47
- fully_deletable_root_count,
48
- blocked_delete_root_count,
49
- protected_root_count
50
- )
51
- VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
52
- `)
53
- .run(targetScanId, row.cleanup_uuid, row.cleanup_started_at, row.github_actions_run_url, row.dry_run, row.planner_inputs_json, row.direct_target_tag_count, row.direct_target_root_count, row.delete_root_candidate_count, row.untag_only_root_count, row.fully_deletable_root_count, row.blocked_delete_root_count, row.protected_root_count).lastInsertRowid);
54
- this.#database
55
- .prepare(`
56
- INSERT INTO cleanup_root_decisions(
57
- cleanup_run_id,
58
- scan_id,
59
- digest,
60
- selection_mode,
61
- selection_reason,
62
- validation_status,
63
- validation_reason_code,
64
- validation_reason,
65
- blocking_digest,
66
- overlap_digest
67
- )
68
- SELECT
69
- ?,
70
- ?,
71
- digest,
72
- selection_mode,
73
- selection_reason,
74
- validation_status,
75
- validation_reason_code,
76
- validation_reason,
77
- blocking_digest,
78
- overlap_digest
79
- FROM ${attachName}.cleanup_root_decisions
80
- WHERE cleanup_run_id = ?
81
- AND scan_id = ?
82
- `)
83
- .run(cleanupRunId, targetScanId, row.cleanup_run_id, sourceScanId);
84
- this.#database
85
- .prepare(`
86
- INSERT INTO cleanup_protected_root_blocks(
87
- cleanup_run_id,
88
- scan_id,
89
- protected_digest,
90
- blocked_digest,
91
- block_reason_code,
92
- overlap_digest
93
- )
94
- SELECT
95
- ?,
96
- ?,
97
- protected_digest,
98
- blocked_digest,
99
- block_reason_code,
100
- overlap_digest
101
- FROM ${attachName}.cleanup_protected_root_blocks
102
- WHERE cleanup_run_id = ?
103
- AND scan_id = ?
104
- `)
105
- .run(cleanupRunId, targetScanId, row.cleanup_run_id, sourceScanId);
57
+ const cleanupRunId = Number(this.#insertCleanupRunStatement.run(targetScanId, row.cleanup_uuid, row.cleanup_started_at, row.github_actions_run_url, row.dry_run, row.planner_inputs_json, row.direct_target_tag_count, row.direct_target_root_count, row.delete_root_candidate_count, row.untag_only_root_count, row.fully_deletable_root_count, row.blocked_delete_root_count, row.protected_root_count).lastInsertRowid);
58
+ this.#copyRootDecisionsStatement(attachName).run(cleanupRunId, targetScanId, row.cleanup_run_id, sourceScanId);
59
+ this.#copySelectedTagsStatement(attachName).run(cleanupRunId, targetScanId, row.cleanup_run_id, sourceScanId);
60
+ this.#copyProtectedRootBlocksStatement(attachName).run(cleanupRunId, targetScanId, row.cleanup_run_id, sourceScanId);
106
61
  knownCleanupUuids.add(row.cleanup_uuid);
107
62
  importedCleanupRunCount += 1;
108
63
  }
109
64
  return importedCleanupRunCount;
110
65
  }
111
66
  listCleanupUuids(tableName, scanId) {
112
- const rows = this.#database
113
- .prepare(`
114
- SELECT cleanup_uuid
115
- FROM ${tableName}
116
- WHERE scan_id = ?
117
- ORDER BY cleanup_run_id
118
- `)
119
- .all(scanId);
67
+ const rows = this.#listCleanupUuidsStatement(tableName).all(scanId);
120
68
  return rows.map((row) => row.cleanup_uuid);
121
69
  }
70
+ #copyRootDecisionsStatement(attachName) {
71
+ const cached = this.#copyRootDecisionsStatementByAttachName.get(attachName);
72
+ if (cached) {
73
+ return cached;
74
+ }
75
+ const statement = this.#database.prepare(`
76
+ INSERT INTO cleanup_root_decisions(
77
+ cleanup_run_id,
78
+ scan_id,
79
+ digest,
80
+ selection_mode,
81
+ selection_reason,
82
+ validation_status,
83
+ validation_reason_code,
84
+ validation_reason,
85
+ blocking_digest,
86
+ overlap_digest
87
+ )
88
+ SELECT
89
+ ?,
90
+ ?,
91
+ digest,
92
+ selection_mode,
93
+ selection_reason,
94
+ validation_status,
95
+ validation_reason_code,
96
+ validation_reason,
97
+ blocking_digest,
98
+ overlap_digest
99
+ FROM ${attachName}.cleanup_root_decisions
100
+ WHERE cleanup_run_id = ?
101
+ AND scan_id = ?
102
+ `);
103
+ this.#copyRootDecisionsStatementByAttachName.set(attachName, statement);
104
+ return statement;
105
+ }
106
+ #copySelectedTagsStatement(attachName) {
107
+ const cached = this.#copySelectedTagsStatementByAttachName.get(attachName);
108
+ if (cached) {
109
+ return cached;
110
+ }
111
+ const statement = this.#database.prepare(`
112
+ INSERT INTO cleanup_selected_tags(
113
+ cleanup_run_id,
114
+ scan_id,
115
+ tag
116
+ )
117
+ SELECT
118
+ ?,
119
+ ?,
120
+ tag
121
+ FROM ${attachName}.cleanup_selected_tags
122
+ WHERE cleanup_run_id = ?
123
+ AND scan_id = ?
124
+ `);
125
+ this.#copySelectedTagsStatementByAttachName.set(attachName, statement);
126
+ return statement;
127
+ }
128
+ #copyProtectedRootBlocksStatement(attachName) {
129
+ const cached = this.#copyProtectedRootBlocksStatementByAttachName.get(attachName);
130
+ if (cached) {
131
+ return cached;
132
+ }
133
+ const statement = this.#database.prepare(`
134
+ INSERT INTO cleanup_protected_root_blocks(
135
+ cleanup_run_id,
136
+ scan_id,
137
+ protected_digest,
138
+ blocked_digest,
139
+ block_reason_code,
140
+ overlap_digest
141
+ )
142
+ SELECT
143
+ ?,
144
+ ?,
145
+ protected_digest,
146
+ blocked_digest,
147
+ block_reason_code,
148
+ overlap_digest
149
+ FROM ${attachName}.cleanup_protected_root_blocks
150
+ WHERE cleanup_run_id = ?
151
+ AND scan_id = ?
152
+ `);
153
+ this.#copyProtectedRootBlocksStatementByAttachName.set(attachName, statement);
154
+ return statement;
155
+ }
156
+ #listCleanupUuidsStatement(tableName) {
157
+ const cached = this.#listCleanupUuidsStatementByTableName.get(tableName);
158
+ if (cached) {
159
+ return cached;
160
+ }
161
+ const statement = this.#database.prepare(`
162
+ SELECT cleanup_uuid
163
+ FROM ${tableName}
164
+ WHERE scan_id = ?
165
+ ORDER BY cleanup_run_id
166
+ `);
167
+ this.#listCleanupUuidsStatementByTableName.set(tableName, statement);
168
+ return statement;
169
+ }
122
170
  }