pi-pr-review 1.10.7 → 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 +7 -0
- package/README.md +18 -5
- package/extensions/pr-review-subagent.ts +133 -3
- package/extensions/review-table.ts +15 -2
- package/lib/pr-review-loop.ts +10 -1
- package/lib/pr-review-publish.ts +168 -10
- package/package.json +1 -1
- package/prompts/pr-review.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [1.10.7](https://github.com/10ego/pi-pr-review/compare/v1.10.6...v1.10.7) (2026-07-17)
|
|
4
11
|
|
|
5
12
|
|
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`
|
|
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
|
|
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`.
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/lib/pr-review-loop.ts
CHANGED
|
@@ -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(
|
|
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 = {
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -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:
|
|
440
|
+
event: ReviewEventType;
|
|
319
441
|
body: string;
|
|
320
442
|
comments?: PublishComment[];
|
|
321
443
|
}
|
|
322
444
|
|
|
323
|
-
/** Build the
|
|
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
|
|
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
|
|
|
@@ -1077,6 +1207,7 @@ function buildLosslessReviewPayload(input: {
|
|
|
1077
1207
|
changedFiles?: readonly ChangedFileLike[];
|
|
1078
1208
|
bodyPreamble?: string;
|
|
1079
1209
|
diagnostics?: readonly string[];
|
|
1210
|
+
event?: ReviewEventType;
|
|
1080
1211
|
}): { payload?: PullReviewPayload; diagnostics: string[]; errors: string[] } {
|
|
1081
1212
|
const diagnostics = [...(input.diagnostics ?? [])];
|
|
1082
1213
|
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.commitId)) {
|
|
@@ -1109,7 +1240,12 @@ function buildLosslessReviewPayload(input: {
|
|
|
1109
1240
|
if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
1110
1241
|
return { diagnostics, errors: ["final review body exceeds 65536 UTF-8 bytes"] };
|
|
1111
1242
|
}
|
|
1112
|
-
const payload = buildPullReviewPayload(
|
|
1243
|
+
const payload = buildPullReviewPayload(
|
|
1244
|
+
input.commitId.toLowerCase(),
|
|
1245
|
+
body,
|
|
1246
|
+
selected.comments,
|
|
1247
|
+
input.event ?? REVIEW_EVENT,
|
|
1248
|
+
);
|
|
1113
1249
|
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
|
|
1114
1250
|
return { diagnostics, errors: ["review payload is too large"] };
|
|
1115
1251
|
}
|
|
@@ -1339,6 +1475,7 @@ export type PublishStatus =
|
|
|
1339
1475
|
export interface PublishResult {
|
|
1340
1476
|
status: PublishStatus;
|
|
1341
1477
|
message: string;
|
|
1478
|
+
event?: ReviewEventType;
|
|
1342
1479
|
reviewId?: number;
|
|
1343
1480
|
url?: string;
|
|
1344
1481
|
reconciled?: boolean;
|
|
@@ -1369,10 +1506,22 @@ export async function publishPullReview(input: {
|
|
|
1369
1506
|
headSha: string;
|
|
1370
1507
|
allowNonOpen: boolean;
|
|
1371
1508
|
allowStale?: boolean;
|
|
1509
|
+
allowStaleApprovals?: boolean;
|
|
1510
|
+
approveMaxPriorityLevel?: ApproveMaxPriorityLevel;
|
|
1372
1511
|
expectedRepository?: RepositoryBinding;
|
|
1373
1512
|
review: ReviewLike;
|
|
1374
1513
|
}): Promise<PublishResult> {
|
|
1375
|
-
const {
|
|
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;
|
|
1376
1525
|
if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
|
|
1377
1526
|
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
|
|
1378
1527
|
const normalizedHeadSha = headSha.toLowerCase();
|
|
@@ -1452,12 +1601,18 @@ export async function publishPullReview(input: {
|
|
|
1452
1601
|
changedFileLookupFailed = true;
|
|
1453
1602
|
}
|
|
1454
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);
|
|
1455
1609
|
const built = buildLosslessReviewPayload({
|
|
1456
1610
|
review: validatedReview,
|
|
1457
1611
|
commitId: headPlan.commitId,
|
|
1458
1612
|
markerHeadSha: normalizedHeadSha,
|
|
1459
1613
|
allowInlineComments,
|
|
1460
1614
|
changedFiles,
|
|
1615
|
+
...(isApprove ? { event: APPROVE_EVENT } : {}),
|
|
1461
1616
|
...(changedFileLookupFailed ? { diagnostics: [CHANGED_FILE_LOOKUP_DIAGNOSTIC] } : {}),
|
|
1462
1617
|
...(headPlan.stale
|
|
1463
1618
|
? { bodyPreamble: buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha) }
|
|
@@ -1497,6 +1652,7 @@ export async function publishPullReview(input: {
|
|
|
1497
1652
|
? `; ${CHANGED_FILE_LOOKUP_DIAGNOSTIC}`
|
|
1498
1653
|
: `; ${built.diagnostics.length} inline finding${built.diagnostics.length === 1 ? "" : "s"} kept in the summary: ${built.diagnostics.join("; ")}`;
|
|
1499
1654
|
const degraded = !isOpen || headPlan.stale || built.diagnostics.length > 0;
|
|
1655
|
+
const eventLabel = payload.event === APPROVE_EVENT ? "APPROVE" : "COMMENT";
|
|
1500
1656
|
const post = await runGh(
|
|
1501
1657
|
githubApiArgs(hostname, "--method", "POST", `repos/${repository}/pulls/${prNumber}/reviews`, "--input", "-"),
|
|
1502
1658
|
cwd,
|
|
@@ -1512,10 +1668,11 @@ export async function publishPullReview(input: {
|
|
|
1512
1668
|
return {
|
|
1513
1669
|
status: degraded ? "posted_degraded" : "posted",
|
|
1514
1670
|
message: headPlan.stale
|
|
1515
|
-
? `body-only stale
|
|
1671
|
+
? `body-only stale ${eventLabel} review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
|
|
1516
1672
|
: isOpen
|
|
1517
|
-
? `GitHub
|
|
1518
|
-
:
|
|
1673
|
+
? `GitHub ${eventLabel} review posted${inlineWarning}`
|
|
1674
|
+
: `body-only ${eventLabel} review posted for non-open PR`,
|
|
1675
|
+
event: payload.event,
|
|
1519
1676
|
reviewId: response.id,
|
|
1520
1677
|
url: response.html_url,
|
|
1521
1678
|
};
|
|
@@ -1525,7 +1682,8 @@ export async function publishPullReview(input: {
|
|
|
1525
1682
|
if (await hasExistingMarker(cwd, hostname, repository, prNumber, identity, normalizedHeadSha)) {
|
|
1526
1683
|
return {
|
|
1527
1684
|
status: degraded ? "posted_degraded" : "posted",
|
|
1528
|
-
message: `GitHub review found during failure reconciliation${inlineWarning}`,
|
|
1685
|
+
message: `GitHub ${eventLabel} review found during failure reconciliation${inlineWarning}`,
|
|
1686
|
+
event: payload.event,
|
|
1529
1687
|
reconciled: true,
|
|
1530
1688
|
};
|
|
1531
1689
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/prompts/pr-review.md
CHANGED
|
@@ -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
|
|
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`
|
|
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
|
|
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
|
|