Skip to content

Commit 0d59704

Browse files
authored
Rebase onto upstream/master (2026-07-16): 68 commits (4a40c0c..f44a002) (#243)
Cherry-picked 68 upstream commits covering: MCP access governance (8-part split), security hardening (cross-tenant oracle fix, invite-token entropy, rate-limit key fix), performance (event-sourced live-runs, tab memory churn cuts, transcript buffer cap), adapters (local process confinement, env forwarding, codex auth fixes), heartbeat/recovery improvements, active PR gardening workflow, skills import, bulk extract, company skill policies, cost event tracking, tool connections, and dependency bumps. - Migration rename: upstream 0147–0171 renumbered to 0148–0172 (offset +1 for fork-specific 0137_acpx_default_engine_migration) - 49 files re-merged preserving fork-specific customizations - Fork test updated for upstream's companyId validation on plugin config reads
1 parent 335ad22 commit 0d59704

650 files changed

Lines changed: 110466 additions & 2473 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
---
2+
name: pr-gardening
3+
description: >
4+
Discover recently referenced Paperclip pull requests, mechanically verify
5+
their current-head readiness, drive non-draft PRs back to green through their
6+
originating issues, and publish a merge-confidence report without merging.
7+
compatibility: Requires Node.js 20+, gh authenticated for GitHub read access, and Paperclip run credentials.
8+
allowed-tools: Bash(node:*) Bash(gh:*) Bash(curl:*)
9+
---
10+
11+
# PR Gardening
12+
13+
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.
14+
15+
## Hard Guardrails
16+
17+
- **Never merge, approve, or close a pull request.**
18+
- **Never instruct another person or agent to merge, approve, or close a pull request.**
19+
- Never use mutating `gh` commands or mutating GitHub API requests. The scripts only use `gh pr view` and read-only `gh api` GET requests.
20+
- Draft pull requests are report-only. Do not post gardening comments for drafts.
21+
- Comment only on existing originating issues. Never create a gardening issue per pull request.
22+
- `--dry-run` suppresses all Paperclip gardening comments. Discovery and GitHub inspection remain read-only in every mode.
23+
24+
## Inputs
25+
26+
- `--days <N>`: issue activity window, default `30`.
27+
- `--repo <owner/repo>`: GitHub repository, default detected by `gh repo view`.
28+
- `--dry-run`: discover, verify, and report without posting Stage C comments.
29+
- `--cooldown-hours <N>`: repeat-comment cooldown, default `48`.
30+
- `--max-rounds <N>`: maximum gardening rounds per PR, default `3`.
31+
32+
Use a run-owned directory such as `$PAPERCLIP_RUN_SCRATCH_DIR/pr-gardening` for generated files.
33+
34+
## Stage A — Discover Candidates
35+
36+
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.
37+
38+
```bash
39+
node .agents/skills/pr-gardening/scripts/find-candidates.mjs \
40+
--days 30 \
41+
--dry-run \
42+
--output "$RUN_DIR/candidates.json"
43+
```
44+
45+
The script calls `GET /api/companies/:companyId/search/extract` with `kind=url`, `scope=all`, and `updatedWithin=<N>d`. Do not replace it with full issue-list fetching or LLM scanning.
46+
47+
## Stage B — Verify Current-Head Readiness
48+
49+
```bash
50+
node .agents/skills/pr-gardening/scripts/check-readiness.mjs \
51+
--input "$RUN_DIR/candidates.json" \
52+
--output "$RUN_DIR/readiness.json" \
53+
--dry-run
54+
```
55+
56+
For every candidate, the script re-fetches the current head SHA and records:
57+
58+
- open/draft state and mergeability/conflicts;
59+
- `statusCheckRollup` check-run and legacy status inventory;
60+
- a completed Greptile check-run on the exact head, clean only for `success` or `neutral`;
61+
- `reviewDecision`;
62+
- commits behind the base branch.
63+
64+
Verdicts are `ready`, `needs_gardening`, or `report_only` for drafts. Always rerun this stage after any wake or claim that a PR was fixed. Never trust issue comments as proof of readiness.
65+
66+
## Stage C — Comment on Originating Issues
67+
68+
Skip this stage in `--dry-run` mode and for `ready` or `report_only` entries.
69+
70+
For each `needs_gardening` PR, use `originatingIssue` from `candidates.json`. Selection priority is:
71+
72+
1. issue carrying the exact PR URL as a `pull_request` work product;
73+
2. issue whose comment mentions the PR;
74+
3. most recently active mentioning issue.
75+
76+
Before commenting, fetch the issue comments and search for this marker:
77+
78+
```text
79+
<!-- pr-gardening:<owner/repo>#<number> -->
80+
```
81+
82+
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.
83+
84+
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.
85+
86+
Suggested body:
87+
88+
```markdown
89+
<!-- pr-gardening:paperclipai/paperclip#1234 -->
90+
@Assignee please run `/prepare-pr` for https://github.com/paperclipai/paperclip/pull/1234.
91+
92+
Current-head verification at `abc123` found:
93+
- failing check: test
94+
- Greptile missing at current head
95+
96+
Gardening round 1/3. Re-verification is required after changes; do not merge based on this comment.
97+
```
98+
99+
## Stage D — Monitor to Termination
100+
101+
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.
102+
103+
On every wake, rerun Stage B first. A PR terminates from active gardening only when one of these is mechanically observed:
104+
105+
- verified `ready` at the current head;
106+
- merged or closed externally;
107+
- maximum rounds reached, reported as not converging.
108+
109+
Do not leave the gardening issue blocked on terminal issues. Do not poll agents or long-running sessions.
110+
111+
## Stage E — Render and Publish the Report
112+
113+
```bash
114+
node .agents/skills/pr-gardening/scripts/render-report.mjs \
115+
--input "$RUN_DIR/readiness.json" \
116+
--output "$RUN_DIR/gardening-report.md"
117+
```
118+
119+
The report groups open PRs by confidence:
120+
121+
- **High:** current-head checks green, no conflicts, Greptile clean, base fresh, originating issue terminal.
122+
- **Medium:** otherwise green but base stale, review not complete, or originating issue active.
123+
- **Low:** failing/pending checks, missing Greptile, draft/just-fixed-unverified state, or no identifiable origin.
124+
125+
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.
126+
127+
## Verification
128+
129+
Run focused script tests:
130+
131+
```bash
132+
node --test .agents/skills/pr-gardening/scripts/pr-gardening.test.mjs
133+
```
134+
135+
For a live dry run, execute Stages A, B, and E 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.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#!/usr/bin/env node
2+
import { pathToFileURL } from "node:url";
3+
import {
4+
ghJson,
5+
isTerminalIssue,
6+
normalizeCheck,
7+
normalizeRepository,
8+
parseArgs,
9+
readJson,
10+
reason,
11+
writeJson,
12+
} from "./lib.mjs";
13+
14+
function assessChecks(contexts) {
15+
const checks = contexts.map(normalizeCheck);
16+
return {
17+
checks,
18+
pending: checks.filter((check) => check.pending),
19+
failing: checks.filter((check) => !check.pending && !check.green),
20+
allGreen: checks.length > 0 && checks.every((check) => check.green),
21+
};
22+
}
23+
24+
function assessGreptile(checkRuns) {
25+
const runs = checkRuns.filter((run) => /greptile/i.test(run.name));
26+
const completed = runs.filter((run) => run.status === "completed");
27+
const clean = completed.filter((run) => run.conclusion === "success" || run.conclusion === "neutral");
28+
const blocking = completed.filter((run) => run.conclusion !== "success" && run.conclusion !== "neutral");
29+
return {
30+
present: runs.length > 0,
31+
pending: runs.some((run) => run.status !== "completed"),
32+
clean: clean.length > 0 && blocking.length === 0,
33+
runs: runs.map((run) => ({
34+
name: run.name,
35+
status: run.status,
36+
conclusion: run.conclusion,
37+
detailsUrl: run.details_url ?? null,
38+
})),
39+
};
40+
}
41+
42+
function fetchCheckRuns(repository, headSha) {
43+
const runs = [];
44+
for (let page = 1; page <= 100; page += 1) {
45+
const response = ghJson([
46+
"api",
47+
`repos/${repository}/commits/${headSha}/check-runs?per_page=100&page=${page}`,
48+
]);
49+
const pageRuns = response.check_runs ?? [];
50+
runs.push(...pageRuns);
51+
if (pageRuns.length < 100) return runs;
52+
}
53+
throw new Error(`Check-run pagination exceeded 100 pages for ${headSha}`);
54+
}
55+
56+
export function readinessVerdict({ pullRequest, checks, greptile, behindBy, originatingIssue }) {
57+
const reasons = [];
58+
if (pullRequest.state !== "OPEN") reasons.push(reason("pr_not_open", `PR is ${pullRequest.state.toLowerCase()}`));
59+
const mergeable = pullRequest.mergeable ?? "UNKNOWN";
60+
if (mergeable === "CONFLICTING") reasons.push(reason("merge_conflict", "GitHub reports merge conflicts"));
61+
if (mergeable === "UNKNOWN") reasons.push(reason("mergeability_unknown", "GitHub has not resolved mergeability"));
62+
if (checks.pending.length > 0) {
63+
reasons.push(reason("checks_pending", `${checks.pending.length} check(s) are pending`, "blocking", { names: checks.pending.map((check) => check.name) }));
64+
}
65+
if (checks.failing.length > 0) {
66+
reasons.push(reason("checks_failing", `${checks.failing.length} check(s) are not green`, "blocking", { names: checks.failing.map((check) => check.name) }));
67+
}
68+
if (checks.checks.length === 0) reasons.push(reason("checks_missing", "No status checks were found at the current head"));
69+
if (!greptile.present) reasons.push(reason("greptile_missing", "No Greptile check-run exists at the current head"));
70+
else if (greptile.pending) reasons.push(reason("greptile_pending", "Greptile has not completed at the current head"));
71+
else if (!greptile.clean) reasons.push(reason("greptile_not_clean", "Greptile did not conclude success or neutral at the current head"));
72+
if (pullRequest.reviewDecision === "CHANGES_REQUESTED") reasons.push(reason("changes_requested", "A review requests changes"));
73+
if (pullRequest.reviewDecision === "REVIEW_REQUIRED") reasons.push(reason("review_required", "Required review approval is missing"));
74+
if (behindBy > 0) reasons.push(reason("base_behind", `Head is ${behindBy} commit(s) behind base`, "blocking", { behindBy }));
75+
if (!originatingIssue) reasons.push(reason("originating_issue_missing", "No originating Paperclip issue was identified", "reporting"));
76+
else if (!isTerminalIssue(originatingIssue.status)) {
77+
reasons.push(reason("originating_issue_active", `Originating issue ${originatingIssue.identifier ?? originatingIssue.issueId} is ${originatingIssue.status}`, "reporting"));
78+
}
79+
80+
if (pullRequest.isDraft) return { verdict: "report_only", reasons };
81+
return { verdict: reasons.some((entry) => entry.severity === "blocking") ? "needs_gardening" : "ready", reasons };
82+
}
83+
84+
export function confidenceFor(entry) {
85+
if (entry.verdict === "report_only") return "low";
86+
const codes = new Set(entry.reasons.map((entryReason) => entryReason.code));
87+
const lowConfidenceCodes = [
88+
"originating_issue_missing",
89+
"greptile_missing",
90+
"greptile_pending",
91+
"greptile_not_clean",
92+
"checks_missing",
93+
"checks_failing",
94+
"checks_pending",
95+
"merge_conflict",
96+
"mergeability_unknown",
97+
"changes_requested",
98+
];
99+
if (lowConfidenceCodes.some((code) => codes.has(code))) {
100+
return "low";
101+
}
102+
if (entry.verdict === "ready" && !codes.has("originating_issue_active")) return "high";
103+
return "medium";
104+
}
105+
106+
export async function checkReadiness(candidatesDocument, options = {}) {
107+
const repository = normalizeRepository(options.repo ?? candidatesDocument.repository);
108+
const results = [];
109+
for (const candidate of candidatesDocument.candidates) {
110+
const pullRequest = ghJson([
111+
"pr",
112+
"view",
113+
String(candidate.number),
114+
"--repo",
115+
repository,
116+
"--json",
117+
"number,url,title,state,isDraft,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,updatedAt",
118+
]);
119+
const checkRuns = fetchCheckRuns(repository, pullRequest.headRefOid);
120+
const comparison = ghJson([
121+
"api",
122+
`repos/${repository}/compare/${encodeURIComponent(pullRequest.baseRefName)}...${encodeURIComponent(pullRequest.headRefOid)}`,
123+
]);
124+
const checks = assessChecks(pullRequest.statusCheckRollup ?? []);
125+
const greptile = assessGreptile(checkRuns);
126+
const assessment = readinessVerdict({
127+
pullRequest,
128+
checks,
129+
greptile,
130+
behindBy: comparison.behind_by ?? 0,
131+
originatingIssue: candidate.originatingIssue,
132+
});
133+
const entry = {
134+
number: pullRequest.number,
135+
url: pullRequest.url,
136+
title: pullRequest.title,
137+
state: pullRequest.state.toLowerCase(),
138+
isDraft: pullRequest.isDraft,
139+
headSha: pullRequest.headRefOid,
140+
baseRefName: pullRequest.baseRefName,
141+
headRefName: pullRequest.headRefName,
142+
mergeable: (pullRequest.mergeable ?? "UNKNOWN").toLowerCase(),
143+
mergeStateStatus: (pullRequest.mergeStateStatus ?? "UNKNOWN").toLowerCase(),
144+
reviewDecision: pullRequest.reviewDecision || null,
145+
behindBy: comparison.behind_by ?? 0,
146+
checks,
147+
greptile,
148+
originatingIssue: candidate.originatingIssue,
149+
sourceIssues: candidate.sourceIssues,
150+
...assessment,
151+
};
152+
entry.confidence = confidenceFor(entry);
153+
results.push(entry);
154+
}
155+
return {
156+
schemaVersion: 1,
157+
generatedAt: new Date().toISOString(),
158+
repository,
159+
candidatesGeneratedAt: candidatesDocument.generatedAt,
160+
dryRun: Boolean(options.dry_run ?? candidatesDocument.dryRun),
161+
summary: {
162+
total: results.length,
163+
ready: results.filter((entry) => entry.verdict === "ready").length,
164+
needsGardening: results.filter((entry) => entry.verdict === "needs_gardening").length,
165+
reportOnly: results.filter((entry) => entry.verdict === "report_only").length,
166+
},
167+
pullRequests: results,
168+
};
169+
}
170+
171+
async function main() {
172+
const options = parseArgs(process.argv.slice(2), { input: "candidates.json", output: "readiness.json" });
173+
writeJson(options.output, await checkReadiness(readJson(options.input), options));
174+
}
175+
176+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
177+
main().catch((error) => {
178+
console.error(error.message);
179+
process.exitCode = 1;
180+
});
181+
}

0 commit comments

Comments
 (0)