feat(flows): NF-6 multi-assignee fan-out for issue.assigned #378

Merged
claude-desktop merged 2 commits from feat/flows-multi-assignee-fanout into main 2026-04-26 11:57:07 +00:00
Collaborator

Phase 1B — closes one of the two remaining cutover blockers for suppress_legacy: ["issue.assigned"]. The other (address-review pivot) is the next PR.

Why

Legacy webhook.ts:184 iterates payload.issue.assignees and dispatches once per assignee. The trigger normaliser (webhook-normalize.ts:194) surfaces only the trailing assignee (assigneeLogin: assignees[length-1]) — so the flow path silently lost every dispatch except the trailing login on multi-assign tickets. Cutover would have regressed multi-assignee behaviour.

What this PR does

New exported helper in flow-dispatch.ts:

export function expandTriggers(trigger: TriggerEvent): TriggerEvent[]
  • issue.assigned with issue.assignees.length > 1 → one trigger per assignee, each with trigger.assignee swapped (issue snapshot shared, since it carries the full assignees list verbatim).
  • issue.assigned with single or empty assignees → original trigger passes through unchanged.
  • All other trigger kinds → pass through.

dispatchToFlows outer loop now iterates expandTriggers(trigger) so each fan-out trigger gets the full per-flow matching + execute + persist cycle. Each fan-out target writes its own flow_runs row with the correct per-assignee event_payload for the divergence join.

Symmetrical pattern lives behind the same helper for the next multi-target trigger we model (pull_request.review_requested carries N reviewers; extend the switch when its flow lands).

Tests

  • 4 unit tests on expandTriggers: non-assignment passthrough, single-assignee passthrough, N-fan-out (3 assignees → 3 triggers, correct per-trigger assignee), empty-array defensive.
  • 2 integration tests on dispatchToFlows: 3-assignee event writes 3 flow_runs rows with sorted assignees [boss, designer, dev] in serialized payloads; single-assignee still writes exactly 1.
  • 1844 server tests pass (was 1838 pre-fan-out); 5 pre-existing failures unchanged.
  • Typecheck + biome clean.

Cutover progress

Blocker Status
Token / model / system_prompt / appendix wiring merged (#377)
Silent-drop config guard merged (#375)
Forge-mutation divergence parity tooling merged (#376)
Multi-assignee fan-out this PR
Outstanding-changes_requestedaddress-review pivot next PR (Phase 1C)

After Phase 1C, suppress_legacy: ["issue.assigned"] is finally safe to set after a soak window of zero divergence on GET /flows/divergence/summary.

🤖 Generated with Claude Code

Phase 1B — closes one of the two remaining cutover blockers for `suppress_legacy: ["issue.assigned"]`. The other (address-review pivot) is the next PR. ## Why Legacy `webhook.ts:184` iterates `payload.issue.assignees` and dispatches once per assignee. The trigger normaliser (`webhook-normalize.ts:194`) surfaces only the trailing assignee (`assigneeLogin: assignees[length-1]`) — so the flow path silently lost every dispatch except the trailing login on multi-assign tickets. Cutover would have regressed multi-assignee behaviour. ## What this PR does New exported helper in `flow-dispatch.ts`: ```ts export function expandTriggers(trigger: TriggerEvent): TriggerEvent[] ``` - `issue.assigned` with `issue.assignees.length > 1` → one trigger per assignee, each with `trigger.assignee` swapped (issue snapshot shared, since it carries the full assignees list verbatim). - `issue.assigned` with single or empty assignees → original trigger passes through unchanged. - All other trigger kinds → pass through. `dispatchToFlows` outer loop now iterates `expandTriggers(trigger)` so each fan-out trigger gets the full per-flow matching + execute + persist cycle. Each fan-out target writes its own `flow_runs` row with the correct per-assignee `event_payload` for the divergence join. Symmetrical pattern lives behind the same helper for the next multi-target trigger we model (`pull_request.review_requested` carries N reviewers; extend the switch when its flow lands). ## Tests - 4 unit tests on `expandTriggers`: non-assignment passthrough, single-assignee passthrough, N-fan-out (3 assignees → 3 triggers, correct per-trigger `assignee`), empty-array defensive. - 2 integration tests on `dispatchToFlows`: 3-assignee event writes 3 `flow_runs` rows with sorted assignees `[boss, designer, dev]` in serialized payloads; single-assignee still writes exactly 1. - 1844 server tests pass (was 1838 pre-fan-out); 5 pre-existing failures unchanged. - Typecheck + biome clean. ## Cutover progress | Blocker | Status | |---|---| | Token / model / system_prompt / appendix wiring | merged (#377) | | Silent-drop config guard | merged (#375) | | Forge-mutation divergence parity tooling | merged (#376) | | Multi-assignee fan-out | **this PR** | | Outstanding-`changes_requested` → `address-review` pivot | next PR (Phase 1C) | After Phase 1C, `suppress_legacy: ["issue.assigned"]` is finally safe to set after a soak window of zero divergence on `GET /flows/divergence/summary`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(flows): NF-6 multi-assignee fan-out for issue.assigned
Some checks failed
qa / qa (pull_request) Has been cancelled
qa / dockerfile (pull_request) Has been cancelled
1426c2ffc0
Closes one of the two remaining cutover blockers for `issue.assigned`.

Legacy webhook.ts:184 iterates `payload.issue.assignees` and dispatches
once per assignee. The trigger normaliser surfaces only the trailing
assignee (`assigneeLogin: assignees[length-1]`), so without fan-out the
flow path silently lost every dispatch except the trailing login on
multi-assign tickets.

Implementation: new `expandTriggers(trigger)` helper in
`flow-dispatch.ts` returns one trigger per logical target. For
`issue.assigned` with `issue.assignees.length > 1` it emits one
trigger per assignee (with `trigger.assignee` swapped, issue snapshot
shared). All other trigger kinds pass through unchanged. The
`dispatchToFlows` outer loop now iterates `expandTriggers(trigger)` so
each fan-out trigger gets the full per-flow matching + execute +
persist cycle.

`pull_request.review_requested` (N reviewers) follows the same
pattern when its flow lands — extend the switch then.

Tests: 6 new — 4 unit on `expandTriggers` (non-assignment passthrough,
single assignee passthrough, N-fan-out, empty-array defensive) + 2
integration on `dispatchToFlows` (3-assignee writes 3 flow_runs with
correct per-assignee event_payload, single-assignee still writes 1).

After this PR + the address-review pivot follow-up,
`suppress_legacy: ["issue.assigned"]` is finally cuttable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(flows): NF-6 fan-out — hoist injections + reframe extension comment
All checks were successful
qa / qa (pull_request) Successful in 6m14s
qa / dockerfile (pull_request) Successful in 10s
34f1d014c4
Round 1 review feedback on PR #378:

- Hoisted `defaultArgInjections()` outside both loops. Was allocated
  once per (trigger, flow) pair; the bundle is stateless from the
  caller's view (stable module fns + singleton dedup surface) so a
  single envelope shared across N×M iterations is safe and saves the
  allocations.
- Reframed `expandTriggers` doc-comment. The previous wording promised
  mechanical reuse for `pull_request_review.review_requested`, but
  that trigger carries `requestedReviewer` (singular) and would need
  both a normaliser change AND a different field name on the spread —
  not a one-line extension. Updated the comment to flag this honestly
  and warn against premature genericisation.
- `expect(out[0]).toBe(t)` → `toEqual(t)` on the single-assignee
  passthrough test. The strict referential identity locked in the
  short-circuit-implementation; loosening lets a future symmetric
  rewrite still pass for the right reason.
- Added insertion-order intent comment on the N-assignee assertion so
  a maintainer doesn't "fix" the order with a sort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-desktop deleted branch feat/flows-multi-assignee-fanout 2026-04-26 11:57:07 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!378
No description provided.