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 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 post the review + inline PR comments (`--comment`).
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 # analysis only prints the JSON report, no GitHub writes
131
- /pr-review 123 --comment # also posts inline review comments to the PR
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/comment on a closed/merged PR when GitHub accepts it
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. If `--comment` is also used and GitHub rejects inline comments on the non-open PR, findings should be folded into the summary comment instead.
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
- `--comment` summary comments include a hidden `pi-pr-review` marker with the reviewed `headRefOid`. A later run skips only when it finds a marker for the **current** head SHA. If new commits were pushed after the previous review, the head SHA changes and `/pr-review` reviews the PR again. Older unmarked comments are treated as unknown/stale, not proof that the current head was already reviewed.
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 for findings that can be posted as GitHub inline review comments. With `--comment`, a summary review is posted plus inline comments (single- or multi-line, using each finding's anchor) for every commentable blocking/P2/P3 finding; nits and off-diff/repo-wide observations fold into the summary.
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 the final JSON as a table (TUI only)
196
- └─ tests/pr-review-policy.test.ts # focused policy compatibility tests
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** mirrors the Claude review workflow (PR-number driven, confirm-before-reviewing closed/merged PRs, skip draft/same-head-already-reviewed, overview + strengths, convention/readability/maintainability, best-effort build/test verification, validate-then-classify, optional comment posting with strict GitHub permalink rules) with bounded parallel multi-model fan-out (configurable light/medium/heavy tiers).
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 tier config" },
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
- : isFallbackKey(id)
1313
- ? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
1314
- : isToolPolicyKey(id)
1315
- ? (draft.toolPolicies[tierFromCompoundKey(id)] ?? INHERIT_TOOL_POLICY)
1316
- : (draft.tiers[id as Tier] ?? UNSET);
1317
- ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
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
- 12,
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 full, readable review in the TUI:
5
- * header, verification, overview, strengths, a findings table (nit P0) with
6
- * per-finding details, correctness/security/performance notes, and the verdict.
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
- * Only rewrites in interactive TUI mode. In print / json / rpc modes the raw JSON is
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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
- if (hasToolCall(event.message)) return; // not the final text-only answer
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
- if (!text.trim()) return;
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: {