Skip to content
Open
242 changes: 242 additions & 0 deletions .github/workflows/overlapping-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
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

# 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 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.
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).
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).` );

// 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 check for line overlaps.
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 );
}
}

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.user.login,
sharedFiles: filesWithOverlap,
} );
}
}

// Build comment body.
const marker = '<!-- overlapping-prs-bot -->';
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 ) );

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 the PR body is empty, this could throw. We should add a guard, something like

Suggested change
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.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 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.includes( marker ) );

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.

Same as previous comment


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