diff --git a/.github/workflows/pr-duplicate-guard.yml b/.github/workflows/pr-duplicate-guard.yml new file mode 100644 index 00000000..27b8abed --- /dev/null +++ b/.github/workflows/pr-duplicate-guard.yml @@ -0,0 +1,48 @@ +name: PR Duplicate Guard + +# Stops duplicate same-fix pull requests from silently piling up (the fleet +# kept dispatching the same finding to multiple lanes: #36/#44/#55/#56, +# #39/#49, #40/#52, #38/#48/#51, ...). On every PR event this job compares +# the PR's diff against every other OPEN pull request. When another open PR +# covers the same fix (>= 80% shared changed-file coverage and >= 50% patch +# similarity on the shared files), the check fails loudly and the job posts +# one marker comment naming the duplicate(s) and the canonical PR. The check +# is deliberately not a required status, so legitimate work never blocks on +# it; it exists to make the duplication visible the moment it happens. +# +# Run locally before opening a PR: +# node scripts/check-pr-duplicates.mjs --pr 0 --no-fail --repo OWNER/REPO +# (use the real PR number once the PR exists). + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + pr-number: + description: "PR number to check (defaults to the PR that triggered the run)" + required: false + type: string + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-duplicate-guard-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + duplicate-guard: + runs-on: [self-hosted, linux, x64, vps-verify] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 20 + - name: Detect same-fix duplicate open PRs + run: node scripts/check-pr-duplicates.mjs --pr "$PR_NUMBER" --comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.inputs.pr-number || github.event.pull_request.number }} diff --git a/README.md b/README.md index 216ff7a9..3319b603 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,23 @@ node scripts/test-cross-repo-service.mjs --public-repo "/absolute/path/to/TinySt git diff --check ``` +## PR duplicate guard + +Same-fix PR pairs kept appearing because the same finding gets dispatched to +multiple lanes (`fix/operator-export-cli-help` #36 and `fix/operator-export-cli-help-lane1` +#44 were byte-identical patches; #39/#49 and #40/#52 followed the same pattern). +The `PR Duplicate Guard` workflow now compares every PR's diff against all +other open PRs and fails loudly (with a comment naming the canonical PR) when +another open PR covers the same fix with the same changed-file set. Run the +same check locally before opening a PR: + +```bash +node scripts/check-pr-duplicates.mjs --pr 0 --repo nish3451/tinystudio-in --no-fail +``` + +The check is informational, not a required status, so it never blocks +legitimate work; it exists to make duplication visible the moment it happens. + ## SaaS graduation evidence gate TinyStudio does not become SaaS by assumption. Graduation requires **at least 10 paid sprints**, the **same problem in at least 7**, **at least 70% workflow repeatability**, **usefulness at least 8/10**, **approval at least 70%**, a **recurring need**, and **at least 3 deposits or preorders**. Until every threshold is evidenced, this remains a managed service. diff --git a/package.json b/package.json index 69f1333f..3fc9fce7 100644 --- a/package.json +++ b/package.json @@ -93,9 +93,9 @@ "product:truth": "node scripts/check-product-truth.mjs", "mobbin:run": "node scripts/run-design-system-proving-lab.mjs", "mobbin:check": "node scripts/run-design-system-proving-lab.mjs --dry-run", - "ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-theme-color.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", + "ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-theme-color.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && node scripts/test-pr-duplicates.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", "check": "npm test", - "test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-theme-color.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", + "test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/test-operator-check-strictness.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-theme-color.mjs && node scripts/test-public-soft-404.mjs && node scripts/test-public-deploy-bundle.mjs && node scripts/test-deploy-public-site-workflow.mjs && node scripts/test-pages-release.mjs && node scripts/test-pr-duplicates.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check", "site:publish": "node scripts/publish-public-site.mjs", "site:publish:check": "node scripts/publish-public-site.mjs --prepare-only" }, diff --git a/scripts/check-pr-duplicates.mjs b/scripts/check-pr-duplicates.mjs new file mode 100644 index 00000000..fd675d8d --- /dev/null +++ b/scripts/check-pr-duplicates.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +// Guard against duplicate same-fix pull requests. +// +// The fleet keeps dispatching the same finding to multiple lanes, which +// produces duplicate same-fix PR pairs (e.g. #36/#44, #39/#49, #40/#52, and +// the #38/#48/#51 heading-hierarchy triple). This script compares the diff of +// the current PR against every other OPEN pull request and fails loudly when +// another open PR covers the same fix: same changed-file set (high coverage) +// with substantially overlapping patch text (high similarity). The workflow +// that calls it posts a comment naming the duplicate and the canonical PR so +// the duplication is visible instead of silent. +// +// Usage: +// node scripts/check-pr-duplicates.mjs \ +// --pr 44 \ +// --repo nish3451/tinystudio-in \ +// [--token "$GITHUB_TOKEN"] \ +// [--json] [--comment] [--no-fail] +// +// Env fallbacks: GITHUB_REPOSITORY, GITHUB_TOKEN, GITHUB_BASE_REF. +// +// Exit code 0 no same-fix duplicate open PR found +// Exit code 1 duplicate found (or API failure), unless --no-fail +// +// Tuning (defaults calibrated against all open PRs in this repository on +// 2026-08-11: every pair meeting these thresholds was a genuine duplicate +// cluster member, and no unrelated pair reached them): +// --threshold-coverage min shared-file coverage of the smaller PR +// (default 0.8; observed duplicates are 1.0) +// --threshold-similarity min patch-text similarity on shared files +// (default 0.5; observed duplicates 0.54-1.0) + +const BASE_URL = "https://api.github.com" +const COMMENT_MARKER = "" +const USER_AGENT = "check-pr-duplicates" + +// --- diff parsing ----------------------------------------------------------- + +// parseDiff(text) -> Map +// Splits a unified diff into one entry per file. Every line that follows a +// `diff --git` header belongs to that file's patch, including the header +// itself, so identical patches produce identical maps. +export function parseDiff(text) { + const files = new Map() + let current = null + for (const line of String(text ?? "").split("\n")) { + if (line.startsWith("diff --git ")) { + const path = line.slice("diff --git a/".length).split(" b/")[0] + current = path + files.set(path, [line]) + } else if (current !== null) { + files.get(current).push(line) + } + } + return files +} + +// --- similarity ------------------------------------------------------------- + +// ratio(a, b) -> number in [0, 1] +// Ratcliff-Obershelp similarity over line arrays (the same family as +// difflib.SequenceMatcher.ratio, which is what the observed duplicate pairs +// were calibrated against: identical patches score 1.0, the loosest observed +// duplicate pair scored 0.67). +export function ratio(a, b) { + if (a.length === 0 && b.length === 0) return 1 + if (a.length === 0 || b.length === 0) return 0 + const bIndex = new Map() + for (let i = 0; i < b.length; i++) { + const line = b[i] + if (!bIndex.has(line)) bIndex.set(line, []) + bIndex.get(line).push(i) + } + const stack = [[0, a.length, 0, b.length]] + let matchTotal = 0 + while (stack.length > 0) { + const [aStart, aEnd, bStart, bEnd] = stack.pop() + let bestA = -1 + let bestB = -1 + let bestLen = 0 + const j2len = new Map() + for (let i = aStart; i < aEnd; i++) { + const hits = bIndex.get(a[i]) ?? [] + const next = new Map() + for (const j of hits) { + if (j < bStart || j >= bEnd) continue + const len = (j2len.get(j - 1) ?? 0) + 1 + next.set(j, len) + if (len > bestLen) { + bestLen = len + bestA = i - len + 1 + bestB = j - len + 1 + } else if (len === bestLen && (bestB === -1 || j < bestB)) { + bestA = i - len + 1 + bestB = j - len + 1 + } + } + j2len.clear() + for (const [k, v] of next) j2len.set(k, v) + } + if (bestLen > 0) { + matchTotal += 2 * bestLen + stack.push([aStart, bestA, bStart, bestB]) + stack.push([bestA + bestLen, aEnd, bestB + bestLen, bEnd]) + } + } + return matchTotal / (a.length + b.length) +} + +// comparePatches(a, b) -> {coverage, similarity, sharedFiles} | null +// coverage: shared changed files / smaller changed-file set. +// similarity: line ratio over the shared files' patches only. +export function comparePatches(a, b) { + const filesA = [...a.keys()] + const filesB = [...b.keys()] + const shared = filesA.filter(path => b.has(path)) + const minFiles = Math.min(filesA.length, filesB.length) + if (minFiles === 0 || shared.length === 0) return null + const coverage = shared.length / minFiles + const linesA = shared.flatMap(path => a.get(path)) + const linesB = shared.flatMap(path => b.get(path)) + return {coverage, similarity: ratio(linesA, linesB), sharedFiles: shared} +} + +// --- GitHub API ------------------------------------------------------------- + +function makeApi({token, fetchImpl}) { + const fetchFn = fetchImpl ?? globalThis.fetch + return async function api(url, {accept, method = "GET", body} = {}) { + const headers = {"User-Agent": USER_AGENT} + if (accept) headers.Accept = accept + if (token) headers.Authorization = `Bearer ${token}` + if (body !== undefined) headers["Content-Type"] = "application/json" + const res = await fetchFn(url, {method, headers, body: body === undefined ? undefined : JSON.stringify(body)}) + if (!res.ok) throw new Error(`GET/POST ${url} -> ${res.status} ${res.statusText}`) + return res + } +} + +export {makeApi} + +// listOpenPrs(api, owner, repo) -> array of PR objects (pulls list items). +export async function listOpenPrs(api, owner, repo) { + const prs = [] + for (let page = 1; ; page++) { + const res = await api(`${BASE_URL}/repos/${owner}/${repo}/pulls?state=open&per_page=100&page=${page}`) + const batch = await res.json() + prs.push(...batch) + if (batch.length < 100) break + } + return prs +} + +// fetchPatches(api, owner, repo, prs) -> Map> +// The pulls-list `diff_url` points at github.com, so build the API endpoint +// from owner/repo/number instead of trusting it. +export async function fetchPatches(api, owner, repo, prs) { + const patches = new Map() + for (const pr of prs) { + const res = await api(`${BASE_URL}/repos/${owner}/${repo}/pulls/${pr.number}`, {accept: "application/vnd.github.v3.diff"}) + patches.set(pr.number, parseDiff(await res.text())) + } + return patches +} + +// detectDuplicates({api, prs, patches, prNumber}) -> report +// Compares the given PR against every other open PR and returns matches above +// the thresholds (applied by the caller so tests can fix their own defaults). +export function detectDuplicates({api, prs, patches, prNumber, coverageThreshold, similarityThreshold}) { + const current = prs.find(pr => pr.number === prNumber) + if (!current) throw new Error(`PR #${prNumber} is not an open PR in this repository`) + const currentPatch = patches.get(prNumber) + const matches = [] + for (const pr of prs) { + if (pr.number === prNumber) continue + const other = patches.get(pr.number) + const m = comparePatches(currentPatch, other) + if (m && m.coverage >= coverageThreshold && m.similarity >= similarityThreshold) { + matches.push({ + number: pr.number, + title: pr.title, + headRef: pr.head.ref, + createdAt: pr.created_at, + draft: pr.draft ?? false, + coverage: m.coverage, + similarity: m.similarity, + sharedFiles: m.sharedFiles, + }) + } + } + return {prNumber, currentTitle: current.title, currentHeadRef: current.head.ref, matches} +} + +// canonicalNumber(matches) -> earliest open PR in the duplicate cluster. +export function canonicalNumber(matches) { + return matches.reduce((best, m) => (m.createdAt < best.createdAt ? m : best), matches[0])?.number ?? null +} + +// upsertComment(api, owner, repo, prNumber, body) +// Replaces any previous guard comment (identified by the marker) so the PR +// carries exactly one authoritative duplicate report. +export async function upsertComment(api, owner, repo, prNumber, body) { + const list = await api(`${BASE_URL}/repos/${owner}/${repo}/issues/${prNumber}/comments`) + const comments = await list.json() + for (const comment of comments) { + if (comment.body.includes(COMMENT_MARKER)) { + await api(`${BASE_URL}/repos/${owner}/${repo}/issues/comments/${comment.id}`, {method: "DELETE"}) + } + } + await api(`${BASE_URL}/repos/${owner}/${repo}/issues/${prNumber}/comments`, {method: "POST", body}) +} + +function formatReport(report, repo) { + const lines = [] + if (report.matches.length === 0) { + lines.push(`No same-fix duplicate open PR for #${report.prNumber}.`) + return lines.join("\n") + } + const canonical = canonicalNumber(report.matches) + lines.push(`Same-fix duplicate open PRs detected for #${report.prNumber} (${report.currentTitle}).`) + lines.push(`Canonical: #${canonical}. Close this PR and keep the canonical one, or consolidate the fix there.`) + for (const m of report.matches) { + lines.push( + `- #${m.number} (${m.headRef}${m.draft ? ", draft" : ""}, opened ${m.createdAt.slice(0, 10)}): ` + + `coverage=${(m.coverage * 100).toFixed(0)}%, similarity=${(m.similarity * 100).toFixed(0)}%, ` + + `shared files: ${m.sharedFiles.slice(0, 8).join(", ")}${m.sharedFiles.length > 8 ? `, +${m.sharedFiles.length - 8} more` : ""}`, + ) + } + lines.push(`${COMMENT_MARKER}`) + return lines.join("\n") +} + +// --- CLI -------------------------------------------------------------------- + +function parseArgs(argv) { + const args = {pr: null, repo: process.env.GITHUB_REPOSITORY ?? null, token: process.env.GITHUB_TOKEN ?? null, json: false, comment: false, noFail: false, coverageThreshold: 0.8, similarityThreshold: 0.5} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + const value = () => { + i++ + if (i >= argv.length) throw new Error(`Missing value for ${arg}`) + return argv[i] + } + if (arg === "--pr") args.pr = Number(value()) + else if (arg === "--repo") args.repo = value() + else if (arg === "--token") args.token = value() + else if (arg === "--json") args.json = true + else if (arg === "--comment") args.comment = true + else if (arg === "--no-fail") args.noFail = true + else if (arg === "--threshold-coverage") args.coverageThreshold = Number(value()) + else if (arg === "--threshold-similarity") args.similarityThreshold = Number(value()) + else if (arg === "--help" || arg === "-h") { printUsage(); process.exit(0) } + else throw new Error(`Unknown argument: ${arg}`) + } + if (!Number.isInteger(args.pr) || args.pr <= 0) throw new Error("--pr NUMBER is required") + if (!args.repo || !args.repo.includes("/")) throw new Error("--repo OWNER/NAME is required (or GITHUB_REPOSITORY)") + return args +} + +function printUsage() { + console.log(`Usage: node scripts/check-pr-duplicates.mjs --pr NUMBER [--repo OWNER/NAME] [--token TOKEN] [--json] [--comment] [--no-fail] + --pr NUMBER current pull request number (required) + --repo OWNER/NAME defaults to $GITHUB_REPOSITORY + --token TOKEN defaults to $GITHUB_TOKEN (public reads work without it) + --json print a machine-readable report + --comment post/update one marker comment on the PR + --no-fail exit 0 even when duplicates are found + --threshold-coverage N min shared-file coverage (default 0.8) + --threshold-similarity N min patch similarity (default 0.5)`) +} + +async function run(argv, env = process.env, fetchImpl) { + const args = parseArgs(argv) + const [owner, repo] = args.repo.split("/") + const api = makeApi({token: args.token, fetchImpl}) + const prs = await listOpenPrs(api, owner, repo) + const patches = await fetchPatches(api, owner, repo, prs) + const report = detectDuplicates({api, prs, patches, prNumber: args.pr, coverageThreshold: args.coverageThreshold, similarityThreshold: args.similarityThreshold}) + + if (args.json) { + console.log(JSON.stringify({...report, canonical: canonicalNumber(report.matches)}, null, 2)) + } else { + console.log(formatReport(report, args.repo)) + } + if (report.matches.length > 0 && args.comment) { + await upsertComment(api, owner, repo, args.pr, formatReport(report, args.repo)) + } + return report.matches.length > 0 && !args.noFail ? 1 : 0 +} + +import {pathToFileURL} from "node:url" + +const isMain = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + run(process.argv.slice(2)).then( + code => process.exit(code), + error => { + console.error(`check-pr-duplicates: ${error.message}`) + process.exit(1) + }, + ) +} diff --git a/scripts/test-pr-duplicates.mjs b/scripts/test-pr-duplicates.mjs new file mode 100644 index 00000000..8faa9c5d --- /dev/null +++ b/scripts/test-pr-duplicates.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict" +import {comparePatches, detectDuplicates, makeApi, parseDiff, ratio, upsertComment} from "./check-pr-duplicates.mjs" + +const {equal: eq, ok, deepEqual: deq} = assert + +// --- similarity ------------------------------------------------------------- + +{ + const lines = ["a", "b", "c"] + eq(ratio(lines, [...lines]), 1, "identical patches score 1") + eq(ratio([], []), 1, "two empty patches score 1") + eq(ratio(lines, ["x", "y", "z"]), 0, "disjoint patches score 0") + eq(ratio(lines, []), 0, "one empty side scores 0") + const near = ["a", "b", "c", "d", "e", "f"] + const near2 = ["a", "b", "c", "D", "e", "f"] + ok(ratio(near, near2) > 0.8 && ratio(near, near2) < 1, "near-identical patches score between 0.8 and 1") + const sub = [...lines, "extra", "lines", "here"] + ok(ratio(lines, sub) > 0.5, "subset/superset patches still score high") +} + +// --- diff parsing ----------------------------------------------------------- + +{ + const diff = [ + "diff --git a/scripts/a.mjs b/scripts/a.mjs", + "index 000..111 100644", + "--- a/scripts/a.mjs", + "+++ b/scripts/a.mjs", + "@@ -1,3 +1,4 @@", + " same", + "-gone", + "+added", + "diff --git a/scripts/b.mjs b/scripts/b.mjs", + "@@ -5 +5 @@", + "-old", + "+new", + "", + ].join("\n") + const files = parseDiff(diff) + eq(files.size, 2, "one entry per changed file") + eq([...files.keys()].join(","), "scripts/a.mjs,scripts/b.mjs", "paths are the a/ paths") + ok(files.get("scripts/a.mjs").includes("+added"), "a-side patch keeps its lines") + eq(parseDiff("").size, 0, "empty diff yields no files") +} + +// --- coverage + similarity comparison -------------------------------------- + +{ + const patchA = parseDiff("diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n") + const patchB = parseDiff("diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n") + const m = comparePatches(patchA, patchB) + eq(m.coverage, 1, "identical file sets have full coverage") + eq(m.similarity, 1, "identical patches have full similarity") + deq(m.sharedFiles, ["x"], "shared file listed once") + + const patchC = parseDiff("diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+z\n") + const m2 = comparePatches(patchA, patchC) + eq(m2.coverage, 1, "same file set still full coverage") + ok(m2.similarity > 0.4 && m2.similarity < 1, "context lines keep small edits somewhat similar") + + const patchD = parseDiff("diff --git a/y b/y\n@@ -1 +1 @@\n-a\n+b\n") + eq(comparePatches(patchA, patchD), null, "disjoint file sets are no match") + eq(comparePatches(patchA, parseDiff("")), null, "empty patch is no match") +} + +// --- duplicate detection with an injected API ------------------------------- + +const DIFF_A = "diff --git a/scripts/export-a.mjs b/scripts/export-a.mjs\n@@ -1,2 +1,3 @@\n line\n-remove\n+added\n" +const DIFF_B = "diff --git a/scripts/export-a.mjs b/scripts/export-a.mjs\n@@ -1,2 +1,3 @@\n line\n-remove\n+added\n" +const DIFF_C = "diff --git a/scripts/export-a.mjs b/scripts/export-a.mjs\n@@ -9,2 +9,2 @@\n other\n-old\n+new\n" +const DIFF_D = "diff --git a/scripts/unrelated.mjs b/scripts/unrelated.mjs\n@@ -1 +1 @@\n-x\n+y\n" + +function fakeApi({pulls, diffs, comments = []}) { + const state = {posted: [], deleted: []} + const fetchImpl = async (url, {method = "GET", body} = {}) => { + if (/\/pulls\?state=open/.test(url)) { + return {ok: true, json: async () => pulls} + } + if (/\/pulls\//.test(url) && method === "GET") { + const n = Number(url.match(/\/pulls\/(\d+)$/)?.[1]) + return {ok: true, text: async () => diffs.get(n) ?? ""} + } + if (/\/issues\/\d+\/comments$/.test(url) && method === "GET") { + return {ok: true, json: async () => comments} + } + if (/\/issues\/comments\/\d+$/.test(url) && method === "DELETE") { + state.deleted.push(Number(url.match(/\/comments\/(\d+)$/)?.[1])) + return {ok: true, text: async () => ""} + } + if (/\/issues\/\d+\/comments$/.test(url) && method === "POST") { + state.posted.push(JSON.parse(body)) + return {ok: true, json: async () => ({})} + } + throw new Error(`unexpected fetch: ${method} ${url}`) + } + return {fetchImpl, state} +} + +{ + const prs = [ + {number: 10, title: "fix(ops): make export-a honor --help", head: {ref: "fix/export-a"}, created_at: "2026-08-01T00:00:00Z", diff_url: "https://api.github.com/repos/o/r/pulls/10"}, + {number: 11, title: "fix(ops): make export-a honor --help", head: {ref: "fix/export-a-lane1"}, created_at: "2026-08-02T00:00:00Z", diff_url: "https://api.github.com/repos/o/r/pulls/11"}, + {number: 12, title: "fix(ops): unrelated change", head: {ref: "fix/unrelated"}, created_at: "2026-08-03T00:00:00Z", diff_url: "https://api.github.com/repos/o/r/pulls/12"}, + ] + const {fetchImpl} = fakeApi({pulls: prs, diffs: new Map([[10, DIFF_A], [11, DIFF_B], [12, DIFF_D]])}) + const report = detectDuplicates({prs, patches: new Map([[10, parseDiff(DIFF_A)], [11, parseDiff(DIFF_B)], [12, parseDiff(DIFF_D)]]), prNumber: 11, coverageThreshold: 0.8, similarityThreshold: 0.5}) + eq(report.matches.length, 1, "later identical fix flags exactly one duplicate") + eq(report.matches[0].number, 10, "the earlier same-fix PR is named") + eq(report.matches[0].similarity, 1, "identical patches report full similarity") +} + +{ + const prs = [ + {number: 20, title: "fix(ops): export-a help", head: {ref: "fix/a"}, created_at: "2026-08-01T00:00:00Z", diff_url: "https://api.github.com/repos/o/r/pulls/20"}, + {number: 21, title: "fix(ops): export-a copy tweak", head: {ref: "fix/b"}, created_at: "2026-08-02T00:00:00Z", diff_url: "https://api.github.com/repos/o/r/pulls/21"}, + ] + const {fetchImpl} = fakeApi({pulls: prs, diffs: new Map([[20, DIFF_A], [21, DIFF_C]])}) + const report = detectDuplicates({prs, patches: new Map([[20, parseDiff(DIFF_A)], [21, parseDiff(DIFF_C)]]), prNumber: 21, coverageThreshold: 0.8, similarityThreshold: 0.5}) + eq(report.matches.length, 0, "same file but different change is not a same-fix duplicate") +} + +{ + // Comment upsert deletes the previous marker comment and posts one new one. + const comments = [{id: 1, body: "\nold report"}, {id: 2, body: "unrelated comment"}] + const {fetchImpl, state} = fakeApi({pulls: [], diffs: new Map(), comments}) + await upsertComment(makeApi({token: null, fetchImpl}), "o", "r", 5, "\nnew report") + deq(state.deleted, [1], "previous guard comment is replaced") + eq(state.posted.length, 1, "one fresh comment is posted") +} + +console.log("test-pr-duplicates: ok")