feat(webhook): multi-repo support with config-gated dispatch #158
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!158
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/152"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #152 (M17-1).
Summary
The webhook now routes against the configured
repos:list instead of assumingcharles/claude-hooks. One agent fleet can serve every personal project with zero per-repo code — add the repo toconfig/agents.json, restart, done.What changed
Config & validation
loadWebhookConfigvalidates eachrepos:entry asowner/name(single/, no whitespace, non-empty segments). Typos fail fast at boot with a path-qualified error —agents.json: repos[1] is not a valid "owner/name" string (got "missing-owner")— instead of silently dropping the repo under the old.filter(...)loader.isValidRepoName(repo)+isKnownRepo(repo)helpers exported fromwebhook-config.ts.Dispatch gate
webhook.tscallsisKnownRepo(repo)after extractingrepository.full_namefrom the payload. Unknown repo →console.warn+ 404, never reaches a handler. Action-run events (repo underrun.repository) go through the same gate.repos:list preserves pre-M17 "accept everything" for back-compat on existing deployments.Label iteration
reconcileAllRepos(repos, token, specs, options)inlabels.ts.main.tsnow calls it once per startup instead of a bespoke for-loop. One-pass reconcile, additive (existing labels' color/description preserved), per-repo failures logged and swallowed so one missing repo never blocks the rest of the fleet.Audit
Every
charles/claude-hooksliteral remaining undersrc/*.tsis either a container-image URL (not a routed repo — inline note added onDEFAULT_CONTAINER_IMAGE) or a test fixture. No dispatch path branches on a hard-coded repo string.Tests
webhook-config.test.ts: 3-repo load + malformed-entry rejection (non-array, no slash, multiple slashes, whitespace, non-string).isValidRepoName+isKnownRepocoverage including the empty-list back-compat path.labels.test.ts:reconcileAllReposapplies the canonical set to three repos in one pass, keeps going when the middle repo 404s, and no-ops on an empty list.webhook.test.ts+webhook-handlers.test.ts: unknown-repo payload returns 404 with no cleanup side-effects (bothrepository.full_nameandrun.repository.full_namepaths), listed repo still dispatches normally.579 tests pass (
bun test), typecheck + biome lint + format all clean.Docs
just labels-bootstrap→ register webhook → verify).reconcileAllRepos.skills/breakdown.md: softened the{{repo}}description — no longer claims "alwayscharles/claude-hooks".Test plan
bun testpasses locally (579 pass, 0 fail)bun x tsc --noEmitcleanbun x biome check src/cleancharles/claude-hookswebhook traffic still dispatches (regression floor asserted in tests)config/agents.json::repos, restart, and verify[webhook] event=… repo=<new-repo>logs for an assigned-issue event from that repoReview: APPROVED ✅
CI is green. Round 1 — full diff reviewed. All acceptance criteria from #152 are met.
Config & validation ✅
isValidRepoNameregex (/^[^\s/]+\/[^\s/]+$/) correctly enforces exactly-one/, no whitespace, non-empty segments — verified.loadWebhookConfig: strictreposvalidation with path-qualified error messages including the failing index and value. A malformed entry ("repos": null, no slash, multiple slashes, whitespace) throws fast at boot with a clear message — tested inwebhook-config.test.ts(124 new lines).isKnownRepo: returnsfalsewhen config is unloaded (safe default),truewhen repos list is empty (back-compat), and does a properincludescheck when a list is configured.Dispatch gate ✅
webhook.ts: gate fires after signature verification, before any handler — correct ordering.run.repository?.full_namefallback foraction_run_*events (where repo is not at the top-levelrepositoryfield) is both implemented and tested in the newwebhook.test.tsM17-1 section.console.warn+ 404, listed repo → 200 + normal dispatch.Label bootstrap ✅
reconcileAllReposwrapsreconcileLabelsin a sequential loop, preserving the best-effort contract — one missing repo never blocks others.main.tswires it up as fire-and-forget (no blocking HTTP startup), using the first agent with a token. The per-repo 404/403 handling insidereconcileLabelsmeans a repo whose token lacks access just logs a warning.just labels-bootstrap <repo>CLI entry point is preserved.Sweeper ✅ (no changes needed)
The existing
sweeper.tsalready handles multiple repos dynamically: Phase 1 reads repo from session files, Phase 2 decodes repo from worktree directory names viadecodeWorktreeDir. Neither phase has a hardcoded repo string. The AC is satisfied without changes.Tests ✅
webhook-config.test.ts: 3-repo load + 5 malformed-entry rejection cases (non-array, no slash, multiple slashes, whitespace, non-string) — 124 new lines.labels.test.ts:reconcileAllReposwith 3 repos, 404 mid-flight, empty list no-op — 86 new lines.webhook.test.ts: bothrepository.full_name(issues) andrun.repository.full_name(action_run) unknown-repo paths — confirmed in the M17-1 section.webhook-handlers.test.ts: unknown-repo 404 + listed-repo regression floor.Docs ✅
README and CLAUDE.md both updated with the "Adding a repo to the fleet" runbook and the
reconcileAllReposarchitecture note.No issues found. Solid implementation.