Skip to content
Open
290 changes: 290 additions & 0 deletions .github/workflows/overlapping-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
name: Overlapping PRs

on:
pull_request:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming that pull_request_target should be the right trigger here (acknowledging that @scruffian already mentinoed it, too)

types:
- opened
- synchronize
- labeled

# 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
if: contains(github.event.pull_request.labels.*.name, 'check-overlaps')
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
timeout-minutes: 10
steps:
- name: Find PRs touching the same lines
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
retries: 2
retry-exempt-status-codes: 418
script: |
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',
'**/CHANGELOG.md',
];

function isIgnored( filename ) {
return IGNORED_PATTERNS.some( ( pattern ) => {
if ( pattern.startsWith( '**/' ) ) {
return filename.endsWith( pattern.slice( 2 ) );
}
return filename === pattern;
} );
}
Comment on lines +37 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works well for the initial implementation, but it may not be sufficient to handle more complex patterns if/when the list grows. We should consider using a more complete tool (maybe the minimatch package or something equilvalent), or at least document the limitation with this initial implementation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I've added a comment documenting exactly what the pattern matcher supports (exact match and **/suffix only) and noting that a glob library like minimatch should be considered if richer patterns are needed.


// 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 [];
}
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;
}
Comment on lines +60 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, this function can highlight is two PRs touch the same region of the original file, but it may miss instances in which two or more PRs add code at the same insertion point but at different new-side offsets.

Probably fine since this is mostly a "heads-up" tool, but we should at least acknowledge the limitation


// 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow runs on every change to every PR, causing 2000+ API requests per workflow run (there are currently 2k open PRs), meaning that it can singlehandedly exceed the rate limit of 15k/hour that is shared across the repo.

Claude suggests a few ideas to improve the situation:

  • Pre-filter with file names first before fetching full patches — skip PRs that don't share any file names entirely (you could use the Search API or a lighter endpoint).
  • Cache results — most open PRs don't change between runs; consider storing/reusing prior results.
  • Limit scope — only check PRs updated within the last N days, or limit to non-draft PRs for the expensive line-range analysis.
  • Add a rate-limit guard — check x-ratelimit-remaining and bail early if running low.

{
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
}
) ) {
for ( const file of response.data ) {
if ( ! isIgnored( file.filename ) ) {
changedFiles.set( file.filename, parseHunks( file.patch ) );
}
}
}

if ( changedFiles.size === 0 ) {
core.info( 'No changed files found.' );
return;
}

core.info( `This PR touches ${ changedFiles.size } file(s).` );

// Marker used to identify the bot's comment for updates/deletion.
const marker = '<!-- overlapping-prs-bot -->';

// 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.' );
}
}

// 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( `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 candidate PR, get its changed files and check for line overlaps.
const overlapping = [];

for ( const [ , pr ] of candidatePRs ) {
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 );
}
}

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 );
}
}

if ( filesWithOverlap.length > 0 ) {
overlapping.push( {
number: pr.number,
title: pr.title,
url: pr.html_url,
draft: pr.draft,
author: pr.author,
sharedFiles: filesWithOverlap,
} );
}
}

// Build comment body.
let body;

if ( overlapping.length === 0 ) {
core.info( 'No overlapping PRs found.' );
await deleteStaleComment();
return;
}

body = `${ marker }\n`;
body += `## Overlapping PRs\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 += `<details>\n<summary>Overlapping files (${ pr.sharedFiles.length })</summary>\n\n`;
for ( const file of pr.sharedFiles ) {
body += `- \`${ file }\`\n`;
}
body += `\n</details>\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 && 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).` );
}
3 changes: 2 additions & 1 deletion packages/block-editor/src/components/index.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to remove these test changes before merging 🧹

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Block Creation Components
* Block Creation Components (testing overlapping PRs bot)
*/

export * from './colors';
Expand Down Expand Up @@ -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';

Expand Down
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
Loading