feat(ci): detect bot signatures in PR description, comments and reviews (#22547)
## 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. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22547?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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<TResponse>(
|
||||
url: string,
|
||||
token: string,
|
||||
): Promise<Commit[]> {
|
||||
const commits: Commit[] = [];
|
||||
): Promise<TResponse> {
|
||||
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<TItem>(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
): Promise<TItem[]> {
|
||||
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<TItem[]>(
|
||||
`${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<Commit[]> {
|
||||
return githubGetPaginated<Commit>(
|
||||
`https://api.github.com/repos/${repo}/pulls/${prNumber}/commits`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchProseSources(
|
||||
repo: string,
|
||||
prNumber: string,
|
||||
token: string,
|
||||
): Promise<ProseSource[]> {
|
||||
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<void> {
|
||||
@@ -75,32 +169,58 @@ async function main(): Promise<void> {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user