From e729212d94e92cfe78d1784b002efe9cabb00560 Mon Sep 17 00:00:00 2001 From: RevealUI Studio <43050008+joshua-v-dev@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:18:56 -0400 Subject: [PATCH] feat(gha): close overlapping open PRs at Jaccard 0.8 GitHub-side lock so two harnesses on the same paste cannot both sit open. Older PR wins. pull_request_target, no head checkout. Path overlap is the key; GAP id in the branch is not required. --- .github/actions/README.md | 32 +++ .github/actions/duplicate-open-pr/action.yml | 32 +++ .../duplicate-open-pr/duplicate-open-pr.cjs | 209 +++++++++++++++ .../duplicate-open-pr.test.cjs | 253 ++++++++++++++++++ .github/workflows/duplicate-open-pr-test.yml | 20 ++ .github/workflows/duplicate-open-pr.yml | 24 ++ 6 files changed, 570 insertions(+) create mode 100644 .github/actions/duplicate-open-pr/action.yml create mode 100644 .github/actions/duplicate-open-pr/duplicate-open-pr.cjs create mode 100644 .github/actions/duplicate-open-pr/duplicate-open-pr.test.cjs create mode 100644 .github/workflows/duplicate-open-pr-test.yml create mode 100644 .github/workflows/duplicate-open-pr.yml diff --git a/.github/actions/README.md b/.github/actions/README.md index 04b01e2..6c5f050 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -55,6 +55,38 @@ jobs: client_leak_patterns: ${{ secrets.CLIENT_LEAK_PATTERNS }} ``` +## duplicate-open-pr (composite) + +Closes a newly opened PR when its changed-path set overlaps an **older** +open PR against the same base at Jaccard ≥ 0.8. Check context name is +exactly `duplicate-open-pr`. Runs via `pull_request_target` on the +**base** branch so Cursor snowflake heads still hit the lock. + +Does not require a GAP id in the branch name. Path overlap is the key. +A 1-file follow-up against a larger sibling PR stays open (Jaccard stays +low). Add `not-a-duplicate` to skip. + +### Caller (thin) + +```yaml +name: duplicate-open-pr +on: + pull_request_target: + types: [opened, reopened, synchronize] +permissions: + contents: read + pull-requests: write +jobs: + duplicate-open-pr: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: RevealUIStudio/.github/.github/actions/duplicate-open-pr@ +``` + +Do not checkout the PR head in this job. + ## issue-leak-scan (reusable workflow) Scans issue/PR/comment bodies via gitleaks + `.gitleaks.issues.toml` on the diff --git a/.github/actions/duplicate-open-pr/action.yml b/.github/actions/duplicate-open-pr/action.yml new file mode 100644 index 0000000..61f2c7c --- /dev/null +++ b/.github/actions/duplicate-open-pr/action.yml @@ -0,0 +1,32 @@ +name: duplicate-open-pr +description: > + Close a newly opened PR when its changed-path set overlaps an older open + PR against the same base at Jaccard >= threshold. Fleet lock so two + harnesses cannot both sit open on the same paste. + +inputs: + github-token: + description: Token with pull-requests write (close + comment) + required: false + default: ${{ github.token }} + threshold: + description: Jaccard close threshold in (0, 1] + required: false + default: '0.8' + +runs: + using: composite + steps: + - name: Detect overlapping open PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DUPLICATE_PR_THRESHOLD: ${{ inputs.threshold }} + with: + github-token: ${{ inputs.github-token }} + script: | + const path = require('node:path'); + const { run } = require(path.join( + process.env.GITHUB_ACTION_PATH, + 'duplicate-open-pr.cjs', + )); + await run({ github, context, core }); diff --git a/.github/actions/duplicate-open-pr/duplicate-open-pr.cjs b/.github/actions/duplicate-open-pr/duplicate-open-pr.cjs new file mode 100644 index 0000000..e9b1c67 --- /dev/null +++ b/.github/actions/duplicate-open-pr/duplicate-open-pr.cjs @@ -0,0 +1,209 @@ +'use strict'; + +/** Label that skips close/fail. Explicit override, not a memory rule. */ +const SKIP_LABEL = 'not-a-duplicate'; +const DEFAULT_THRESHOLD = 0.8; + +/** + * Jaccard similarity of two filename lists: |A∩B| / |A∪B|. + * Empty vs empty is 0 so two empty diffs are not treated as duplicates. + */ +function jaccard(filesA, filesB) { + const a = uniqueNames(filesA); + const b = uniqueNames(filesB); + if (a.size === 0 && b.size === 0) return 0; + let intersection = 0; + for (const name of a) { + if (b.has(name)) intersection += 1; + } + const union = a.size + b.size - intersection; + if (union === 0) return 0; + return intersection / union; +} + +function uniqueNames(files) { + const set = new Set(); + if (!Array.isArray(files)) return set; + for (const item of files) { + if (typeof item === 'string' && item.length > 0) set.add(item); + } + return set; +} + +function labelNames(labels) { + const names = []; + if (!Array.isArray(labels)) return names; + for (const label of labels) { + if (typeof label === 'string') { + names.push(label); + continue; + } + if (label && typeof label.name === 'string') names.push(label.name); + } + return names; +} + +function isOlderOrEqual(other, current) { + const otherTime = Date.parse(other.createdAt); + const currentTime = Date.parse(current.createdAt); + if (Number.isNaN(otherTime) || Number.isNaN(currentTime)) { + return other.number < current.number; + } + if (otherTime < currentTime) return true; + if (otherTime > currentTime) return false; + return other.number < current.number; +} + +/** + * Return the oldest open PR that overlaps `current` at >= threshold, or null. + * Newer PRs never win the lock. The current PR is skipped. + */ +function pickDuplicate(current, others, threshold) { + const floor = typeof threshold === 'number' ? threshold : DEFAULT_THRESHOLD; + if (labelNames(current.labels).includes(SKIP_LABEL)) return null; + let winner = null; + if (!Array.isArray(others)) return null; + for (const other of others) { + if (!other || other.number === current.number) continue; + if (!isOlderOrEqual(other, current)) continue; + const score = jaccard(current.files, other.files); + if (score < floor) continue; + if (!winner || other.number < winner.number) { + winner = { number: other.number, score, htmlUrl: other.htmlUrl || '' }; + } + } + return winner; +} + +async function defaultListFiles(github, owner, repo, pullNumber) { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + const names = []; + for (const file of files) { + if (file && typeof file.filename === 'string') names.push(file.filename); + } + return names; +} + +async function defaultListOpenPrs(github, owner, repo, base) { + return github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + base, + per_page: 100, + }); +} + +async function run({ + github, + context, + core, + listFilesFn, + listOpenPrsFn, + threshold, +} = {}) { + if (!core || typeof core.info !== 'function' || typeof core.setFailed !== 'function') { + throw new Error('run() requires core.info and core.setFailed'); + } + const pr = context && context.payload && context.payload.pull_request; + if (!pr) { + core.info('No pull_request in payload; skip.'); + return { skipped: true, reason: 'no-pull-request' }; + } + const repoCtx = context.repo || {}; + const owner = repoCtx.owner; + const repo = repoCtx.repo; + if (!owner || !repo) { + core.setFailed('Missing context.repo.owner/repo'); + return { skipped: true, reason: 'no-repo' }; + } + + const floor = + typeof threshold === 'number' + ? threshold + : parseFloat(process.env.DUPLICATE_PR_THRESHOLD || String(DEFAULT_THRESHOLD)); + if (!Number.isFinite(floor) || floor <= 0 || floor > 1) { + core.setFailed('Invalid duplicate-open-pr threshold'); + return { skipped: true, reason: 'bad-threshold' }; + } + + const labels = labelNames(pr.labels); + if (labels.includes(SKIP_LABEL)) { + core.info('Label not-a-duplicate present; skip.'); + return { skipped: true, reason: 'skip-label' }; + } + + const listFiles = listFilesFn || ((n) => defaultListFiles(github, owner, repo, n)); + const listOpen = listOpenPrsFn || (() => defaultListOpenPrs(github, owner, repo, pr.base.ref)); + + const currentFiles = await listFiles(pr.number); + const open = await listOpen(); + const others = []; + for (const other of open) { + if (!other || other.number === pr.number) continue; + const files = await listFiles(other.number); + others.push({ + number: other.number, + createdAt: other.created_at, + files, + htmlUrl: other.html_url, + }); + } + + const hit = pickDuplicate( + { + number: pr.number, + createdAt: pr.created_at, + files: currentFiles, + labels, + }, + others, + floor, + ); + if (!hit) { + core.info('No overlapping open PR at threshold ' + String(floor)); + return { skipped: false, duplicate: null }; + } + + const score = hit.score.toFixed(2); + const body = [ + 'Closed as a duplicate of #' + + String(hit.number) + + ' (changed-path Jaccard ' + + score + + ', threshold ' + + floor.toFixed(2) + + ').', + '', + 'The older PR keeps the lock so two harnesses on the same paste cannot both sit open.', + 'If this is a different change, add the `not-a-duplicate` label and reopen.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body, + }); + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + core.setFailed('Duplicate of #' + String(hit.number) + ' (Jaccard ' + score + ')'); + return { skipped: false, duplicate: hit }; +} + +module.exports = { + SKIP_LABEL, + DEFAULT_THRESHOLD, + jaccard, + pickDuplicate, + run, +}; diff --git a/.github/actions/duplicate-open-pr/duplicate-open-pr.test.cjs b/.github/actions/duplicate-open-pr/duplicate-open-pr.test.cjs new file mode 100644 index 0000000..3ba2a49 --- /dev/null +++ b/.github/actions/duplicate-open-pr/duplicate-open-pr.test.cjs @@ -0,0 +1,253 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + DEFAULT_THRESHOLD, + jaccard, + pickDuplicate, + run, + SKIP_LABEL, +} = require('./duplicate-open-pr.cjs'); + +describe('jaccard', () => { + it('returns 0 for two empty lists', () => { + assert.equal(jaccard([], []), 0); + }); + + it('returns 1 for identical path sets', () => { + const files = ['app/lib/engagements.ts', 'app/lib/quote.ts']; + assert.equal(jaccard(files, files.slice()), 1); + }); + + it('returns ~1 for the agency#164 vs #165 strip (same copy files)', () => { + const a = [ + 'README.md', + 'app/lib/engagements.ts', + 'app/lib/quote.ts', + 'app/lib/__tests__/engagements.test.ts', + 'app/lib/__tests__/quote.test.ts', + 'app/lib/__tests__/public-copy.test.ts', + ]; + const b = a.slice(); + b.push('app/routes/__tests__/ProcessPage.test.tsx'); + const score = jaccard(a, b); + assert.ok(score >= DEFAULT_THRESHOLD, 'score=' + String(score)); + }); + + it('stays low for jv#1518 vs #1521 (one shared file in a larger board PR)', () => { + const board = [ + 'docs/gaps/GAP-194.yml', + 'docs/gaps/GAP-300.yml', + 'docs/gaps/GAP-416.yml', + 'docs/gaps/GAP-426.yml', + 'docs/gaps/GAP-465.yml', + 'docs/gaps/GAP-466.yml', + 'docs/gaps/GAP-360.yml', + 'docs/gaps/closed/GAP-448.yml', + 'docs/gaps/closed/GAP-485.yml', + 'docs/initiatives/admin-dashboard.yml', + 'docs/initiatives/product-frontend-ves.yml', + ]; + const liveNote = ['docs/gaps/closed/GAP-485.yml']; + const score = jaccard(board, liveNote); + assert.ok(score < 0.3, 'score=' + String(score)); + assert.ok(score < DEFAULT_THRESHOLD); + }); +}); + +describe('pickDuplicate', () => { + const stripFiles = ['app/lib/engagements.ts', 'app/lib/quote.ts']; + + it('selects the older overlapping PR (164 wins over 165)', () => { + const current = { + number: 165, + createdAt: '2026-08-28T06:56:49Z', + files: stripFiles, + labels: [], + }; + const hit = pickDuplicate( + current, + [ + { + number: 164, + createdAt: '2026-08-28T06:50:21Z', + files: stripFiles, + htmlUrl: 'https://github.com/RevealUIStudio/agency/pull/164', + }, + ], + DEFAULT_THRESHOLD, + ); + assert.ok(hit); + assert.equal(hit.number, 164); + assert.equal(hit.score, 1); + }); + + it('does not select a newer overlapping PR', () => { + const current = { + number: 164, + createdAt: '2026-08-28T06:50:21Z', + files: stripFiles, + labels: [], + }; + const hit = pickDuplicate( + current, + [ + { + number: 165, + createdAt: '2026-08-28T06:56:49Z', + files: stripFiles, + }, + ], + DEFAULT_THRESHOLD, + ); + assert.equal(hit, null); + }); + + it('does not close the 485 live-note PR against the 360 paper-close PR', () => { + const current = { + number: 1521, + createdAt: '2026-08-28T07:09:22Z', + files: ['docs/gaps/closed/GAP-485.yml'], + labels: [], + }; + const hit = pickDuplicate( + current, + [ + { + number: 1518, + createdAt: '2026-08-28T06:56:52Z', + files: [ + 'docs/gaps/GAP-360.yml', + 'docs/gaps/closed/GAP-485.yml', + 'docs/gaps/GAP-466.yml', + 'docs/initiatives/admin-dashboard.yml', + ], + }, + ], + DEFAULT_THRESHOLD, + ); + assert.equal(hit, null); + }); + + it('skips when not-a-duplicate is present', () => { + const current = { + number: 2, + createdAt: '2026-08-28T07:00:00Z', + files: stripFiles, + labels: [{ name: SKIP_LABEL }], + }; + const hit = pickDuplicate( + current, + [{ number: 1, createdAt: '2026-08-28T06:00:00Z', files: stripFiles }], + DEFAULT_THRESHOLD, + ); + assert.equal(hit, null); + }); +}); + +describe('run', () => { + function mockCore() { + const lines = []; + return { + lines, + info(msg) { + lines.push(['info', msg]); + }, + setFailed(msg) { + lines.push(['fail', msg]); + }, + }; + } + + it('closes the newer PR and fails the check', async () => { + const comments = []; + const updates = []; + const core = mockCore(); + const result = await run({ + github: { + rest: { + issues: { + async createComment(args) { + comments.push(args); + }, + }, + pulls: { + async update(args) { + updates.push(args); + }, + }, + }, + }, + context: { + repo: { owner: 'RevealUIStudio', repo: 'agency' }, + payload: { + pull_request: { + number: 165, + created_at: '2026-08-28T06:56:49Z', + base: { ref: 'test' }, + labels: [], + }, + }, + }, + core, + async listFilesFn(n) { + return n === 164 || n === 165 ? ['app/lib/engagements.ts'] : []; + }, + async listOpenPrsFn() { + return [ + { + number: 164, + created_at: '2026-08-28T06:50:21Z', + html_url: 'https://github.com/RevealUIStudio/agency/pull/164', + }, + { + number: 165, + created_at: '2026-08-28T06:56:49Z', + html_url: 'https://github.com/RevealUIStudio/agency/pull/165', + }, + ]; + }, + }); + assert.equal(result.duplicate.number, 164); + assert.equal(comments.length, 1); + assert.equal(updates.length, 1); + assert.equal(updates[0].state, 'closed'); + assert.equal(updates[0].pull_number, 165); + assert.ok(comments[0].body.includes('#164')); + assert.equal(core.lines[0][0], 'fail'); + }); + + it('no-ops when there is no overlap', async () => { + const core = mockCore(); + const result = await run({ + github: { rest: { issues: {}, pulls: {} } }, + context: { + repo: { owner: 'RevealUIStudio', repo: 'agency' }, + payload: { + pull_request: { + number: 10, + created_at: '2026-08-28T07:00:00Z', + base: { ref: 'test' }, + labels: [], + }, + }, + }, + core, + async listFilesFn(n) { + return n === 10 ? ['README.md'] : ['app/lib/engagements.ts']; + }, + async listOpenPrsFn() { + return [ + { number: 9, created_at: '2026-08-28T06:00:00Z', html_url: '' }, + { number: 10, created_at: '2026-08-28T07:00:00Z', html_url: '' }, + ]; + }, + }); + assert.equal(result.duplicate, null); + assert.equal( + core.lines.some((line) => line[0] === 'fail'), + false, + ); + }); +}); diff --git a/.github/workflows/duplicate-open-pr-test.yml b/.github/workflows/duplicate-open-pr-test.yml new file mode 100644 index 0000000..79dfd9c --- /dev/null +++ b/.github/workflows/duplicate-open-pr-test.yml @@ -0,0 +1,20 @@ +name: duplicate-open-pr tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Unit test Jaccard gate + run: node --test .github/actions/duplicate-open-pr/duplicate-open-pr.test.cjs diff --git a/.github/workflows/duplicate-open-pr.yml b/.github/workflows/duplicate-open-pr.yml new file mode 100644 index 0000000..d528c5f --- /dev/null +++ b/.github/workflows/duplicate-open-pr.yml @@ -0,0 +1,24 @@ +name: duplicate-open-pr + +# Thin caller for THIS repo. Other fleet repos pin the composite action SHA. +# pull_request_target + no head checkout: the gate must run from the base +# branch so Cursor snowflake branches still hit it. + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + duplicate-open-pr: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: ./.github/actions/duplicate-open-pr