diff --git a/.env.example b/.env.example index 8c8e65c..871fbfd 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ DEDUP_TTL_MS=300000 # Timeout in milliseconds for the call to the Anthropic API (prevents it from # hanging indefinitely if there's no outbound internet access) FIRE_TIMEOUT_MS=10000 + +# Gitea username to match against issue/PR assignees for "issue_assign" and +# "pull_request_assign" events. Only deliveries assigning this user forward +# a routine fire; other assignment changes are ignored (200 "ignored"). +CLAUDE_GITEA_USERNAME=claude diff --git a/README.md b/README.md index 41210fa..5bcbc3e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,21 @@ send itself (`Authorization: Bearer `, `anthropic-beta`, with that `text` and logs the outcome (success, 401/429/5xx, or a network failure) without crashing the process. +### Issue/PR assignment to Claude + +Gitea's coarse `X-Gitea-Event` header (`issues`, `pull_request`) doesn't say +*what changed* — the precise sub-event is in `X-Gitea-Event-Type`. The proxy +reads that header to detect `issue_assign` and `pull_request_assign` +deliveries and only forwards them when the assignees list actually includes +`CLAUDE_GITEA_USERNAME`; any other assignment change gets a `200 {"status": +"ignored"}` without hitting the routine. + +**Known limitation:** Gitea (tested on 1.25.4) doesn't tell you *who* was +just added to `assignees`, only the full current list. If Claude was already +assigned and someone else gets added too, this still counts as "assigned to +Claude" and fires again — telling the two apart would require comparing +against the previous assignees, which Gitea doesn't send. + ## Environment variables See [.env.example](.env.example). Copy it and fill in the real values @@ -41,6 +56,7 @@ cp .env.example .env | `ROUTINE_TOKEN` | Routine token (`sk-ant-oat01-...`), sent as `Bearer`. | | `DEDUP_TTL_MS` | TTL in ms for the in-memory delivery dedup (defaults to 300000 = 5 min). | | `FIRE_TIMEOUT_MS` | Timeout in ms for the call to Anthropic's API (defaults to 10000). | +| `CLAUDE_GITEA_USERNAME` | Gitea username to match on issue/PR assignment events (defaults to `claude`). | ## Running locally @@ -60,8 +76,8 @@ On the repo/organization → Settings → Webhooks → Add webhook: - Content type: `application/json` - Secret: same value as `GITEA_WEBHOOK_SECRET` - Authorization header: same value as `GITEA_AUTH_HEADER` -- Events: `Push` and/or `Pull Request` (any other event is forwarded with a - generic message) +- Events: `Push`, `Issues` (at least "Assigned"), and/or `Pull Request` (at + least "Assigned") — any other event is forwarded with a generic message ## Docker diff --git a/server.js b/server.js index 60106a1..264d63e 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const { ROUTINE_TOKEN, DEDUP_TTL_MS = String(5 * 60 * 1000), FIRE_TIMEOUT_MS = String(10 * 1000), + CLAUDE_GITEA_USERNAME = 'claude', } = process.env; for (const [name, value] of Object.entries({ @@ -127,6 +128,42 @@ function buildTextFromPullRequest(payload) { .join('\n'); } +// Gitea's X-Gitea-Event header only gives the coarse event ("issues", +// "pull_request"); X-Gitea-Event-Type gives the precise sub-event. These are +// the ones that mean "someone was (re-)assigned" and carry an assignees list +// on the issue/pull_request. See https://docs.gitea.com/usage/repository/webhooks/ +const ASSIGN_EVENT_SUBJECT = { + issue_assign: 'issue', + pull_request_assign: 'pull_request', +}; + +function isAssignedTo(subject, username) { + if (!subject) return false; + const target = username.toLowerCase(); + if (Array.isArray(subject.assignees) && subject.assignees.some((u) => u.login?.toLowerCase() === target)) { + return true; + } + return subject.assignee?.login?.toLowerCase() === target; +} + +function buildTextFromAssignment(kind, payload) { + const repo = payload.repository?.full_name || 'unknown repo'; + const subject = kind === 'issue' ? payload.issue : payload.pull_request; + const label = kind === 'issue' ? 'Issue' : 'Pull request'; + const number = payload.number ?? subject?.number ?? ''; + const title = subject?.title || '(no title)'; + const url = subject?.html_url || payload.repository?.html_url || ''; + const assigner = payload.sender?.login || 'someone'; + + return [ + `[${repo}] ${label} #${number} assigned to ${CLAUDE_GITEA_USERNAME} by ${assigner}`, + `${title}`, + url ? `URL: ${url}` : null, + ] + .filter(Boolean) + .join('\n'); +} + function buildText(event, payload) { let text; switch (event) { @@ -205,16 +242,29 @@ app.post( (req, res) => { const deliveryId = req.header('X-Gitea-Delivery') || null; const event = req.header('X-Gitea-Event') || 'unknown'; + const eventType = req.header('X-Gitea-Event-Type') || event; + const payload = req.body || {}; if (isDuplicateDelivery(deliveryId)) { - log('info', { deliveryId, event, result: 'duplicate_ignored' }); + log('info', { deliveryId, event, eventType, result: 'duplicate_ignored' }); return res.status(200).json({ status: 'duplicate_ignored' }); } + const assignSubjectKind = ASSIGN_EVENT_SUBJECT[eventType]; + if (assignSubjectKind) { + const subject = assignSubjectKind === 'issue' ? payload.issue : payload.pull_request; + if (payload.action !== 'assigned' || !isAssignedTo(subject, CLAUDE_GITEA_USERNAME)) { + log('info', { deliveryId, event, eventType, result: 'ignored', reason: 'not_assigned_to_claude' }); + return res.status(200).json({ status: 'ignored' }); + } + } + res.status(200).json({ status: 'accepted' }); - const text = buildText(event, req.body || {}); - log('info', { deliveryId, event, result: 'accepted' }); + const text = assignSubjectKind + ? buildTextFromAssignment(assignSubjectKind, payload) + : buildText(event, payload); + log('info', { deliveryId, event, eventType, result: 'accepted' }); fireRoutine(text, deliveryId, event); } );