Only fire the routine when an issue/PR is assigned to Claude
Gitea's X-Gitea-Event only gives the coarse event; the precise "assigned" sub-event lives in X-Gitea-Event-Type (issue_assign, pull_request_assign). Read that header and check the assignees list against CLAUDE_GITEA_USERNAME so unrelated assignment changes get a 200 "ignored" instead of triggering a routine fire. Document the known Gitea limitation where it can't tell us who was just added to assignees, only the full current list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,3 +21,8 @@ DEDUP_TTL_MS=300000
|
|||||||
# Timeout in milliseconds for the call to the Anthropic API (prevents it from
|
# Timeout in milliseconds for the call to the Anthropic API (prevents it from
|
||||||
# hanging indefinitely if there's no outbound internet access)
|
# hanging indefinitely if there's no outbound internet access)
|
||||||
FIRE_TIMEOUT_MS=10000
|
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
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -23,6 +23,21 @@ send itself (`Authorization: Bearer <token>`, `anthropic-beta`,
|
|||||||
with that `text` and logs the outcome (success, 401/429/5xx, or a
|
with that `text` and logs the outcome (success, 401/429/5xx, or a
|
||||||
network failure) without crashing the process.
|
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
|
## Environment variables
|
||||||
|
|
||||||
See [.env.example](.env.example). Copy it and fill in the real values
|
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`. |
|
| `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). |
|
| `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). |
|
| `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
|
## Running locally
|
||||||
|
|
||||||
@@ -60,8 +76,8 @@ On the repo/organization → Settings → Webhooks → Add webhook:
|
|||||||
- Content type: `application/json`
|
- Content type: `application/json`
|
||||||
- Secret: same value as `GITEA_WEBHOOK_SECRET`
|
- Secret: same value as `GITEA_WEBHOOK_SECRET`
|
||||||
- Authorization header: same value as `GITEA_AUTH_HEADER`
|
- Authorization header: same value as `GITEA_AUTH_HEADER`
|
||||||
- Events: `Push` and/or `Pull Request` (any other event is forwarded with a
|
- Events: `Push`, `Issues` (at least "Assigned"), and/or `Pull Request` (at
|
||||||
generic message)
|
least "Assigned") — any other event is forwarded with a generic message
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
56
server.js
56
server.js
@@ -11,6 +11,7 @@ const {
|
|||||||
ROUTINE_TOKEN,
|
ROUTINE_TOKEN,
|
||||||
DEDUP_TTL_MS = String(5 * 60 * 1000),
|
DEDUP_TTL_MS = String(5 * 60 * 1000),
|
||||||
FIRE_TIMEOUT_MS = String(10 * 1000),
|
FIRE_TIMEOUT_MS = String(10 * 1000),
|
||||||
|
CLAUDE_GITEA_USERNAME = 'claude',
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
for (const [name, value] of Object.entries({
|
for (const [name, value] of Object.entries({
|
||||||
@@ -127,6 +128,42 @@ function buildTextFromPullRequest(payload) {
|
|||||||
.join('\n');
|
.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) {
|
function buildText(event, payload) {
|
||||||
let text;
|
let text;
|
||||||
switch (event) {
|
switch (event) {
|
||||||
@@ -205,16 +242,29 @@ app.post(
|
|||||||
(req, res) => {
|
(req, res) => {
|
||||||
const deliveryId = req.header('X-Gitea-Delivery') || null;
|
const deliveryId = req.header('X-Gitea-Delivery') || null;
|
||||||
const event = req.header('X-Gitea-Event') || 'unknown';
|
const event = req.header('X-Gitea-Event') || 'unknown';
|
||||||
|
const eventType = req.header('X-Gitea-Event-Type') || event;
|
||||||
|
const payload = req.body || {};
|
||||||
|
|
||||||
if (isDuplicateDelivery(deliveryId)) {
|
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' });
|
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' });
|
res.status(200).json({ status: 'accepted' });
|
||||||
|
|
||||||
const text = buildText(event, req.body || {});
|
const text = assignSubjectKind
|
||||||
log('info', { deliveryId, event, result: 'accepted' });
|
? buildTextFromAssignment(assignSubjectKind, payload)
|
||||||
|
: buildText(event, payload);
|
||||||
|
log('info', { deliveryId, event, eventType, result: 'accepted' });
|
||||||
fireRoutine(text, deliveryId, event);
|
fireRoutine(text, deliveryId, event);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user