scripts/find-candidates.mjs
scripts/find-candidates.mjsBrowse 6 files
1,825 tokens
7,628 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1#!/usr/bin/env node2import { pathToFileURL } from "node:url";3import {4 chooseOriginatingIssue,5 extractPullRequestNumber,6 ghJson,7 isMissingPullRequestError,8 issueSummary,9 normalizeRepository,10 paperclipGet,11 parseArgs,12 prUrl,13 repositoryFromGh,14 resolveAuthorAllowlist,15 writeJson,16} from "./lib.mjs";17 18export async function findCandidates(options) {19 const getPaperclip = options.paperclip_get ?? paperclipGet;20 const getGhJson = options.gh_json ?? ghJson;21 const repository = normalizeRepository(options.repo ?? repositoryFromGh());22 const authorAllowlist = resolveAuthorAllowlist(options, getGhJson);23 const days = Number(options.days ?? 14);24 if (!Number.isInteger(days) || days < 1 || days > 999) throw new Error("--days must be an integer from 1 to 999");25 const now = options.now ? new Date(options.now) : new Date();26 const windowStartMs = now.getTime() - days * 24 * 60 * 60 * 1000;27 28 const apiUrl = options.api_url ?? process.env.PAPERCLIP_API_URL;29 const apiKey = options.api_key ?? process.env.PAPERCLIP_API_KEY;30 const companyId = options.company_id ?? process.env.PAPERCLIP_COMPANY_ID;31 if (!apiUrl || !apiKey || !companyId) {32 throw new Error("PAPERCLIP_API_URL, PAPERCLIP_API_KEY, and PAPERCLIP_COMPANY_ID are required");33 }34 35 const contains = `github.com/${repository}/pull`;36 const limit = 200;37 const matchesPerIssue = 200;38 const issueMap = new Map();39 let offset = 0;40 41 while (true) {42 const query = new URLSearchParams({43 contains,44 kind: "url",45 scope: "all",46 updatedWithin: `${days}d`,47 limit: String(limit),48 offset: String(offset),49 matchesPerIssue: String(matchesPerIssue),50 });51 const page = await getPaperclip(`/companies/${companyId}/search/extract?${query}`, { apiUrl, apiKey });52 for (const issue of page.results) issueMap.set(issue.issueId, issue);53 if (!page.hasMore) break;54 offset += limit;55 if (offset > 5000) throw new Error("Extract-search pagination exceeded the supported 5000 issue offset");56 }57 // Issues that hit the per-issue match cap (typically digest/QA issues that58 // enumerate hundreds of PR URLs) lose mentions beyond the cap. That only59 // weakens attribution for those issues, so record them and continue rather60 // than refusing the whole run.61 const truncatedIssues = [...issueMap.values()]62 .filter((issue) => issue.matchesTruncated)63 .map((issue) => ({ issueId: issue.issueId, identifier: issue.identifier, title: issue.title }));64 if (truncatedIssues.length > 0) {65 process.stderr.write(66 `warning: ${truncatedIssues.length} issue(s) exceeded the ${matchesPerIssue}-match extract cap; PR mentions beyond the cap were not scanned: ${truncatedIssues.map((issue) => issue.identifier ?? issue.issueId).join(", ")}\n`,67 );68 }69 70 const pullRequests = new Map();71 for (const issue of issueMap.values()) {72 for (const match of issue.matches) {73 const number = extractPullRequestNumber(match.value, repository);74 if (!number) continue;75 const entry = pullRequests.get(number) ?? { number, issueMentions: new Map() };76 const sourceIssue = entry.issueMentions.get(issue.issueId) ?? {77 ...issueSummary(issue),78 mentions: [],79 workProducts: [],80 };81 sourceIssue.mentions.push({82 value: match.value,83 field: match.field,84 label: match.label,85 source: match.source,86 });87 entry.issueMentions.set(issue.issueId, sourceIssue);88 pullRequests.set(number, entry);89 }90 }91 92 const uniqueIssueIds = new Set([...pullRequests.values()].flatMap((entry) => [...entry.issueMentions.keys()]));93 const issueIds = [...uniqueIssueIds];94 const workers = Array.from({ length: Math.min(8, issueIds.length) }, async (_, workerIndex) => {95 for (let index = workerIndex; index < issueIds.length; index += 8) {96 const issueId = issueIds[index];97 const workProducts = await getPaperclip(`/issues/${issueId}/work-products`, { apiUrl, apiKey });98 for (const entry of pullRequests.values()) {99 const issue = entry.issueMentions.get(issueId);100 if (issue) issue.workProducts = workProducts;101 }102 }103 });104 await Promise.all(workers);105 106 const candidates = [];107 const closed = [];108 const unavailable = [];109 const community = [];110 const stale = [];111 for (const entry of [...pullRequests.values()].sort((left, right) => left.number - right.number)) {112 let pullRequest;113 try {114 pullRequest = getGhJson([115 "pr",116 "view",117 String(entry.number),118 "--repo",119 repository,120 "--json",121 "number,url,title,author,state,isDraft,headRefOid,updatedAt",122 ]);123 } catch (error) {124 if (!isMissingPullRequestError(error)) throw error;125 unavailable.push({126 number: entry.number,127 url: prUrl(repository, entry.number),128 state: "unavailable",129 reason: "GitHub could not resolve this pull request",130 });131 continue;132 }133 const author = pullRequest.author?.login ?? null;134 if (authorAllowlist && !authorAllowlist.includes(author?.toLowerCase())) {135 community.push({136 number: entry.number,137 url: prUrl(repository, entry.number),138 author,139 state: pullRequest.state.toLowerCase(),140 });141 continue;142 }143 const sourceIssues = [...entry.issueMentions.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));144 const candidate = {145 number: pullRequest.number,146 url: pullRequest.url,147 title: pullRequest.title,148 author,149 state: pullRequest.state.toLowerCase(),150 isDraft: pullRequest.isDraft,151 headSha: pullRequest.headRefOid,152 updatedAt: pullRequest.updatedAt,153 sourceIssues,154 originatingIssue: chooseOriginatingIssue(sourceIssues, prUrl(repository, entry.number)),155 };156 if (pullRequest.state !== "OPEN") {157 closed.push({ number: candidate.number, url: candidate.url, state: candidate.state });158 } else if (new Date(pullRequest.updatedAt).getTime() < windowStartMs) {159 // A recently active issue can mention a long-dormant PR (old digests,160 // salvage discussions); gardening only drives PRs with activity inside161 // the window.162 stale.push({ number: candidate.number, url: candidate.url, author, updatedAt: pullRequest.updatedAt });163 } else {164 candidates.push(candidate);165 }166 }167 168 return {169 schemaVersion: 1,170 generatedAt: new Date().toISOString(),171 repository,172 windowDays: days,173 dryRun: Boolean(options.dry_run),174 query: { contains, kind: "url", scope: "all", updatedWithin: `${days}d`, authors: authorAllowlist },175 source: {176 issueCount: issueMap.size,177 mentionCount: [...pullRequests.values()].reduce(178 (total, entry) => total + [...entry.issueMentions.values()].reduce((sum, issue) => sum + issue.mentions.length, 0),179 0,180 ),181 distinctPullRequestCount: pullRequests.size,182 openPullRequestCount: candidates.length,183 droppedClosedPullRequests: closed,184 droppedUnavailablePullRequests: unavailable,185 droppedCommunityPullRequests: community,186 droppedStalePullRequests: stale,187 truncated: truncatedIssues.length > 0,188 truncatedIssues,189 },190 candidates,191 };192}193 194async function main() {195 const options = parseArgs(process.argv.slice(2), { output: "candidates.json" });196 writeJson(options.output, await findCandidates(options));197}198 199if (import.meta.url === pathToFileURL(process.argv[1]).href) {200 main().catch((error) => {201 console.error(error.message);202 process.exitCode = 1;203 });204}205