From e0d64458c21cf187f1bf852268d170eeb277b544 Mon Sep 17 00:00:00 2001 From: Pedro Paulo Vezza Campos Date: Fri, 17 Apr 2026 12:28:28 -0700 Subject: [PATCH 1/2] test: add failing test for iteration dedup on same head_sha Reproduces #486: running captureIteration twice with the same headSha currently produces two iteration rows (revision=1 and revision=2). Verified failing: AssertionError: expected 2 to be 1 at src/capture/iteration-capture.test.ts:96 Co-Authored-By: Claude Opus 4.7 (1M context) --- action/src/capture/iteration-capture.test.ts | 106 +++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 action/src/capture/iteration-capture.test.ts diff --git a/action/src/capture/iteration-capture.test.ts b/action/src/capture/iteration-capture.test.ts new file mode 100644 index 00000000..451ff4e1 --- /dev/null +++ b/action/src/capture/iteration-capture.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for captureIteration + * + * Verifies that re-running captureIteration on the same head_sha does not + * produce duplicate phantom iterations (issue #486). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { IterationDatabase } from '../db/database'; +import { captureIteration } from './iteration-capture'; + +// Mock logger +vi.mock('../utils/logger', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock @actions/github so github.context.actor is defined +vi.mock('@actions/github', () => ({ + context: { + actor: 'test-actor', + repo: { owner: 'octo', repo: 'repo' }, + payload: {}, + }, + getOctokit: vi.fn(), +})); + +// Mock file-fetcher to avoid network +vi.mock('./file-fetcher', () => ({ + fetchFileContent: vi.fn(async () => 'file contents'), +})); + +function makeOctokit() { + const listFiles = vi.fn(); + // paginate just calls the method with the params and returns a fixed list + const paginate = vi.fn(async () => [ + { + filename: 'foo.txt', + status: 'modified', + sha: 'file-sha-1', + }, + ]); + return { + paginate, + rest: { + pulls: { listFiles }, + }, + } as unknown as Parameters[1]['octokit']; +} + +describe('captureIteration dedup on same head_sha (issue #486)', () => { + let db: IterationDatabase; + let dbPath: string; + + beforeEach(() => { + const tempDir = os.tmpdir(); + dbPath = path.join(tempDir, `codjiflo-dedup-test-${Date.now()}-${Math.random()}.db`); + db = new IterationDatabase(dbPath); + }); + + afterEach(() => { + db.close(); + if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath); + const walPath = dbPath + '-wal'; + const shmPath = dbPath + '-shm'; + if (fs.existsSync(walPath)) fs.unlinkSync(walPath); + if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath); + }); + + it('does not create duplicate iteration rows when run twice with same head_sha', async () => { + const ctx = { + octokit: makeOctokit(), + owner: 'octo', + repo: 'repo', + prNumber: 1, + headSha: 'deadbeef', + baseSha: 'basefeed', + beforeSha: null, + }; + + const firstId = await captureIteration(db, ctx); + const firstSnapshotCount = (db as unknown as { + db: { prepare: (sql: string) => { get: () => { count: number } } }; + }).db.prepare('SELECT COUNT(*) as count FROM artifact_snapshots').get().count; + + const secondId = await captureIteration(db, ctx); + + // Only one iteration row should exist + expect(db.getIterationCount()).toBe(1); + // Second call should short-circuit and return the same iteration id + expect(secondId).toBe(firstId); + + // No new snapshots should have been written by the second call + const secondSnapshotCount = (db as unknown as { + db: { prepare: (sql: string) => { get: () => { count: number } } }; + }).db.prepare('SELECT COUNT(*) as count FROM artifact_snapshots').get().count; + expect(secondSnapshotCount).toBe(firstSnapshotCount); + }); +}); From 8ffd7ac130d4859f88bc51e82b10812ed24b2de2 Mon Sep 17 00:00:00 2001 From: Pedro Paulo Vezza Campos Date: Fri, 17 Apr 2026 12:28:57 -0700 Subject: [PATCH 2/2] fix: dedup iterations by head_sha to avoid phantom re-runs (#486) captureIteration previously computed revision = getIterationCount() + 1 on every run and inserted a new row with no head_sha uniqueness check. Re-running the workflow on the same commit (via "Re-run all jobs" or workflow_dispatch with the same PR_NUMBER) therefore produced duplicate phantom iterations sharing an identical head_sha. Now captureIteration short-circuits when an iteration with the given head_sha already exists, returning the existing id and skipping the snapshot work. Added getIterationByHeadSha() helper to IterationDatabase. Closes #486 Co-Authored-By: Claude Opus 4.7 (1M context) --- action/src/capture/iteration-capture.ts | 16 ++++++++++++++++ action/src/db/database.ts | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/action/src/capture/iteration-capture.ts b/action/src/capture/iteration-capture.ts index a8f76ae6..9eb96f06 100644 --- a/action/src/capture/iteration-capture.ts +++ b/action/src/capture/iteration-capture.ts @@ -7,6 +7,7 @@ import * as github from '@actions/github'; import type { IterationDatabase } from '../db/database'; import { fetchFileContent } from './file-fetcher'; +import { logger } from '../utils/logger'; // ============================================================================ // Types @@ -40,6 +41,21 @@ export async function captureIteration( db: IterationDatabase, ctx: CaptureContext ): Promise { + // Dedup: if an iteration with this head_sha already exists, short-circuit. + // This prevents phantom duplicate iterations when the workflow is re-run + // on the same commit (e.g. "Re-run all jobs" or workflow_dispatch with the + // same PR_NUMBER). See issue #486. + const existing = db.getIterationByHeadSha(ctx.headSha); + if (existing) { + logger.info({ + event: 'iteration_already_captured', + 'iteration.id': existing.id, + 'iteration.revision': existing.revision, + 'iteration.head_sha': existing.head_sha, + }, 'Iteration for this head_sha already exists; skipping capture'); + return existing.id; + } + const iterationCount = db.getIterationCount(); const revision = iterationCount + 1; diff --git a/action/src/db/database.ts b/action/src/db/database.ts index f9e57d4d..e1320a4e 100644 --- a/action/src/db/database.ts +++ b/action/src/db/database.ts @@ -107,6 +107,12 @@ export class IterationDatabase { `).get(); } + getIterationByHeadSha(headSha: string): IterationRow | undefined { + return this.db.prepare<[string], IterationRow>(` + SELECT * FROM iterations WHERE head_sha = ? LIMIT 1 + `).get(headSha); + } + getIterationCount(): number { const row = this.db.prepare<[], { count: number }>(` SELECT COUNT(*) as count FROM iterations