Skip to content

refactor(triggers): consolidate handler boilerplate via shared skip + gates#1237

Merged
zbigniewsobiecki merged 4 commits intodevfrom
refactor/trigger-handlers-r1
Apr 29, 2026
Merged

refactor(triggers): consolidate handler boilerplate via shared skip + gates#1237
zbigniewsobiecki merged 4 commits intodevfrom
refactor/trigger-handlers-r1

Conversation

@zbigniewsobiecki
Copy link
Copy Markdown
Member

Summary

Past 3 months produced 14+ bug fixes in the trigger/webhook/dispatch surface, clustering into 5 recurring classes (skip opacity, dedup collisions, ALS scope leakage, racing writers, gate divergence). Two GitHub handlers had literal copies of the same skip(handler, message) helper; ~30 other return null self-skip sites still produced the generic 'No trigger matched for event' decisionReason despite the structured skipReason plumbing that landed in #1235.

R1 of the refactor plan from /home/zbigniew/.claude/plans/elegant-dazzling-rabbit.md. Pure consolidation — no cross-layer contract change.

What's new

Shared modules:

  • src/triggers/shared/skip.ts — single canonical skip() builder (replaces the duplicated literal helpers).
  • src/triggers/shared/gates.ts — composable pure-function gates: gateTriggerEnabled, gateBaseBranch, gateCascadePersona, gateAttemptLimit. Plus requirePersonaIdentities — a type-narrowing variant returning { ok: true, value } | { ok: false, skip } so callers no longer need ctx.personaIdentities! non-null assertions.
  • tests/helpers/triggerAssertions.tsexpectSkip(result, handler, msg) + expectSkipFor(handler) factory.

Static guardtests/unit/triggers/handler-shape.test.ts walks every handler under src/triggers/ and asserts no file defines a local function skip(, and every GitHub handler using return skip(...) imports from ../shared/skip.js. Same pattern as trigger-event-consistency.test.ts + pm-router-adapter-pm-scope.test.ts.

Handler refactors — 9 GitHub handlers (check-suite-failure, check-suite-success, pr-comment-mention, pr-conflict-detected, pr-merged, pr-opened, pr-ready-to-merge, pr-review-submitted, review-requested) now compose gates from the shared module. Sync gate chains use ?? for short-circuit composition. ~30 bare return null self-skips converted to structured skips with case-specific messages (PR # included, attempt counts spelled out, base branches named).

Bug-class impact

  • Class 1 (skip opacity) — every self-skip on the GitHub side now surfaces in webhook log decisionReason.
  • Class 5 (gate divergence) — the 4 persona-gate variants are consolidated to one shared gateCascadePersona (which uses isCascadeBot).

R2 (tagged-union dispatch decision) and R3 (GitHub-specific TriggerContext) remain pending.

Test plan

  • All 8749 unit tests pass.
  • All 550 integration tests pass.
  • npm run lint clean (13 pre-existing warnings).
  • npm run typecheck clean.
  • New tests: 19 in shared/skip.test.ts + shared/gates.test.ts, 2 in handler-shape.test.ts.
  • Two integration tests in github-personas.test.ts updated to seed both agent + trigger configs (the test was previously asserting null returned from the disabled-trigger path, not the loop-prevention persona path; the new assertions exercise the actual claimed behavior).

🤖 Generated with Claude Code

zbigniewsobiecki and others added 4 commits April 29, 2026 19:05
Merge dev → main: Fix 1 (BullMQ coalesce unique jobIds) + Fix 2 (snapshot rmi on PR merge)
Merge dev → main: backlog-manager scope safety (#1233)
Merge dev → main: respond-to-ci diagnostics + persona-scope hardening (#1235)
… gates

The trigger/webhook layer had 14+ bug fixes in the past 3 months clustering
into 5 recurring classes (skip opacity, dedup collisions, ALS scope leakage,
racing writers, gate divergence). The two GitHub handlers most affected by
the latest two — check-suite-failure and pr-conflict-detected — had literal
copies of the same skip(handler, message) helper. ~30 other return-null
self-skip sites still produced the generic "No trigger matched for event"
decisionReason despite the structured skipReason plumbing landed last week.

This refactor consolidates the common shape into shared modules and pins
the invariant with a static-grep guard.

New shared modules:
- src/triggers/shared/skip.ts — single canonical skip() builder.
- src/triggers/shared/gates.ts — composable pure-function gates returning
  TriggerResult | null:
    - gateTriggerEnabled — async; wraps checkTriggerEnabled
    - gateBaseBranch — sync; PR base ref vs project.baseBranch
    - gateCascadePersona — sync; wraps isCascadeBot()
    - gateAttemptLimit — sync; per-PR retry counter check
    - requirePersonaIdentities — type-narrowing variant returning a
      { ok: true, value } | { ok: false, skip } discriminated union so
      callers no longer need ctx.personaIdentities! non-null assertions
- tests/helpers/triggerAssertions.ts — expectSkip(result, handler, msg)
  + expectSkipFor(handler) factory for terse per-handler assertions.

Static guard: tests/unit/triggers/handler-shape.test.ts walks every handler
under src/triggers/ and asserts no file defines a local function skip(
AND every GitHub handler that uses return skip(...) imports from
../shared/skip.js. Same pattern as trigger-event-consistency.test.ts and
pm-router-adapter-pm-scope.test.ts.

Handler-side changes:
- 9 GitHub handlers compose gates from shared/gates.ts. Sync gate chains
  use ?? for short-circuit composition.
- ~30 bare return null self-skip sites converted to structured skips with
  case-specific messages (PR # included, attempt counts, base branches).
- pr-comment-mention pre-extracts prNumberHint so the persona-skip carries
  PR context.
- Two integration tests updated to seed both agent + trigger configs so
  the gate exercises the actual loop-prevention persona check.

Net diff: -297 / +823. Most additions are the new shared modules and
their dedicated tests; handler files are flatter (typical handler lost
~30 lines of boilerplate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 29, 2026

Codecov Report

❌ Patch coverage is 96.07143% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/triggers/github/pr-comment-mention.ts 87.09% 4 Missing ⚠️
src/triggers/github/review-requested.ts 87.09% 4 Missing ⚠️
src/triggers/github/pr-ready-to-merge.ts 93.75% 2 Missing ⚠️
src/triggers/github/check-suite-success.ts 95.45% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Collaborator

@nhopeatall nhopeatall left a comment

Choose a reason for hiding this comment

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

Summary

Clean, well-executed refactoring that consolidates duplicated trigger handler boilerplate into shared modules. Code is correct, gates are well-documented, static guard tests prevent regression, and the integration test fixes are genuine improvements (they were previously testing the wrong skip path).

Should Fix (non-blocking)

Log level downgrade for missing persona identities

requirePersonaIdentities in gates.ts:177 uses logger.info, but 3 of the 5 original call sites used logger.warn (pr-comment-mention, pr-review-submitted, review-requested). Missing persona identities signals a token resolution failure — while the Sentry capture happens upstream in resolvePersonaCached (#1235), warn is arguably the right log level at the gate since it represents an unexpected state (not a routine skip like base-branch mismatch). Consider logger.warn for consistency with the severity of the condition.

Nitpick

Misleading variable name at gateTriggerEnabled call sites

Every handler uses const enabled = await gateTriggerEnabled(...); if (enabled) return enabled; — when enabled is truthy, the trigger is not enabled (it is a skip result). Compare with the clearer names used at other gate call sites: gateChainSkip, baseSkip, limitSkip. A name like disabledSkip or triggerSkip would read more naturally. Not worth blocking given the convention is documented and consistent across all 6 call sites.

🕵️ claude-code · claude-opus-4-6 · run details

handlerName: string,
): RequireGateResult<PersonaIdentities> {
if (personaIdentities) return { ok: true, value: personaIdentities };
logger.info(`No persona identities available, skipping ${handlerName}`, { prNumber });
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[SHOULD_FIX] This uses logger.info but three of the five original call sites (pr-comment-mention, pr-review-submitted, review-requested) used logger.warn. Missing persona identities is an unexpected state (token resolution failure), not a routine skip — warn would better match the severity. The other two call sites (check-suite-failure, pr-conflict-detected) already used info, so this is a judgment call, but the warn-level sites had the right instinct.

'scm:check-suite-failure',
this.name,
);
if (enabled) return enabled;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[NITPICK] The variable name enabled is confusing here — when truthy, it means the trigger is not enabled (the value is a skip result). Other gate call sites in this PR use clearer names like gateChainSkip, baseSkip, limitSkip. Consider disabledSkip or triggerSkip for readability.

@zbigniewsobiecki zbigniewsobiecki merged commit 33e4216 into dev Apr 29, 2026
9 checks passed
@zbigniewsobiecki zbigniewsobiecki deleted the refactor/trigger-handlers-r1 branch April 29, 2026 21:06
zbigniewsobiecki added a commit that referenced this pull request Apr 29, 2026
Merge dev → main: refactor(triggers) consolidate handler boilerplate (#1237)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants