#!/usr/bin/env bun import isBotUser from '@github/libs/isBotUser'; import isProposal from '@github/libs/isProposal'; /** * Harvests real ProposalPolice cases out of Expensify/App into candidate fixtures for the offline * evals in `evals/proposalPolice`. * * Run it with: * * bun ./scripts/harvestProposalPoliceEvals.ts * bun ./scripts/harvestProposalPoliceEvals.ts --set=duplicateCheck --issues=40 * bun ./scripts/harvestProposalPoliceEvals.ts --set=commentIntent --issueNumbers=98504,78241 * * It shells out to an authenticated `gh`, so no token plumbing is needed. `--issueNumbers` skips the * search and walks exactly the issues named, which is how a case spotted by hand gets pulled in — the * canonical bid-spam fixture lives on an issue that never carried the `Help Wanted` label, so no search * this script runs would ever have found it. * * WHERE THE ORIGINAL TEXT COMES FROM * * The text these fixtures need is not in the REST API. When ProposalPolice withdraws a duplicate it * *replaces* the comment body with the withdraw message, and when it flags a substantive edit it * *prepends* a banner to the body, so `body` on a harvested comment is the bot's text rather than the * contributor's. * * Both originals come back from the GraphQL `userContentEdits` connection on `IssueComment`, which was * verified against live data before this script was written. Despite the field name, `diff` is **not** a * unified diff: it is the **full comment body as of that revision**. Two more properties of the * connection that this script relies on, also verified against live data: * * - Nodes come back newest-first. * - The oldest node's `editedAt` equals the comment's `createdAt`, i.e. GitHub records the body the * comment was created with as the first revision. A comment that was never edited has no nodes at * all, so a comment with revisions always has at least two. * * So for a comment whose revision N is the bot's withdraw message, revision N+1 holds the proposal the * contributor actually posted; and for a comment whose revision N carries the substantive-edit banner, * revision N+1 is the body the contributor edited *to* and revision N+2 the body they edited *from*. * * RE-RUNNING * * Harvesting is separate from labelling, because the expected outcome on a fixture is a hand-applied * judgement that a re-harvest must never clobber. This script only ever writes to * `evals/proposalPolice/fixtures/harvested/`, which is gitignored. The labelled fixtures the evals * actually read — `commentIntent.json`, `duplicateCheck.json`, `editCheck.json` — are checked in beside * that directory and are edited by hand. After each run the script reports which harvested candidates * are not yet represented in the labelled files, matched on `sourceHash`, so growing a fixture set is a * matter of copying a candidate across, replacing `sourceUrl` with its `sourceHash`, and filling in the * expected outcome. * * ANONYMIZATION * * This repository is public and the intent labels include SPAM, so a labelled fixture must never read as * an accusation against a named contributor. Checked-in fixtures therefore carry no `sourceUrl` and no * real GitHub comment IDs — the duplicate-check sets renumber theirs to small synthetic values, since * nothing depends on the originals beyond matching `expectedDuplicateCommentID`. * * Author logins are replaced with obviously-fictional stand-ins. Assignment is per-fixture rather than * global, so a login appearing in two fixtures can be `snorkmaiden` in one and `little-my` in the other. * What matters is that same-author and different-author relationships survive *within* a fixture, because * the duplicate check must never report a contributor's own earlier proposal. @-mentions inside bodies * are rewritten the same way, and mentions of anyone not otherwise in the fixture become `@user`, so * replaying a fixture can never ping a real person. Profile links, email addresses, and the login spelt * into a link to the author's own fork are neutralized the same way, since contributors paste all three. * * The rest of a github.com URL — repository, issue number, permalink, line range — is left intact. That * is the technical substance of a proposal, and blanking it would change what the model is being asked to * judge. */ import {SUBSTANTIVE_EDIT_MESSAGE_REGEX} from '@prompts/proposalPolice/messages'; import CLI from 'expensify-common/CLI'; import {spawnSync} from 'node:child_process'; import {createHash} from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; const REPO = 'Expensify/App'; const FIXTURES_DIR = path.resolve(__dirname, '../evals/proposalPolice/fixtures'); const HARVEST_DIR = path.join(FIXTURES_DIR, 'harvested'); /** The bot's own text, matched against comment bodies to recognize the cases worth harvesting. */ const WITHDRAW_MESSAGE = '#### 🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.'; const EDIT_BANNER_PREFIX = '🚨 Edited by **proposal-police**:'; /** The notice the bot posts alongside a withdrawal, which names the proposal it considered the original. */ const DUPLICATE_NOTICE_REGEX = /Your proposal is a duplicate of an already \[existing proposal]\(https:\/\/github\.com\/Expensify\/App\/issues\/\d+#issuecomment-(\d+)\)/; /** GitHub caps `search/issues` at 100 results per page. */ const SEARCH_PAGE_SIZE = 100; /** GitHub caps connection page sizes at 100. */ const COMMENTS_PAGE_SIZE = 100; /** Deep enough for the busiest proposal comments observed, which sit in the low tens of revisions. */ const EDITS_PAGE_SIZE = 50; const COMMENTS_QUERY = ` query($owner: String!, $repo: String!, $number: Int!, $after: String) { repository(owner: $owner, name: $repo) { issue(number: $number) { comments(first: ${COMMENTS_PAGE_SIZE}, after: $after) { pageInfo { hasNextPage endCursor } nodes { databaseId body createdAt author { login typename: __typename } userContentEdits(first: ${EDITS_PAGE_SIZE}) { nodes { editedAt diff } } } } } } }`; type FixtureSetName = 'commentIntent' | 'duplicateCheck' | 'editCheck'; type Comment = { commentID: number; /** The body as it stands today, which for a harvested case is usually the bot's text. */ body: string; /** Revision bodies, newest-first. Empty for a comment that was never edited. */ revisions: string[]; author: string; isBot: boolean; createdAt: string; sourceUrl: string; }; type CommentIntentCandidate = { body: string; id: string; origin: 'harvested'; sourceUrl: string; }; type DuplicateCheckProposal = { author: string; body: string; commentID: number; sourceUrl: string; }; type DuplicateCheckCandidate = { id: string; newProposal: DuplicateCheckProposal; origin: 'harvested'; priorProposals: DuplicateCheckProposal[]; /** The prior proposal ProposalPolice linked in its notice, i.e. what production decided. */ withdrawnAgainstCommentID: number | null; }; type EditCheckCandidate = { after: string; before: string; id: string; origin: 'harvested'; sourceUrl: string; }; /** * Runs `gh` without a shell (so a search query can't be word-split or expanded) and parses its JSON. */ function gh(args: string[]): T { const result = spawnSync('gh', args, {encoding: 'utf8', maxBuffer: 64 * 1024 * 1024}); if (result.status !== 0) { throw new Error(`gh ${args.join(' ')} failed: ${result.stderr || result.stdout}`); } const parsed: unknown = JSON.parse(result.stdout); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the caller names the shape it asked GitHub for; a bad shape surfaces as an undefined field on a developer-run script, not in production. return parsed as T; } /** * Obviously-fictional stand-in logins, so nobody reading a fixture mistakes one for a real contributor. * Assigned in order within a fixture; a fixture needing more authors than this wraps with a numeric suffix. */ const STAND_IN_LOGINS = ['snorkmaiden', 'hemulen', 'little-my', 'sniff', 'too-ticky', 'stinky', 'mymble', 'hodgkins', 'fillyjonk', 'whomper']; /** * Rewrites author logins to stand-in logins and neutralizes every @-mention in the given bodies, so a * fixture carries the same author relationships as the real thread without naming anyone. The caller * passes the logins whose identity the fixture depends on; anyone else mentioned in the text collapses * to `@user`. */ function anonymize(logins: string[], bodies: string[]): {aliases: Map; bodies: string[]} { const aliases = new Map(); for (const login of logins) { if (aliases.has(login)) { continue; } const index = aliases.size; const suffix = index >= STAND_IN_LOGINS.length ? `-${Math.floor(index / STAND_IN_LOGINS.length)}` : ''; aliases.set(login, `${STAND_IN_LOGINS.at(index % STAND_IN_LOGINS.length)}${suffix}`); } const anonymizedBodies = bodies.map((body) => { let anonymized = body // GitHub logins are alphanumerics and hyphens, and a mention is only a mention at a word boundary. .replaceAll(/(^|[^\w/])@([A-Za-z\d](?:[A-Za-z\d-]*[A-Za-z\d])?)/g, (match, prefix: string, login: string) => `${prefix}@${aliases.get(login) ?? 'user'}`) // A github.com URL with a single path segment is a profile link, so it names someone the same way a mention does. .replaceAll(/https:\/\/github\.com\/([A-Za-z\d](?:[A-Za-z\d-]*[A-Za-z\d])?)(?![\w/-])/g, (match, login: string) => `https://github.com/${aliases.get(login) ?? 'user'}`) .replaceAll(/[\w.+-]+@[\w-]+\.[\w.]+/g, 'user@example.com'); // Contributors routinely link a branch on their own fork, which spells their login into a URL path // (`github.com//App/tree/…`) or a compare ref (`compare/main...:App:…`) rather than // into a mention. Those are exactly the logins already in the alias map, so replace them wherever // they appear as a whole word. for (const [login, alias] of aliases) { anonymized = anonymized.replaceAll(new RegExp(`(^|[^\\w-])${login}(?![\\w-])`, 'g'), `$1${alias}`); } return anonymized; }); return {aliases, bodies: anonymizedBodies}; } /** * Fetches every comment on an issue along with its revision history. */ function fetchComments(issueNumber: number): Comment[] { type CommentsPage = { data: { repository: { issue: { comments: { pageInfo: {hasNextPage: boolean; endCursor: string | null}; nodes: Array<{ databaseId: number; body: string; createdAt: string; author: {login: string; typename: string} | null; userContentEdits: {nodes: Array<{editedAt: string; diff: string | null}>}; } | null>; }; } | null; }; }; }; const comments: Comment[] = []; let after: string | null = null; do { const args = ['api', 'graphql', '-f', `query=${COMMENTS_QUERY}`, '-f', 'owner=Expensify', '-f', 'repo=App', '-F', `number=${issueNumber}`]; if (after) { args.push('-f', `after=${after}`); } const page: CommentsPage = gh(args); const connection = page.data.repository.issue?.comments; if (!connection) { break; } for (const node of connection.nodes) { // A comment whose author deleted their account comes back with a null author and is unusable // for a fixture, since same-author relationships can no longer be established for it. if (!node?.author) { continue; } comments.push({ commentID: node.databaseId, body: node.body, revisions: node.userContentEdits.nodes.map((edit) => edit.diff ?? ''), author: node.author.login, isBot: isBotUser(node.author.login, node.author.typename), createdAt: node.createdAt, sourceUrl: `https://github.com/${REPO}/issues/${issueNumber}#issuecomment-${node.databaseId}`, }); } after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor : null; } while (after); return comments; } /** * Searches issues by comment text and returns their numbers, newest first. */ function searchIssueNumbers(query: string, limit: number): number[] { type SearchPage = {items: Array<{number: number}>}; const numbers: number[] = []; for (let page = 1; numbers.length < limit; page++) { const results = gh([ 'api', '-X', 'GET', 'search/issues', '-f', `q=${query}`, '-f', 'sort=created', '-f', 'order=desc', '-F', `per_page=${Math.min(SEARCH_PAGE_SIZE, limit - numbers.length)}`, '-F', `page=${page}`, ]); if (results.items.length === 0) { break; } numbers.push(...results.items.map((item) => item.number)); } return numbers.slice(0, limit); } /** * Ordinary contributor comments, which is what the intent classifier sees in production. Comments the * bot has touched are skipped: a withdrawn or bannered body is the bot's text, and a comment matching * the template never reaches the classifier because the deterministic template check catches it first. */ function harvestCommentIntentCandidates(comments: Comment[]): CommentIntentCandidate[] { const candidates: CommentIntentCandidate[] = []; for (const comment of comments) { if (comment.isBot || isProposal(comment.body) || comment.body.startsWith(WITHDRAW_MESSAGE) || comment.body.startsWith(EDIT_BANNER_PREFIX)) { continue; } const {bodies} = anonymize([comment.author], [comment.body]); candidates.push({ body: bodies.at(0) ?? '', id: `commentIntent-${comment.commentID}`, origin: 'harvested', sourceUrl: comment.sourceUrl, }); } return candidates; } /** * Withdrawn duplicates, reassembled into what the duplicate check saw: the proposal as posted, plus * every proposal that predates it in the same issue. */ function harvestDuplicateCheckCandidates(comments: Comment[]): DuplicateCheckCandidate[] { const candidates: DuplicateCheckCandidate[] = []; for (const comment of comments) { const withdrawnAt = comment.revisions.findIndex((revision) => revision.startsWith(WITHDRAW_MESSAGE)); const originalBody = withdrawnAt === -1 ? undefined : comment.revisions.at(withdrawnAt + 1); if (!originalBody || !isProposal(originalBody)) { continue; } // The bot's notice names the proposal it withdrew against, which is the outcome to compare an eval // run to. It's posted right after the withdrawal and addresses the withdrawn proposal's author. const notice = comments.find((other) => other.isBot && other.createdAt > comment.createdAt && other.body.includes(`@${comment.author} `) && DUPLICATE_NOTICE_REGEX.test(other.body)); const withdrawnAgainstCommentID = Number(DUPLICATE_NOTICE_REGEX.exec(notice?.body ?? '')?.at(1) ?? '') || null; // Only proposals that existed when the bot ran were candidates, and each is taken as it read then // rather than as it reads now, since a later withdrawal or edit would have rewritten its body. const priors = comments .filter((other) => other.commentID !== comment.commentID && other.createdAt < comment.createdAt && !other.isBot) .map((other) => ({...other, body: other.revisions.at(-1) ?? other.body})) .filter((other) => isProposal(other.body)); if (priors.length === 0) { continue; } const {aliases, bodies} = anonymize([comment.author, ...priors.map((prior) => prior.author)], [originalBody, ...priors.map((prior) => prior.body)]); candidates.push({ id: `duplicateCheck-${comment.commentID}`, newProposal: { author: aliases.get(comment.author) ?? '', body: bodies.at(0) ?? '', commentID: comment.commentID, sourceUrl: comment.sourceUrl, }, origin: 'harvested', priorProposals: priors.map((prior, index) => ({ author: aliases.get(prior.author) ?? '', body: bodies.at(index + 1) ?? '', commentID: prior.commentID, sourceUrl: prior.sourceUrl, })), withdrawnAgainstCommentID, }); } return candidates; } /** * Real edits to proposals, as `(before, after)` pairs. Edits the bot flagged are recovered from the * revision either side of the banner it prepended; edits it left alone are the plain revision pairs on * a proposal comment it never touched. */ function harvestEditCheckCandidates(comments: Comment[]): EditCheckCandidate[] { const candidates: EditCheckCandidate[] = []; for (const comment of comments) { const bannerAt = comment.revisions.findIndex((revision) => revision.startsWith(EDIT_BANNER_PREFIX)); const [before, after] = bannerAt === -1 ? [comment.revisions.at(1), comment.revisions.at(0)] : [comment.revisions.at(bannerAt + 2), comment.revisions.at(bannerAt + 1)]; // An earlier run's banner survives into later revisions, and the edit check is fed the proposal // rather than the banner, so strip any that made it into the pair. const beforeBody = before?.replace(SUBSTANTIVE_EDIT_MESSAGE_REGEX, ''); const afterBody = after?.replace(SUBSTANTIVE_EDIT_MESSAGE_REGEX, ''); if (!beforeBody || !afterBody || beforeBody === afterBody || !isProposal(beforeBody) || !isProposal(afterBody)) { continue; } const {bodies} = anonymize([comment.author], [beforeBody, afterBody]); candidates.push({ after: bodies.at(1) ?? '', before: bodies.at(0) ?? '', id: `editCheck-${comment.commentID}`, origin: 'harvested', sourceUrl: comment.sourceUrl, }); } return candidates; } /** * The key a labelled fixture is matched on when reporting which candidates are new. Fixtures carry this * rather than the source URL, so a checked-in file can't be read as a public accusation against a named * contributor — this repository is public, and the intent labels include SPAM. */ function sourceHash(sourceUrl: string): string { return createHash('sha256').update(sourceUrl).digest('hex').slice(0, 12); } /** * Writes a candidate file and reports how much of it is new against the hand-labelled fixture set. */ function writeCandidates(set: FixtureSetName, candidates: Array<{sourceUrl?: string; newProposal?: {sourceUrl: string}}>): void { fs.mkdirSync(HARVEST_DIR, {recursive: true}); fs.writeFileSync(path.join(HARVEST_DIR, `${set}.json`), `${JSON.stringify(candidates, null, 4)}\n`); const labelledPath = path.join(FIXTURES_DIR, `${set}.json`); const parsedLabelled: unknown = fs.existsSync(labelledPath) ? JSON.parse(fs.readFileSync(labelledPath, 'utf8')) : []; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only sourceHash is read, and a fixture file missing it just reports the candidate as unlabelled. const labelled = parsedLabelled as Array<{sourceHash?: string}>; const labelledSourceHashes = new Set(labelled.map((fixture) => fixture.sourceHash)); const unlabelled = candidates.filter((candidate) => !labelledSourceHashes.has(sourceHash(candidate.sourceUrl ?? candidate.newProposal?.sourceUrl ?? ''))); console.log(`${set}: harvested ${candidates.length} candidates, ${labelled.length} already labelled in ${set}.json, ${unlabelled.length} not yet labelled.`); console.log(` → ${path.relative(process.cwd(), path.join(HARVEST_DIR, `${set}.json`))}`); } const cli = new CLI({ namedArgs: { issues: { description: 'How many issues to walk per fixture set', default: 25, parse: (value: string) => Number(value), }, issueNumbers: { description: 'Comma-separated issue numbers to walk instead of searching, for pulling in a specific case by hand', default: '', supersedes: ['issues'], }, set: { description: 'Harvest only one of: commentIntent, duplicateCheck, editCheck', default: 'all', }, }, } as const); const issueLimit = cli.namedArgs.issues; const requestedSet = cli.namedArgs.set; const explicitIssueNumbers = cli.namedArgs.issueNumbers .split(',') .filter(Boolean) .map((issueNumber) => Number(issueNumber)); if (requestedSet === 'all' || requestedSet === 'commentIntent') { // Sampled from Help Wanted issues rather than from anything the bot acted on, because the intent // classifier's real population is ordinary discussion: almost none of it mentions "Proposal" at all. // // Note that the comments the bot answered with a template reminder are *not* a useful second // population here: every one sampled so far already satisfies `isProposal`, since the reminder fires // on proposals that the stale dashboard prompt judged against sections the template no longer has. // The deterministic template check now catches all of those before the classifier ever runs. const issueNumbers = explicitIssueNumbers.length > 0 ? explicitIssueNumbers : searchIssueNumbers(`repo:${REPO} is:issue label:"Help Wanted"`, issueLimit); console.log(`commentIntent: walking ${issueNumbers.length} issues…`); writeCandidates( 'commentIntent', issueNumbers.flatMap((issueNumber) => harvestCommentIntentCandidates(fetchComments(issueNumber))), ); } if (requestedSet === 'all' || requestedSet === 'duplicateCheck') { const issueNumbers = explicitIssueNumbers.length > 0 ? explicitIssueNumbers : searchIssueNumbers(`repo:${REPO} "${WITHDRAW_MESSAGE.replaceAll('#### ', '')}" in:comments`, issueLimit); console.log(`duplicateCheck: walking ${issueNumbers.length} issues with a withdrawn duplicate…`); writeCandidates( 'duplicateCheck', issueNumbers.flatMap((issueNumber) => harvestDuplicateCheckCandidates(fetchComments(issueNumber))), ); } if (requestedSet === 'all' || requestedSet === 'editCheck') { const issueNumbers = explicitIssueNumbers.length > 0 ? explicitIssueNumbers : searchIssueNumbers(`repo:${REPO} "${EDIT_BANNER_PREFIX}" in:comments`, issueLimit); console.log(`editCheck: walking ${issueNumbers.length} issues with a flagged edit…`); writeCandidates( 'editCheck', issueNumbers.flatMap((issueNumber) => harvestEditCheckCandidates(fetchComments(issueNumber))), ); }