diff --git a/.github/workflows/ci-blocked-contributors.yaml b/.github/workflows/ci-blocked-contributors.yaml new file mode 100644 index 00000000000..791c1fb09ef --- /dev/null +++ b/.github/workflows/ci-blocked-contributors.yaml @@ -0,0 +1,27 @@ +name: Blocked Contributors Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + check-blocked-contributors: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Install dependencies + uses: ./.github/actions/yarn-install + + - name: Check PR commits 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 }} diff --git a/packages/twenty-server/scripts/check-blocked-contributors.ts b/packages/twenty-server/scripts/check-blocked-contributors.ts new file mode 100644 index 00000000000..ea4451350e0 --- /dev/null +++ b/packages/twenty-server/scripts/check-blocked-contributors.ts @@ -0,0 +1,109 @@ +// 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 = [ + /noreply@anthropic\.com/i, + /@anthropic\.com/i, + /cursoragent@cursor\.com/i, + /copilot-swe-agent\[bot\]/i, +]; + +type Commit = { + sha: string; + commit: { + message: string; + author: { name: string; email: string }; + committer: { name: string; email: string }; + }; +}; + +async function fetchPrCommits( + repo: string, + prNumber: string, + token: string, +): Promise { + const commits: Commit[] = []; + 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', + }, + }, + ); + + if (!response.ok) { + throw new Error( + `GitHub API ${response.status}: ${await response.text()}`, + ); + } + + const batch = (await response.json()) as Commit[]; + commits.push(...batch); + + if (batch.length < 100) { + return commits; + } + + page += 1; + } +} + +function findMatches(commit: Commit): string[] { + const haystack = [ + commit.commit.author.name, + commit.commit.author.email, + commit.commit.committer.name, + commit.commit.committer.email, + commit.commit.message, + ].join(' '); + + return BLOCKED_PATTERNS.flatMap((pattern) => { + const match = haystack.match(pattern); + return match ? [match[0]] : []; + }); +} + +async function main(): Promise { + const token = process.env.GITHUB_TOKEN; + const repo = process.env.GITHUB_REPOSITORY; + const prNumber = process.env.PR_NUMBER; + + if (!token || !repo || !prNumber) { + console.error('Error: GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER are required'); + process.exit(1); + } + + const commits = await fetchPrCommits(repo, prNumber, token); + let violations = 0; + + for (const commit of commits) { + const matches = findMatches(commit); + + if (matches.length > 0) { + console.error( + `::error::Commit ${commit.sha} is attributed to a blocked contributor (matched: ${matches.join(', ')})`, + ); + 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"); + process.exit(1); + } + + console.log('No blocked contributors found in PR commits.'); +} + +main().catch((error) => { + console.error('Error:', error); + process.exit(1); +});