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
142 changes: 142 additions & 0 deletions openspec/changes/github-alerts/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Design: GitHub Alerts Routed to Linked Forum Topics

## Technical Approach

This change reuses the existing hexagonal layout. A new Hono route, `POST /github/webhook`, reads the raw body and verifies `X-Hub-Signature-256` with WebCrypto before anything is parsed. An adapter mapper (`adapters/github`) then turns the JSON into a small domain `GithubEvent`, or `null` when the event is unsupported. A pure use case, `routeGithubEvent`, resolves org to claim to team to link, formats a plain-text alert and sends it through an `AlertSender` port. That port is backed by grammY's `Api.sendMessage` with `message_thread_id`. Linking uses three admin commands that call pure use cases (`linkRepoToTopic`, `unlinkRepo`, `listRepoLinks`), so a future LLM layer can call the same code. There is no cron, no event storage and no change to the logger allowlist.

## Architecture Decisions

| Topic | Choice | Rejected (tradeoff) |
|---|---|---|
| Signature check | Validate the header against `^sha256=[0-9a-f]{64}$`, then `crypto.subtle.sign("HMAC")` over the `arrayBuffer()` body, then `timingSafeEqual`, all before `JSON.parse`. An empty or missing secret is treated as a config error, never as a verify against an empty key | `subtle.verify` (constant-time is not asserted by an existing runtime test); parse-then-verify (spoofing risk) |
| Secret scope | One global `GITHUB_WEBHOOK_SECRET` | Per-team secrets (no UX to reveal them once). Future path: an additive encrypted secret column on `github_org_claims` plus a `/github/webhook/:claimId` route, because the secret must be known before the body is parsed |
| Tenancy for routing | `GithubOrgClaimRepo.findTeamByOrg` is the only cross-team lookup (documented, like `MembershipRepo.findByUser`). Everything after it takes `TeamId` first | Scan links across teams (leak-prone) |
| Claim enforcement | Checked in the use case and enforced again in D1 with a composite FK `(team_id, org_login)` pointing at the claim | Use-case check only |
| Event filtering | Done in the adapter mapper. The domain never sees raw payload shapes | Domain parses JSON (couples the domain to GitHub) |
| Message | Plain text with no `parse_mode`. Title capped at 256 chars and the whole message at 4096 | MarkdownV2 (escaping bugs cause failed sends) |
| Sender | `new Api(BOT_TOKEN)` in composition. No `Bot` and no `PII_KEYRING` on this route | `buildBot()` (a broken keyring would also break alerts) |
| Logging | Existing `LogEvent`. `reason` holds only fixed strings (`ignored:unlinked-repo`, and so on) | New allowlisted fields (repo names are not needed to operate) |

### GitHub route status policy (mirrors the Telegram RES-001/002 rule: permanent means 2xx, transient infra means 500)

| Case | Status |
|---|---|
| Signature header missing, malformed or wrong | 401, body never parsed |
| `GITHUB_WEBHOOK_SECRET` unset or empty | 500, logged as a `ConfigError` reason |
| `ping` | 200 |
| Invalid JSON, a non-object, or an unsupported event or action | 200, logged |
| Org not claimed, or repo not linked | 200, `outcome: "ok"` with an `ignored:*` reason |
| Telegram send fails (for example, the topic was deleted) | 200, `errorCode: "AlertSendFailed"` |
| Unexpected error (for example, D1) | 500. GitHub does not auto-retry, so the delivery stays visible for a manual redeliver |

## Data Flow

```
GitHub ─POST /github/webhook─> Hono: raw bytes ─> HMAC check ─401?─┐
└─> JSON.parse ─> mapGithubEvent (null => 200 ignored) │
└─> routeGithubEvent(event, deps) │
claimRepo.findTeamByOrg ─> linkRepo.get(teamId, repo)
─> teamRepo.get(teamId).chatId ─> formatGithubAlert
─> AlertSender.send(chatId, threadId, text) ─> 200
Telegram /linkrepo (in topic) ─> resolveGroupMembership ─> linkRepoToTopic
```

## File Changes

| Path | Action | Description |
|---|---|---|
| `migrations/0002_github_alerts.sql` | Create | Claims and links tables |
| `src/domain/github.ts` | Create | `RepoFullName`, `parseRepoFullName`, `GithubEvent`, `formatGithubAlert` |
| `src/domain/{entities,ports,errors}.ts` | Modify | `RepoTopicLink`; three ports; `InvalidRepoError`, `OrgNotClaimedError`, `AlertSendFailedError` |
| `src/domain/usecases/{link-repo,unlink-repo,list-repo-links,route-github-event}.ts` | Create | Pure use cases |
| `src/adapters/d1/{github-org-claim-repo,repo-topic-link-repo}.ts` | Create | Tenant-scoped SQL |
| `src/adapters/github/{signature,event-mapper}.ts` | Create | HMAC verify and allowlisted field extraction |
| `src/adapters/telegram/alert-sender.ts` | Create | `api.sendMessage` wrapper |
| `src/adapters/telegram/commands.ts` | Modify | `/linkrepo`, `/unlinkrepo`, `/repos` |
| `src/index.ts`, `src/composition.ts`, `src/env.ts` | Modify | Route, `buildGithubRouter(env)`, secret |
| `vitest.config.ts`, `.dev.vars.example` | Modify | Test and example `GITHUB_WEBHOOK_SECRET` |

```sql
-- 0002_github_alerts.sql (lowercase logins, epoch-ms)
CREATE TABLE github_org_claims (
org_login TEXT PRIMARY KEY CHECK (org_login = lower(org_login)), -- one org -> one team
team_id TEXT NOT NULL REFERENCES teams(id),
created_at INTEGER NOT NULL,
UNIQUE (team_id, org_login)
);
CREATE TABLE repo_topic_links (
team_id TEXT NOT NULL,
repo_full_name TEXT NOT NULL CHECK (repo_full_name = lower(repo_full_name)),
org_login TEXT NOT NULL,
thread_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (team_id, repo_full_name), -- one topic per repo per team
FOREIGN KEY (team_id, org_login) REFERENCES github_org_claims(team_id, org_login)
);
```

## Interfaces / Contracts

```ts
type GithubEvent = { org: string; repo: RepoFullName; kind: "pull_request" | "issues";
action: "opened" | "closed" | "merged" | "review_requested";
number: number; title: string; url: string; actor: string; reviewer?: string }; // logins only
interface GithubOrgClaimRepo {
findTeamByOrg(orgLogin: string): Promise<TeamId | null>; // sole cross-team lookup
isClaimedBy(teamId: TeamId, orgLogin: string): Promise<boolean>;
}
interface RepoTopicLinkRepo {
get(teamId: TeamId, repo: RepoFullName): Promise<RepoTopicLink | null>;
upsert(teamId: TeamId, link: RepoTopicLink): Promise<void>; // ON CONFLICT DO UPDATE thread_id
remove(teamId: TeamId, repo: RepoFullName): Promise<boolean>;
list(teamId: TeamId): Promise<RepoTopicLink[]>;
}
interface AlertSender { send(chatId: number, threadId: number, text: string): Promise<void>; } // throws AlertSendFailedError
linkRepoToTopic({ teamId, actorMembershipId, repo, threadId }, deps): Promise<{ repo; previousThreadId: number | null }>
routeGithubEvent(event, deps): Promise<{ kind: "delivered" | "send-failed"; teamId } | { kind: "ignored"; reason: "unclaimed-org" | "unlinked-repo" }>
// alert-sender.ts
await api.sendMessage(chatId, text, { message_thread_id: threadId, link_preview_options: { is_disabled: true } });
```

`merged` means `closed` with `pull_request.merged === true`. `reviewer` is `requested_reviewer.login`, or `requested_team.slug` when a team was requested. Link and unlink need an admin, the thread must not be null (the same refusal as `/datachannel`), and the repo owner must be claimed by the team.

## Testing Strategy

| Layer | What | Approach |
|---|---|---|
| Domain (first) | Parsing, admin gate, claim gate, move reply, routing outcomes, truncation | Vitest with fakes in `test/fakes` |
| D1 | Upsert and move, FK rejection of an unclaimed org, two-team isolation | vitest-pool-workers |
| Adapters | HMAC (valid, wrong, same-length wrong, malformed header, empty secret); mapper allowlist (a commit email in the fixture never appears in the output) | Workers runtime |
| HTTP | The status policy table above; `sendMessage` carries `message_thread_id`; console output holds no payload fixture strings | `SELF.fetch` with `vi.stubGlobal(fetch)`, as in `webhook-e2e.test.ts` |

## Threat Matrix

| Boundary | Applicability |
|---|---|
| Documentation-like paths, git selection, commit, push, PR commands | N/A: no shell, subprocess, VCS or executable-file boundary |
| HTTP routing (new ingress) | Applicable. The RED tests are the signature and status rows above; they must propagate to tasks |

## Migration / Rollout

The migration is additive. Operator steps:

1. `openssl rand -hex 32`, save it in the password manager, then `npx wrangler secret put GITHUB_WEBHOOK_SECRET`.
2. `npx wrangler d1 migrations apply hack-bot-db --remote`, then deploy.
3. In the GitHub org, open Settings → Webhooks → Add. Payload URL: `https://hack-bot.<subdomain>.workers.dev/github/webhook`. Content type: **application/json**. Secret: the same value. Events: Pull requests and Issues. The ping should return 200.
4. Claim the org. First find the team id with `npx wrangler d1 execute hack-bot-db --remote --command "SELECT id, telegram_chat_id FROM teams"`. Then run `npx wrangler d1 execute hack-bot-db --remote --command "INSERT INTO github_org_claims (org_login, team_id, created_at) VALUES (lower('<org>'), '<team-id>', unixepoch()*1000)"`.
5. Run `/linkrepo <org>/<repo>` inside the target topic.

Rollback: delete the org webhook, or redeploy the previous version. The tables can stay.

**PR slicing** (feature-branch chain, each PR under 400 lines):

1. The migration and domain (`github.ts`, ports, errors, four use cases), plus fakes and tests (~350).
2. The D1 repos and their tests (~250).
3. `signature.ts`, the route skeleton (401, 500, ping and malformed JSON), the env and the test binding (~250).
4. The mapper, alert sender, `buildGithubRouter` wiring and the end-to-end delivery tests (~300).
5. The `/linkrepo`, `/unlinkrepo` and `/repos` commands and their tests (~250). This PR must merge before the operator runs step 5.

## Open Questions

- [x] Can any team member run `/repos`, or only admins? Resolved: any registered team member, anywhere in the group; only admins can link or unlink.
55 changes: 55 additions & 0 deletions openspec/changes/github-alerts/explore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
## Exploration: GitHub alerts (org webhook alerts + digest via Cloudflare cron)

> Mirror of Engram `sdd/github-alerts/explore` (id 2337). Read-only investigation against `main`.
> Note: the product vision (Engram `product/vision`, `product/roadmap`) later changed the delivery target from the team data channel to per-repo linked forum topics. See `proposal.md`.

### 1. Current architecture / extension points
- Hexagonal Worker. Hono entry `src/index.ts`: single route `POST /telegram/webhook` (checks `X-Telegram-Bot-Api-Secret-Token` via `crypto.subtle.timingSafeEqual`, length-checked first) + `GET /health`. Composition root `src/composition.ts::buildBot(env)` wires D1 repos -> use cases -> grammY bot per request; isolate-cached `FieldCipher` only (no other module state).
- `src/domain/{entities,ports,errors,access-policy,usecases/*}.ts` is framework-free (no grammY/Hono/D1 imports). Every tenant-scoped port method takes `TeamId` first (type-checkable tenancy guard).
- Adapters: `src/adapters/d1/*-repo.ts` (tenant-scoped SQL, one `DB.batch()` per write+audit), `src/adapters/crypto/*` (AES-GCM key ring, AAD = `table|teamId|rowKey|field`), `src/adapters/telegram/{bot,commands,context,team-picker,chat-admin-checker}.ts` (thin grammY edge; `registerCommands(bot, deps)` in commands.ts, each command uses `runCommand` with an explicit `errorReplies` map).
- Schema (`migrations/0001_init.sql`, only migration so far): `teams(id, telegram_chat_id UNIQUE, data_topic_thread_id NULL, created_at)`, `members`, `memberships(UNIQUE(team_id,member_id), UNIQUE(team_id,id))`, `profile_fields` (encrypted except `github_username`, explicitly plaintext "used for integrations in later changes" per pii-protection spec — this change is that integration), `audit_log`, `dm_selections`.
- Team = 1 Telegram supergroup (`teams.telegram_chat_id`). Data channel = a forum topic (`data_topic_thread_id`), bound via admin-only `/datachannel`; alerts/digest should reuse this as delivery target — no new "channel" concept needed.
- `Env` (`src/env.ts`) currently only has `DB, BOT_TOKEN, WEBHOOK_SECRET, PII_KEYRING, BOT_INFO`. No cron trigger configured in `wrangler.jsonc` yet (`triggers.crons` absent), no queues/DOs.
- Test conventions: `test/domain` (pure, fakes), `test/adapters/d1` (vitest-pool-workers + migrations), `test/http` (`SELF.fetch`, e.g. `test/http/webhook-secret.test.ts` pattern for secret/signature tests), `test/runtime-assumptions` for platform behavior checks. Strict TDD, RED first.

### 2. GitHub webhook integration
- **Org webhook (not GitHub App) recommended for MVP.** One webhook configured once at the GitHub org level delivers all repo events to one endpoint; avoids GitHub App complexity (JWT signing, installation tokens, install flow) which has no UI story yet for a solo/no-dashboard bot. Defer GitHub App to a later change if private-repo API polling or self-serve multi-org install is needed.
- **HMAC-SHA256 verification** (`X-Hub-Signature-256: sha256=<hex>`) differs from the Telegram pattern (static token compare): must `crypto.subtle.importKey("raw", secretBytes, {name:"HMAC",hash:"SHA-256"}, false, ["verify"])` then `crypto.subtle.verify` over the **raw body bytes** (verify before `JSON.parse`, mirroring index.ts's "check auth before touching body" order but needs the raw ArrayBuffer, not just a header).
- **Org/repo -> team mapping**: new table, e.g. `github_links(team_id, org_login, repo_full_name NULL /*org-wide*/, created_at)`.
- **Secret storage**: real multi-tenant design wants a secret per team (each team's own org sets its own secret), but building the "create/rotate/reveal-once" UX without a dashboard is nontrivial. MVP recommendation: single global secret via Worker secret (`GITHUB_WEBHOOK_SECRET`), schema still shaped as per-team-capable for a fast follow. Flag as an explicit decision point for `sdd-propose`.
- **First events to support**: `pull_request` (opened, closed/merged) and `issues` (opened, closed) — high value, low noise. Defer `workflow_run`/`check_run`, `release`, `discussion`, `push` (too noisy without filtering).

### 3. Alert configuration model
- D1: `github_alert_rules(team_id, repo_full_name NULL, event_type, enabled, created_at)`.
- Telegram commands (admin-only, same pattern as `/datachannel`'s `UnauthorizedError` reuse): `/githubrepo add|remove <org/repo>`, `/alerts on|off <event>`. Refuse enabling alerts until the team's data channel is bound (mirrors existing `/datachannel` gating).
- Delivery via `bot.api.sendMessage(team.chatId, text, {message_thread_id: team.dataTopicThreadId})` — reuses the pattern already used by `chatAdminChecker` (`bot.api` usable outside the webhook update context).

### 4. Digest
- Needs `wrangler.jsonc` `"triggers": {"crons": [...]}` and an exported `scheduled(event, env, ctx)` handler alongside the Hono `fetch` export (Hono's `app.fetch` doesn't cover `scheduled`; export `{fetch: app.fetch, scheduled}` from `src/index.ts`).
- Aggregate from **stored events**, not live GitHub API queries (avoids GitHub rate limits, keeps one source of truth). Needs a `github_events` table populated by the webhook handler at ingest time.
- True per-team schedule isn't possible with Workers cron (one Worker-wide schedule); MVP = single global cron (e.g. hourly) that checks each team's configured `digest_hour_utc` and only sends when due. Defer arbitrary per-team cron granularity.
- Idempotency: conditional D1 update pattern already used for `changeRole`'s "last admin" guard (`UPDATE ... SET last_digest_at=? WHERE team_id=? AND last_digest_at<?`) to survive duplicate/overlapping cron invocations.

### 5. Risks
- Webhook spoofing if HMAC isn't verified over raw bytes before parsing.
- Telegram rate limits (per-chat ~1 msg/sec, global ~30/sec) — high-volume repos could spam a chat; needs batching/coalescing eventually.
- Telegram 4096-char message cap — digest/alert formatting must truncate.
- D1 growth: a `github_events` table for digest aggregation grows unboundedly, same unresolved retention gap as the existing unbounded `audit_log` — flag for a retention/cleanup follow-up.
- PII: GitHub payloads carry committer emails/real names in commit objects; even though `github_username` is explicitly plaintext-allowed per the PII spec, raw webhook payloads (with commit-author emails) must not be logged verbatim or stored as raw blobs without review — only store/log the fields actually needed (repo, event type, actor login, PR/issue number/title), not the full payload.
- Testing: HTTP signature tests mirror `test/http/webhook-secret.test.ts` (valid/missing/wrong signature, same-length-wrong-signature style cases already established for the Telegram secret). Scheduled-handler tests need vitest-pool-workers `SELF.scheduled()` support — verify before committing to the digest slice.

### 6. MVP recommendation
Ship **alerts before digest** (no cron complexity, faster to prod):
- Slice 1: migration for `github_links` + `github_alert_rules`, domain use cases (pure event->rule matching), `/github/webhook` HTTP route with HMAC verify (single global secret), admin commands `/githubrepo`, `/alerts`, dispatch to data channel. 2 events only (`pull_request`, `issues`).
- Slice 2 (separate PR/possibly separate change): `github_events` table, `scheduled` handler, `wrangler.jsonc` cron config, idempotency guard, single global daily digest time (no per-team schedule yet).
- Defer: GitHub App/install flow, per-team webhook secrets, CI/workflow alerts, release alerts, per-team cron schedule, event coalescing, retention job.

### PR breakdown (400-line review budget each)
1. Migration + domain entities/ports/pure use cases for link+rule config, tests (~250-350 lines).
2. D1 adapters for the two new repos + vitest-pool-workers tests (~200-300 lines).
3. `/github/webhook` route + HMAC WebCrypto verification + composition wiring + signature tests (~200-250 lines).
4. Alert dispatch use case (event -> matching rules -> Telegram send) + wiring + tests (~200-300 lines).
5. Admin commands `/githubrepo`, `/alerts` in `commands.ts` + tests (~150-250 lines).
6. (Digest slice, likely its own change/PR set) `github_events` table, `scheduled` handler, cron config, idempotency tests (~250-350 lines).

**Where**: `src/index.ts`, `src/composition.ts`, `src/domain/{entities,ports,usecases}.ts`, `src/adapters/d1/*`, `src/adapters/telegram/commands.ts`, `migrations/0001_init.sql` (reference only, new migration needed), `wrangler.jsonc`, `openspec/specs/{telegram-webhook,pii-protection}/spec.md`, `openspec/changes/archive/2026-09-23-team-foundation/design.md`.
Loading
Loading