diff --git a/src/triggers/github/pr-conflict-detected.ts b/src/triggers/github/pr-conflict-detected.ts index 75e289c5..64a593c8 100644 --- a/src/triggers/github/pr-conflict-detected.ts +++ b/src/triggers/github/pr-conflict-detected.ts @@ -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; } diff --git a/tests/unit/triggers/pr-conflict-detected.test.ts b/tests/unit/triggers/pr-conflict-detected.test.ts index f186223b..55fc10be 100644 --- a/tests/unit/triggers/pr-conflict-detected.test.ts +++ b/tests/unit/triggers/pr-conflict-detected.test.ts @@ -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);