pi-pr-review 1.10.6 → 1.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0](https://github.com/10ego/pi-pr-review/compare/v1.10.7...v1.11.0) (2026-07-17)
4
+
5
+
6
+ ### Features
7
+
8
+ * submit APPROVE review event with configurable priority gate ([#35](https://github.com/10ego/pi-pr-review/issues/35)) ([ec47045](https://github.com/10ego/pi-pr-review/commit/ec47045245541c9182ac7e1b120765bbc5e77696))
9
+
10
+ ## [1.10.7](https://github.com/10ego/pi-pr-review/compare/v1.10.6...v1.10.7) (2026-07-17)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **publish:** auto-heal a Markdown-fenced review JSON object ([#37](https://github.com/10ego/pi-pr-review/issues/37)) ([9ecdd05](https://github.com/10ego/pi-pr-review/commit/9ecdd05e8f00b56176c52f5ca50fcf55698bd2b8))
16
+
3
17
  ## [1.10.6](https://github.com/10ego/pi-pr-review/compare/v1.10.5...v1.10.6) (2026-07-17)
4
18
 
5
19
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ Give it a PR number and it will:
8
8
  - run focused review passes in parallel using models you choose;
9
9
  - validate candidate findings before reporting them;
10
10
  - render a structured review with severity, location, confidence, and verdict;
11
- - optionally publish one safe GitHub `COMMENT` review with inline comments.
11
+ - optionally publish one safe GitHub review with inline comments (`COMMENT` by default; gated `APPROVE` when configured).
12
12
 
13
13
  The default review prioritizes P0–P2 defects and allows up to three minor findings. Use `--full` for exhaustive convention, maintainability, and minor coverage.
14
14
 
@@ -114,6 +114,8 @@ Common settings:
114
114
  /pr-review-config tools=read,bash,grep,find,ls
115
115
  /pr-review-config auto_post_reviews=true
116
116
  /pr-review-config allow_stale_publish=false
117
+ /pr-review-config allow_stale_approvals=true
118
+ /pr-review-config approve_max_priority_level=P2
117
119
  /pr-review-config medium=unset
118
120
  ```
119
121
 
@@ -152,7 +154,9 @@ Example:
152
154
  },
153
155
  "tools": ["read", "bash", "grep", "find", "ls"],
154
156
  "autoPostReviews": false,
155
- "allowStalePublish": true
157
+ "allowStalePublish": true,
158
+ "allowStaleApprovals": false,
159
+ "approveMaxPriorityLevel": "off"
156
160
  }
157
161
  ```
158
162
 
@@ -188,7 +192,7 @@ If the agent's final review is not valid exact-contract JSON, the extension logs
188
192
 
189
193
  You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
190
194
 
191
- Every authorized publish path builds one `COMMENT` payload and sends at most one GitHub review `POST`; it never submits `APPROVE` or `REQUEST_CHANGES` and never retries a rejected write with a fallback POST. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale or authorized non-open reviews are body-only.
195
+ Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. It emits `COMMENT` by default, or a gated `APPROVE` when configured below. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale or authorized non-open reviews are body-only. A stale review additionally requires `allowStaleApprovals: true` before it may record `APPROVE`.
192
196
 
193
197
  The same safety gates apply to every path: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown or invalid states fail closed before a write.
194
198
 
@@ -198,7 +202,7 @@ Stale publication is enabled by default through `allowStalePublish: true`; disab
198
202
  /pr-review-publish 123 --allow-stale
199
203
  ```
200
204
 
201
- A matching direct request permits stale publication. Inline comments are always disabled for stale reviews because the original anchors may no longer be valid; the body identifies both the reviewed and current commit. The session-backed cache survives extension reloads and session resumes and remains bound to the originating session instance and repository.
205
+ A matching direct request permits stale publication. Inline comments are always disabled for stale reviews because the original anchors may no longer be valid; the body identifies both the reviewed and current commit. A stale publication remains `COMMENT` by default even when `approveMaxPriorityLevel` qualifies it; set trusted `allowStaleApprovals: true` before starting the review to permit its eligible `APPROVE`. Authorized non-open publications may record `APPROVE` when the priority gate qualifies the review. The session-backed cache survives extension reloads and session resumes and remains bound to the originating session instance and repository.
202
206
 
203
207
  ## Optional verification
204
208
 
@@ -247,7 +251,16 @@ Each finding includes:
247
251
  | `P3` | Low-priority improvement. |
248
252
  | `nit` | Trivial or optional. |
249
253
 
250
- The verdict is `request_changes` only when a validated P0 or P1 finding exists. Otherwise it is `approve` or `comment`. The displayed verdict is advisory even when the review is published, because publication always uses the GitHub `COMMENT` event.
254
+ The verdict is `request_changes` only when a validated P0 or P1 finding exists. Otherwise it is `approve` or `comment`. By default, publication uses the GitHub `COMMENT` event. When `approveMaxPriorityLevel` is set to `P2`, `P3`, or `nit`, a review whose verdict is `approve` and whose findings are all at or below that level is published as a GitHub `APPROVE` event instead. A stale publication additionally requires `allowStaleApprovals: true`; authorized non-open publications do not.
255
+
256
+ | Setting | Behavior |
257
+ |---|---|
258
+ | `off` (default) | Always `COMMENT`; never auto-approve. |
259
+ | `P2` | `APPROVE` if verdict is `approve` and all findings are P2/P3/nit. |
260
+ | `P3` | `APPROVE` if verdict is `approve` and all findings are P3/nit. |
261
+ | `nit` | `APPROVE` only if verdict is `approve` and all findings are nits. |
262
+
263
+ Configure it with `/pr-review-config approve_max_priority_level=P2`. To permit an eligible stale review to approve, also set `/pr-review-config allow_stale_approvals=true` before starting the review. Both settings follow the same user/project overlay pattern as `autoPostReviews`.
251
264
 
252
265
  ## Safety and cost
253
266
 
@@ -75,8 +75,11 @@ import {
75
75
  type ToolPolicy,
76
76
  } from "../lib/pr-review-policy.ts";
77
77
  import {
78
+ resolveAllowStaleApprovalsSetting,
78
79
  resolveAllowStalePublishSetting,
79
80
  resolveAutoPostSetting,
81
+ resolveApproveMaxPriorityLevelSetting,
82
+ type ApproveMaxPriorityLevel,
80
83
  } from "../lib/pr-review-publish.ts";
81
84
  import { monotonicNow } from "../lib/pr-review-telemetry.ts";
82
85
  import {
@@ -131,6 +134,10 @@ interface PrReviewConfig {
131
134
  autoPostReviews: boolean;
132
135
  /** Permit stale publication as body-only with reviewed/current SHAs disclosed. Enabled by default. */
133
136
  allowStalePublish: boolean;
137
+ /** Permit otherwise-qualified stale reviews to record APPROVE. Disabled by default. */
138
+ allowStaleApprovals: boolean;
139
+ /** Maximum severity finding that permits an APPROVE event (off disables auto-approve). */
140
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel;
134
141
  /** Trusted user-level named verification profiles. Project config never overlays these. */
135
142
  verificationBaselines: VerificationBaselines;
136
143
  /** Tools granted to review subagents whose effective policy is configured. */
@@ -218,6 +225,28 @@ function resolveAllowStaleForContext(ctx: Pick<ExtensionContext, "cwd" | "isProj
218
225
  return resolveAllowStalePublishSetting(user, project);
219
226
  }
220
227
 
228
+ function resolveAllowStaleApprovalsForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">) {
229
+ const user = readConfigFile(userConfigPath());
230
+ let project: Partial<PrReviewConfig> | undefined;
231
+ try {
232
+ if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
233
+ } catch {
234
+ /* user config only */
235
+ }
236
+ return resolveAllowStaleApprovalsSetting(user, project);
237
+ }
238
+
239
+ function resolveApproveMaxPriorityForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">) {
240
+ const user = readConfigFile(userConfigPath());
241
+ let project: Partial<PrReviewConfig> | undefined;
242
+ try {
243
+ if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
244
+ } catch {
245
+ /* user config only */
246
+ }
247
+ return resolveApproveMaxPriorityLevelSetting(user, project);
248
+ }
249
+
221
250
  /** User config overlaid by a trusted project, except user-only verification profiles. */
222
251
  function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
223
252
  const user = readConfigFile(userConfigPath());
@@ -229,6 +258,8 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
229
258
  }
230
259
  const autoPost = resolveAutoPostSetting(user, project);
231
260
  const allowStale = resolveAllowStalePublishSetting(user, project);
261
+ const allowStaleApprovals = resolveAllowStaleApprovalsSetting(user, project);
262
+ const approveMaxPriority = resolveApproveMaxPriorityLevelSetting(user, project);
232
263
  return {
233
264
  tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
234
265
  fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
@@ -242,6 +273,8 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
242
273
  },
243
274
  autoPostReviews: autoPost.valid ? autoPost.value : false,
244
275
  allowStalePublish: allowStale.valid ? allowStale.value : false,
276
+ allowStaleApprovals: allowStaleApprovals.valid ? allowStaleApprovals.value : false,
277
+ approveMaxPriorityLevel: approveMaxPriority.valid ? approveMaxPriority.value : "off",
245
278
  // Verification is intentionally user-owned. Never accept a profile from
246
279
  // the repository-controlled project overlay, even for trusted projects.
247
280
  verificationBaselines: resolveUserVerificationBaselines(user, project),
@@ -254,6 +287,8 @@ function readUserConfig(): PrReviewConfig {
254
287
  const raw = readConfigFile(userConfigPath());
255
288
  const autoPost = resolveAutoPostSetting(raw);
256
289
  const allowStale = resolveAllowStalePublishSetting(raw);
290
+ const allowStaleApprovals = resolveAllowStaleApprovalsSetting(raw);
291
+ const approveMaxPriority = resolveApproveMaxPriorityLevelSetting(raw);
257
292
  return {
258
293
  tiers: { ...(raw.tiers ?? {}) },
259
294
  fallbacks: normalizeFallbacks(raw.fallbacks),
@@ -261,6 +296,8 @@ function readUserConfig(): PrReviewConfig {
261
296
  toolPolicies: normalizeToolPolicies(raw.toolPolicies),
262
297
  autoPostReviews: autoPost.valid ? autoPost.value : false,
263
298
  allowStalePublish: allowStale.valid ? allowStale.value : false,
299
+ allowStaleApprovals: allowStaleApprovals.valid ? allowStaleApprovals.value : false,
300
+ approveMaxPriorityLevel: approveMaxPriority.valid ? approveMaxPriority.value : "off",
264
301
  verificationBaselines: resolveUserVerificationBaselines(raw),
265
302
  tools: raw.tools ?? [...DEFAULT_TOOLS],
266
303
  };
@@ -1682,6 +1719,8 @@ interface ConfigPatch {
1682
1719
  toolPolicies: Partial<Record<Tier, ToolPolicy | null>>;
1683
1720
  autoPostReviews?: boolean;
1684
1721
  allowStalePublish?: boolean;
1722
+ allowStaleApprovals?: boolean;
1723
+ approveMaxPriorityLevel?: ApproveMaxPriorityLevel;
1685
1724
  tools?: string[];
1686
1725
  }
1687
1726
 
@@ -1726,11 +1765,30 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1726
1765
  if (value === "true") patch.allowStalePublish = true;
1727
1766
  else if (value === "false") patch.allowStalePublish = false;
1728
1767
  else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1768
+ } else if (key === "allow_stale_approvals" || key === "allowstaleapprovals" || key === "allow-stale-approvals") {
1769
+ if (value === "true") patch.allowStaleApprovals = true;
1770
+ else if (value === "false") patch.allowStaleApprovals = false;
1771
+ else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1772
+ } else if (
1773
+ key === "approve_max_priority_level" ||
1774
+ key === "approvemaxprioritylevel" ||
1775
+ key === "approve-max-priority-level"
1776
+ ) {
1777
+ const normalized = value.toLowerCase();
1778
+ if (normalized === "off") {
1779
+ patch.approveMaxPriorityLevel = "off";
1780
+ } else if (["p2", "p3"].includes(normalized)) {
1781
+ patch.approveMaxPriorityLevel = normalized.toUpperCase() as ApproveMaxPriorityLevel;
1782
+ } else if (normalized === "nit") {
1783
+ patch.approveMaxPriorityLevel = "nit";
1784
+ } else {
1785
+ errors.push(`invalid ${key} "${value}" (expected off|P2|P3|nit)`);
1786
+ }
1729
1787
  } else if (key === "tools") {
1730
1788
  patch.tools = splitCommaList(value);
1731
1789
  } else {
1732
1790
  errors.push(
1733
- `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|allow_stale_publish|tools)`,
1791
+ `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|allow_stale_publish|allow_stale_approvals|approve_max_priority_level|tools)`,
1734
1792
  );
1735
1793
  }
1736
1794
  }
@@ -1741,6 +1799,8 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1741
1799
  Object.keys(patch.toolPolicies).length > 0 ||
1742
1800
  patch.autoPostReviews !== undefined ||
1743
1801
  patch.allowStalePublish !== undefined ||
1802
+ patch.allowStaleApprovals !== undefined ||
1803
+ patch.approveMaxPriorityLevel !== undefined ||
1744
1804
  patch.tools !== undefined;
1745
1805
  return { patch, hasChanges, errors };
1746
1806
  }
@@ -1753,6 +1813,8 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1753
1813
  toolPolicies: normalizeToolPolicies(base.toolPolicies),
1754
1814
  autoPostReviews: base.autoPostReviews,
1755
1815
  allowStalePublish: base.allowStalePublish,
1816
+ allowStaleApprovals: base.allowStaleApprovals,
1817
+ approveMaxPriorityLevel: base.approveMaxPriorityLevel,
1756
1818
  verificationBaselines: { ...base.verificationBaselines },
1757
1819
  tools: [...base.tools],
1758
1820
  };
@@ -1780,6 +1842,8 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1780
1842
  }
1781
1843
  if (patch.autoPostReviews !== undefined) next.autoPostReviews = patch.autoPostReviews;
1782
1844
  if (patch.allowStalePublish !== undefined) next.allowStalePublish = patch.allowStalePublish;
1845
+ if (patch.allowStaleApprovals !== undefined) next.allowStaleApprovals = patch.allowStaleApprovals;
1846
+ if (patch.approveMaxPriorityLevel !== undefined) next.approveMaxPriorityLevel = patch.approveMaxPriorityLevel;
1783
1847
  if (patch.tools) next.tools = patch.tools;
1784
1848
  return next;
1785
1849
  }
@@ -1805,6 +1869,8 @@ function summarizeConfig(
1805
1869
  }
1806
1870
  const autoPost = resolveAutoPostForContext(ctx);
1807
1871
  const allowStale = resolveAllowStaleForContext(ctx);
1872
+ const allowStaleApprovals = resolveAllowStaleApprovalsForContext(ctx);
1873
+ const approveMaxPriority = resolveApproveMaxPriorityForContext(ctx);
1808
1874
  const lines = [
1809
1875
  `# PR review config${changed ? " updated" : ""}`,
1810
1876
  "",
@@ -1814,8 +1880,10 @@ function summarizeConfig(
1814
1880
  (t) =>
1815
1881
  `| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
1816
1882
  ),
1817
- `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub \`COMMENT\` review; default \`false\` |`,
1883
+ `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub review; default \`false\` |`,
1818
1884
  `| \`allowStalePublish\` | \`${user.allowStalePublish}\` | \`${effective.allowStalePublish}\` (${allowStale.source}) | permit body-only stale publication with reviewed/current SHAs; default \`true\` |`,
1885
+ `| \`allowStaleApprovals\` | \`${user.allowStaleApprovals}\` | \`${effective.allowStaleApprovals}\` (${allowStaleApprovals.source}) | permit qualified stale reviews to record APPROVE; default \`false\` |`,
1886
+ `| \`approveMaxPriorityLevel\` | \`${user.approveMaxPriorityLevel}\` | \`${effective.approveMaxPriorityLevel}\` (${approveMaxPriority.source}) | max severity for auto-APPROVE; \`off\` posts COMMENT only; default \`off\` |`,
1819
1887
  `| \`verificationBaselines\` | \`${Object.keys(user.verificationBaselines).length} configured\` | user scope only | strict named argv profiles; project overlays ignored |`,
1820
1888
  `| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`,
1821
1889
  "",
@@ -1839,6 +1907,14 @@ function summarizeConfig(
1839
1907
  else if (allowStale.source === "project") {
1840
1908
  lines.push("Stale publication is controlled by the trusted project overlay; this command edits user config only.");
1841
1909
  }
1910
+ if (!allowStaleApprovals.valid) lines.push(`Stale approval config error: ${allowStaleApprovals.error}`);
1911
+ else if (allowStaleApprovals.source === "project") {
1912
+ lines.push("Stale approval is controlled by the trusted project overlay; this command edits user config only.");
1913
+ }
1914
+ if (!approveMaxPriority.valid) lines.push(`Auto-approve priority config error: ${approveMaxPriority.error}`);
1915
+ else if (approveMaxPriority.source === "project") {
1916
+ lines.push("Auto-approve priority is controlled by the trusted project overlay; this command edits user config only.");
1917
+ }
1842
1918
  lines.push(
1843
1919
  "",
1844
1920
  "## Usage",
@@ -1851,6 +1927,10 @@ function summarizeConfig(
1851
1927
  "- Disable automatic GitHub review posting: `/pr-review-config auto_post_reviews=false`",
1852
1928
  "- Disable stale publication: `/pr-review-config allow_stale_publish=false`",
1853
1929
  "- Enable stale publication (default): `/pr-review-config allow_stale_publish=true`",
1930
+ "- Permit qualified stale reviews to record APPROVE: `/pr-review-config allow_stale_approvals=true`",
1931
+ "- Keep stale reviews as COMMENT (default): `/pr-review-config allow_stale_approvals=false`",
1932
+ "- Enable auto-approve for low-severity reviews: `/pr-review-config approve_max_priority_level=P2`",
1933
+ "- Disable auto-approve (default): `/pr-review-config approve_max_priority_level=off`",
1854
1934
  "- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
1855
1935
  "- Clear a tier: `/pr-review-config medium=unset`",
1856
1936
  "- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
@@ -1987,6 +2067,20 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
1987
2067
  currentValue: String(cfg.allowStalePublish),
1988
2068
  values: ["true", "false"],
1989
2069
  },
2070
+ {
2071
+ id: "allow_stale_approvals",
2072
+ label: "stale approval setting",
2073
+ description: "Permit otherwise-qualified stale reviews to record APPROVE. Disabled by default.",
2074
+ currentValue: String(cfg.allowStaleApprovals),
2075
+ values: ["false", "true"],
2076
+ },
2077
+ {
2078
+ id: "approve_max_priority_level",
2079
+ label: "auto-approve priority gate",
2080
+ description: "Maximum severity that permits an APPROVE event (off = COMMENT only). Enter/Space cycles values.",
2081
+ currentValue: String(cfg.approveMaxPriorityLevel),
2082
+ values: ["off", "P2", "P3", "nit"],
2083
+ },
1990
2084
  {
1991
2085
  id: "tools",
1992
2086
  label: "configured tool allowlist",
@@ -2021,6 +2115,7 @@ async function showConfigMenu(
2021
2115
  }
2022
2116
  settingsList.updateValue("auto_post_reviews", String(draft.autoPostReviews));
2023
2117
  settingsList.updateValue("allow_stale_publish", String(draft.allowStalePublish));
2118
+ settingsList.updateValue("allow_stale_approvals", String(draft.allowStaleApprovals));
2024
2119
  settingsList.updateValue("tools", draft.tools.join(","));
2025
2120
  };
2026
2121
 
@@ -2054,6 +2149,17 @@ async function showConfigMenu(
2054
2149
  draft.autoPostReviews = newValue === "true";
2055
2150
  } else if (id === "allow_stale_publish") {
2056
2151
  draft.allowStalePublish = newValue === "true";
2152
+ } else if (id === "allow_stale_approvals") {
2153
+ draft.allowStaleApprovals = newValue === "true";
2154
+ } else if (id === "approve_max_priority_level") {
2155
+ const normalized = newValue.toLowerCase();
2156
+ if (normalized === "off") {
2157
+ draft.approveMaxPriorityLevel = "off";
2158
+ } else if (["p2", "p3"].includes(normalized)) {
2159
+ draft.approveMaxPriorityLevel = normalized.toUpperCase() as ApproveMaxPriorityLevel;
2160
+ } else if (normalized === "nit") {
2161
+ draft.approveMaxPriorityLevel = "nit";
2162
+ }
2057
2163
  } else if (id === "tools") {
2058
2164
  draft.tools = splitCommaList(newValue);
2059
2165
  } else {
@@ -2068,7 +2174,11 @@ async function showConfigMenu(
2068
2174
  ? String(draft.autoPostReviews)
2069
2175
  : id === "allow_stale_publish"
2070
2176
  ? String(draft.allowStalePublish)
2071
- : isFallbackKey(id)
2177
+ : id === "allow_stale_approvals"
2178
+ ? String(draft.allowStaleApprovals)
2179
+ : id === "approve_max_priority_level"
2180
+ ? String(draft.approveMaxPriorityLevel)
2181
+ : isFallbackKey(id)
2072
2182
  ? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
2073
2183
  : isThinkingKey(id)
2074
2184
  ? (draft.thinkingLevels[tierFromCompoundKey(id)] ?? INHERIT_THINKING)
@@ -2095,6 +2205,26 @@ async function showConfigMenu(
2095
2205
  } else {
2096
2206
  ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
2097
2207
  }
2208
+ } else if (id === "allow_stale_approvals") {
2209
+ const effective = resolveAllowStaleApprovalsForContext(ctx);
2210
+ if (effective.source === "project") {
2211
+ ctx.ui.notify(
2212
+ `User allowStaleApprovals saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`,
2213
+ "warning",
2214
+ );
2215
+ } else {
2216
+ ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
2217
+ }
2218
+ } else if (id === "approve_max_priority_level") {
2219
+ const effective = resolveApproveMaxPriorityForContext(ctx);
2220
+ if (effective.source === "project") {
2221
+ ctx.ui.notify(
2222
+ `User approveMaxPriorityLevel saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`,
2223
+ "warning",
2224
+ );
2225
+ } else {
2226
+ ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
2227
+ }
2098
2228
  } else {
2099
2229
  ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
2100
2230
  }
@@ -30,13 +30,16 @@ import {
30
30
  parsePublishMode,
31
31
  parsePublishableReview,
32
32
  publishPullReview,
33
+ resolveAllowStaleApprovalsSetting,
33
34
  resolveAllowStalePublishSetting,
34
35
  resolveAutoPostSetting,
36
+ resolveApproveMaxPriorityLevelSetting,
35
37
  resolveRepositoryBinding,
36
38
  restoreCompletedReviewBranch,
37
39
  shouldPublishReview,
38
40
  validateReviewInvocation,
39
41
  type AutoPostResolution,
42
+ type ApproveMaxPriorityLevelResolution,
40
43
  type CompletedReviewRecord,
41
44
  type CompletedReviewSessionIdentity,
42
45
  type ReviewInvocation,
@@ -336,11 +339,14 @@ function readJsonObject(filePath: string): ConfigReadResult {
336
339
  interface PublishingConfigResolution {
337
340
  autoPost: AutoPostResolution;
338
341
  allowStale: AutoPostResolution;
342
+ allowStaleApprovals: AutoPostResolution;
343
+ approveMaxPriority: ApproveMaxPriorityLevelResolution;
339
344
  }
340
345
 
341
346
  function invalidPublishingConfig(source: "user" | "project", error: string): PublishingConfigResolution {
342
347
  const invalid = { value: false, valid: false, source, error } as const;
343
- return { autoPost: invalid, allowStale: invalid };
348
+ const invalidLevel = { value: "off" as const, valid: false, source, error } as const;
349
+ return { autoPost: invalid, allowStale: invalid, allowStaleApprovals: invalid, approveMaxPriority: invalidLevel };
344
350
  }
345
351
 
346
352
  function resolvePublishingConfig(ctx: ExtensionContext): PublishingConfigResolution {
@@ -358,6 +364,8 @@ function resolvePublishingConfig(ctx: ExtensionContext): PublishingConfigResolut
358
364
  return {
359
365
  autoPost: resolveAutoPostSetting(user.value, project?.value),
360
366
  allowStale: resolveAllowStalePublishSetting(user.value, project?.value),
367
+ allowStaleApprovals: resolveAllowStaleApprovalsSetting(user.value, project?.value),
368
+ approveMaxPriority: resolveApproveMaxPriorityLevelSetting(user.value, project?.value),
361
369
  };
362
370
  }
363
371
 
@@ -367,7 +375,8 @@ function notifyPublishResult(
367
375
  ctx: ExtensionContext,
368
376
  ): void {
369
377
  if (result.status === "posted") {
370
- ctx.ui.notify(`PR review posted as COMMENT (${source})${result.url ? `: ${result.url}` : ""}`, "info");
378
+ const label = result.event === "APPROVE" ? "APPROVE" : "COMMENT";
379
+ ctx.ui.notify(`PR review posted as ${label} (${source})${result.url ? `: ${result.url}` : ""}`, "info");
371
380
  } else if (result.status === "posted_degraded") {
372
381
  ctx.ui.notify(`PR review posted (${source}): ${result.message}${result.url ? ` ${result.url}` : ""}`, "warning");
373
382
  } else if (result.status === "skipped_duplicate") {
@@ -415,6 +424,8 @@ async function publishCompletedReview(
415
424
  headSha,
416
425
  allowNonOpen: record.invocation.allowNonOpen,
417
426
  allowStale,
427
+ allowStaleApprovals: record.invocation.allowStaleApprovals,
428
+ approveMaxPriorityLevel: record.invocation.approveMaxPriorityLevel,
418
429
  expectedRepository: record.repository,
419
430
  review: record.review,
420
431
  });
@@ -674,6 +685,8 @@ export default function registerReviewTable(
674
685
  source,
675
686
  ctx,
676
687
  publishingConfig.allowStale.valid && publishingConfig.allowStale.value,
688
+ publishingConfig.allowStaleApprovals.valid && publishingConfig.allowStaleApprovals.value,
689
+ publishingConfig.approveMaxPriority.valid ? publishingConfig.approveMaxPriority.value : "off",
677
690
  );
678
691
  if (!gate.accepted) {
679
692
  ctx.ui.notify(`Invalid /pr-review invocation: ${gate.error}`, "error");
@@ -2,6 +2,7 @@ import * as path from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  ReviewInvocationGate,
5
+ type ApproveMaxPriorityLevel,
5
6
  type AutoPostResolution,
6
7
  type PublishModeParseResult,
7
8
  type ReviewInvocation,
@@ -103,11 +104,19 @@ export class ReviewLoopCoordinator {
103
104
  source: ReviewLoopInputSource,
104
105
  ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
105
106
  allowStalePublish = true,
107
+ allowStaleApprovals = false,
108
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel = "off",
106
109
  ): { accepted: boolean; error?: string } {
107
110
  if (source !== "interactive" && source !== "rpc") {
108
111
  return { accepted: false, error: "/pr-review must be initiated directly by an interactive or RPC user" };
109
112
  }
110
- const started = this.invocationGate.begin(parsed, autoPost, allowStalePublish);
113
+ const started = this.invocationGate.begin(
114
+ parsed,
115
+ autoPost,
116
+ allowStalePublish,
117
+ allowStaleApprovals,
118
+ approveMaxPriorityLevel,
119
+ );
111
120
  if (!started.accepted) return started;
112
121
  const current = sessionBinding(ctx);
113
122
  this.binding = {
@@ -6,6 +6,88 @@ export type PublishMode = "auto" | "force" | "disabled";
6
6
  export type AutoPostSource = "default" | "user" | "project";
7
7
  export type CompletionAction = "continue_tools" | "accept_final" | "clear_invocation";
8
8
 
9
+ /** Maximum severity that may appear in a review for auto-APPROVE to be granted. */
10
+ export type ApproveMaxPriorityLevel = "off" | "P2" | "P3" | "nit";
11
+
12
+ const APPROVE_PRIORITY_LEVELS = ["P2", "P3", "nit"] as const;
13
+ const APPROVE_PRIORITY_RANK: Record<string, number> = {
14
+ P0: 4,
15
+ P1: 3,
16
+ P2: 2,
17
+ P3: 1,
18
+ nit: 0,
19
+ NIT: 0,
20
+ };
21
+
22
+ export interface ApproveMaxPriorityLevelResolution {
23
+ readonly value: ApproveMaxPriorityLevel;
24
+ readonly valid: boolean;
25
+ readonly source: AutoPostSource;
26
+ readonly error?: string;
27
+ }
28
+
29
+ function isValidApproveLevel(value: unknown): value is ApproveMaxPriorityLevel {
30
+ return value === "off" || (typeof value === "string" && APPROVE_PRIORITY_LEVELS.includes(value as (typeof APPROVE_PRIORITY_LEVELS)[number]));
31
+ }
32
+
33
+ /** Resolve the auto-approve priority gate with trusted project config overlaying user config. */
34
+ export function resolveApproveMaxPriorityLevelSetting(
35
+ user: unknown,
36
+ trustedProject?: unknown,
37
+ ): ApproveMaxPriorityLevelResolution {
38
+ if (hasOwn(trustedProject, "approveMaxPriorityLevel")) {
39
+ const value = (trustedProject as { approveMaxPriorityLevel?: unknown }).approveMaxPriorityLevel;
40
+ return isValidApproveLevel(value)
41
+ ? { value, valid: true, source: "project" }
42
+ : {
43
+ value: "off",
44
+ valid: false,
45
+ source: "project",
46
+ error: `project approveMaxPriorityLevel must be one of: off, ${APPROVE_PRIORITY_LEVELS.join(", ")}`,
47
+ };
48
+ }
49
+ if (hasOwn(user, "approveMaxPriorityLevel")) {
50
+ const value = (user as { approveMaxPriorityLevel?: unknown }).approveMaxPriorityLevel;
51
+ return isValidApproveLevel(value)
52
+ ? { value, valid: true, source: "user" }
53
+ : {
54
+ value: "off",
55
+ valid: false,
56
+ source: "user",
57
+ error: `user approveMaxPriorityLevel must be one of: off, ${APPROVE_PRIORITY_LEVELS.join(", ")}`,
58
+ };
59
+ }
60
+ return { value: "off", valid: true, source: "default" };
61
+ }
62
+
63
+ /** Whether all findings in a review are at or below the configured maximum priority. */
64
+ export function findingsWithinApproveMaxPriority(
65
+ review: ReviewLike,
66
+ level: ApproveMaxPriorityLevel,
67
+ ): boolean {
68
+ if (level === "off") return false;
69
+ const maxRank = APPROVE_PRIORITY_RANK[level];
70
+ if (maxRank === undefined) return false;
71
+ const findings = Array.isArray(review.findings) ? review.findings : [];
72
+ return findings.every(
73
+ (finding) => (APPROVE_PRIORITY_RANK[String(finding.severity ?? "").toUpperCase()] ?? Infinity) <= maxRank,
74
+ );
75
+ }
76
+
77
+ /** Decide whether a review should be published as APPROVE instead of COMMENT. */
78
+ export function shouldApproveReview(
79
+ review: ReviewLike,
80
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel,
81
+ ): boolean {
82
+ const findings = Array.isArray(review.findings) ? review.findings : [];
83
+ return (
84
+ review.verdict === "approve" &&
85
+ approveMaxPriorityLevel !== "off" &&
86
+ findings.every((finding) => !finding.blocking) &&
87
+ findingsWithinApproveMaxPriority(review, approveMaxPriorityLevel)
88
+ );
89
+ }
90
+
9
91
  export function classifyAssistantCompletion(
10
92
  stopReason: string | undefined,
11
93
  hasToolCall: boolean,
@@ -83,6 +165,36 @@ export function resolveAllowStalePublishSetting(
83
165
  return { value: true, valid: true, source: "default" };
84
166
  }
85
167
 
168
+ /** Resolve whether an otherwise-qualified stale review may record APPROVE. Disabled by default. */
169
+ export function resolveAllowStaleApprovalsSetting(
170
+ user: unknown,
171
+ trustedProject?: unknown,
172
+ ): AutoPostResolution {
173
+ if (hasOwn(trustedProject, "allowStaleApprovals")) {
174
+ const value = (trustedProject as { allowStaleApprovals?: unknown }).allowStaleApprovals;
175
+ return typeof value === "boolean"
176
+ ? { value, valid: true, source: "project" }
177
+ : {
178
+ value: false,
179
+ valid: false,
180
+ source: "project",
181
+ error: "project allowStaleApprovals must be a boolean",
182
+ };
183
+ }
184
+ if (hasOwn(user, "allowStaleApprovals")) {
185
+ const value = (user as { allowStaleApprovals?: unknown }).allowStaleApprovals;
186
+ return typeof value === "boolean"
187
+ ? { value, valid: true, source: "user" }
188
+ : {
189
+ value: false,
190
+ valid: false,
191
+ source: "user",
192
+ error: "user allowStaleApprovals must be a boolean",
193
+ };
194
+ }
195
+ return { value: false, valid: true, source: "default" };
196
+ }
197
+
86
198
  export interface PublishModeParseResult {
87
199
  matched: boolean;
88
200
  mode?: PublishMode;
@@ -125,8 +237,12 @@ export interface ReviewInvocation {
125
237
  readonly allowNonOpen: boolean;
126
238
  /** Trusted stale-publication setting captured before review execution begins. */
127
239
  readonly allowStalePublish: boolean;
240
+ /** Trusted stale-approval setting captured before review execution begins. */
241
+ readonly allowStaleApprovals: boolean;
128
242
  /** Trusted automatic-posting decision captured before review execution begins. */
129
243
  readonly autoPost: Readonly<AutoPostResolution>;
244
+ /** Trusted auto-approve priority gate captured before review execution begins. */
245
+ readonly approveMaxPriorityLevel: ApproveMaxPriorityLevel;
130
246
  }
131
247
 
132
248
  export interface ReviewPublicationDecision {
@@ -222,6 +338,8 @@ export class ReviewInvocationGate {
222
338
  parsed: PublishModeParseResult,
223
339
  autoPost: AutoPostResolution,
224
340
  allowStalePublish = true,
341
+ allowStaleApprovals = false,
342
+ approveMaxPriorityLevel: ApproveMaxPriorityLevel = "off",
225
343
  ): { accepted: boolean; error?: string } {
226
344
  if (!parsed.matched) return { accepted: false, error: "not a pr-review invocation" };
227
345
  if (this.active) {
@@ -241,7 +359,9 @@ export class ReviewInvocationGate {
241
359
  prNumber: parsed.prNumber,
242
360
  allowNonOpen: parsed.allowNonOpen === true,
243
361
  allowStalePublish,
362
+ allowStaleApprovals,
244
363
  autoPost: snapshot,
364
+ approveMaxPriorityLevel,
245
365
  });
246
366
  this.currentPhase = "reviewing";
247
367
  return { accepted: true };
@@ -297,6 +417,8 @@ export function githubApiArgs(hostname: string, ...args: string[]): string[] {
297
417
  }
298
418
 
299
419
  export const REVIEW_EVENT = "COMMENT" as const;
420
+ export const APPROVE_EVENT = "APPROVE" as const;
421
+ export type ReviewEventType = typeof REVIEW_EVENT | typeof APPROVE_EVENT;
300
422
  export const MAX_INLINE_COMMENTS = 50;
301
423
  const MAX_BODY_BYTES = 65_536;
302
424
  const MAX_PAYLOAD_BYTES = 900_000;
@@ -315,20 +437,21 @@ export interface PublishComment {
315
437
 
316
438
  export interface PullReviewPayload {
317
439
  commit_id: string;
318
- event: typeof REVIEW_EVENT;
440
+ event: ReviewEventType;
319
441
  body: string;
320
442
  comments?: PublishComment[];
321
443
  }
322
444
 
323
- /** Build the only GitHub review payload this package can emit. */
445
+ /** Build the GitHub review payload, optionally with an APPROVE event. */
324
446
  export function buildPullReviewPayload(
325
447
  headSha: string,
326
448
  body: string,
327
449
  comments: PublishComment[],
450
+ event: ReviewEventType = REVIEW_EVENT,
328
451
  ): PullReviewPayload {
329
452
  return {
330
453
  commit_id: headSha,
331
- event: REVIEW_EVENT,
454
+ event,
332
455
  body,
333
456
  ...(comments.length > 0 ? { comments } : {}),
334
457
  };
@@ -438,7 +561,8 @@ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined
438
561
  !Number.isInteger(value.prNumber) ||
439
562
  Number(value.prNumber) <= 0 ||
440
563
  typeof value.allowNonOpen !== "boolean" ||
441
- (value.allowStalePublish !== undefined && typeof value.allowStalePublish !== "boolean")
564
+ (value.allowStalePublish !== undefined && typeof value.allowStalePublish !== "boolean") ||
565
+ (value.allowStaleApprovals !== undefined && typeof value.allowStaleApprovals !== "boolean")
442
566
  ) {
443
567
  return undefined;
444
568
  }
@@ -459,12 +583,18 @@ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined
459
583
  // Schema v2 records created before this setting existed inherit the new
460
584
  // safe default: stale publication is body-only with both SHAs disclosed.
461
585
  allowStalePublish: typeof value.allowStalePublish === "boolean" ? value.allowStalePublish : true,
586
+ // Legacy records never opt into merge-relevant stale approvals.
587
+ allowStaleApprovals: typeof value.allowStaleApprovals === "boolean" ? value.allowStaleApprovals : false,
462
588
  autoPost: {
463
589
  value: autoPost.value,
464
590
  valid: autoPost.valid,
465
591
  source: autoPost.source as AutoPostSource,
466
592
  ...(typeof autoPost.error === "string" ? { error: autoPost.error } : {}),
467
593
  },
594
+ // Schema v2 records created before this setting existed inherit the safe
595
+ // default: auto-approve is disabled (publication uses COMMENT only).
596
+ approveMaxPriorityLevel:
597
+ isValidApproveLevel(value.approveMaxPriorityLevel) ? value.approveMaxPriorityLevel : "off",
468
598
  };
469
599
  }
470
600
 
@@ -619,11 +749,26 @@ function isConfidence(value: unknown): value is number {
619
749
  return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
620
750
  }
621
751
 
622
- /** Publication accepts only one complete JSON object, never fenced/prose-wrapped drafts. */
752
+ /**
753
+ * Strip a single surrounding Markdown fenced code block (```lang … ```) so a model
754
+ * response that wraps the review object in a code fence — despite the instruction
755
+ * to emit exactly one JSON object — can still be parsed without a repair round-trip.
756
+ * Prose-wrapped or mixed drafts are intentionally left untouched (still rejected).
757
+ * Returns the original text when there is no recognizable outer fence.
758
+ */
759
+ function stripMarkdownCodeFence(text: string): string {
760
+ const match = text.trim().match(/^```[^\n]*\n([\s\S]*)\n```[ \t]*$/);
761
+ return match ? match[1] : text;
762
+ }
763
+
764
+ /**
765
+ * Publication accepts one complete JSON object. A single surrounding Markdown code
766
+ * fence is tolerated and stripped; prose-wrapped or partial drafts are rejected.
767
+ */
623
768
  export function parsePublishableReview(text: string): PublishableReviewParseResult {
624
769
  let value: unknown;
625
770
  try {
626
- value = JSON.parse(text.trim());
771
+ value = JSON.parse(stripMarkdownCodeFence(text).trim());
627
772
  } catch {
628
773
  return { error: "final response is not exactly one JSON object" };
629
774
  }
@@ -1062,6 +1207,7 @@ function buildLosslessReviewPayload(input: {
1062
1207
  changedFiles?: readonly ChangedFileLike[];
1063
1208
  bodyPreamble?: string;
1064
1209
  diagnostics?: readonly string[];
1210
+ event?: ReviewEventType;
1065
1211
  }): { payload?: PullReviewPayload; diagnostics: string[]; errors: string[] } {
1066
1212
  const diagnostics = [...(input.diagnostics ?? [])];
1067
1213
  if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.commitId)) {
@@ -1094,7 +1240,12 @@ function buildLosslessReviewPayload(input: {
1094
1240
  if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
1095
1241
  return { diagnostics, errors: ["final review body exceeds 65536 UTF-8 bytes"] };
1096
1242
  }
1097
- const payload = buildPullReviewPayload(input.commitId.toLowerCase(), body, selected.comments);
1243
+ const payload = buildPullReviewPayload(
1244
+ input.commitId.toLowerCase(),
1245
+ body,
1246
+ selected.comments,
1247
+ input.event ?? REVIEW_EVENT,
1248
+ );
1098
1249
  if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
1099
1250
  return { diagnostics, errors: ["review payload is too large"] };
1100
1251
  }
@@ -1324,6 +1475,7 @@ export type PublishStatus =
1324
1475
  export interface PublishResult {
1325
1476
  status: PublishStatus;
1326
1477
  message: string;
1478
+ event?: ReviewEventType;
1327
1479
  reviewId?: number;
1328
1480
  url?: string;
1329
1481
  reconciled?: boolean;
@@ -1354,10 +1506,22 @@ export async function publishPullReview(input: {
1354
1506
  headSha: string;
1355
1507
  allowNonOpen: boolean;
1356
1508
  allowStale?: boolean;
1509
+ allowStaleApprovals?: boolean;
1510
+ approveMaxPriorityLevel?: ApproveMaxPriorityLevel;
1357
1511
  expectedRepository?: RepositoryBinding;
1358
1512
  review: ReviewLike;
1359
1513
  }): Promise<PublishResult> {
1360
- const { cwd, prNumber, headSha, allowNonOpen, allowStale = false, expectedRepository, review } = input;
1514
+ const {
1515
+ cwd,
1516
+ prNumber,
1517
+ headSha,
1518
+ allowNonOpen,
1519
+ allowStale = false,
1520
+ allowStaleApprovals = false,
1521
+ approveMaxPriorityLevel = "off",
1522
+ expectedRepository,
1523
+ review,
1524
+ } = input;
1361
1525
  if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
1362
1526
  if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
1363
1527
  const normalizedHeadSha = headSha.toLowerCase();
@@ -1437,12 +1601,18 @@ export async function publishPullReview(input: {
1437
1601
  changedFileLookupFailed = true;
1438
1602
  }
1439
1603
  }
1604
+ // Stale publication authorization is independent from merge-relevant stale
1605
+ // approval. The latter requires its own explicit frozen config opt-in.
1606
+ const isApprove =
1607
+ (!headPlan.stale || allowStaleApprovals) &&
1608
+ shouldApproveReview(validatedReview, approveMaxPriorityLevel);
1440
1609
  const built = buildLosslessReviewPayload({
1441
1610
  review: validatedReview,
1442
1611
  commitId: headPlan.commitId,
1443
1612
  markerHeadSha: normalizedHeadSha,
1444
1613
  allowInlineComments,
1445
1614
  changedFiles,
1615
+ ...(isApprove ? { event: APPROVE_EVENT } : {}),
1446
1616
  ...(changedFileLookupFailed ? { diagnostics: [CHANGED_FILE_LOOKUP_DIAGNOSTIC] } : {}),
1447
1617
  ...(headPlan.stale
1448
1618
  ? { bodyPreamble: buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha) }
@@ -1482,6 +1652,7 @@ export async function publishPullReview(input: {
1482
1652
  ? `; ${CHANGED_FILE_LOOKUP_DIAGNOSTIC}`
1483
1653
  : `; ${built.diagnostics.length} inline finding${built.diagnostics.length === 1 ? "" : "s"} kept in the summary: ${built.diagnostics.join("; ")}`;
1484
1654
  const degraded = !isOpen || headPlan.stale || built.diagnostics.length > 0;
1655
+ const eventLabel = payload.event === APPROVE_EVENT ? "APPROVE" : "COMMENT";
1485
1656
  const post = await runGh(
1486
1657
  githubApiArgs(hostname, "--method", "POST", `repos/${repository}/pulls/${prNumber}/reviews`, "--input", "-"),
1487
1658
  cwd,
@@ -1497,10 +1668,11 @@ export async function publishPullReview(input: {
1497
1668
  return {
1498
1669
  status: degraded ? "posted_degraded" : "posted",
1499
1670
  message: headPlan.stale
1500
- ? `body-only stale COMMENT review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
1671
+ ? `body-only stale ${eventLabel} review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
1501
1672
  : isOpen
1502
- ? `GitHub COMMENT review posted${inlineWarning}`
1503
- : "body-only COMMENT review posted for non-open PR",
1673
+ ? `GitHub ${eventLabel} review posted${inlineWarning}`
1674
+ : `body-only ${eventLabel} review posted for non-open PR`,
1675
+ event: payload.event,
1504
1676
  reviewId: response.id,
1505
1677
  url: response.html_url,
1506
1678
  };
@@ -1510,7 +1682,8 @@ export async function publishPullReview(input: {
1510
1682
  if (await hasExistingMarker(cwd, hostname, repository, prNumber, identity, normalizedHeadSha)) {
1511
1683
  return {
1512
1684
  status: degraded ? "posted_degraded" : "posted",
1513
- message: `GitHub review found during failure reconciliation${inlineWarning}`,
1685
+ message: `GitHub ${eventLabel} review found during failure reconciliation${inlineWarning}`,
1686
+ event: payload.event,
1514
1687
  reconciled: true,
1515
1688
  };
1516
1689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.10.6",
3
+ "version": "1.11.0",
4
4
  "description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and safe COMMENT-only publishing.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -53,7 +53,7 @@ Tier→model mapping is set with `/pr-review-config`; if a tier is unset the sub
53
53
 
54
54
  Arguments for this run: `$@`
55
55
  - `$1` is the PR number (required).
56
- - `--comment` explicitly requests one GitHub `COMMENT` review for this run, even when automatic posting is disabled.
56
+ - `--comment` explicitly requests one GitHub review for this run, even when automatic posting is disabled. It is `COMMENT` by default and may be a gated `APPROVE` when approval configuration qualifies the final review.
57
57
  - `--no-comment` suppresses posting for this run, even when automatic posting is enabled.
58
58
  - With no review-mode flag, use the balanced five-pass default.
59
59
  - `--balanced` explicitly selects the same balanced default for backward compatibility.
@@ -99,7 +99,7 @@ gh api --hostname "$repo_host" user --jq .login
99
99
 
100
100
  **Duplicate-review reconciliation.** Prior issue comments and formal review bodies are authoritative only when authored by the current `gh` identity and containing the exact marker form `<!-- pi-pr-review: {"schema":1,"headRefOid":"<full-head-SHA>"} -->`. If the marker SHA differs from the current `headRefOid`, the PR has new commits since the previous review, so continue and review the current diff. If prior content is unmarked, treat it as unknown/stale and continue; it cannot prove the current head was reviewed. The publishing extension performs an additional identity-scoped, paginated duplicate check immediately before any write.
101
101
 
102
- Otherwise continue and set final `disposition: "reviewed"`. For closed/merged PRs that were explicitly confirmed or allowed with `--include-closed`/`--review-closed`, review the fetched base↔head diff normally. If publication is enabled, the extension folds inline findings into one body-only formal `COMMENT` review; the orchestrator still emits only JSON.
102
+ Otherwise continue and set final `disposition: "reviewed"`. For closed/merged PRs that were explicitly confirmed or allowed with `--include-closed`/`--review-closed`, review the fetched base↔head diff normally. If publication is enabled, the extension folds inline findings into one body-only formal review; it is `COMMENT` by default and may be a gated `APPROVE`. The orchestrator still emits only JSON.
103
103
 
104
104
  ## Step 2 — Gather project convention files
105
105
 
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
196
196
 
197
197
  After exact-contract validation, the extension caches one validated completed review per repository and PR in the current Pi session. `autoPostReviews` and `--comment` publish that cached review after completion; `--no-comment` suppresses publication for the run. `/pr-review-publish` and a matching direct request publish only the cache and never start or rerun review agents. On a later turn, the extension intercepts that direct input before an agent turn and permits stale publication without asking the orchestrator to recreate the review.
198
198
 
199
- Every authorized publish path builds one `COMMENT` payload and sends at most one GitHub review `POST`. The event is always `COMMENT`, never `APPROVE` or `REQUEST_CHANGES`. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale reviews and authorized closed or merged reviews are body-only. A failed write never triggers a fallback POST.
199
+ Every authorized publish path builds one GitHub review payload and sends at most one review `POST`. It is `COMMENT` by default, or `APPROVE` only when the trusted approval configuration qualifies an `approve` verdict; it never submits `REQUEST_CHANGES`. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale reviews and authorized closed or merged reviews are body-only. A stale review may record a qualified `APPROVE` only with the separate trusted `allowStaleApprovals: true` opt-in captured before review execution. A failed write never triggers a fallback POST.
200
200
 
201
201
  Every path retains the same safety gates: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown lifecycle states and unconfirmed non-open writes fail closed. The session-backed cache survives extension reloads and session resumes but remains bound to the originating session instance and repository. If the captured stale setting disabled publication, the user may explicitly run `/pr-review-publish <PR-NUM> --allow-stale`. Never rerun the review merely to change posting intent, and never attempt a direct GitHub write yourself.
202
202