pi-pr-review 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/extensions/pr-review-subagent.ts +68 -9
- package/extensions/review-table.ts +152 -11
- package/lib/pr-review-publish.ts +878 -0
- package/package.json +1 -1
- package/prompts/pr-review.md +24 -30
- package/tests/pr-review-publish.test.ts +261 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Pass a PR number and pi will:
|
|
|
8
8
|
2. Derive the **base branch** and **head (merging) branch** automatically from the PR.
|
|
9
9
|
3. Review the base↔head diff with disciplined passes (overview, convention compliance, bugs, security/perf, readability), best-effort build/test verification, then validate each candidate.
|
|
10
10
|
4. Return a **full structured review**: overview, strengths, verification, findings at **every** severity (`nit → P3 → P2 → P1 → P0`) with a blocking flag, correctness/security/performance notes, and a verdict.
|
|
11
|
-
5. Optionally
|
|
11
|
+
5. Optionally publish one formal GitHub `COMMENT` review with a top-level body and associated inline comments.
|
|
12
12
|
|
|
13
13
|
**Captures everything, then ranks it.** Unlike a high-signal-only reviewer, it does not discard minor issues — nits, style, naming, missing edge cases, and "worth confirming" observations are all reported as low-severity findings. The **verdict** depends only on *blocking* (P0/P1) findings, so a clean PR is still approved while its nits are still recorded.
|
|
14
14
|
|
|
@@ -66,6 +66,8 @@ The `/pr-review-config` command maps three labels to models:
|
|
|
66
66
|
/pr-review-config light=<spec> medium=<spec> heavy=<spec> # set primary tier models
|
|
67
67
|
/pr-review-config heavy_fallbacks=<spec>,<spec> # retry chain for quota/rate-limit failures
|
|
68
68
|
/pr-review-config light_tool_policy=none # tier default when a pass omits tool_policy
|
|
69
|
+
/pr-review-config auto_post_reviews=true # opt in to automatic GitHub COMMENT reviews
|
|
70
|
+
/pr-review-config auto_post_reviews=false # disable automatic posting (the default)
|
|
69
71
|
/pr-review-config medium=unset # clear a tier (back to pi default)
|
|
70
72
|
/pr-review-config heavy_fallbacks=unset # clear a fallback chain
|
|
71
73
|
/pr-review-config light_tool_policy=unset # restore legacy configured-tool behavior
|
|
@@ -74,7 +76,7 @@ The `/pr-review-config` command maps three labels to models:
|
|
|
74
76
|
|
|
75
77
|
Running `/pr-review-config` with no arguments in the TUI opens an interactive settings menu that mirrors pi's `/settings` and the NERVous `/nervous:config`:
|
|
76
78
|
|
|
77
|
-
- One primary-model, fallback-model, and tool-policy row per tier (`light` / `medium` / `heavy`) plus a configured-tool allowlist row.
|
|
79
|
+
- One primary-model, fallback-model, and tool-policy row per tier (`light` / `medium` / `heavy`), an automatic-posting toggle, plus a configured-tool allowlist row.
|
|
78
80
|
- Press Enter on a primary or fallback row to pick a model from a searchable list (or unset it); Enter/Space cycles tool policies and allowlist presets. The menu sets one fallback model at a time; use the `key=value` form for longer fallback chains.
|
|
79
81
|
- Selections apply and persist **immediately**; Esc closes the menu.
|
|
80
82
|
- Type to search, and tab-completion is available for the `key=value` form.
|
|
@@ -105,6 +107,7 @@ Example `pr-review.json`:
|
|
|
105
107
|
"medium": "configured",
|
|
106
108
|
"heavy": "configured"
|
|
107
109
|
},
|
|
110
|
+
"autoPostReviews": false,
|
|
108
111
|
"tools": ["read", "bash", "grep", "find", "ls"]
|
|
109
112
|
}
|
|
110
113
|
```
|
|
@@ -127,21 +130,28 @@ The orchestrator (which fetches the PR, merges findings, and emits the JSON) and
|
|
|
127
130
|
Type `/` in the pi editor and pick `pr-review`, or:
|
|
128
131
|
|
|
129
132
|
```
|
|
130
|
-
/pr-review 123 #
|
|
131
|
-
/pr-review 123 --comment #
|
|
133
|
+
/pr-review 123 # use autoPostReviews (false by default)
|
|
134
|
+
/pr-review 123 --comment # force one COMMENT review for this run
|
|
135
|
+
/pr-review 123 --no-comment # suppress posting for this run
|
|
132
136
|
/pr-review 123 --include-closed # review a closed/merged PR without a confirmation prompt
|
|
133
|
-
/pr-review 123 --review-closed --comment # review
|
|
137
|
+
/pr-review 123 --review-closed --comment # review and attempt a body-only COMMENT review
|
|
134
138
|
```
|
|
135
139
|
|
|
136
140
|
`123` is the PR number in the current repo.
|
|
137
141
|
|
|
138
142
|
### Closed or merged PRs
|
|
139
143
|
|
|
140
|
-
Closed/merged PRs no longer hard-skip. If you run `/pr-review 123` on a non-open PR, the prompt asks whether to continue before producing a review. Use `--include-closed` or `--review-closed` to proceed non-interactively.
|
|
144
|
+
Closed/merged PRs no longer hard-skip. If you run `/pr-review 123` on a non-open PR, the prompt asks whether to continue before producing a review. Use `--include-closed` or `--review-closed` to proceed non-interactively. When publication is enabled, the extension preemptively folds inline findings into one body-only formal `COMMENT` review; if GitHub rejects it, publication fails explicitly without creating an issue comment.
|
|
145
|
+
|
|
146
|
+
### Automatic GitHub review posting
|
|
147
|
+
|
|
148
|
+
`autoPostReviews` is a strict top-level boolean and defaults to `false`. A trusted project `.pi/pr-review.json` value overlays the user value; malformed effective values never enable posting. `/pr-review-config` edits user scope only and displays the effective value/source—if a trusted project overlay wins, edit that project file or use `--no-comment` for the run. Invocation flags are captured before prompt expansion: `--comment` forces posting for one run, `--no-comment` suppresses it, and using both is rejected.
|
|
149
|
+
|
|
150
|
+
Publishing is extension-owned—the model never constructs the write request, and final JSON marked `disposition: "skipped"` is never published. It creates exactly one formal pull-request review containing a top-level body and associated P0–P3 inline comments. The GitHub event is hardcoded to `COMMENT`; the publisher cannot send `APPROVE` or `REQUEST_CHANGES`, even when the review's suggested verdict is `request_changes`. It validates the current head and every inline diff anchor before the single POST, so an invalid open-PR inline comment fails without leaving a partial review. Closed/merged PRs require either `--include-closed`/`--review-closed` or the one-shot affirmative confirmation, then use one body-only `COMMENT` review with inline findings folded into the body. Unknown or unconfirmed non-open lifecycle states fail without posting.
|
|
141
151
|
|
|
142
152
|
### Duplicate review handling
|
|
143
153
|
|
|
144
|
-
|
|
154
|
+
Published reviews include a hidden `pi-pr-review` marker with the reviewed `headRefOid`. A later run skips only when it finds a same-head marker authored by the current authenticated GitHub identity in either formal reviews or legacy issue comments. If new commits were pushed, the head SHA changes and `/pr-review` reviews the PR again. Older or unmarked content is not proof that the current head was reviewed.
|
|
145
155
|
|
|
146
156
|
### Response format
|
|
147
157
|
|
|
@@ -153,7 +163,8 @@ Example payload:
|
|
|
153
163
|
|
|
154
164
|
```json
|
|
155
165
|
{
|
|
156
|
-
"pr": { "number": 33, "title": "fix(logs): parse date-time log timestamps" },
|
|
166
|
+
"pr": { "number": 33, "title": "fix(logs): parse date-time log timestamps", "head_sha": "0123456789abcdef0123456789abcdef01234567" },
|
|
167
|
+
"disposition": "reviewed",
|
|
157
168
|
"verification": "`go build ./...` ✅, `go test ./...` ✅ (130 passed)",
|
|
158
169
|
"overview": "Migrates log timestamps from epoch-ms to RFC3339 to match the endpoint contract.",
|
|
159
170
|
"strengths": ["Reuses FormatTimestamp instead of ad-hoc formatting; net -3 lines."],
|
|
@@ -182,7 +193,7 @@ Example payload:
|
|
|
182
193
|
|
|
183
194
|
Severity tags: `[P0]` blocking/drop-everything · `[P1]` blocking/urgent · `[P2]` normal · `[P3]` low · `[nit]` trivial/optional. Verdict is `approve` (no blocking findings), `request_changes` (a blocking finding exists), or `comment`.
|
|
184
195
|
|
|
185
|
-
**Inline-comment ready.** Each finding's `code_location` is diff-anchored — repo-relative `absolute_file_path`, `line_range` on `side` (`RIGHT` for added/context lines, `LEFT` for removed), and `commentable` (whether the lines are inside a diff hunk). The rendered table shows an **Inline** ✎ column
|
|
196
|
+
**Inline-comment ready.** Each finding's `code_location` is diff-anchored — repo-relative `absolute_file_path`, `line_range` on `side` (`RIGHT` for added/context lines, `LEFT` for removed), and `commentable` (whether the lines are inside a diff hunk). The rendered table shows an **Inline** ✎ column. When publishing is enabled, the extension validates these anchors against current GitHub diff metadata and attaches eligible P0–P3 findings under the single formal review; nits and off-diff observations stay in the top-level body.
|
|
186
197
|
|
|
187
198
|
## What's in the package
|
|
188
199
|
|
|
@@ -191,9 +202,11 @@ pi-pr-review/
|
|
|
191
202
|
├─ package.json # pi manifest: prompts + extensions
|
|
192
203
|
├─ prompts/pr-review.md # the /pr-review orchestrator prompt
|
|
193
204
|
├─ lib/pr-review-policy.ts # pure tool-policy resolution/argv helpers
|
|
205
|
+
├─ lib/pr-review-publish.ts # safe COMMENT-review payload, validation, and gh publisher
|
|
194
206
|
├─ extensions/pr-review-subagent.ts # review_subagents/review_subagent tools + /pr-review-config command
|
|
195
|
-
├─ extensions/review-table.ts # renders
|
|
196
|
-
|
|
207
|
+
├─ extensions/review-table.ts # renders JSON and triggers trusted configured publishing
|
|
208
|
+
├─ tests/pr-review-policy.test.ts # focused policy compatibility tests
|
|
209
|
+
└─ tests/pr-review-publish.test.ts # posting gate, payload, marker, and anchor tests
|
|
197
210
|
```
|
|
198
211
|
|
|
199
212
|
## Speed, security & cost notes
|
|
@@ -201,12 +214,14 @@ pi-pr-review/
|
|
|
201
214
|
- The four independent review lenses remain intact. Only overview runs context-only with `--no-tools`; medium and both heavy specialists retain configured tools for surrounding-file validation. All subprocesses use `--no-context-files` because the orchestrator supplies the base review context explicitly, and convention excerpts are sent only to the medium pass instead of every model.
|
|
202
215
|
- Tool results include effective `toolPolicy` and `elapsedMs` telemetry (plus per-attempt timing) so repeated representative runs can be compared at p50/p95 without guessing. Restore tools for a custom pass by sending `tool_policy: "configured"`; callers that omit policy retain legacy behavior unless `toolPolicies` config says otherwise.
|
|
203
216
|
- The `review_subagents` batch tool and `review_subagent` fallback spawn isolated `pi` subprocesses (`--mode json -p --no-session`) on your configured tier models. Reviewer prompts prohibit modifications, but a configured allowlist containing `bash` is not technically read-only; use a narrower allowlist if stronger enforcement is required.
|
|
204
|
-
- Project-local `pr-review.json` is only read when the project is trusted.
|
|
217
|
+
- Project-local `pr-review.json` is only read when the project is trusted. Because project `autoPostReviews: true` causes writes under your authenticated `gh` identity, the effective setting and source are surfaced when publication occurs.
|
|
218
|
+
- GitHub publication uses `gh` only, verifies the current identity and PR head, checks paginated formal reviews and legacy comments for same-head markers, and hardcodes `event: COMMENT`. Ambiguous transport failures are reconciled once and never blindly retried.
|
|
219
|
+
- **`gh api -f` caution:** `gh api ... -f body=@/tmp/file.md` posts the literal text `@/tmp/file.md`; unlike `gh pr comment --body-file`, `-f` does not expand `@file`. Use a JSON payload via `gh api ... --input -` for API requests. The built-in publisher already does this.
|
|
205
220
|
- Tiered review calls multiple models per PR, concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for deep passes, and configure per-tier `fallbacks` only for acceptable backup models because retries can increase cost.
|
|
206
221
|
|
|
207
222
|
## Design notes
|
|
208
223
|
|
|
209
|
-
- **Process**
|
|
224
|
+
- **Process** is PR-number driven: confirm non-open PRs, skip draft/same-head-already-reviewed work, fan out four review lenses, verify, validate/classify, emit JSON, then optionally publish one extension-owned formal `COMMENT` review.
|
|
210
225
|
- **Captures every severity** (`nit → P0`) with a `blocking` flag; the verdict depends only on blocking findings, so nothing minor is lost but a clean PR still gets approved.
|
|
211
226
|
- **Verification is non-destructive:** any build/test runs in an isolated `git worktree` on the PR head — the prompt never checks out, commits, or pushes in your working tree.
|
|
212
227
|
- pi has no built-in sub-agents, so tiering is implemented as an extension that spawns isolated `pi` subprocesses per tier; the batch tool gives deterministic parallelism, and the prompt degrades gracefully to single-pass or inline review when the extension is absent.
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
resolveToolPolicy,
|
|
54
54
|
type ToolPolicy,
|
|
55
55
|
} from "../lib/pr-review-policy.ts";
|
|
56
|
+
import { resolveAutoPostSetting } from "../lib/pr-review-publish.ts";
|
|
56
57
|
|
|
57
58
|
// ---------------------------------------------------------------------------
|
|
58
59
|
// Config
|
|
@@ -80,6 +81,8 @@ interface PrReviewConfig {
|
|
|
80
81
|
fallbacks: Partial<Record<Tier, string[]>>;
|
|
81
82
|
/** Optional tier-level policy used when a tool call does not override it. */
|
|
82
83
|
toolPolicies: Partial<Record<Tier, ToolPolicy>>;
|
|
84
|
+
/** Automatically publish final review JSON as a GitHub COMMENT review. Disabled by default. */
|
|
85
|
+
autoPostReviews: boolean;
|
|
83
86
|
/** Tools granted to review subagents whose effective policy is configured. */
|
|
84
87
|
tools: string[];
|
|
85
88
|
}
|
|
@@ -136,6 +139,17 @@ function normalizeToolPolicies(
|
|
|
136
139
|
return out;
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
function resolveAutoPostForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">) {
|
|
143
|
+
const user = readConfigFile(userConfigPath());
|
|
144
|
+
let project: Partial<PrReviewConfig> | undefined;
|
|
145
|
+
try {
|
|
146
|
+
if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
|
|
147
|
+
} catch {
|
|
148
|
+
/* user config only */
|
|
149
|
+
}
|
|
150
|
+
return resolveAutoPostSetting(user, project);
|
|
151
|
+
}
|
|
152
|
+
|
|
139
153
|
/** User config, overlaid by project config when the project is trusted. */
|
|
140
154
|
function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
|
|
141
155
|
const user = readConfigFile(userConfigPath());
|
|
@@ -145,6 +159,7 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
|
|
|
145
159
|
} catch {
|
|
146
160
|
/* trust check unavailable -> user config only */
|
|
147
161
|
}
|
|
162
|
+
const autoPost = resolveAutoPostSetting(user, project);
|
|
148
163
|
return {
|
|
149
164
|
tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
|
|
150
165
|
fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
|
|
@@ -152,6 +167,7 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
|
|
|
152
167
|
...normalizeToolPolicies(user.toolPolicies),
|
|
153
168
|
...normalizeToolPolicies(project.toolPolicies),
|
|
154
169
|
},
|
|
170
|
+
autoPostReviews: autoPost.valid ? autoPost.value : false,
|
|
155
171
|
tools: project.tools ?? user.tools ?? DEFAULT_TOOLS,
|
|
156
172
|
};
|
|
157
173
|
}
|
|
@@ -159,10 +175,12 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
|
|
|
159
175
|
/** User-level config only (the scope the config command edits), with defaults. */
|
|
160
176
|
function readUserConfig(): PrReviewConfig {
|
|
161
177
|
const raw = readConfigFile(userConfigPath());
|
|
178
|
+
const autoPost = resolveAutoPostSetting(raw);
|
|
162
179
|
return {
|
|
163
180
|
tiers: { ...(raw.tiers ?? {}) },
|
|
164
181
|
fallbacks: normalizeFallbacks(raw.fallbacks),
|
|
165
182
|
toolPolicies: normalizeToolPolicies(raw.toolPolicies),
|
|
183
|
+
autoPostReviews: autoPost.valid ? autoPost.value : false,
|
|
166
184
|
tools: raw.tools ?? [...DEFAULT_TOOLS],
|
|
167
185
|
};
|
|
168
186
|
}
|
|
@@ -952,8 +970,9 @@ const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
|
|
|
952
970
|
value: `${t}_tool_policy=`,
|
|
953
971
|
label: `${t}_tool_policy=<none|configured|unset> — default tool access when a pass does not override it`,
|
|
954
972
|
})),
|
|
973
|
+
{ value: "auto_post_reviews=", label: "auto_post_reviews=<true|false> — automatically post COMMENT reviews (default false)" },
|
|
955
974
|
{ value: "tools=", label: "tools=read,bash,grep,find,ls — allowlist used by configured policy" },
|
|
956
|
-
{ value: "show", label: "show — print the current
|
|
975
|
+
{ value: "show", label: "show — print the current review config" },
|
|
957
976
|
];
|
|
958
977
|
|
|
959
978
|
/** Split respecting single/double quotes so `evidence`-style values with spaces survive. */
|
|
@@ -1019,6 +1038,7 @@ interface ConfigPatch {
|
|
|
1019
1038
|
tiers: Partial<Record<Tier, string | null>>;
|
|
1020
1039
|
fallbacks: Partial<Record<Tier, string[] | null>>;
|
|
1021
1040
|
toolPolicies: Partial<Record<Tier, ToolPolicy | null>>;
|
|
1041
|
+
autoPostReviews?: boolean;
|
|
1022
1042
|
tools?: string[];
|
|
1023
1043
|
}
|
|
1024
1044
|
|
|
@@ -1047,11 +1067,15 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
|
|
|
1047
1067
|
if (policy) patch.toolPolicies[tier] = policy;
|
|
1048
1068
|
else errors.push(`invalid ${key} "${value}" (expected none|configured|unset)`);
|
|
1049
1069
|
}
|
|
1070
|
+
} else if (key === "auto_post_reviews" || key === "autopostreviews" || key === "auto-post-reviews") {
|
|
1071
|
+
if (value === "true") patch.autoPostReviews = true;
|
|
1072
|
+
else if (value === "false") patch.autoPostReviews = false;
|
|
1073
|
+
else errors.push(`invalid ${key} "${value}" (expected true|false)`);
|
|
1050
1074
|
} else if (key === "tools") {
|
|
1051
1075
|
patch.tools = splitCommaList(value);
|
|
1052
1076
|
} else {
|
|
1053
1077
|
errors.push(
|
|
1054
|
-
`unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_tool_policy|tools)`,
|
|
1078
|
+
`unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_tool_policy|auto_post_reviews|tools)`,
|
|
1055
1079
|
);
|
|
1056
1080
|
}
|
|
1057
1081
|
}
|
|
@@ -1059,6 +1083,7 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
|
|
|
1059
1083
|
Object.keys(patch.tiers).length > 0 ||
|
|
1060
1084
|
Object.keys(patch.fallbacks).length > 0 ||
|
|
1061
1085
|
Object.keys(patch.toolPolicies).length > 0 ||
|
|
1086
|
+
patch.autoPostReviews !== undefined ||
|
|
1062
1087
|
patch.tools !== undefined;
|
|
1063
1088
|
return { patch, hasChanges, errors };
|
|
1064
1089
|
}
|
|
@@ -1068,6 +1093,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
|
|
|
1068
1093
|
tiers: { ...base.tiers },
|
|
1069
1094
|
fallbacks: normalizeFallbacks(base.fallbacks),
|
|
1070
1095
|
toolPolicies: normalizeToolPolicies(base.toolPolicies),
|
|
1096
|
+
autoPostReviews: base.autoPostReviews,
|
|
1071
1097
|
tools: [...base.tools],
|
|
1072
1098
|
};
|
|
1073
1099
|
for (const tier of TIERS) {
|
|
@@ -1087,6 +1113,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
|
|
|
1087
1113
|
else next.toolPolicies[tier] = value;
|
|
1088
1114
|
}
|
|
1089
1115
|
}
|
|
1116
|
+
if (patch.autoPostReviews !== undefined) next.autoPostReviews = patch.autoPostReviews;
|
|
1090
1117
|
if (patch.tools) next.tools = patch.tools;
|
|
1091
1118
|
return next;
|
|
1092
1119
|
}
|
|
@@ -1110,6 +1137,7 @@ function summarizeConfig(
|
|
|
1110
1137
|
} catch {
|
|
1111
1138
|
/* ignore */
|
|
1112
1139
|
}
|
|
1140
|
+
const autoPost = resolveAutoPostForContext(ctx);
|
|
1113
1141
|
const lines = [
|
|
1114
1142
|
`# PR review config${changed ? " updated" : ""}`,
|
|
1115
1143
|
"",
|
|
@@ -1119,6 +1147,7 @@ function summarizeConfig(
|
|
|
1119
1147
|
(t) =>
|
|
1120
1148
|
`| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
|
|
1121
1149
|
),
|
|
1150
|
+
`| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub \`COMMENT\` review; default \`false\` |`,
|
|
1122
1151
|
`| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`,
|
|
1123
1152
|
"",
|
|
1124
1153
|
"| Tier | Your fallbacks | Effective fallbacks | Tool policy |",
|
|
@@ -1131,6 +1160,10 @@ function summarizeConfig(
|
|
|
1131
1160
|
`User config: \`${userConfigPath()}\``,
|
|
1132
1161
|
];
|
|
1133
1162
|
if (projectPath) lines.push(`Project overlay (trusted): \`${projectPath}\``);
|
|
1163
|
+
if (!autoPost.valid) lines.push(`Automatic posting config error: ${autoPost.error}`);
|
|
1164
|
+
else if (autoPost.source === "project") {
|
|
1165
|
+
lines.push("Automatic posting is controlled by the trusted project overlay; this command edits user config only.");
|
|
1166
|
+
}
|
|
1134
1167
|
lines.push(
|
|
1135
1168
|
"",
|
|
1136
1169
|
"## Usage",
|
|
@@ -1138,6 +1171,8 @@ function summarizeConfig(
|
|
|
1138
1171
|
"- Print this summary: `/pr-review-config show`",
|
|
1139
1172
|
"- Set directly: `/pr-review-config light=provider/model heavy=provider/model:high`",
|
|
1140
1173
|
"- Set fallback chain: `/pr-review-config heavy_fallbacks=provider/backup:high,provider/backup2`",
|
|
1174
|
+
"- Enable automatic GitHub review posting: `/pr-review-config auto_post_reviews=true`",
|
|
1175
|
+
"- Disable automatic GitHub review posting: `/pr-review-config auto_post_reviews=false`",
|
|
1141
1176
|
"- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
|
|
1142
1177
|
"- Clear a tier: `/pr-review-config medium=unset`",
|
|
1143
1178
|
"- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
|
|
@@ -1250,6 +1285,13 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
|
|
|
1250
1285
|
...tierItems,
|
|
1251
1286
|
...fallbackItems,
|
|
1252
1287
|
...policyItems,
|
|
1288
|
+
{
|
|
1289
|
+
id: "auto_post_reviews",
|
|
1290
|
+
label: "user automatic posting setting",
|
|
1291
|
+
description: "Post one GitHub COMMENT review after final JSON. Disabled by default.",
|
|
1292
|
+
currentValue: String(cfg.autoPostReviews),
|
|
1293
|
+
values: ["false", "true"],
|
|
1294
|
+
},
|
|
1253
1295
|
{
|
|
1254
1296
|
id: "tools",
|
|
1255
1297
|
label: "configured tool allowlist",
|
|
@@ -1281,6 +1323,7 @@ async function showConfigMenu(
|
|
|
1281
1323
|
settingsList.updateValue(`${tier}_fallbacks`, draft.fallbacks[tier]?.join(",") || "(none)");
|
|
1282
1324
|
settingsList.updateValue(`${tier}_tool_policy`, draft.toolPolicies[tier] ?? INHERIT_TOOL_POLICY);
|
|
1283
1325
|
}
|
|
1326
|
+
settingsList.updateValue("auto_post_reviews", String(draft.autoPostReviews));
|
|
1284
1327
|
settingsList.updateValue("tools", draft.tools.join(","));
|
|
1285
1328
|
};
|
|
1286
1329
|
|
|
@@ -1299,6 +1342,8 @@ async function showConfigMenu(
|
|
|
1299
1342
|
const policy = normalizeToolPolicy(newValue);
|
|
1300
1343
|
if (policy) draft.toolPolicies[tier] = policy;
|
|
1301
1344
|
}
|
|
1345
|
+
} else if (id === "auto_post_reviews") {
|
|
1346
|
+
draft.autoPostReviews = newValue === "true";
|
|
1302
1347
|
} else if (id === "tools") {
|
|
1303
1348
|
draft.tools = splitCommaList(newValue);
|
|
1304
1349
|
} else {
|
|
@@ -1309,12 +1354,26 @@ async function showConfigMenu(
|
|
|
1309
1354
|
refresh();
|
|
1310
1355
|
const shown = id === "tools"
|
|
1311
1356
|
? draft.tools.join(",")
|
|
1312
|
-
:
|
|
1313
|
-
? (draft.
|
|
1314
|
-
:
|
|
1315
|
-
? (draft.
|
|
1316
|
-
: (
|
|
1317
|
-
|
|
1357
|
+
: id === "auto_post_reviews"
|
|
1358
|
+
? String(draft.autoPostReviews)
|
|
1359
|
+
: isFallbackKey(id)
|
|
1360
|
+
? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
|
|
1361
|
+
: isToolPolicyKey(id)
|
|
1362
|
+
? (draft.toolPolicies[tierFromCompoundKey(id)] ?? INHERIT_TOOL_POLICY)
|
|
1363
|
+
: (draft.tiers[id as Tier] ?? UNSET);
|
|
1364
|
+
if (id === "auto_post_reviews") {
|
|
1365
|
+
const effective = resolveAutoPostForContext(ctx);
|
|
1366
|
+
if (effective.source === "project") {
|
|
1367
|
+
ctx.ui.notify(
|
|
1368
|
+
`User autoPostReviews saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)} or use --no-comment.`,
|
|
1369
|
+
"warning",
|
|
1370
|
+
);
|
|
1371
|
+
} else {
|
|
1372
|
+
ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
|
|
1373
|
+
}
|
|
1374
|
+
} else {
|
|
1375
|
+
ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
|
|
1376
|
+
}
|
|
1318
1377
|
} catch (e) {
|
|
1319
1378
|
ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error");
|
|
1320
1379
|
}
|
|
@@ -1323,7 +1382,7 @@ async function showConfigMenu(
|
|
|
1323
1382
|
|
|
1324
1383
|
settingsList = new SettingsList(
|
|
1325
1384
|
configMenuItems(draft, available),
|
|
1326
|
-
|
|
1385
|
+
13,
|
|
1327
1386
|
getSettingsListTheme(),
|
|
1328
1387
|
(id, newValue) => persist(id, newValue),
|
|
1329
1388
|
() => done(undefined),
|
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* review-table
|
|
3
3
|
*
|
|
4
|
-
* Renders the /pr-review final JSON response as a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Renders the /pr-review final JSON response as a readable TUI review and owns
|
|
5
|
+
* configured GitHub publication after valid final JSON. Publishing is bound to raw
|
|
6
|
+
* invocation flags/config, validates current PR state and anchors, and can emit only
|
|
7
|
+
* one formal COMMENT review with associated inline comments.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
* left untouched so piping and automation keep a machine-readable payload.
|
|
9
|
+
* Rendering only rewrites interactive TUI output. Print/json/rpc modes retain raw JSON.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
CONFIG_DIR_NAME,
|
|
16
|
+
type ExtensionAPI,
|
|
17
|
+
type ExtensionContext,
|
|
18
|
+
getAgentDir,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import {
|
|
21
|
+
classifyAssistantCompletion,
|
|
22
|
+
isNonOpenConfirmationPrompt,
|
|
23
|
+
parsePublishMode,
|
|
24
|
+
parsePublishableReview,
|
|
25
|
+
publishPullReview,
|
|
26
|
+
resolveAutoPostSetting,
|
|
27
|
+
ReviewInvocationGate,
|
|
28
|
+
shouldPublishReview,
|
|
29
|
+
validateReviewInvocation,
|
|
30
|
+
type AutoPostResolution,
|
|
31
|
+
type ReviewInvocation,
|
|
32
|
+
type ReviewLike,
|
|
33
|
+
} from "../lib/pr-review-publish.ts";
|
|
13
34
|
|
|
14
35
|
type Severity = "P0" | "P1" | "P2" | "P3" | "nit";
|
|
15
36
|
|
|
@@ -29,7 +50,8 @@ interface Finding {
|
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
interface Review {
|
|
32
|
-
pr?: { number?: number | null; title?: string | null } | null;
|
|
53
|
+
pr?: { number?: number | null; title?: string | null; head_sha?: string | null } | null;
|
|
54
|
+
disposition?: "reviewed" | "skipped";
|
|
33
55
|
verification?: string;
|
|
34
56
|
overview?: string;
|
|
35
57
|
strengths?: string[];
|
|
@@ -256,19 +278,138 @@ function renderReviewMarkdown(r: Review): string {
|
|
|
256
278
|
return out.join("\n").trimEnd();
|
|
257
279
|
}
|
|
258
280
|
|
|
281
|
+
interface ConfigReadResult {
|
|
282
|
+
value: Record<string, unknown>;
|
|
283
|
+
error?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function readJsonObject(filePath: string): ConfigReadResult {
|
|
287
|
+
try {
|
|
288
|
+
if (!fs.existsSync(filePath)) return { value: {} };
|
|
289
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
290
|
+
return parsed && typeof parsed === "object"
|
|
291
|
+
? { value: parsed as Record<string, unknown> }
|
|
292
|
+
: { value: {}, error: `${filePath} must contain a JSON object` };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { value: {}, error: `${filePath} is invalid JSON: ${String(error)}` };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function resolvePublishingConfig(ctx: ExtensionContext): AutoPostResolution {
|
|
299
|
+
const user = readJsonObject(path.join(getAgentDir(), "pr-review.json"));
|
|
300
|
+
if (user.error) return { value: false, valid: false, source: "user", error: user.error };
|
|
301
|
+
let project: ConfigReadResult | undefined;
|
|
302
|
+
try {
|
|
303
|
+
if (ctx.isProjectTrusted()) {
|
|
304
|
+
project = readJsonObject(path.join(ctx.cwd, CONFIG_DIR_NAME, "pr-review.json"));
|
|
305
|
+
if (project.error) return { value: false, valid: false, source: "project", error: project.error };
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
/* user config only */
|
|
309
|
+
}
|
|
310
|
+
return resolveAutoPostSetting(user.value, project?.value);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function maybePublishReview(text: string, invocation: ReviewInvocation, ctx: ExtensionContext): Promise<void> {
|
|
314
|
+
if (invocation.mode === "disabled") return;
|
|
315
|
+
const setting = resolvePublishingConfig(ctx);
|
|
316
|
+
if (invocation.mode === "auto") {
|
|
317
|
+
if (!setting.valid) {
|
|
318
|
+
ctx.ui.notify(`PR review was not posted: ${setting.error}`, "error");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (!setting.value) return;
|
|
322
|
+
}
|
|
323
|
+
const parsed = parsePublishableReview(text);
|
|
324
|
+
if (!parsed.review) {
|
|
325
|
+
ctx.ui.notify(`PR review was not posted: ${parsed.error}`, "error");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const bindingError = validateReviewInvocation(parsed.review, invocation);
|
|
329
|
+
if (bindingError) {
|
|
330
|
+
ctx.ui.notify(`PR review was not posted: ${bindingError}`, "error");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!shouldPublishReview(parsed.review)) return;
|
|
334
|
+
const headSha = parsed.review.pr?.head_sha;
|
|
335
|
+
if (typeof headSha !== "string") {
|
|
336
|
+
ctx.ui.notify("PR review was not posted: final JSON is missing pr.head_sha", "error");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const result = await publishPullReview({
|
|
340
|
+
cwd: ctx.cwd,
|
|
341
|
+
prNumber: invocation.prNumber,
|
|
342
|
+
headSha,
|
|
343
|
+
allowNonOpen: invocation.allowNonOpen,
|
|
344
|
+
review: parsed.review as ReviewLike,
|
|
345
|
+
});
|
|
346
|
+
const source = invocation.mode === "force" ? "--comment" : `${setting.source} config`;
|
|
347
|
+
if (result.status === "posted") {
|
|
348
|
+
ctx.ui.notify(`PR review posted as COMMENT (${source})${result.url ? `: ${result.url}` : ""}`, "info");
|
|
349
|
+
} else if (result.status === "posted_degraded") {
|
|
350
|
+
ctx.ui.notify(`PR review posted as body-only COMMENT for non-open PR (${source})`, "warning");
|
|
351
|
+
} else if (result.status === "skipped_duplicate") {
|
|
352
|
+
ctx.ui.notify("PR review not reposted: this head was already reviewed by the current GitHub identity", "info");
|
|
353
|
+
} else {
|
|
354
|
+
ctx.ui.notify(`PR review publish ${result.status}: ${result.message}`, "error");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
259
358
|
export default function (pi: ExtensionAPI) {
|
|
359
|
+
const invocationGate = new ReviewInvocationGate();
|
|
360
|
+
|
|
361
|
+
pi.on("session_start", () => {
|
|
362
|
+
invocationGate.clear();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
pi.on("input", (event, ctx) => {
|
|
366
|
+
if (invocationGate.phase() === "awaiting_confirmation") {
|
|
367
|
+
const confirmation = invocationGate.resolveConfirmationInput(event.text);
|
|
368
|
+
if (confirmation === "confirmed") return;
|
|
369
|
+
// Negative/unrelated input clears authority; a fresh /pr-review may bind below.
|
|
370
|
+
}
|
|
371
|
+
const parsed = parsePublishMode(event.text);
|
|
372
|
+
if (!parsed.matched) return;
|
|
373
|
+
if (invocationGate.peek() && event.streamingBehavior === undefined) {
|
|
374
|
+
// Replace an abandoned/settled invocation, but never a queued/steering one.
|
|
375
|
+
invocationGate.clear();
|
|
376
|
+
}
|
|
377
|
+
const gate = invocationGate.begin(parsed);
|
|
378
|
+
if (!gate.accepted) {
|
|
379
|
+
ctx.ui.notify(`Invalid /pr-review invocation: ${gate.error}`, "error");
|
|
380
|
+
return { action: "handled" as const };
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
260
384
|
pi.on("message_end", async (event, ctx) => {
|
|
261
|
-
// Keep raw JSON for automation; only prettify for interactive terminals.
|
|
262
|
-
if (ctx.mode !== "tui") return;
|
|
263
385
|
if (event.message.role !== "assistant") return;
|
|
264
|
-
|
|
386
|
+
const completion = classifyAssistantCompletion(event.message.stopReason, hasToolCall(event.message));
|
|
387
|
+
if (completion === "continue_tools") return;
|
|
388
|
+
if (completion === "clear_invocation") {
|
|
389
|
+
invocationGate.clear();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
265
392
|
|
|
266
393
|
const text = assistantText(event.message);
|
|
267
|
-
|
|
394
|
+
const active = invocationGate.peek();
|
|
395
|
+
if (
|
|
396
|
+
active &&
|
|
397
|
+
invocationGate.phase() === "reviewing" &&
|
|
398
|
+
isNonOpenConfirmationPrompt(text, active.prNumber)
|
|
399
|
+
) {
|
|
400
|
+
invocationGate.markAwaitingConfirmation();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
268
403
|
|
|
404
|
+
// Every other terminal response consumes authority, whether valid, empty, or unrelated.
|
|
405
|
+
const invocation = active ? invocationGate.consume() : undefined;
|
|
406
|
+
if (!text.trim()) return;
|
|
269
407
|
const review = parseReview(text);
|
|
270
408
|
if (!review) return; // not a /pr-review JSON payload — leave untouched
|
|
409
|
+
if (invocation) await maybePublishReview(text, invocation, ctx);
|
|
271
410
|
|
|
411
|
+
// Keep raw JSON for automation; only prettify for interactive terminals.
|
|
412
|
+
if (ctx.mode !== "tui") return;
|
|
272
413
|
const nonText = (event.message.content as MessagePart[]).filter((p) => p.type !== "text");
|
|
273
414
|
return {
|
|
274
415
|
message: {
|