name: Flag PRs for triage # Labels pull requests whose author's public activity suggests that an LLM is # writing them without supervision, and records the evidence in the workflow # run summary so that triaging one does not require reading a user profile. # # Four independent signals, any of which is enough to label. Each one abstains # when the data it needs is unavailable, so a missing signal never counts # against an author: # # - Rejection burst: pull requests of theirs closed unmerged elsewhere within # the last month. Volume of rejections in absolute terms separates spraying # from ordinary contribution far better than a merge ratio does, since # ratios reward authors who accumulate merges in trivial repositories. # - Spray breadth: unrelated repositories they open pull requests against # within one week. Breadth catches an agent on its first day, before any of # its pull requests have been closed, and it comes from the event feed, so it # also covers authors that the search API refuses to return. # - Assistant voice: their recent comments across GitHub read as assistant # output rather than as a developer talking, by section headings, bullet # lists, em dash density or stock acknowledgement phrases. # - Agent branch: the branch name carries an agent prefix. # # Authors that the organisations behind this repository already trust are left # alone before any of that runs: public members of those organisations, and # authors with a track record of pull requests merged into their repositories. # Trust from a merge record rather than from a list of names keeps the exemption # in step with who is actually contributing. # # Deliberately not used: account age, fork age, follower count, total pull # request count and cross-repository merge ratio. All of them were measured # against hand-labelled pull requests and either failed to separate or, in the # case of the merge ratio, inverted on held-out data. # # The label is advisory, and it says the author's history is worth a look # before reviewing in depth; it does not say the pull request is bad. # # The workflow only reads pull request and public activity metadata through the # API, it never checks out or runs pull request code, so pull_request_target is # safe here. on: # zizmor: ignore[dangerous-triggers] pull_request_target: types: [opened] permissions: contents: read pull-requests: write jobs: flag-pr-for-triage: name: Label PR if the author's activity suggests unsupervised LLM use runs-on: ubuntu-latest steps: - name: Score the author and label the PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const LABEL = 'needs triage'; const RETRIES = 5; const RETRY_WAIT_MS = 60000; const REJECTION_WINDOW_DAYS = 30; const MIN_REJECTIONS = 1; const MIN_COMMENTS = 2; const MAX_REPOS_PER_WEEK = 2; const VOICE = { structure: 0.10, emDashPerKChar: 0.30, acknowledgement: 0.40 }; const EVENT_PAGES = 3; const TRUSTED_ORGS = ['scrapy', 'scrapy-plugins', 'scrapinghub', 'zytedata']; const MIN_TRUSTED_MERGES = 10; const AGENT_BRANCH = /^(agent|codex|claude|cursor|devin|copilot|jules|bot)[\/_-]/i; const { owner, repo } = context.repo; const pr = context.payload.pull_request; const author = pr.user.login; if (pr.user.type === 'Bot' || ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(pr.author_association)) { core.info(`Skipping PR #${pr.number} by ${author} (${pr.user.type}, ${pr.author_association}).`); return; } // Rate and abuse limits reset on the order of a minute, so waiting // is enough; other errors are not worth retrying. const retriable = new Set([403, 429, 500, 502, 503, 504]); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); async function withRetries(description, call) { for (let attempt = 1; ; attempt++) { try { return await call(); } catch (error) { if (!retriable.has(error.status) || attempt > RETRIES) throw error; const reset = Number(error.response?.headers?.['x-ratelimit-reset']) * 1000 - Date.now(); const after = Number(error.response?.headers?.['retry-after']) * 1000; const wait = Math.min(Math.max(after || reset || RETRY_WAIT_MS, RETRY_WAIT_MS), 15 * RETRY_WAIT_MS); core.info(`${description} failed with ${error.status}, retrying in ${Math.round(wait / 1000)}s (attempt ${attempt}/${RETRIES}).`); await sleep(wait); } } } // Accounts excluded from search, deleted users and the like leave a // signal unmeasurable rather than negative. const orNull = promise => promise.catch(error => { if ([404, 410, 422].includes(error.status)) return null; throw error; }); // author_association only reports membership of the organisation // that owns this repository, and only when it is public, so trust // in the author is established here instead. const trustedOrg = (await Promise.all(TRUSTED_ORGS.map(org => orNull(withRetries(`Checking public membership of ${org}`, () => github.rest.orgs.checkPublicMembershipForUser({ org, username: author }), )).then(response => response && org), ))).find(Boolean); if (trustedOrg) { core.info(`Skipping PR #${pr.number} by ${author} (public member of ${trustedOrg}).`); return; } // Repeating a qualifier narrows the search instead of widening it, // hence the explicit disjunction. const trustedMerges = await orNull(withRetries('Counting merged PRs in trusted organisations', () => github.rest.search.issuesAndPullRequests({ q: `author:${author} type:pr is:merged` + ` (${TRUSTED_ORGS.map(org => `org:${org}`).join(' OR ')})`, advanced_search: 'true', per_page: 1, }).then(response => response.data.total_count), )); if (trustedMerges >= MIN_TRUSTED_MERGES) { core.info(`Skipping PR #${pr.number} by ${author}` + ` (${trustedMerges} PR(s) merged into ${TRUSTED_ORGS.join(', ')}).`); return; } const opened = new Date(pr.created_at); const daysBefore = date => (opened - new Date(date)) / 86400000; // Signal 1: pull requests closed unmerged elsewhere, recently. const search = await orNull(withRetries('Searching for PRs by the author', () => github.rest.search.issuesAndPullRequests({ q: `author:${author} type:pr`, advanced_search: 'true', sort: 'created', order: 'desc', per_page: 100, }).then(response => response.data), )); let rejections = null; if (search) { rejections = search.items.filter(item => { const itemOwner = item.repository_url.split('/repos/')[1].split('/')[0].toLowerCase(); return itemOwner !== author.toLowerCase() && item.state === 'closed' && !item.pull_request?.merged_at && daysBefore(item.created_at) >= 0 && daysBefore(item.created_at) <= REJECTION_WINDOW_DAYS; }).map(item => item.html_url); } // Signal 2: how their recent comments across GitHub read. const events = []; for (let page = 1; page <= EVENT_PAGES; page++) { const batch = await orNull(withRetries(`Reading public events page ${page}`, () => github.rest.activity.listPublicEventsForUser({ username: author, per_page: 100, page, }).then(response => response.data), )); if (!batch?.length) break; events.push(...batch); if (batch.length < 100) break; } const comments = events .filter(event => ['IssueCommentEvent', 'PullRequestReviewCommentEvent'].includes(event.type)) .map(event => event.payload?.comment?.body) .filter(Boolean); // Signal 3: how many unrelated projects they open pull requests // against in a single week. Breadth rather than volume: a focused // contributor sends many pull requests to few repositories, while // an unattended agent sprays a few across many. Taken from the // event feed, which unlike search covers authors that search // refuses to return. const weeks = {}; for (const event of events) { if (event.type !== 'PullRequestEvent' || event.payload?.action !== 'opened') continue; const name = event.repo?.name; if (!name || name.toLowerCase().startsWith(`${author.toLowerCase()}/`)) continue; const week = Math.floor(new Date(event.created_at) / (7 * 86400000)); (weeks[week] ??= new Set()).add(name); } const breadth = events.length ? Math.max(0, ...Object.values(weeks).map(repos => repos.size)) : null; const STRUCTURE = [/^\s*#{2,3}\s/m, /^\s*[-*]\s.+\n\s*[-*]\s/m, /\*\*[^*]+\*\*/, /```/]; const ACKNOWLEDGEMENT = [ /thanks for (the )?(review|feedback|pointing|catching|flagging|clarif)/i, /you'?re (absolutely )?right/i, /great catch/i, /that makes sense/i, /i'?ll (continue|investigate|update|submit|look into|make sure)/i, /let me know (if|whether)/i, /happy to (update|adjust|revise|change)/i, /i understand that/i, /thanks for your time/i, /just following up/i, /hope (this|that) helps/i, /please let me know/i, /i'?ve (updated|addressed|fixed)/i, ]; let voice = null; if (comments.length >= MIN_COMMENTS) { const chars = comments.reduce((total, body) => total + body.length, 0); const rate = patterns => comments.filter(body => patterns.some(re => re.test(body))).length / comments.length; voice = { comments: comments.length, structure: rate(STRUCTURE), acknowledgement: rate(ACKNOWLEDGEMENT), emDashPerKChar: 1000 * comments.reduce((total, body) => total + (body.match(/—/g) || []).length, 0) / chars, }; } const reasons = []; if (rejections && rejections.length >= MIN_REJECTIONS) { reasons.push(`${rejections.length} PR(s) of theirs closed unmerged elsewhere in the last` + ` ${REJECTION_WINDOW_DAYS} days: ${rejections.slice(0, 10).join(' ')}`); } if (voice && (voice.structure > VOICE.structure || voice.emDashPerKChar > VOICE.emDashPerKChar || voice.acknowledgement > VOICE.acknowledgement)) { reasons.push(`comment style over ${voice.comments} recent comments:` + ` ${(100 * voice.structure).toFixed(0)}% structured,` + ` ${(100 * voice.acknowledgement).toFixed(0)}% stock acknowledgements,` + ` ${voice.emDashPerKChar.toFixed(2)} em dashes per 1000 characters`); } if (breadth !== null && breadth > MAX_REPOS_PER_WEEK) { reasons.push(`opened pull requests against ${breadth} unrelated repositories within a week`); } if (AGENT_BRANCH.test(pr.head?.ref || '')) { reasons.push(`branch name carries an agent prefix: ${pr.head.ref}`); } await core.summary .addHeading(`PR #${pr.number} by ${author}`, 3) .addList([ rejections === null ? 'recent rejections elsewhere: unmeasurable, the author cannot be searched' : `recent rejections elsewhere: ${rejections.length}`, voice === null ? `comment style: unmeasurable, fewer than ${MIN_COMMENTS} recent comments found` : `comment style: ${(100 * voice.structure).toFixed(0)}% structured,` + ` ${(100 * voice.acknowledgement).toFixed(0)}% stock acknowledgements,` + ` ${voice.emDashPerKChar.toFixed(2)} em dashes per 1000 characters` + ` over ${voice.comments} comments`, breadth === null ? 'repositories per week: unmeasurable, no public events found' : `repositories per week, at most: ${breadth}`, `branch: ${pr.head?.ref ?? 'unknown'}`, `verdict: ${reasons.length ? `labelled "${LABEL}"` : 'not labelled'}`, ]) .addRaw(reasons.length ? `\n${reasons.map(reason => `- ${reason}`).join('\n')}\n` : '') .write(); if (!reasons.length) { core.info(`Not labelling PR #${pr.number}.`); return; } await withRetries('Adding the label', () => github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }), ); core.info(`Labelled PR #${pr.number}: ${reasons.join(' | ')}`);