diff --git a/.agents/skills/pr-gardening/SKILL.md b/.agents/skills/pr-gardening/SKILL.md index 5819c3d595..abf82b5ad4 100644 --- a/.agents/skills/pr-gardening/SKILL.md +++ b/.agents/skills/pr-gardening/SKILL.md @@ -1,49 +1,57 @@ --- name: pr-gardening description: > - Discover recently referenced Paperclip pull requests, mechanically verify - their current-head readiness, drive non-draft PRs back to green through their - originating issues, and publish a merge-confidence report without merging. + Discover the pull requests this Paperclip instance opened (never community + contributions), report what each is for and how confident we are that it is + merge-ready, and automatically drive the non-ready ones back to green with + /prepare-paperclip-pr — without ever merging. compatibility: Requires Node.js 20+, gh authenticated for GitHub read access, and Paperclip run credentials. allowed-tools: Bash(node:*) Bash(gh:*) Bash(curl:*) --- # PR Gardening -Actively garden pull requests referenced by Paperclip issues active in a recent window. Candidate discovery and readiness checking are scripts, not LLM analysis. GitHub access is read-only throughout this workflow. +Actively garden the pull requests **this Paperclip instance opened** that are referenced by Paperclip issues active in a recent window (default 14 days). Candidate discovery and readiness checking are scripts, not LLM analysis. GitHub access is read-only throughout this workflow. + +## Scope — Our PRs Only + +By default the workflow gardens only pull requests authored by this instance's GitHub identity (the `gh` authenticated login, e.g. `cryppadotta`). Community contributions and dependabot PRs are excluded mechanically by author login in Stage A and land in `droppedCommunityPullRequests`; never re-add them by hand, widen the scope only when the caller explicitly passes `--authors` or `--include-community`. ## Hard Guardrails - **Never merge, approve, or close a pull request.** - **Never instruct another person or agent to merge, approve, or close a pull request.** +- **Never garden, comment on, or run `/prepare-paperclip-pr` against a community PR.** Only PRs from the Stage A author allowlist are actionable. - Never use mutating `gh` commands or mutating GitHub API requests. The scripts only use `gh pr view` and read-only `gh api` GET requests. - Draft pull requests are report-only. Do not post gardening comments for drafts. - Comment only on existing originating issues. Never create a gardening issue per pull request. -- `--dry-run` suppresses all Paperclip mutations, including gardening comments and inbox archives. Discovery and GitHub inspection remain read-only in every mode. +- `--dry-run` suppresses all Paperclip mutations, including gardening comments, prepare tasks, and inbox archives. Discovery and GitHub inspection remain read-only in every mode. ## Inputs -- `--days `: issue activity window, default `30`. +- `--days `: activity window, default `14`. Applies to both mentioning-issue activity and the PR's own `updatedAt`; open PRs with no activity inside the window are dropped as stale. +- `--authors `: comma-separated GitHub logins whose PRs are in scope; default is the `gh` authenticated user. +- `--include-community`: disable the author filter entirely. Only on explicit caller request. - `--repo `: GitHub repository, default detected by `gh repo view`. -- `--dry-run`: discover, verify, and report without posting comments or archiving inbox entries. +- `--dry-run`: discover, verify, and report without posting comments, creating prepare tasks, or archiving inbox entries. - `--archive-inbox`: after GitHub confirms a candidate PR is merged at its current head, archive the originating issue from the responsible user's inbox in Stage D. -- `--cooldown-hours `: repeat-comment cooldown, default `48`. +- `--cooldown-hours `: repeat-gardening cooldown, default `48`. - `--max-rounds `: maximum gardening rounds per PR, default `3`. Use a run-owned directory such as `$PAPERCLIP_RUN_SCRATCH_DIR/pr-gardening` for generated files. ## Stage A — Discover Candidates -Run the extract-search path. It scans every result page, rejects truncated match sets, normalizes PR URLs, deduplicates PR numbers, records every mentioning issue, checks issue work products to identify the origin, and drops PRs that GitHub says are merged or closed. +Run the extract-search path. It scans every result page, normalizes PR URLs, deduplicates PR numbers, records every mentioning issue, checks issue work products to identify the origin, drops PRs that GitHub says are merged or closed, drops PRs whose author is outside the allowlist (community contributions) into `droppedCommunityPullRequests`, and drops open PRs whose own `updatedAt` is older than the window into `droppedStalePullRequests`. Issues that exceed the per-issue extract match cap (typically digest or QA issues that enumerate hundreds of PR URLs) are recorded in `source.truncatedIssues` and noted in the report instead of aborting the run. ```bash node .agents/skills/pr-gardening/scripts/find-candidates.mjs \ - --days 30 \ + --days 14 \ --dry-run \ --output "$RUN_DIR/candidates.json" ``` -The script calls `GET /api/companies/:companyId/search/extract` with `kind=url`, `scope=all`, and `updatedWithin=d`. Do not replace it with full issue-list fetching or LLM scanning. +The script calls `GET /api/companies/:companyId/search/extract` with `kind=url`, `scope=all`, and `updatedWithin=d`, then resolves the author allowlist from `gh api user` unless `--authors` or `--include-community` overrides it. Do not replace it with full issue-list fetching or LLM scanning. ## Stage B — Verify Current-Head Readiness @@ -75,33 +83,33 @@ For each branch, process one branch at a time and do this serially: 3. If an equivalent open task exists, reuse it: add a concise comment with the current PR/head/reason context and link it from the gardening issue or blocker list. Do not create another task. 4. Only if no equivalent open task exists, create exactly one follow-up task for that branch. -Never fan out follow-up task creation in parallel. Do not issue concurrent `POST /api/companies/:companyId/issues` calls for create-PR tasks. After P1's issue-create idempotency support is available, every create-PR follow-up task creation must include `idempotencyKey: "pr-gardening:create-pr:{branch}"`, where `{branch}` is the exact branch name. +Never fan out follow-up task creation in parallel. Do not issue concurrent `POST /api/companies/:companyId/issues` calls for create-PR or prepare-PR tasks. After P1's issue-create idempotency support is available, every create-PR follow-up task creation must include `idempotencyKey: "pr-gardening:create-pr:{branch}"` and every prepare-PR task `idempotencyKey: "pr-gardening:prepare-pr:{owner/repo}#{number}"`. -## Stage C — Comment on Originating Issues +## Stage C — Drive Our PRs to Ready with /prepare-paperclip-pr Skip this stage in `--dry-run` mode and for `ready` or `report_only` entries. -For each `needs_gardening` PR, use `originatingIssue` from `candidates.json`. Selection priority is: +Every `needs_gardening` PR here was opened by this instance (Stage A guarantees it), so do not just report — actively get it merge-ready by running the `/prepare-paperclip-pr` skill against it. Process PRs one at a time: -1. issue carrying the exact PR URL as a `pull_request` work product; -2. issue whose comment mentions the PR; -3. most recently active mentioning issue. +1. **Cooldown and rounds.** Locate the `originatingIssue` from `candidates.json` (selection priority: issue carrying the exact PR URL as a `pull_request` work product; then issue whose comment mentions the PR; then most recently active mentioning issue). Fetch its comments and search for the marker: -Before commenting, fetch the issue comments and search for this marker: + ```text + + ``` -```text - -``` + Skip the PR if the latest matching marker is newer than the cooldown. Track rounds from matching markers; after three rounds, stop and report `not converging; recommend close or human decision`. That is a recommendation for human disposition, not an instruction to close the PR. -Do not comment if the latest matching marker is newer than the cooldown. Track rounds from matching markers; after three rounds, stop nagging and report `not converging; recommend close or human decision`. This is a recommendation for human disposition, not an instruction to close the PR. +2. **Deduplicate.** Search open Paperclip issues for the PR number/branch. If an equivalent open prepare-PR task already exists, reuse it with a concise status comment instead of creating another (see the deduplication section above). -When a comment is allowed, mention the originating issue assignee, instruct them to run `/prepare-pr`, include the current head SHA, and copy the exact machine-detected `reasons[]`. Use `POST /api/issues/:issueId/comments` with `X-Paperclip-Run-Id`. Include `resume: true` when the issue is terminal so the comment creates a live continuation. +3. **Run the prepare skill.** Create one focused child task per PR assigned to a coder agent (prefer CodexCoder) instructing it to run `/prepare-paperclip-pr` for that PR — include the PR URL, branch, current head SHA, and the exact machine-detected `reasons[]` from `readiness.json`. If you are the gardener and already have the PR's branch checked out in a worktree, you may run `/prepare-paperclip-pr` directly instead of delegating. Either way, the prepare work must never merge, approve, or close the PR. + +4. **Leave the marker comment.** Comment on the originating issue with the marker above, the current head SHA, the copied `reasons[]`, the round counter, and a link to the prepare task. Use `POST /api/issues/:issueId/comments` with `X-Paperclip-Run-Id`. Include `resume: true` when the issue is terminal so the comment creates a live continuation. Suggested body: ```markdown -@Assignee please run `/prepare-pr` for https://github.com/paperclipai/paperclip/pull/1234. +Gardening: dispatched `/prepare-paperclip-pr` for https://github.com/paperclipai/paperclip/pull/1234 via PAP-XXXX. Current-head verification at `abc123` found: - failing check: test @@ -133,7 +141,7 @@ Use `POST /api/issues/:issueId/comments` and include `X-Paperclip-Run-Id` on bot ## Stage E — Monitor to Termination -Set the gardening run issue's `blockedByIssueIds` to the non-terminal issues commented in Stage C so blocker resolution wakes the gardener. A scheduled or manual rerun is the fallback. +Set the gardening run issue's `blockedByIssueIds` to the non-terminal prepare tasks and originating issues engaged in Stage C so blocker resolution wakes the gardener. A scheduled or manual rerun is the fallback. On every wake, rerun Stage B first. A PR terminates from active gardening only when one of these is mechanically observed: @@ -151,12 +159,14 @@ node .agents/skills/pr-gardening/scripts/render-report.mjs \ --output "$RUN_DIR/gardening-report.md" ``` -The report groups open PRs by confidence: +The report states the scope (authors + window), and for every open PR gives its author, a one-line purpose summary taken from the PR description, and a readiness confidence bucket: - **High:** current-head checks green, no conflicts, Greptile clean, base fresh, originating issue terminal. - **Medium:** otherwise green but base stale, review not complete, or originating issue active. - **Low:** failing/pending checks, missing Greptile, draft/just-fixed-unverified state, or no identifiable origin. +If a PR's generated purpose line is empty or unhelpful, write a one-sentence description from the PR title and diff summary when publishing the report. + Upload `candidates.json`, `readiness.json`, and `gardening-report.md` to the gardening issue, create/update the `gardening-report` issue document with the Markdown body, and leave a summary comment linking the artifacts. The report is the deliverable; it is never authorization to merge. ## Verification @@ -167,4 +177,4 @@ Run focused script tests: node --test .agents/skills/pr-gardening/scripts/pr-gardening.test.mjs ``` -For a live dry run, execute Stages A, B, and F with `--dry-run`, then sanity-check named PRs only if they are still open. Merged or closed examples should appear under `droppedClosedPullRequests`, not in readiness results. If also exercising `--archive-inbox`, confirm the report describes the suppressed Stage D action and that no Paperclip archive or marker-comment mutation occurred. +For a live dry run, execute Stages A, B, and F with `--dry-run`, then sanity-check named PRs only if they are still open. Merged or closed examples should appear under `droppedClosedPullRequests`, not in readiness results, and community-authored PRs must appear only under `droppedCommunityPullRequests` — a candidate or report entry with an author outside the allowlist is a scope failure. If also exercising `--archive-inbox`, confirm the report describes the suppressed Stage D action and that no Paperclip archive or marker-comment mutation occurred. diff --git a/.agents/skills/pr-gardening/scripts/check-readiness.mjs b/.agents/skills/pr-gardening/scripts/check-readiness.mjs index 14227a3474..3b97a9db9d 100755 --- a/.agents/skills/pr-gardening/scripts/check-readiness.mjs +++ b/.agents/skills/pr-gardening/scripts/check-readiness.mjs @@ -8,6 +8,7 @@ import { parseArgs, readJson, reason, + summarizePullRequestBody, writeJson, } from "./lib.mjs"; @@ -114,7 +115,7 @@ export async function checkReadiness(candidatesDocument, options = {}) { "--repo", repository, "--json", - "number,url,title,state,isDraft,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,updatedAt", + "number,url,title,author,body,state,isDraft,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,updatedAt", ]); const checkRuns = fetchCheckRuns(repository, pullRequest.headRefOid); const comparison = ghJson([ @@ -134,6 +135,8 @@ export async function checkReadiness(candidatesDocument, options = {}) { number: pullRequest.number, url: pullRequest.url, title: pullRequest.title, + author: pullRequest.author?.login ?? candidate.author ?? null, + purpose: summarizePullRequestBody(pullRequest.body), state: pullRequest.state.toLowerCase(), isDraft: pullRequest.isDraft, headSha: pullRequest.headRefOid, @@ -156,6 +159,9 @@ export async function checkReadiness(candidatesDocument, options = {}) { schemaVersion: 1, generatedAt: new Date().toISOString(), repository, + windowDays: candidatesDocument.windowDays ?? null, + authors: candidatesDocument.query?.authors ?? null, + truncatedIssues: candidatesDocument.source?.truncatedIssues ?? [], candidatesGeneratedAt: candidatesDocument.generatedAt, dryRun: Boolean(options.dry_run ?? candidatesDocument.dryRun), summary: { diff --git a/.agents/skills/pr-gardening/scripts/find-candidates.mjs b/.agents/skills/pr-gardening/scripts/find-candidates.mjs index 616ccb2e8c..60b33b0c2e 100755 --- a/.agents/skills/pr-gardening/scripts/find-candidates.mjs +++ b/.agents/skills/pr-gardening/scripts/find-candidates.mjs @@ -11,6 +11,7 @@ import { parseArgs, prUrl, repositoryFromGh, + resolveAuthorAllowlist, writeJson, } from "./lib.mjs"; @@ -18,8 +19,11 @@ export async function findCandidates(options) { const getPaperclip = options.paperclip_get ?? paperclipGet; const getGhJson = options.gh_json ?? ghJson; const repository = normalizeRepository(options.repo ?? repositoryFromGh()); - const days = Number(options.days ?? 30); + const authorAllowlist = resolveAuthorAllowlist(options, getGhJson); + const days = Number(options.days ?? 14); if (!Number.isInteger(days) || days < 1 || days > 999) throw new Error("--days must be an integer from 1 to 999"); + const now = options.now ? new Date(options.now) : new Date(); + const windowStartMs = now.getTime() - days * 24 * 60 * 60 * 1000; const apiUrl = options.api_url ?? process.env.PAPERCLIP_API_URL; const apiKey = options.api_key ?? process.env.PAPERCLIP_API_KEY; @@ -33,7 +37,6 @@ export async function findCandidates(options) { const matchesPerIssue = 200; const issueMap = new Map(); let offset = 0; - let truncated = false; while (true) { const query = new URLSearchParams({ @@ -47,12 +50,22 @@ export async function findCandidates(options) { }); const page = await getPaperclip(`/companies/${companyId}/search/extract?${query}`, { apiUrl, apiKey }); for (const issue of page.results) issueMap.set(issue.issueId, issue); - truncated ||= page.results.some((issue) => issue.matchesTruncated); if (!page.hasMore) break; offset += limit; if (offset > 5000) throw new Error("Extract-search pagination exceeded the supported 5000 issue offset"); } - if (truncated) throw new Error("Extract-search truncated one or more issue match sets; refusing an incomplete candidate report"); + // Issues that hit the per-issue match cap (typically digest/QA issues that + // enumerate hundreds of PR URLs) lose mentions beyond the cap. That only + // weakens attribution for those issues, so record them and continue rather + // than refusing the whole run. + const truncatedIssues = [...issueMap.values()] + .filter((issue) => issue.matchesTruncated) + .map((issue) => ({ issueId: issue.issueId, identifier: issue.identifier, title: issue.title })); + if (truncatedIssues.length > 0) { + process.stderr.write( + `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`, + ); + } const pullRequests = new Map(); for (const issue of issueMap.values()) { @@ -93,6 +106,8 @@ export async function findCandidates(options) { const candidates = []; const closed = []; const unavailable = []; + const community = []; + const stale = []; for (const entry of [...pullRequests.values()].sort((left, right) => left.number - right.number)) { let pullRequest; try { @@ -103,7 +118,7 @@ export async function findCandidates(options) { "--repo", repository, "--json", - "number,url,title,state,isDraft,headRefOid,updatedAt", + "number,url,title,author,state,isDraft,headRefOid,updatedAt", ]); } catch (error) { if (!isMissingPullRequestError(error)) throw error; @@ -115,11 +130,22 @@ export async function findCandidates(options) { }); continue; } + const author = pullRequest.author?.login ?? null; + if (authorAllowlist && !authorAllowlist.includes(author?.toLowerCase())) { + community.push({ + number: entry.number, + url: prUrl(repository, entry.number), + author, + state: pullRequest.state.toLowerCase(), + }); + continue; + } const sourceIssues = [...entry.issueMentions.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); const candidate = { number: pullRequest.number, url: pullRequest.url, title: pullRequest.title, + author, state: pullRequest.state.toLowerCase(), isDraft: pullRequest.isDraft, headSha: pullRequest.headRefOid, @@ -127,8 +153,16 @@ export async function findCandidates(options) { sourceIssues, originatingIssue: chooseOriginatingIssue(sourceIssues, prUrl(repository, entry.number)), }; - if (pullRequest.state === "OPEN") candidates.push(candidate); - else closed.push({ number: candidate.number, url: candidate.url, state: candidate.state }); + if (pullRequest.state !== "OPEN") { + closed.push({ number: candidate.number, url: candidate.url, state: candidate.state }); + } else if (new Date(pullRequest.updatedAt).getTime() < windowStartMs) { + // A recently active issue can mention a long-dormant PR (old digests, + // salvage discussions); gardening only drives PRs with activity inside + // the window. + stale.push({ number: candidate.number, url: candidate.url, author, updatedAt: pullRequest.updatedAt }); + } else { + candidates.push(candidate); + } } return { @@ -137,7 +171,7 @@ export async function findCandidates(options) { repository, windowDays: days, dryRun: Boolean(options.dry_run), - query: { contains, kind: "url", scope: "all", updatedWithin: `${days}d` }, + query: { contains, kind: "url", scope: "all", updatedWithin: `${days}d`, authors: authorAllowlist }, source: { issueCount: issueMap.size, mentionCount: [...pullRequests.values()].reduce( @@ -148,7 +182,10 @@ export async function findCandidates(options) { openPullRequestCount: candidates.length, droppedClosedPullRequests: closed, droppedUnavailablePullRequests: unavailable, - truncated: false, + droppedCommunityPullRequests: community, + droppedStalePullRequests: stale, + truncated: truncatedIssues.length > 0, + truncatedIssues, }, candidates, }; diff --git a/.agents/skills/pr-gardening/scripts/lib.mjs b/.agents/skills/pr-gardening/scripts/lib.mjs index 77dfcbc374..2f04480a3e 100644 --- a/.agents/skills/pr-gardening/scripts/lib.mjs +++ b/.agents/skills/pr-gardening/scripts/lib.mjs @@ -75,6 +75,37 @@ export function prUrl(repository, number) { return `https://github.com/${repository}/pull/${number}`; } +export function resolveAuthorAllowlist(options, getGhJson) { + if (options.include_community) return null; + if (options.authors === true) throw new Error("--authors requires a comma-separated list of GitHub logins"); + // Default to the authenticated gh identity: every PR this Paperclip instance + // opens is authored by that login, so it is the scope boundary that excludes + // community contributions without maintaining a separate roster. + const raw = options.authors ?? getGhJson(["api", "user"]).login; + const authors = String(raw) + .split(",") + .map((login) => login.trim().toLowerCase()) + .filter(Boolean); + if (authors.length === 0) throw new Error("--authors requires at least one GitHub login"); + return authors; +} + +export function summarizePullRequestBody(body) { + const text = String(body ?? "") + .replace(//g, "") + .replaceAll("\r", ""); + for (const block of text.split(/\n\s*\n/)) { + const line = block + .split("\n") + .map((entry) => entry.replace(/^[\s>]*(?:[-*]\s+)?/, "").trim()) + .filter((entry) => entry && !entry.startsWith("#")) + .join(" "); + if (!line) continue; + return line.length > 280 ? `${line.slice(0, 277)}…` : line; + } + return null; +} + export function pullRequestIdentity(value) { const match = String(value).match(/(?:https?:\/\/)?github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i); if (!match) return null; diff --git a/.agents/skills/pr-gardening/scripts/pr-gardening.test.mjs b/.agents/skills/pr-gardening/scripts/pr-gardening.test.mjs index f697635ccc..31581568a2 100644 --- a/.agents/skills/pr-gardening/scripts/pr-gardening.test.mjs +++ b/.agents/skills/pr-gardening/scripts/pr-gardening.test.mjs @@ -2,7 +2,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { confidenceFor, readinessVerdict } from "./check-readiness.mjs"; import { findCandidates } from "./find-candidates.mjs"; -import { chooseOriginatingIssue, extractPullRequestNumber, isMissingPullRequestError, normalizeCheck } from "./lib.mjs"; +import { + chooseOriginatingIssue, + extractPullRequestNumber, + isMissingPullRequestError, + normalizeCheck, + resolveAuthorAllowlist, + summarizePullRequestBody, +} from "./lib.mjs"; import { renderReport } from "./render-report.mjs"; test("extracts only pull requests from the requested repository", () => { @@ -37,7 +44,7 @@ test("origin selection prioritizes work products then comment mentions", () => { assert.equal(chooseOriginatingIssue(issues, "https://github.com/paperclipai/paperclip/pull/9507").issueId, "origin"); }); -test("candidate discovery deduplicates mentions and drops closed PRs", async () => { +function discoveryFixture() { let extractPath = ""; const paperclipGet = async (path) => { if (path.includes("search/extract")) { @@ -58,6 +65,7 @@ test("candidate discovery deduplicates mentions and drops closed PRs", async () { value: "https://github.com/paperclipai/paperclip/pull/1", field: "document_body", label: "Document", source: { type: "document", documentId: "d1", documentKey: "plan" } }, { value: "https://github.com/paperclipai/paperclip/pull/2", field: "description", label: "Description", source: { type: "issue", issueId: "issue-1" } }, { value: "https://github.com/paperclipai/paperclip/pull/3", field: "description", label: "Description", source: { type: "issue", issueId: "issue-1" } }, + { value: "https://github.com/paperclipai/paperclip/pull/4", field: "comment", label: "Comment", source: { type: "comment", commentId: "c2" } }, ], }, ], @@ -66,32 +74,126 @@ test("candidate discovery deduplicates mentions and drops closed PRs", async () return [{ type: "pull_request", url: "https://github.com/paperclipai/paperclip/pull/1/" }]; }; const ghJson = (args) => { + if (args[0] === "api" && args[1] === "user") return { login: "Cryppadotta" }; const number = Number(args[2]); if (number === 3) throw new Error("GraphQL: Could not resolve to a PullRequest with the number of 3"); return { number, url: `https://github.com/paperclipai/paperclip/pull/${number}`, title: `PR ${number}`, - state: number === 1 ? "OPEN" : "MERGED", + author: { login: number === 4 ? "community-dev" : "cryppadotta" }, + state: number === 1 || number === 4 ? "OPEN" : "MERGED", isDraft: false, headRefOid: `sha-${number}`, updatedAt: "2026-07-13T00:00:00Z", }; }; + return { paperclipGet, ghJson, extractPath: () => extractPath }; +} + +test("candidate discovery deduplicates mentions, drops closed PRs, and excludes community authors by default", async () => { + const fixture = discoveryFixture(); + const result = await findCandidates({ + repo: "paperclipai/paperclip", + api_url: "http://paperclip.test", + api_key: "test-key", + company_id: "company-1", + now: "2026-07-20T00:00:00Z", + paperclip_get: fixture.paperclipGet, + gh_json: fixture.ghJson, + }); + assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1]); + assert.equal(result.candidates[0].author, "cryppadotta"); + const query = new URL(`http://paperclip.test${fixture.extractPath()}`).searchParams; + assert.equal(query.get("matchesPerIssue"), "200"); + assert.equal(query.get("updatedWithin"), "14d"); + assert.deepEqual(result.query.authors, ["cryppadotta"]); + assert.equal(result.candidates[0].sourceIssues[0].mentions.length, 2); + assert.equal(result.candidates[0].originatingIssue.selectionBasis, "pull_request_work_product"); + assert.deepEqual(result.source.droppedClosedPullRequests.map((pullRequest) => pullRequest.number), [2]); + assert.deepEqual(result.source.droppedUnavailablePullRequests.map((pullRequest) => pullRequest.number), [3]); + assert.deepEqual( + result.source.droppedCommunityPullRequests.map((pullRequest) => [pullRequest.number, pullRequest.author]), + [[4, "community-dev"]], + ); +}); + +test("capped match sets are recorded instead of aborting discovery", async () => { + const fixture = discoveryFixture(); + const paperclipGet = async (path) => { + const page = await fixture.paperclipGet(path); + if (!path.includes("search/extract")) return page; + return { ...page, results: page.results.map((issue) => ({ ...issue, matchesTruncated: true })) }; + }; const result = await findCandidates({ repo: "paperclipai/paperclip", api_url: "http://paperclip.test", api_key: "test-key", company_id: "company-1", + now: "2026-07-20T00:00:00Z", paperclip_get: paperclipGet, - gh_json: ghJson, + gh_json: fixture.ghJson, }); assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1]); - assert.equal(new URL(`http://paperclip.test${extractPath}`).searchParams.get("matchesPerIssue"), "200"); - assert.equal(result.candidates[0].sourceIssues[0].mentions.length, 2); - assert.equal(result.candidates[0].originatingIssue.selectionBasis, "pull_request_work_product"); - assert.deepEqual(result.source.droppedClosedPullRequests.map((pullRequest) => pullRequest.number), [2]); - assert.deepEqual(result.source.droppedUnavailablePullRequests.map((pullRequest) => pullRequest.number), [3]); + assert.equal(result.source.truncated, true); + assert.deepEqual(result.source.truncatedIssues.map((issue) => issue.identifier), ["PAP-1"]); +}); + +test("--include-community disables the author filter", async () => { + const fixture = discoveryFixture(); + const result = await findCandidates({ + repo: "paperclipai/paperclip", + api_url: "http://paperclip.test", + api_key: "test-key", + company_id: "company-1", + include_community: true, + now: "2026-07-20T00:00:00Z", + paperclip_get: fixture.paperclipGet, + gh_json: fixture.ghJson, + }); + assert.deepEqual(result.candidates.map((candidate) => candidate.number), [1, 4]); + assert.equal(result.query.authors, null); + assert.deepEqual(result.source.droppedCommunityPullRequests, []); +}); + +test("open PRs with no activity inside the window are dropped as stale", async () => { + const fixture = discoveryFixture(); + const result = await findCandidates({ + repo: "paperclipai/paperclip", + api_url: "http://paperclip.test", + api_key: "test-key", + company_id: "company-1", + now: "2026-09-01T00:00:00Z", + paperclip_get: fixture.paperclipGet, + gh_json: fixture.ghJson, + }); + assert.deepEqual(result.candidates, []); + assert.deepEqual( + result.source.droppedStalePullRequests.map((pullRequest) => pullRequest.number), + [1], + ); +}); + +test("author allowlist resolves from gh identity and honors --authors overrides", () => { + const ghJson = () => ({ login: "Cryppadotta" }); + assert.deepEqual(resolveAuthorAllowlist({}, ghJson), ["cryppadotta"]); + assert.deepEqual(resolveAuthorAllowlist({ authors: "Alice, bob" }, ghJson), ["alice", "bob"]); + assert.equal(resolveAuthorAllowlist({ include_community: true }, ghJson), null); + assert.throws(() => resolveAuthorAllowlist({ authors: true }, ghJson), /comma-separated list/); +}); + +test("summarizes PR bodies into a one-line purpose", () => { + assert.equal( + summarizePullRequestBody("\n## Summary\n\nFixes the flaky retry loop\nso wakes stop duplicating.\n\nDetails follow."), + "Fixes the flaky retry loop so wakes stop duplicating.", + ); + assert.equal( + summarizePullRequestBody("> - Paperclip is the control plane.\n> - Blocker edges gate work."), + "Paperclip is the control plane. Blocker edges gate work.", + ); + assert.equal(summarizePullRequestBody(""), null); + assert.equal(summarizePullRequestBody(null), null); + assert.equal(summarizePullRequestBody(`${"x".repeat(400)}`).length, 278); }); test("missing-PR detection matches only deleted/nonexistent PR signals", () => { @@ -133,11 +235,13 @@ test("unresolved nullable mergeability is reported instead of crashing", () => { assert.equal(result.reasons[0].code, "mergeability_unknown"); }); -test("renders confidence groups and immutable guardrail", () => { +test("renders scope, purpose, confidence groups, and immutable guardrail", () => { const entry = { number: 1, url: "https://github.com/paperclipai/paperclip/pull/1", title: "Example", + author: "cryppadotta", + purpose: "Fixes the retry loop.", state: "open", isDraft: false, verdict: "ready", @@ -153,14 +257,53 @@ test("renders confidence groups and immutable guardrail", () => { assert.equal(confidenceFor(entry), "high"); const report = renderReport({ repository: "paperclipai/paperclip", + windowDays: 14, + authors: ["cryppadotta"], generatedAt: "2026-07-13T00:00:00Z", summary: { ready: 1, needsGardening: 0, reportOnly: 0 }, pullRequests: [entry], }); + assert.match(report, /Scope: PRs authored by `cryppadotta` \(this Paperclip instance\) referenced by issues active in the last 14 day\(s\)/); + assert.match(report, /- Purpose: Fixes the retry loop\./); + assert.match(report, /- Author: `cryppadotta`/); assert.match(report, /## High Confidence/); assert.match(report, /never merges, approves, or closes/); }); +test("escapes contributor-controlled Markdown in report titles and purposes", () => { + const report = renderReport({ + repository: "paperclipai/paperclip", + windowDays: 14, + authors: null, + generatedAt: "2026-07-27T00:00:00Z", + summary: { ready: 0, needsGardening: 1, reportOnly: 0 }, + pullRequests: [ + { + number: 2, + url: "https://github.com/paperclipai/paperclip/pull/2", + title: "[Injected](https://example.test)", + author: "community-user", + purpose: "![tracking pixel](https://example.test/pixel.png) ", + state: "open", + isDraft: false, + verdict: "needs_gardening", + confidence: "medium", + headSha: "def", + originatingIssue: null, + checks: { checks: [{}], pending: [], failing: [] }, + greptile: { clean: false, present: false }, + behindBy: 0, + baseRefName: "master", + reasons: [], + }, + ], + }); + assert.ok(report.includes("\\[Injected\\]\\(https://example.test\\)")); + assert.ok(report.includes("\\!\\[tracking pixel\\]\\(https://example.test/pixel.png\\)")); + assert.ok(report.includes("\\")); + assert.doesNotMatch(report, /!\[tracking pixel\]|/); +}); + test("scripts contain no mutating GitHub commands", async () => { const { readFile } = await import("node:fs/promises"); const scripts = await Promise.all([ diff --git a/.agents/skills/pr-gardening/scripts/render-report.mjs b/.agents/skills/pr-gardening/scripts/render-report.mjs index 7a2619dcc0..ae547b55af 100755 --- a/.agents/skills/pr-gardening/scripts/render-report.mjs +++ b/.agents/skills/pr-gardening/scripts/render-report.mjs @@ -5,6 +5,10 @@ import { parseArgs, readJson } from "./lib.mjs"; const LABELS = { high: "High", medium: "Medium", low: "Low" }; +function escapeMarkdownText(value) { + return String(value).replace(/([\\`*_[\]()!|<>])/g, "\\$1"); +} + function issueLabel(issue) { if (!issue) return "No originating issue"; return issue.identifier ? `${issue.identifier} (${issue.status})` : `${issue.issueId} (${issue.status})`; @@ -15,11 +19,20 @@ function reasonText(entry) { return entry.reasons.map((entryReason) => entryReason.message).join("; "); } +function scopeText(readiness) { + const authors = readiness.authors?.length + ? `PRs authored by ${readiness.authors.map((author) => `\`${author}\``).join(", ")} (this Paperclip instance)` + : "PRs by any author (community included)"; + const window = readiness.windowDays ? ` referenced by issues active in the last ${readiness.windowDays} day(s)` : ""; + return `${authors}${window}`; +} + export function renderReport(readiness) { const lines = [ "# PR Gardening Report", "", `Repository: \`${readiness.repository}\` `, + `Scope: ${scopeText(readiness)} `, `Generated: ${readiness.generatedAt} `, `Head-SHA verification: every verdict below was computed from the recorded current head SHA.`, "", @@ -27,6 +40,13 @@ export function renderReport(readiness) { "", ]; + if (readiness.truncatedIssues?.length) { + lines.push( + `Discovery caveat: ${readiness.truncatedIssues.length} issue(s) hit the per-issue extract match cap, so PR mentions beyond the cap were not scanned: ${readiness.truncatedIssues.map((issue) => issue.identifier ?? issue.issueId).join(", ")}.`, + "", + ); + } + for (const confidence of ["high", "medium", "low"]) { const entries = readiness.pullRequests.filter((entry) => entry.confidence === confidence && entry.state === "open"); lines.push(`## ${LABELS[confidence]} Confidence`, ""); @@ -37,8 +57,10 @@ export function renderReport(readiness) { for (const entry of entries) { const draft = entry.isDraft ? " — draft (report only)" : ""; lines.push( - `### [#${entry.number}](${entry.url}) — ${entry.title}${draft}`, + `### [#${entry.number}](${entry.url}) — ${escapeMarkdownText(entry.title)}${draft}`, "", + `- Purpose: ${escapeMarkdownText(entry.purpose ?? "No description provided.")}`, + `- Author: ${entry.author ? `\`${entry.author}\`` : "unknown"}`, `- Verdict: \`${entry.verdict}\``, `- Head: \`${entry.headSha}\``, `- Originating issue: ${issueLabel(entry.originatingIssue)}`,