From cfe0fc7ce6de6b616fca05b20f458d6dbf65b6cc Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 4 Jul 2026 15:11:32 +0200 Subject: [PATCH] feat(ci): detect bot signatures in PR description, comments and reviews (#22547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Extends the **Blocked Contributors Check** beyond commits so it also scans a PR's: - **Description** (PR body) - **Conversation comments** - **Inline review comments** - **Review summaries** ## Why The check already fails a PR when a commit is attributed to a known bot (via author/committer/email and `Co-Authored-By` trailers). But bot-generated content also leaks into PR prose — descriptions and comments carry attribution footers like `🤖 Generated with Claude Code` that the commit-only scan never saw. ## How - **Commits** keep matching on bot *identity* (`IDENTITY_PATTERNS`: `@anthropic.com`, `cursoragent@cursor.com`, `copilot-swe-agent[bot]`). - **Prose surfaces** are matched only on `SIGNATURE_PATTERNS` — the verbatim auto-generated attribution footers (`Generated with Claude Code`, `Co-Authored-By: Claude`, Cursor equivalents). This is deliberately tight: contributors legitimately discuss Claude/Cursor in comments, so a bare product-name mention must **not** trip the check. Verified that "I used Claude Code to draft this but rewrote it", "works great in Cursor", and human `Co-Authored-By` lines all stay clean while real footers flag. - The workflow now also triggers on `issue_comment`, `pull_request_review` and `pull_request_review_comment` (plus PR `edited`), so bot prose added *between* commit pushes is still caught. `issue_comment` is guarded to PRs only, and `PR_NUMBER` resolves from either event. - Each prose violation reports the surface kind and a clickable URL. ## Notes `SIGNATURE_PATTERNS` are conservative by design and won't catch a footer someone reworded by hand. Widening them is a follow-up if we decide to trade some false positives for broader coverage. Review in cubic --- .../workflows/ci-blocked-contributors.yaml | 14 +- .../scripts/check-blocked-contributors.ts | 200 ++++++++++++++---- 2 files changed, 171 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci-blocked-contributors.yaml b/.github/workflows/ci-blocked-contributors.yaml index 791c1fb09e..ae27b743cf 100644 --- a/.github/workflows/ci-blocked-contributors.yaml +++ b/.github/workflows/ci-blocked-contributors.yaml @@ -2,16 +2,24 @@ name: Blocked Contributors Check on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, edited] + issue_comment: + types: [created, edited] + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] permissions: contents: read + issues: read pull-requests: read jobs: check-blocked-contributors: runs-on: ubuntu-latest timeout-minutes: 10 + if: ${{ github.event_name != 'issue_comment' || github.event.issue.pull_request }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -19,9 +27,9 @@ jobs: - name: Install dependencies uses: ./.github/actions/yarn-install - - name: Check PR commits for blocked contributors + - name: Check PR commits, description, comments and reviews for blocked contributors run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/check-blocked-contributors.ts env: GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} diff --git a/packages/twenty-server/scripts/check-blocked-contributors.ts b/packages/twenty-server/scripts/check-blocked-contributors.ts index ea4451350e..c3e954a3df 100644 --- a/packages/twenty-server/scripts/check-blocked-contributors.ts +++ b/packages/twenty-server/scripts/check-blocked-contributors.ts @@ -1,14 +1,17 @@ -// Fails a PR when any commit is attributed to a known bot (author, committer, -// or Co-Authored-By trailer). Patterns match bot identities, not human names. -// Usage: GITHUB_TOKEN=xxx GITHUB_REPOSITORY=owner/repo PR_NUMBER=123 npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/check-blocked-contributors.ts - -const BLOCKED_PATTERNS = [ +const IDENTITY_PATTERNS = [ /noreply@anthropic\.com/i, /@anthropic\.com/i, /cursoragent@cursor\.com/i, /copilot-swe-agent\[bot\]/i, ]; +const SIGNATURE_PATTERNS = [ + /Generated with \[Claude Code\]\(/i, + /Co-Authored-By:[^\n]*<[^>]*@anthropic\.com>/i, + /Generated with \[Cursor( Agent)?\]\(/i, + /Co-Authored-By:[^\n]*cursoragent/i, +]; + type Commit = { sha: string; commit: { @@ -18,44 +21,67 @@ type Commit = { }; }; -async function fetchPrCommits( - repo: string, - prNumber: string, +type ProseSource = { + kind: string; + ref: string; + body: string; +}; + +async function githubGet( + url: string, token: string, -): Promise { - const commits: Commit[] = []; +): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${await response.text()}`); + } + + return (await response.json()) as TResponse; +} + +async function githubGetPaginated( + baseUrl: string, + token: string, +): Promise { + const items: TItem[] = []; let page = 1; for (;;) { - const response = await fetch( - `https://api.github.com/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - }, - }, + const separator = baseUrl.includes('?') ? '&' : '?'; + const batch = await githubGet( + `${baseUrl}${separator}per_page=100&page=${page}`, + token, ); - if (!response.ok) { - throw new Error( - `GitHub API ${response.status}: ${await response.text()}`, - ); - } - - const batch = (await response.json()) as Commit[]; - commits.push(...batch); + items.push(...batch); if (batch.length < 100) { - return commits; + return items; } page += 1; } } -function findMatches(commit: Commit): string[] { - const haystack = [ +function matchPatterns(text: string, patterns: RegExp[]): string[] { + if (!text) { + return []; + } + + return patterns.flatMap((pattern) => { + const match = text.match(pattern); + return match ? [match[0]] : []; + }); +} + +function findCommitMatches(commit: Commit): string[] { + const identityHaystack = [ commit.commit.author.name, commit.commit.author.email, commit.commit.committer.name, @@ -63,10 +89,78 @@ function findMatches(commit: Commit): string[] { commit.commit.message, ].join(' '); - return BLOCKED_PATTERNS.flatMap((pattern) => { - const match = haystack.match(pattern); - return match ? [match[0]] : []; + return [ + ...matchPatterns(identityHaystack, IDENTITY_PATTERNS), + ...matchPatterns(commit.commit.message, SIGNATURE_PATTERNS), + ]; +} + +async function fetchPrCommits( + repo: string, + prNumber: string, + token: string, +): Promise { + return githubGetPaginated( + `https://api.github.com/repos/${repo}/pulls/${prNumber}/commits`, + token, + ); +} + +async function fetchProseSources( + repo: string, + prNumber: string, + token: string, +): Promise { + const base = `https://api.github.com/repos/${repo}`; + const sources: ProseSource[] = []; + + const pullRequest = await githubGet<{ + body: string | null; + html_url: string; + }>(`${base}/pulls/${prNumber}`, token); + sources.push({ + kind: 'PR description', + ref: pullRequest.html_url, + body: pullRequest.body ?? '', }); + + const conversationComments = await githubGetPaginated<{ + body: string | null; + html_url: string; + }>(`${base}/issues/${prNumber}/comments`, token); + for (const comment of conversationComments) { + sources.push({ + kind: 'Conversation comment', + ref: comment.html_url, + body: comment.body ?? '', + }); + } + + const reviewComments = await githubGetPaginated<{ + body: string | null; + html_url: string; + }>(`${base}/pulls/${prNumber}/comments`, token); + for (const comment of reviewComments) { + sources.push({ + kind: 'Inline review comment', + ref: comment.html_url, + body: comment.body ?? '', + }); + } + + const reviews = await githubGetPaginated<{ + body: string | null; + html_url: string; + }>(`${base}/pulls/${prNumber}/reviews`, token); + for (const review of reviews) { + sources.push({ + kind: 'Review summary', + ref: review.html_url, + body: review.body ?? '', + }); + } + + return sources; } async function main(): Promise { @@ -75,32 +169,58 @@ async function main(): Promise { const prNumber = process.env.PR_NUMBER; if (!token || !repo || !prNumber) { - console.error('Error: GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER are required'); + console.error( + 'Error: GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER are required', + ); process.exit(1); } - const commits = await fetchPrCommits(repo, prNumber, token); + const [commits, proseSources] = await Promise.all([ + fetchPrCommits(repo, prNumber, token), + fetchProseSources(repo, prNumber, token), + ]); + let violations = 0; for (const commit of commits) { - const matches = findMatches(commit); + const matches = findCommitMatches(commit); if (matches.length > 0) { console.error( - `::error::Commit ${commit.sha} is attributed to a blocked contributor (matched: ${matches.join(', ')})`, + `::error::Commit ${commit.sha} contains a blocked bot attribution or signature (matched: ${matches.join(', ')})`, + ); + violations += 1; + } + } + + for (const source of proseSources) { + const matches = matchPatterns(source.body, SIGNATURE_PATTERNS); + + if (matches.length > 0) { + console.error( + `::error::${source.kind} contains a bot signature (matched: ${matches.join(', ')}) — ${source.ref}`, ); violations += 1; } } if (violations > 0) { - console.error(`\nFound ${violations} commit(s) attributed to blocked bot contributors.`); - console.error("Rewrite the author/committer and strip Co-Authored-By trailers, then force-push:"); - console.error(" git rebase -i --exec 'git commit --amend --reset-author --no-edit' origin/main"); + console.error( + `\nFound ${violations} item(s) attributed to blocked bot contributors.`, + ); + console.error( + 'For commits: rewrite the author/committer and strip Co-Authored-By trailers, then force-push:', + ); + console.error( + " git rebase -i --exec 'git commit --amend --reset-author --no-edit' origin/main", + ); + console.error( + 'For the PR description, comments or reviews: remove the auto-generated attribution footer.', + ); process.exit(1); } - console.log('No blocked contributors found in PR commits.'); + console.log('No blocked contributors found in PR commits, description, comments or reviews.'); } main().catch((error) => {