From 963667a2eb2202b2f1dad78cb1cf934b01c84f56 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 28 Sep 2026 15:09:16 +0000 Subject: [PATCH] Claude: address #2 --- README.md | 9 ++-- src/events.js | 35 ++++++++++++--- test/events.test.js | 102 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 780311c..09500a9 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,11 @@ See `.env.example`. 6. In Gitea: Site Administration -> Integrations -> Webhooks -> **System Webhooks** -> Add, URL `http://claude-hook:3000/`, trigger on **Issue Comment** (and **Issues**, if you want `@claude` in a fresh issue body - to work too). If you set `WEBHOOK_SECRET`, put the same value here. + to work too). To also support pull requests, enable **Pull Request** + (for `@claude` in a fresh PR description), **Pull Request Comment** + (general PR comments — routed the same as issue comments), **Pull + Request Review**, and **Pull Request Review Comment** (inline diff + comments). If you set `WEBHOOK_SECRET`, put the same value here. 7. Add `GITEA__webhook__ALLOWED_HOST_LIST=private` to Gitea's own environment and restart it — Gitea refuses to call private addresses otherwise. @@ -97,9 +101,6 @@ or pipeable into `jq`. Key events to look for: ## Known limitations -- Only `issue_comment` and `issues` events are handled; PR *review* comments - (`pull_request_review_comment`) are a separate event type and are not - wired up. - Runs execute inside this one long-lived container, not a fresh sandbox per job — don't point `OWNER_LOGIN` checks loosely, and don't run this against repos where you don't trust every collaborator who can comment. diff --git a/src/events.js b/src/events.js index f69a9bc..bdf1885 100644 --- a/src/events.js +++ b/src/events.js @@ -25,13 +25,38 @@ export function classifyEvent(eventName, payload, { ownerLogin, triggerPhrase }) let author; let text; + let entity; // the issue or pull_request object carrying number/title/body + let isPullRequest; if (eventName === 'issue_comment' && payload.action === 'created') { + // Gitea (like GitHub) routes comments on both issues and pull requests + // through this single event; payload.issue.pull_request distinguishes them. author = payload.comment?.user?.login; text = payload.comment?.body; + entity = payload.issue; + isPullRequest = Boolean(payload.issue?.pull_request); } else if (eventName === 'issues' && ['opened', 'edited'].includes(payload.action)) { author = payload.issue?.user?.login; text = payload.issue?.body; + entity = payload.issue; + isPullRequest = false; + } else if (eventName === 'pull_request' && ['opened', 'edited'].includes(payload.action)) { + author = payload.pull_request?.user?.login; + text = payload.pull_request?.body; + entity = payload.pull_request; + isPullRequest = true; + } else if (eventName === 'pull_request_review_comment' && payload.action === 'created') { + // Inline review comments on a PR's diff; a separate event type from issue_comment. + author = payload.comment?.user?.login; + text = payload.comment?.body; + entity = payload.pull_request; + isPullRequest = true; + } else if (eventName === 'pull_request_review' && payload.action === 'submitted') { + // A review's overall summary comment, left when submitting an approval/rejection. + author = payload.review?.user?.login; + text = payload.review?.body ?? payload.review?.content; + entity = payload.pull_request; + isPullRequest = true; } else { return { trigger: null, reason: `event "${eventName}" action "${payload.action}" is not handled` }; } @@ -42,17 +67,17 @@ export function classifyEvent(eventName, payload, { ownerLogin, triggerPhrase }) if (!text || !text.includes(triggerPhrase)) { return { trigger: null, reason: `text does not contain trigger phrase "${triggerPhrase}"` }; } - if (!payload.repository?.full_name || !payload.issue?.number) { + if (!payload.repository?.full_name || !entity?.number) { return { trigger: null, reason: 'payload is missing repository or issue information' }; } return { trigger: { repo: payload.repository.full_name, - issueNumber: payload.issue.number, - issueTitle: payload.issue.title ?? '', - issueBody: payload.issue.body ?? '', - isPullRequest: Boolean(payload.issue.pull_request), + issueNumber: entity.number, + issueTitle: entity.title ?? '', + issueBody: entity.body ?? '', + isPullRequest, defaultBranch: payload.repository.default_branch || 'main', requestText: text, author, diff --git a/test/events.test.js b/test/events.test.js index 757a716..40b2545 100644 --- a/test/events.test.js +++ b/test/events.test.js @@ -90,3 +90,105 @@ test('defaults default_branch to main when absent', () => { const { trigger } = classifyEvent('issue_comment', payload, opts); assert.equal(trigger.defaultBranch, 'main'); }); + +function pullRequestPayload(overrides = {}) { + return { + action: 'opened', + pull_request: { number: 5, title: 'Add feature', body: 'please @claude review', user: { login: 'selimaj-dev' } }, + repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' }, + ...overrides, + }; +} + +test('triggers on a new pull request whose body mentions the phrase', () => { + const { trigger, reason } = classifyEvent('pull_request', pullRequestPayload(), opts); + assert.equal(reason, null); + assert.deepEqual(trigger, { + repo: 'selimaj-dev/repo', + issueNumber: 5, + issueTitle: 'Add feature', + issueBody: 'please @claude review', + isPullRequest: true, + defaultBranch: 'main', + requestText: 'please @claude review', + author: 'selimaj-dev', + }); +}); + +test('triggers on an edited pull request body', () => { + const { trigger } = classifyEvent('pull_request', pullRequestPayload({ action: 'edited' }), opts); + assert.ok(trigger); +}); + +test('ignores pull_request actions other than opened/edited', () => { + const { trigger, reason } = classifyEvent('pull_request', pullRequestPayload({ action: 'closed' }), opts); + assert.equal(trigger, null); + assert.match(reason, /not handled/); +}); + +test('ignores pull requests not from the configured owner', () => { + const payload = pullRequestPayload({ pull_request: { number: 5, title: 'x', body: '@claude help', user: { login: 'someone-else' } } }); + const { trigger, reason } = classifyEvent('pull_request', payload, opts); + assert.equal(trigger, null); + assert.match(reason, /is not the configured owner/); +}); + +function pullRequestReviewCommentPayload(overrides = {}) { + return { + action: 'created', + comment: { user: { login: 'selimaj-dev' }, body: '@claude fix this line' }, + pull_request: { number: 9, title: 'PR title', body: 'pr description' }, + repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' }, + ...overrides, + }; +} + +test('triggers on a pull request review (inline) comment mentioning the phrase', () => { + const { trigger, reason } = classifyEvent('pull_request_review_comment', pullRequestReviewCommentPayload(), opts); + assert.equal(reason, null); + assert.deepEqual(trigger, { + repo: 'selimaj-dev/repo', + issueNumber: 9, + issueTitle: 'PR title', + issueBody: 'pr description', + isPullRequest: true, + defaultBranch: 'main', + requestText: '@claude fix this line', + author: 'selimaj-dev', + }); +}); + +test('ignores non-created pull_request_review_comment actions', () => { + const { trigger, reason } = classifyEvent( + 'pull_request_review_comment', + pullRequestReviewCommentPayload({ action: 'deleted' }), + opts, + ); + assert.equal(trigger, null); + assert.match(reason, /not handled/); +}); + +function pullRequestReviewPayload(overrides = {}) { + return { + action: 'submitted', + review: { user: { login: 'selimaj-dev' }, body: '@claude address these comments' }, + pull_request: { number: 11, title: 'PR title', body: 'pr description' }, + repository: { full_name: 'selimaj-dev/repo', default_branch: 'main' }, + ...overrides, + }; +} + +test('triggers on a submitted pull request review mentioning the phrase', () => { + const { trigger, reason } = classifyEvent('pull_request_review', pullRequestReviewPayload(), opts); + assert.equal(reason, null); + assert.equal(trigger.requestText, '@claude address these comments'); + assert.equal(trigger.isPullRequest, true); + assert.equal(trigger.issueNumber, 11); +}); + +test('ignores pull_request_review without the trigger phrase', () => { + const payload = pullRequestReviewPayload({ review: { user: { login: 'selimaj-dev' }, body: 'looks good' } }); + const { trigger, reason } = classifyEvent('pull_request_review', payload, opts); + assert.equal(trigger, null); + assert.match(reason, /trigger phrase/); +}); -- 2.54.0