scripts/pr-gardening.test.mjs
scripts/pr-gardening.test.mjsBrowse 6 files
3,696 tokens
13,797 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1import assert from "node:assert/strict";2import test from "node:test";3import { confidenceFor, readinessVerdict } from "./check-readiness.mjs";4import { findCandidates } from "./find-candidates.mjs";5import {6 chooseOriginatingIssue,7 extractPullRequestNumber,8 isMissingPullRequestError,9 normalizeCheck,10 resolveAuthorAllowlist,11 summarizePullRequestBody,12} from "./lib.mjs";13import { renderReport } from "./render-report.mjs";14 15test("extracts only pull requests from the requested repository", () => {16 assert.equal(extractPullRequestNumber("https://github.com/paperclipai/paperclip/pull/9507", "paperclipai/paperclip"), 9507);17 assert.equal(extractPullRequestNumber("github.com/paperclipai/paperclip/pull/9507", "paperclipai/paperclip"), 9507);18 assert.equal(extractPullRequestNumber("https://github.com/other/repo/pull/9507", "paperclipai/paperclip"), null);19});20 21test("origin selection prioritizes work products then comment mentions", () => {22 const issues = [23 {24 issueId: "recent",25 identifier: "PAP-2",26 title: "Recent",27 status: "in_progress",28 assigneeAgentId: "agent-2",29 updatedAt: "2026-07-13T12:00:00Z",30 mentions: [{ field: "description" }],31 workProducts: [],32 },33 {34 issueId: "origin",35 identifier: "PAP-1",36 title: "Origin",37 status: "done",38 assigneeAgentId: "agent-1",39 updatedAt: "2026-07-12T12:00:00Z",40 mentions: [{ field: "comment" }],41 workProducts: [{ type: "pull_request", url: "http://github.com/paperclipai/paperclip/pull/9507?source=paperclip#review" }],42 },43 ];44 assert.equal(chooseOriginatingIssue(issues, "https://github.com/paperclipai/paperclip/pull/9507").issueId, "origin");45});46 47function discoveryFixture() {48 let extractPath = "";49 const paperclipGet = async (path) => {50 if (path.includes("search/extract")) {51 extractPath = path;52 return {53 hasMore: false,54 results: [55 {56 issueId: "issue-1",57 identifier: "PAP-1",58 title: "Source",59 status: "done",60 assigneeAgentId: "agent-1",61 updatedAt: "2026-07-13T00:00:00Z",62 matchesTruncated: false,63 matches: [64 { value: "https://github.com/paperclipai/paperclip/pull/1", field: "comment", label: "Comment", source: { type: "comment", commentId: "c1" } },65 { value: "https://github.com/paperclipai/paperclip/pull/1", field: "document_body", label: "Document", source: { type: "document", documentId: "d1", documentKey: "plan" } },66 { value: "https://github.com/paperclipai/paperclip/pull/2", field: "description", label: "Description", source: { type: "issue", issueId: "issue-1" } },67 { value: "https://github.com/paperclipai/paperclip/pull/3", field: "description", label: "Description", source: { type: "issue", issueId: "issue-1" } },68 { value: "https://github.com/paperclipai/paperclip/pull/4", field: "comment", label: "Comment", source: { type: "comment", commentId: "c2" } },69 ],70 },71 ],72 };73 }74 return [{ type: "pull_request", url: "https://github.com/paperclipai/paperclip/pull/1/" }];75 };76 const ghJson = (args) => {77 if (args[0] === "api" && args[1] === "user") return { login: "Cryppadotta" };78 const number = Number(args[2]);79 if (number === 3) throw new Error("GraphQL: Could not resolve to a PullRequest with the number of 3");80 return {81 number,82 url: `https://github.com/paperclipai/paperclip/pull/${number}`,83 title: `PR ${number}`,84 author: { login: number === 4 ? "community-dev" : "cryppadotta" },85 state: number === 1 || number === 4 ? "OPEN" : "MERGED",86 isDraft: false,87 headRefOid: `sha-${number}`,88 updatedAt: "2026-07-13T00:00:00Z",89 };90 };91 return { paperclipGet, ghJson, extractPath: () => extractPath };92}93 94test("candidate discovery deduplicates mentions, drops closed PRs, and excludes community authors by default", async () => {95 const fixture = discoveryFixture();96 const result = await findCandidates({97 repo: "paperclipai/paperclip",98 api_url: "http://paperclip.test",99 api_key: "test-key",100 company_id: "company-1",101 now: "2026-07-20T00:00:00Z",102 paperclip_get: fixture.paperclipGet,103 gh_json: fixture.ghJson,104 });105 assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1]);106 assert.equal(result.candidates[0].author, "cryppadotta");107 const query = new URL(`http://paperclip.test${fixture.extractPath()}`).searchParams;108 assert.equal(query.get("matchesPerIssue"), "200");109 assert.equal(query.get("updatedWithin"), "14d");110 assert.deepEqual(result.query.authors, ["cryppadotta"]);111 assert.equal(result.candidates[0].sourceIssues[0].mentions.length, 2);112 assert.equal(result.candidates[0].originatingIssue.selectionBasis, "pull_request_work_product");113 assert.deepEqual(result.source.droppedClosedPullRequests.map((pullRequest) => pullRequest.number), [2]);114 assert.deepEqual(result.source.droppedUnavailablePullRequests.map((pullRequest) => pullRequest.number), [3]);115 assert.deepEqual(116 result.source.droppedCommunityPullRequests.map((pullRequest) => [pullRequest.number, pullRequest.author]),117 [[4, "community-dev"]],118 );119});120 121test("capped match sets are recorded instead of aborting discovery", async () => {122 const fixture = discoveryFixture();123 const paperclipGet = async (path) => {124 const page = await fixture.paperclipGet(path);125 if (!path.includes("search/extract")) return page;126 return { ...page, results: page.results.map((issue) => ({ ...issue, matchesTruncated: true })) };127 };128 const result = await findCandidates({129 repo: "paperclipai/paperclip",130 api_url: "http://paperclip.test",131 api_key: "test-key",132 company_id: "company-1",133 now: "2026-07-20T00:00:00Z",134 paperclip_get: paperclipGet,135 gh_json: fixture.ghJson,136 });137 assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1]);138 assert.equal(result.source.truncated, true);139 assert.deepEqual(result.source.truncatedIssues.map((issue) => issue.identifier), ["PAP-1"]);140});141 142test("--include-community disables the author filter", async () => {143 const fixture = discoveryFixture();144 const result = await findCandidates({145 repo: "paperclipai/paperclip",146 api_url: "http://paperclip.test",147 api_key: "test-key",148 company_id: "company-1",149 include_community: true,150 now: "2026-07-20T00:00:00Z",151 paperclip_get: fixture.paperclipGet,152 gh_json: fixture.ghJson,153 });154 assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1, 4]);155 assert.equal(result.query.authors, null);156 assert.deepEqual(result.source.droppedCommunityPullRequests, []);157});158 159test("open PRs with no activity inside the window are dropped as stale", async () => {160 const fixture = discoveryFixture();161 const result = await findCandidates({162 repo: "paperclipai/paperclip",163 api_url: "http://paperclip.test",164 api_key: "test-key",165 company_id: "company-1",166 now: "2026-09-01T00:00:00Z",167 paperclip_get: fixture.paperclipGet,168 gh_json: fixture.ghJson,169 });170 assert.deepEqual(result.candidates, []);171 assert.deepEqual(172 result.source.droppedStalePullRequests.map((pullRequest) => pullRequest.number),173 [1],174 );175});176 177test("author allowlist resolves from gh identity and honors --authors overrides", () => {178 const ghJson = () => ({ login: "Cryppadotta" });179 assert.deepEqual(resolveAuthorAllowlist({}, ghJson), ["cryppadotta"]);180 assert.deepEqual(resolveAuthorAllowlist({ authors: "Alice, bob" }, ghJson), ["alice", "bob"]);181 assert.equal(resolveAuthorAllowlist({ include_community: true }, ghJson), null);182 assert.throws(() => resolveAuthorAllowlist({ authors: true }, ghJson), /comma-separated list/);183});184 185test("summarizes PR bodies into a one-line purpose", () => {186 assert.equal(187 summarizePullRequestBody("<!-- generated -->\n## Summary\n\nFixes the flaky retry loop\nso wakes stop duplicating.\n\nDetails follow."),188 "Fixes the flaky retry loop so wakes stop duplicating.",189 );190 assert.equal(191 summarizePullRequestBody("> - Paperclip is the control plane.\n> - Blocker edges gate work."),192 "Paperclip is the control plane. Blocker edges gate work.",193 );194 assert.equal(summarizePullRequestBody(""), null);195 assert.equal(summarizePullRequestBody(null), null);196 assert.equal(summarizePullRequestBody(`${"x".repeat(400)}`).length, 278);197});198 199test("missing-PR detection matches only deleted/nonexistent PR signals", () => {200 // gh's real signals for a deleted/nonexistent PR: GraphQL resolution failure and REST 404.201 assert.equal(isMissingPullRequestError(new Error("GraphQL: Could not resolve to a PullRequest with the number of 3")), true);202 assert.equal(isMissingPullRequestError({ stderr: "gh: Not Found (HTTP 404)" }), true);203 // Unrelated failures that merely contain "not found" must not be treated as skippable.204 assert.equal(isMissingPullRequestError(new Error("repository not found")), false);205 assert.equal(isMissingPullRequestError(new Error("could not connect to github.com")), false);206 assert.equal(isMissingPullRequestError(undefined), false);207});208 209test("normalizes check runs and status contexts", () => {210 assert.equal(normalizeCheck({ __typename: "CheckRun", name: "ci", status: "COMPLETED", conclusion: "SUCCESS" }).green, true);211 assert.equal(normalizeCheck({ __typename: "StatusContext", context: "legacy", state: "FAILURE" }).green, false);212});213 214test("drafts are report-only and missing Greptile blocks normal PRs", () => {215 const base = {216 pullRequest: { state: "OPEN", isDraft: false, mergeable: "MERGEABLE", reviewDecision: "APPROVED" },217 checks: { checks: [{}], pending: [], failing: [] },218 greptile: { present: false, pending: false, clean: false },219 behindBy: 0,220 originatingIssue: { status: "done", identifier: "PAP-1" },221 };222 assert.equal(readinessVerdict(base).verdict, "needs_gardening");223 assert.equal(readinessVerdict({ ...base, pullRequest: { ...base.pullRequest, isDraft: true } }).verdict, "report_only");224});225 226test("unresolved nullable mergeability is reported instead of crashing", () => {227 const result = readinessVerdict({228 pullRequest: { state: "OPEN", isDraft: false, mergeable: null, mergeStateStatus: null, reviewDecision: "" },229 checks: { checks: [{}], pending: [], failing: [] },230 greptile: { present: true, pending: false, clean: true },231 behindBy: 0,232 originatingIssue: { status: "done", identifier: "PAP-1" },233 });234 assert.equal(result.verdict, "needs_gardening");235 assert.equal(result.reasons[0].code, "mergeability_unknown");236});237 238test("renders scope, purpose, confidence groups, and immutable guardrail", () => {239 const entry = {240 number: 1,241 url: "https://github.com/paperclipai/paperclip/pull/1",242 title: "Example",243 author: "cryppadotta",244 purpose: "Fixes the retry loop.",245 state: "open",246 isDraft: false,247 verdict: "ready",248 confidence: "high",249 headSha: "abc",250 originatingIssue: { identifier: "PAP-1", status: "done" },251 checks: { checks: [{}], pending: [], failing: [] },252 greptile: { clean: true, present: true },253 behindBy: 0,254 baseRefName: "master",255 reasons: [],256 };257 assert.equal(confidenceFor(entry), "high");258 const report = renderReport({259 repository: "paperclipai/paperclip",260 windowDays: 14,261 authors: ["cryppadotta"],262 generatedAt: "2026-07-13T00:00:00Z",263 summary: { ready: 1, needsGardening: 0, reportOnly: 0 },264 pullRequests: [entry],265 });266 assert.match(report, /Scope: PRs authored by `cryppadotta` \(this Paperclip instance\) referenced by issues active in the last 14 day\(s\)/);267 assert.match(report, /- Purpose: Fixes the retry loop\./);268 assert.match(report, /- Author: `cryppadotta`/);269 assert.match(report, /## High Confidence/);270 assert.match(report, /never merges, approves, or closes/);271});272 273test("escapes contributor-controlled Markdown in report titles and purposes", () => {274 const report = renderReport({275 repository: "paperclipai/paperclip",276 windowDays: 14,277 authors: null,278 generatedAt: "2026-07-27T00:00:00Z",279 summary: { ready: 0, needsGardening: 1, reportOnly: 0 },280 pullRequests: [281 {282 number: 2,283 url: "https://github.com/paperclipai/paperclip/pull/2",284 title: "[Injected](https://example.test)",285 author: "community-user",286 purpose: " <img src=x>",287 state: "open",288 isDraft: false,289 verdict: "needs_gardening",290 confidence: "medium",291 headSha: "def",292 originatingIssue: null,293 checks: { checks: [{}], pending: [], failing: [] },294 greptile: { clean: false, present: false },295 behindBy: 0,296 baseRefName: "master",297 reasons: [],298 },299 ],300 });301 assert.ok(report.includes("\\[Injected\\]\\(https://example.test\\)"));302 assert.ok(report.includes("\\!\\[tracking pixel\\]\\(https://example.test/pixel.png\\)"));303 assert.ok(report.includes("\\<img src=x\\>"));304 assert.doesNotMatch(report, /!\[tracking pixel\]|<img src=x>/);305});306 307test("scripts contain no mutating GitHub commands", async () => {308 const { readFile } = await import("node:fs/promises");309 const scripts = await Promise.all([310 readFile(new URL("./find-candidates.mjs", import.meta.url), "utf8"),311 readFile(new URL("./check-readiness.mjs", import.meta.url), "utf8"),312 readFile(new URL("./render-report.mjs", import.meta.url), "utf8"),313 ]);314 const source = scripts.join("\n");315 assert.doesNotMatch(source, /\bgh\s+pr\s+(merge|close|review|comment|ready|reopen)\b/i);316 assert.doesNotMatch(source, /--method\s+(POST|PATCH|PUT|DELETE)\b/i);317});318