Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions src/triggers/github/pr-conflict-detected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,27 @@ export class PRConflictDetectedTrigger implements TriggerHandler {

const payload = ctx.payload;

// Only trigger on synchronize events (when PR head is pushed/updated)
if (payload.action !== 'synchronize') return false;
// Trigger on `opened`, `reopened`, and `synchronize` — the three
// actions that produce a candidate head SHA whose mergeability we
// should check:
// - opened: brand-new PR. Bit us on ucho/PR #226 (2026-05-02) —
// the impl bot opened the PR already CONFLICTING against `dev`,
// and because the matcher previously accepted only `synchronize`,
// `resolve-conflicts` never fired until someone pushed a commit.
// - reopened: closed PR brought back; mergeability may have flipped
// against the now-advanced base.
// - synchronize: new commit pushed to existing PR (the original
// intent of this trigger).
// `closed`, `edited`, `labeled`, etc. correctly stay rejected.
// The handler's `mergeable === null` retry loop covers GitHub's async
// mergeability computation that's most prominent on `opened`.
if (
payload.action !== 'opened' &&
payload.action !== 'reopened' &&
payload.action !== 'synchronize'
) {
return false;
}

return true;
}
Expand Down
30 changes: 26 additions & 4 deletions tests/unit/triggers/pr-conflict-detected.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,43 @@ describe('PRConflictDetectedTrigger', () => {
expect(trigger.matches(ctx)).toBe(false);
});

it('does not match non-synchronize action', () => {
// PR #226 (2026-05-02) regression pin: the impl bot opened the PR
// already conflicting against `dev`, but the matcher previously
// accepted only `synchronize`, so resolve-conflicts never fired
// until someone pushed a commit. Both `opened` and `reopened` must
// match so the handler's mergeability retry + dispatch path runs.
it('matches opened action (PR #226 regression pin)', () => {
const ctx: TriggerContext = {
project: mockProject,
source: 'github',
payload: makeSynchronizePayload({ action: 'opened' }),
};

expect(trigger.matches(ctx)).toBe(false);
expect(trigger.matches(ctx)).toBe(true);
});

it('matches reopened action', () => {
const ctx: TriggerContext = {
project: mockProject,
source: 'github',
payload: makeSynchronizePayload({ action: 'reopened' }),
};

expect(trigger.matches(ctx)).toBe(true);
});

it('does not match closed action', () => {
it.each([
['closed'],
['edited'],
['labeled'],
['unlabeled'],
['assigned'],
['ready_for_review'],
])('does not match %s action', (action) => {
const ctx: TriggerContext = {
project: mockProject,
source: 'github',
payload: makeSynchronizePayload({ action: 'closed' }),
payload: makeSynchronizePayload({ action }),
};

expect(trigger.matches(ctx)).toBe(false);
Expand Down
Loading