From 2361f5b5e099b6592d29c289b663db3b897c859f Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Thu, 26 Mar 2026 14:05:11 +0900 Subject: [PATCH 1/9] Add GitHub Action to find overlapping PRs Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 182 ++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 .github/workflows/overlapping-prs.yml diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml new file mode 100644 index 00000000000000..700f9d3214a8db --- /dev/null +++ b/.github/workflows/overlapping-prs.yml @@ -0,0 +1,182 @@ +name: Overlapping PRs + +on: + pull_request_target: + types: + - opened + - synchronize + +# Cancels all previous workflow runs for pull requests that have not completed. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +# Disable permissions for all available scopes by default. +# Any needed permissions should be configured at the job level. +permissions: {} + +jobs: + overlapping-prs: + name: Find overlapping PRs + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + contents: read + timeout-minutes: 10 + steps: + - name: Find PRs touching the same files + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + retries: 2 + retry-exempt-status-codes: 418 + script: | + const prNumber = context.payload.pull_request.number; + + // Get files changed in this PR. + const changedFiles = []; + for await ( const response of github.paginate.iterator( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100, + } + ) ) { + for ( const file of response.data ) { + changedFiles.push( file.filename ); + } + } + + if ( changedFiles.length === 0 ) { + core.info( 'No changed files found.' ); + return; + } + + core.info( `This PR touches ${ changedFiles.length } file(s).` ); + + // Get all open PRs (including drafts). + const openPRs = []; + for await ( const response of github.paginate.iterator( + github.rest.pulls.list, + { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + } + ) ) { + for ( const pr of response.data ) { + if ( pr.number !== prNumber ) { + openPRs.push( pr ); + } + } + } + + core.info( `Found ${ openPRs.length } other open PR(s) to check.` ); + + // For each open PR, get its changed files and find overlaps. + const changedFilesSet = new Set( changedFiles ); + const overlapping = []; + + for ( const pr of openPRs ) { + const prFiles = []; + for await ( const response of github.paginate.iterator( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + } + ) ) { + for ( const file of response.data ) { + prFiles.push( file.filename ); + } + } + + const shared = prFiles.filter( ( f ) => changedFilesSet.has( f ) ); + if ( shared.length > 0 ) { + overlapping.push( { + number: pr.number, + title: pr.title, + url: pr.html_url, + draft: pr.draft, + author: pr.user.login, + sharedFiles: shared, + } ); + } + } + + // Build comment body. + const marker = ''; + let body; + + if ( overlapping.length === 0 ) { + core.info( 'No overlapping PRs found.' ); + // Remove existing comment if no overlaps remain. + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + } + ); + const existing = comments.find( ( c ) => c.body.includes( marker ) ); + if ( existing ) { + await github.rest.issues.deleteComment( { + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + } ); + core.info( 'Deleted stale overlapping PRs comment.' ); + } + return; + } + + body = `${ marker }\n`; + body += `## Overlapping PRs\n\n`; + body += `The following open PRs touch some of the same files as this PR:\n\n`; + + for ( const pr of overlapping ) { + const draftLabel = pr.draft ? ' (draft)' : ''; + body += `### #${ pr.number }${ draftLabel } — ${ pr.title }\n`; + body += `by @${ pr.author }\n\n`; + body += `
\nShared files (${ pr.sharedFiles.length })\n\n`; + for ( const file of pr.sharedFiles ) { + body += `- \`${ file }\`\n`; + } + body += `\n
\n\n`; + } + + // Find existing comment or create a new one. + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + } + ); + const existing = comments.find( ( c ) => c.body.includes( marker ) ); + + if ( existing ) { + await github.rest.issues.updateComment( { + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + } ); + core.info( `Updated existing comment with ${ overlapping.length } overlapping PR(s).` ); + } else { + await github.rest.issues.createComment( { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + } ); + core.info( `Created comment with ${ overlapping.length } overlapping PR(s).` ); + } From 8589fe6b0c19b2163a26300de0bd1cfafe7c4afd Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Thu, 26 Mar 2026 14:17:05 +0900 Subject: [PATCH 2/9] Switch to pull_request trigger for testing and touch shared file Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 2 +- packages/components/CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml index 700f9d3214a8db..27ad36093e0bdd 100644 --- a/.github/workflows/overlapping-prs.yml +++ b/.github/workflows/overlapping-prs.yml @@ -1,7 +1,7 @@ name: Overlapping PRs on: - pull_request_target: + pull_request: types: - opened - synchronize diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index ac350d1f6190e1..0c6c41d8891c7b 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- Testing overlapping PRs bot ([#76820](https://github.com/WordPress/gutenberg/pull/76820)). + ### Bug Fixes - `RadioControl`: Add `role="radiogroup"` to the wrapping `fieldset` element ([#76745](https://github.com/WordPress/gutenberg/pull/76745)). From 526a7c0710863523e18b4a341507388429b8bd62 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Thu, 26 Mar 2026 14:38:50 +0900 Subject: [PATCH 3/9] Compare diff line ranges instead of just filenames Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 70 +++++++++++++++++++++------ 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml index 27ad36093e0bdd..73db924f6d951a 100644 --- a/.github/workflows/overlapping-prs.yml +++ b/.github/workflows/overlapping-prs.yml @@ -24,7 +24,7 @@ jobs: contents: read timeout-minutes: 10 steps: - - name: Find PRs touching the same files + - name: Find PRs touching the same lines uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: retries: 2 @@ -32,8 +32,39 @@ jobs: script: | const prNumber = context.payload.pull_request.number; - // Get files changed in this PR. - const changedFiles = []; + // Parse hunk headers from a patch string. + // Returns an array of { start, end } line ranges (base side). + function parseHunks( patch ) { + if ( ! patch ) { + return []; + } + const hunks = []; + const regex = /^@@ -(\d+)(?:,(\d+))? /gm; + let match; + while ( ( match = regex.exec( patch ) ) !== null ) { + const start = parseInt( match[ 1 ], 10 ); + const count = match[ 2 ] !== undefined ? parseInt( match[ 2 ], 10 ) : 1; + if ( count > 0 ) { + hunks.push( { start, end: start + count - 1 } ); + } + } + return hunks; + } + + // Check if two sets of line ranges overlap. + function rangesOverlap( rangesA, rangesB ) { + for ( const a of rangesA ) { + for ( const b of rangesB ) { + if ( a.start <= b.end && b.start <= a.end ) { + return true; + } + } + } + return false; + } + + // Get files changed in this PR with their patches. + const changedFiles = new Map(); for await ( const response of github.paginate.iterator( github.rest.pulls.listFiles, { @@ -44,16 +75,16 @@ jobs: } ) ) { for ( const file of response.data ) { - changedFiles.push( file.filename ); + changedFiles.set( file.filename, parseHunks( file.patch ) ); } } - if ( changedFiles.length === 0 ) { + if ( changedFiles.size === 0 ) { core.info( 'No changed files found.' ); return; } - core.info( `This PR touches ${ changedFiles.length } file(s).` ); + core.info( `This PR touches ${ changedFiles.size } file(s).` ); // Get all open PRs (including drafts). const openPRs = []; @@ -75,8 +106,7 @@ jobs: core.info( `Found ${ openPRs.length } other open PR(s) to check.` ); - // For each open PR, get its changed files and find overlaps. - const changedFilesSet = new Set( changedFiles ); + // For each open PR, get its changed files and check for line overlaps. const overlapping = []; for ( const pr of openPRs ) { @@ -91,19 +121,31 @@ jobs: } ) ) { for ( const file of response.data ) { - prFiles.push( file.filename ); + prFiles.push( file ); + } + } + + const filesWithOverlap = []; + for ( const file of prFiles ) { + const ourHunks = changedFiles.get( file.filename ); + if ( ! ourHunks ) { + continue; + } + const theirHunks = parseHunks( file.patch ); + // If either side has no hunks (e.g. binary file), fall back to file-level match. + if ( ourHunks.length === 0 || theirHunks.length === 0 || rangesOverlap( ourHunks, theirHunks ) ) { + filesWithOverlap.push( file.filename ); } } - const shared = prFiles.filter( ( f ) => changedFilesSet.has( f ) ); - if ( shared.length > 0 ) { + if ( filesWithOverlap.length > 0 ) { overlapping.push( { number: pr.number, title: pr.title, url: pr.html_url, draft: pr.draft, author: pr.user.login, - sharedFiles: shared, + sharedFiles: filesWithOverlap, } ); } } @@ -138,13 +180,13 @@ jobs: body = `${ marker }\n`; body += `## Overlapping PRs\n\n`; - body += `The following open PRs touch some of the same files as this PR:\n\n`; + body += `The following open PRs modify the same lines of code as this PR:\n\n`; for ( const pr of overlapping ) { const draftLabel = pr.draft ? ' (draft)' : ''; body += `### #${ pr.number }${ draftLabel } — ${ pr.title }\n`; body += `by @${ pr.author }\n\n`; - body += `
\nShared files (${ pr.sharedFiles.length })\n\n`; + body += `
\nOverlapping files (${ pr.sharedFiles.length })\n\n`; for ( const file of pr.sharedFiles ) { body += `- \`${ file }\`\n`; } From bf137680bc5f1ce5215bb23bb628befad066bcc8 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Thu, 26 Mar 2026 15:06:30 +0900 Subject: [PATCH 4/9] Exclude CHANGELOG.md, package-lock.json, and composer.lock from overlap detection Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml index 73db924f6d951a..bfd3d5b4da93e1 100644 --- a/.github/workflows/overlapping-prs.yml +++ b/.github/workflows/overlapping-prs.yml @@ -32,6 +32,22 @@ jobs: script: | const prNumber = context.payload.pull_request.number; + // Files to ignore — these always overlap and conflicts are trivial. + const IGNORED_PATTERNS = [ + 'package-lock.json', + 'composer.lock', + '**/CHANGELOG.md', + ]; + + function isIgnored( filename ) { + return IGNORED_PATTERNS.some( ( pattern ) => { + if ( pattern.startsWith( '**/' ) ) { + return filename.endsWith( pattern.slice( 2 ) ); + } + return filename === pattern; + } ); + } + // Parse hunk headers from a patch string. // Returns an array of { start, end } line ranges (base side). function parseHunks( patch ) { @@ -75,7 +91,9 @@ jobs: } ) ) { for ( const file of response.data ) { - changedFiles.set( file.filename, parseHunks( file.patch ) ); + if ( ! isIgnored( file.filename ) ) { + changedFiles.set( file.filename, parseHunks( file.patch ) ); + } } } From e812dc082dc2e80c8788b1e6b642451cc5fa7619 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Thu, 26 Mar 2026 15:07:49 +0900 Subject: [PATCH 5/9] Test: touch block-editor index.js to trigger overlap detection Co-Authored-By: Claude Opus 4.6 --- packages/block-editor/src/components/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index b16b5e8307cd8b..84b62cf1c23c39 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -1,5 +1,5 @@ /* - * Block Creation Components + * Block Creation Components (testing overlapping PRs bot) */ export * from './colors'; From 8683ae0158c920394c00760c3fa7f08698f1ba26 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Thu, 26 Mar 2026 15:18:20 +0900 Subject: [PATCH 6/9] Test: touch line 170 of index.js to overlap with PR #76787 Co-Authored-By: Claude Opus 4.6 --- packages/block-editor/src/components/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 84b62cf1c23c39..bc021cfa7fee12 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -167,6 +167,7 @@ export { export { default as __experimentalBlockPatternsList } from './block-patterns-list'; export { default as __experimentalPublishDateTimePicker } from './publish-date-time-picker'; export { default as __experimentalInspectorPopoverHeader } from './inspector-popover-header'; +// Testing overlapping PRs bot — this line should overlap with PR #76787 export { default as BlockPopover } from './block-popover'; export { useBlockEditingMode } from './block-editing-mode'; From 36ff4bb0752e6989174218152f785cf54122ca91 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Sat, 28 Mar 2026 10:26:03 +0900 Subject: [PATCH 7/9] Address review feedback: Search API pre-filter, null body guards, limitation docs - Use GitHub Search API to find candidate PRs by filename before fetching patches, reducing API calls from 2000+ to only the relevant few - Add rate-limit guard that bails if remaining requests < 500 - Guard against null comment body in all .find() calls - Document the base-side line range limitation on parseHunks - Extract deleteStaleComment() helper to reduce duplication Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 115 +++++++++++++++++--------- 1 file changed, 77 insertions(+), 38 deletions(-) diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml index bfd3d5b4da93e1..d67b408b01d104 100644 --- a/.github/workflows/overlapping-prs.yml +++ b/.github/workflows/overlapping-prs.yml @@ -50,6 +50,11 @@ jobs: // Parse hunk headers from a patch string. // Returns an array of { start, end } line ranges (base side). + // Note: this compares base-side line ranges, so it catches edits + // to the same original lines. It may miss pure insertions at the + // same point if the base-side context ranges don't overlap (e.g. + // two PRs both inserting at line 100 but with ,0 count). This is + // acceptable since the tool is a heads-up, not a merge-conflict detector. function parseHunks( patch ) { if ( ! patch ) { return []; @@ -104,30 +109,83 @@ jobs: core.info( `This PR touches ${ changedFiles.size } file(s).` ); - // Get all open PRs (including drafts). - const openPRs = []; - for await ( const response of github.paginate.iterator( - github.rest.pulls.list, - { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100, + // Marker used to identify the bot's comment for updates/deletion. + const marker = ''; + + // Helper to clean up a stale bot comment when there are no overlaps. + async function deleteStaleComment() { + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + } + ); + const existing = comments.find( ( c ) => c.body && c.body.includes( marker ) ); + if ( existing ) { + await github.rest.issues.deleteComment( { + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + } ); + core.info( 'Deleted stale overlapping PRs comment.' ); } - ) ) { - for ( const pr of response.data ) { - if ( pr.number !== prNumber ) { - openPRs.push( pr ); + } + + // Pre-filter: use the Search API to find PRs that touch the same files. + // This avoids fetching patches for all 2000+ open PRs and blowing + // through the API rate limit. + const candidatePRs = new Map(); // PR number -> { number, title, ... } + const ourFilenames = [ ...changedFiles.keys() ]; + + for ( const filename of ourFilenames ) { + // Search for open PRs (excluding ours) that touch this file. + // The search API is much cheaper than listing files for every PR. + const query = `repo:${ context.repo.owner }/${ context.repo.repo } is:pr is:open "${ filename }" -number:${ prNumber }`; + try { + const searchResults = await github.rest.search.issuesAndPullRequests( { + q: query, + per_page: 100, + } ); + for ( const item of searchResults.data.items ) { + if ( ! candidatePRs.has( item.number ) ) { + candidatePRs.set( item.number, { + number: item.number, + title: item.title, + draft: item.draft, + author: item.user.login, + html_url: item.html_url, + } ); + } } + } catch ( e ) { + core.warning( `Search failed for file "${ filename }": ${ e.message }` ); } } - core.info( `Found ${ openPRs.length } other open PR(s) to check.` ); + core.info( `Search found ${ candidatePRs.size } candidate PR(s) sharing filenames.` ); + + if ( candidatePRs.size === 0 ) { + core.info( 'No candidate PRs found — skipping patch comparison.' ); + await deleteStaleComment(); + return; + } + + // Check rate limit before the expensive part. + const rateLimit = await github.rest.rateLimit.get(); + const remaining = rateLimit.data.resources.core.remaining; + core.info( `API rate limit remaining: ${ remaining }` ); + if ( remaining < 500 ) { + core.warning( `Rate limit too low (${ remaining } remaining) — skipping patch comparison.` ); + return; + } - // For each open PR, get its changed files and check for line overlaps. + // For each candidate PR, get its changed files and check for line overlaps. const overlapping = []; - for ( const pr of openPRs ) { + for ( const [ , pr ] of candidatePRs ) { const prFiles = []; for await ( const response of github.paginate.iterator( github.rest.pulls.listFiles, @@ -162,37 +220,18 @@ jobs: title: pr.title, url: pr.html_url, draft: pr.draft, - author: pr.user.login, + author: pr.author, sharedFiles: filesWithOverlap, } ); } } // Build comment body. - const marker = ''; let body; if ( overlapping.length === 0 ) { core.info( 'No overlapping PRs found.' ); - // Remove existing comment if no overlaps remain. - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - per_page: 100, - } - ); - const existing = comments.find( ( c ) => c.body.includes( marker ) ); - if ( existing ) { - await github.rest.issues.deleteComment( { - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - } ); - core.info( 'Deleted stale overlapping PRs comment.' ); - } + await deleteStaleComment(); return; } @@ -221,7 +260,7 @@ jobs: per_page: 100, } ); - const existing = comments.find( ( c ) => c.body.includes( marker ) ); + const existing = comments.find( ( c ) => c.body && c.body.includes( marker ) ); if ( existing ) { await github.rest.issues.updateComment( { From eb58731590819fc28dcc6ae7ac96d8a660ab3cd4 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Sat, 28 Mar 2026 10:56:39 +0900 Subject: [PATCH 8/9] Document supported ignore pattern syntax and limitations Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml index d67b408b01d104..7315a7ef43cbe8 100644 --- a/.github/workflows/overlapping-prs.yml +++ b/.github/workflows/overlapping-prs.yml @@ -33,6 +33,13 @@ jobs: const prNumber = context.payload.pull_request.number; // Files to ignore — these always overlap and conflicts are trivial. + // Supported pattern syntax (intentionally minimal): + // - Exact match: 'package-lock.json' matches only that path. + // - '**/suffix': matches any file whose path ends with '/suffix' + // (e.g. '**/CHANGELOG.md' matches 'packages/foo/CHANGELOG.md'). + // More complex globs (e.g. '*.json', 'packages/*/src/**') are NOT + // supported. If richer patterns are needed in the future, consider + // pulling in a glob library like minimatch. const IGNORED_PATTERNS = [ 'package-lock.json', 'composer.lock', From 60643a337ec8209e9d0e8fe5f965d408e0072db3 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Tue, 31 Mar 2026 19:52:04 +0100 Subject: [PATCH 9/9] Only run overlap check when check-overlaps label is present Co-Authored-By: Claude Opus 4.6 --- .github/workflows/overlapping-prs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/overlapping-prs.yml b/.github/workflows/overlapping-prs.yml index 7315a7ef43cbe8..712dece88927eb 100644 --- a/.github/workflows/overlapping-prs.yml +++ b/.github/workflows/overlapping-prs.yml @@ -5,6 +5,7 @@ on: types: - opened - synchronize + - labeled # Cancels all previous workflow runs for pull requests that have not completed. concurrency: @@ -18,6 +19,7 @@ permissions: {} jobs: overlapping-prs: name: Find overlapping PRs + if: contains(github.event.pull_request.labels.*.name, 'check-overlaps') runs-on: ubuntu-24.04 permissions: pull-requests: write