neo-agent-skills 0.1.71 → 0.1.73
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.
|
@@ -84,26 +84,12 @@ Apply the verdict logic in this order (highest precedence first):
|
|
|
84
84
|
|
|
85
85
|
If an epic closeout hinges on ADR chronology, apply the ADR successor-risk audit before computing the verdict. The closeout comment must name the related ADR, whether it supersedes the epic or requires a challenge/amendment path, and the public evidence that supports the route.
|
|
86
86
|
|
|
87
|
-
**Verdict authority:** the
|
|
87
|
+
**Verdict authority:** the review is the gate, and the reviewer takes the terminal action its verdict names. Only `gh pr merge` is human-only (§0 Invariant 1). An issue close reopens with one click and is a maintainer act (operator, 2026-05-10 and 2026-10-28). Empirical anchors: neomjs/neo#10691 sat seven days past a clean verdict, and neomjs/neo-stage#26 waited on a retire close.
|
|
88
88
|
|
|
89
|
-
- **`RECOMMEND_CLOSE_COMPLETED
|
|
90
|
-
|
|
91
|
-
- **`
|
|
92
|
-
|
|
93
|
-
- **`RECOMMEND_CREATE_MISSING_SUBS`**: no terminal action. Recommendation surfaces; operator authorizes new-sub creation; assigned owner files via `/ticket-create`.
|
|
94
|
-
|
|
95
|
-
- **`RECOMMEND_RETIRE_OR_SUPERSEDE`**: no terminal action. Reviewer-agent does NOT auto-close-as-not-planned; operator authority for substrate-cohesion reasons (potential separate substrate concern — could be sub-issue if a §0-parallel claim is correct for retire-action specifically).
|
|
96
|
-
|
|
97
|
-
For `RECOMMEND_CREATE_MISSING_SUBS`:
|
|
98
|
-
1. List the gaps explicitly (which AC, what evidence is missing).
|
|
99
|
-
2. Identify proposed owners (agents) for each new sub. If unclear, A2A the team to volunteer.
|
|
100
|
-
3. Coordinate via A2A — DO NOT autonomously create the new subs without operator approval.
|
|
101
|
-
4. The skill output recommends; the operator authorizes; the assigned owner files via `ticket-create`.
|
|
102
|
-
|
|
103
|
-
For `RECOMMEND_RETIRE_OR_SUPERSEDE`:
|
|
104
|
-
1. Articulate the rationale clearly (what changed since the epic was filed; what supersedes it).
|
|
105
|
-
2. Cross-reference the superseding epic / discussion if any.
|
|
106
|
-
3. Operator acts on the recommendation.
|
|
89
|
+
- **`RECOMMEND_CLOSE_COMPLETED`** (zero unresolved residuals): close the epic with `gh issue close --reason completed`.
|
|
90
|
+
- **`RECOMMEND_RETIRE_OR_SUPERSEDE`**: name what changed and what supersedes the epic, and run the [intent check](../../ticket-create/references/decision-substrate-sweeps.md#same-turn-reconciliation). Then close it as not planned with those links. Its open subs move to a live parent or close the same way.
|
|
91
|
+
- **`RECOMMEND_CREATE_MISSING_SUBS`**: list each gap (which AC, what evidence is missing) with a proposed owner, or A2A the team to volunteer. An owner who accepts files via `/ticket-create`.
|
|
92
|
+
- **`RECOMMEND_KEEP_OPEN`**: no terminal action. The review names the blockers and residuals for their owners.
|
|
107
93
|
|
|
108
94
|
## 5. Post the verdict comment
|
|
109
95
|
|
|
@@ -127,13 +113,13 @@ Update your `## Epic Resolution Review (in progress)` comment to the final shape
|
|
|
127
113
|
|
|
128
114
|
<2-4 paragraphs explaining the verdict. For KEEP_OPEN: which residuals/blockers + path to close. For CREATE_MISSING_SUBS: enumerate gaps + proposed owners. For RETIRE: superseding artifact. For CLOSE_COMPLETED: confirmation that all residuals are tracked elsewhere.>
|
|
129
115
|
|
|
130
|
-
###
|
|
116
|
+
### Terminal action
|
|
131
117
|
|
|
132
|
-
<
|
|
118
|
+
<What the review did: closed as completed; closed as not planned, with the superseding links; gaps offered to named owners; or none (keep open).>
|
|
133
119
|
|
|
134
120
|
### A2A coordination
|
|
135
121
|
|
|
136
|
-
<List peers A2A'd, with subject + commentId for the
|
|
122
|
+
<List peers A2A'd, with subject + commentId for the audit trail.>
|
|
137
123
|
|
|
138
124
|
Origin Session ID: <your session UUID>
|
|
139
125
|
```
|
|
@@ -142,7 +128,7 @@ Origin Session ID: <your session UUID>
|
|
|
142
128
|
|
|
143
129
|
After posting the verdict comment, A2A the relevant peers:
|
|
144
130
|
|
|
145
|
-
- For `RECOMMEND_CLOSE_COMPLETED`: A2A all agents who had subs in the epic, FYI of closure
|
|
131
|
+
- For `RECOMMEND_CLOSE_COMPLETED`: A2A all agents who had subs in the epic, FYI of the closure.
|
|
146
132
|
- For `RECOMMEND_KEEP_OPEN` with BLOCKER: A2A the blocked sub's owner with the blocker rationale + ask whether they need help unblocking.
|
|
147
133
|
- For `RECOMMEND_CREATE_MISSING_SUBS`: A2A proposed owners with the gap + ask whether they accept ownership.
|
|
148
134
|
- For `RECOMMEND_RETIRE_OR_SUPERSEDE`: A2A the superseding effort's owner with the link.
|
|
@@ -64,10 +64,12 @@ Prose discipline that leaves no artifact cannot be checked; these arms exist bec
|
|
|
64
64
|
|
|
65
65
|
If content belongs on a surviving parent, port it there in the same turn. An accepted direction-changing fold, graduation or successor publication must also reconcile the older work it replaces. A new exploratory option alone retires nothing.
|
|
66
66
|
|
|
67
|
+
**The intent check (every not-planned close, issues and PRs alike):** an artifact's intent, the outcome it wanted, outlives its prescription, the way it proposed to get there. A changed architecture retires the prescription, not the intent. The closing comment names each still-valid intent and where it lives now: an open successor, or this artifact re-scoped instead of closed. Only an intent that is itself wrong closes with the artifact, and the comment says why. Closing your own artifact takes this check. Closing another author's takes two agreeing peers: you and its author, or a cross-family peer when the author is unreachable. Origin: the operator, 2026-10-28, after one seat closed a run of still-valid engine PRs.
|
|
68
|
+
|
|
67
69
|
Reuse adjacency results and predecessor PR links: read affected tickets, open PRs (draft or ready), research recommendations, tool assumptions and active handovers/checklists. Find predecessors before filing; disposition them once successor IDs exist. Abandonment reconciles immediately against existing outcome/research owners:
|
|
68
70
|
|
|
69
71
|
1. Cite the accepted decision and compare each recommendation with the **current outcome and required interactions**, not just its technical feasibility. A reusable reference does not authorize a superseded research/delivery path. Keep, narrow, transfer or retire each prescription; move unique obligations, evidence and dissent to a named surviving owner first. Existing research can own the remainder without another ticket.
|
|
70
|
-
2. Update old entry heads and both sides' successor links. Retire obsolete instructions; preserve history. Close superseded PRs with decision/successor links, or retarget surviving scope. A finally rejected narrow implementation ticket closes `not_planned` per `ticket-intake` §4; unresolved outcomes stay open, and partial overlap leaves remaining obligations current. Epic closure uses `epic-resolution`.
|
|
72
|
+
2. Update old entry heads and both sides' successor links. Retire obsolete instructions; preserve history. Close superseded PRs after the intent check, with decision/successor links, or retarget surviving scope. A finally rejected narrow implementation ticket closes `not_planned` per `ticket-intake` §4; unresolved outcomes stay open, and partial overlap leaves remaining obligations current. Epic closure uses `epic-resolution`.
|
|
71
73
|
3. Apply own-authored or explicitly authorized changes. For foreign bodies/ACs, follow `ticket-create` §11: propose exact text and record the owner/application still owed. A pending proposal is not applied reconciliation.
|
|
72
74
|
4. For immutable entries, put exact superseded IDs → current authority on the surviving owner's current entry and active handover. Preserve history. Read back bodies, links, states and mappings; mutable old heads must reveal disposition/owner. Report pending owner actions separately; unapplied cleanup is incomplete.
|
|
73
75
|
|
package/README.md
CHANGED
|
@@ -166,6 +166,27 @@ Consumer repositories call the stable `Secrets` job in `.github/workflows/reusab
|
|
|
166
166
|
scans **every tracked file** at the pull request's head, not only the diff: a credential already on
|
|
167
167
|
the base branch still fails, because it is one to revoke whichever pull request finds it.
|
|
168
168
|
|
|
169
|
+
## Shared PR-review check
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npx --no-install neo-agent-skills-pr-review-body --envelope review.json --json --comment-file comment.md
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import {judgeReview} from 'neo-agent-skills/pr-review-policy';
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Brain's `manage_pr_review` refuses a malformed agent review before it posts. A review submitted with
|
|
180
|
+
`gh pr review` or in the UI skips that gate, so consumer repositories call
|
|
181
|
+
`.github/workflows/reusable-pr-review.yml` on `pull_request_review` and get the same policy afterwards: a
|
|
182
|
+
red `Review body` check and one corrective comment per failing review, edited in place when the review
|
|
183
|
+
is. The check is informative, not required: it runs once per review, so a new head has no result until
|
|
184
|
+
the next one.
|
|
185
|
+
|
|
186
|
+
The policy ships beside the templates it enforces, so one release carries both. A diagnostic names at
|
|
187
|
+
most one missing anchor and never a structural one. The bin exits 0 on a pass or on a review that is
|
|
188
|
+
not an agent's, 1 on a policy failure, and 2 on a malformed envelope.
|
|
189
|
+
|
|
169
190
|
## Generated `AGENTS.md`
|
|
170
191
|
|
|
171
192
|
```bash
|
|
@@ -197,6 +218,7 @@ import writes nothing: Fleet puts the text into each seat's harness home with it
|
|
|
197
218
|
| `scripts/check-ticket-archaeology.mjs` | the portable comment/JSDoc archaeology guard |
|
|
198
219
|
| `scripts/check-npm-overrides.mjs` | the portable stale-`overrides` guard |
|
|
199
220
|
| `scripts/check-substrate-size.mjs` | the portable per-turn substrate byte-budget guard |
|
|
221
|
+
| `scripts/check-pr-review-body.mjs` | the agent PR-review policy (`./pr-review-policy`) and its bin |
|
|
200
222
|
|
|
201
223
|
The manifest governs **projection**: a skill declaring `claudeSymlinkRequired: false` is a declared
|
|
202
224
|
opt-out and is correctly absent from the façade. Per-skill links rather than one directory link,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neo-agent-skills",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.73",
|
|
4
4
|
"description": "Install the workflow substrate a cross-model AI team actually runs on: ticket intake, PR review gates, lane coordination and guards, versioned as a package. The skills Neo.mjs maintainers load every session — materialized into your repo, not copied.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"bin": {
|
|
23
23
|
"neo-agent-skills-materialize": "./scripts/materialize-harness-skills.mjs",
|
|
24
24
|
"neo-agent-skills-pr-body": "./scripts/check-pr-body.mjs",
|
|
25
|
+
"neo-agent-skills-pr-review-body": "./scripts/check-pr-review-body.mjs",
|
|
25
26
|
"neo-agent-skills-ticket-body": "./scripts/check-ticket-body.mjs",
|
|
26
27
|
"neo-agent-skills-substrate-size": "./scripts/check-substrate-size.mjs",
|
|
27
28
|
"neo-agent-skills-ticket-archaeology": "./scripts/check-ticket-archaeology.mjs",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"exports": {
|
|
36
37
|
"./agents-md": "./scripts/generate-agents-md.mjs",
|
|
37
38
|
"./manifest": "./.agents/skills/skills.manifest.json",
|
|
39
|
+
"./pr-review-policy": "./scripts/check-pr-review-body.mjs",
|
|
38
40
|
"./package.json": "./package.json"
|
|
39
41
|
},
|
|
40
42
|
"files": [
|
|
@@ -45,6 +47,7 @@
|
|
|
45
47
|
"scripts/check-discussion-fidelity.mjs",
|
|
46
48
|
"scripts/check-npm-overrides.mjs",
|
|
47
49
|
"scripts/check-pr-body.mjs",
|
|
50
|
+
"scripts/check-pr-review-body.mjs",
|
|
48
51
|
"scripts/check-secrets.mjs",
|
|
49
52
|
"scripts/check-substrate-size.mjs",
|
|
50
53
|
"scripts/check-ticket-archaeology.mjs",
|
|
@@ -56,7 +59,7 @@
|
|
|
56
59
|
"scripts": {
|
|
57
60
|
"materialize": "node scripts/materialize-harness-skills.mjs",
|
|
58
61
|
"lint": "node scripts/lint-skill-corpus.mjs",
|
|
59
|
-
"test": "node scripts/test-lint-skill-corpus.mjs && node scripts/test-reusable-pr-baseline.mjs && node scripts/test-reusable-dependabot-automerge.mjs && node scripts/test-ticket-archaeology.mjs && node scripts/test-substrate-size.mjs && node scripts/test-check-pr-body.mjs && node scripts/test-check-ticket-body.mjs && node scripts/test-workflow-concurrency.mjs && node scripts/test-generate-agents-md.mjs && node scripts/test-materialize-harness-skills.mjs && node scripts/test-npm-overrides.mjs && node scripts/test-secrets.mjs && node scripts/test-commit-authorship.mjs && node scripts/test-check-discussion-fidelity.mjs && node scripts/test-tag-release.mjs && node scripts/test-check-version-bump.mjs && node scripts/test-release-origin.mjs",
|
|
62
|
+
"test": "node scripts/test-lint-skill-corpus.mjs && node scripts/test-reusable-pr-baseline.mjs && node scripts/test-reusable-dependabot-automerge.mjs && node scripts/test-ticket-archaeology.mjs && node scripts/test-substrate-size.mjs && node scripts/test-check-pr-body.mjs && node scripts/test-check-pr-review-body.mjs && node scripts/test-reusable-pr-review.mjs && node scripts/test-check-ticket-body.mjs && node scripts/test-workflow-concurrency.mjs && node scripts/test-generate-agents-md.mjs && node scripts/test-materialize-harness-skills.mjs && node scripts/test-npm-overrides.mjs && node scripts/test-secrets.mjs && node scripts/test-commit-authorship.mjs && node scripts/test-check-discussion-fidelity.mjs && node scripts/test-tag-release.mjs && node scripts/test-check-version-bump.mjs && node scripts/test-release-origin.mjs",
|
|
60
63
|
"generate-agents-md": "node scripts/generate-agents-md.mjs",
|
|
61
64
|
"postpublish": "node scripts/tag-release.mjs"
|
|
62
65
|
},
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @summary Judges one submitted agent pull-request review against the pr-review templates this package ships.
|
|
4
|
+
*
|
|
5
|
+
* The decision half only, like `check-pr-body.mjs`: it receives a normalized review envelope and returns a
|
|
6
|
+
* verdict. It never reads GitHub and never posts. The reusable `pull_request_review` workflow acquires the
|
|
7
|
+
* event, makes the bounded reads, and owns the one corrective comment.
|
|
8
|
+
*
|
|
9
|
+
* **Ownership.** This package owns the rules a review body alone decides. Brain's managed gate
|
|
10
|
+
* (`manage_pr_review`) applies them before submission and keeps every decision that needs pull-request history:
|
|
11
|
+
* the Round-2 relation to the cited review, acknowledgement of an outstanding Request Changes, and follow-up
|
|
12
|
+
* ownership against the close targets. This module judges a review after submission, which is the path
|
|
13
|
+
* `gh pr review` and the UI take around that gate.
|
|
14
|
+
*
|
|
15
|
+
* **The anchors are declared here and bound to the templates by the contract suite.** Each anchor is asserted
|
|
16
|
+
* to occur in the template it names, so a template edit that drops or renames one fails the release that ships
|
|
17
|
+
* it. Reading the templates at runtime instead would need a fallback for a missing file, and a fallback is a
|
|
18
|
+
* second, silent copy of the contract.
|
|
19
|
+
*
|
|
20
|
+
* **Nothing from the review body reaches a diagnostic.** A finding is always one of this file's own strings,
|
|
21
|
+
* so the corrective comment cannot be made to say what a reviewer wrote.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {readFileSync, realpathSync, writeFileSync} from 'node:fs';
|
|
25
|
+
import process from 'node:process';
|
|
26
|
+
import {parseArgs} from 'node:util';
|
|
27
|
+
import {fileURLToPath} from 'node:url';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Package-relative template paths, keyed by the review shape that selects them.
|
|
31
|
+
* @member {Object<String, String>} REVIEW_TEMPLATES
|
|
32
|
+
*/
|
|
33
|
+
export const REVIEW_TEMPLATES = Object.freeze({
|
|
34
|
+
'canonical' : '.agents/skills/pr-review/assets/pr-review-template.md',
|
|
35
|
+
'follow-up' : '.agents/skills/pr-review/assets/pr-review-followup-template.md',
|
|
36
|
+
'micro' : '.agents/skills/pr-review/assets/pr-review-micro-review-template.md',
|
|
37
|
+
'micro-delta': '.agents/skills/pr-review/assets/pr-review-micro-delta-template.md',
|
|
38
|
+
'round-2' : '.agents/skills/pr-review/assets/pr-review-round-2-template.md'
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The seven evaluation-metric tags of the full and follow-up templates. Named in a diagnostic when missing.
|
|
43
|
+
* @member {String[]} VISIBLE_METRIC_ANCHORS
|
|
44
|
+
*/
|
|
45
|
+
export const VISIBLE_METRIC_ANCHORS = Object.freeze([
|
|
46
|
+
'[ARCH_ALIGNMENT]', '[CONTENT_COMPLETENESS]', '[EXECUTION_QUALITY]', '[PRODUCTIVITY]', '[IMPACT]', '[COMPLEXITY]', '[EFFORT_PROFILE]'
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Structural substrings checked and never named. A body rebuilt from a diagnostic carries what the diagnostic
|
|
51
|
+
* named and omits these, so the check still refuses it; that holds only while no message ever names them.
|
|
52
|
+
* @member {String[]} INVISIBLE_STRUCTURE_ANCHORS
|
|
53
|
+
*/
|
|
54
|
+
export const INVISIBLE_STRUCTURE_ANCHORS = Object.freeze(['Depth Floor', 'Required Actions', 'Strategic-Fit Decision']);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The four premise-snapshot fields every full and follow-up review carries, as their bold template labels.
|
|
58
|
+
* @member {Array<{label: String, token: String}>} PREMISE_ANCHORS
|
|
59
|
+
*/
|
|
60
|
+
export const PREMISE_ANCHORS = Object.freeze([
|
|
61
|
+
{label: 'Inputs Read Before Patch', token: '**Inputs Read Before Patch:**'},
|
|
62
|
+
{label: 'Expected Solution Shape', token: '**Expected Solution Shape:**'},
|
|
63
|
+
{label: 'Patch Verdict', token: '**Patch Verdict:**'},
|
|
64
|
+
{label: 'Premise Coherence', token: '**Premise Coherence:**'}
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Each shape's skeleton: the headings and fixed lines its template prints verbatim. Checked silently, like the
|
|
69
|
+
* invisible anchors, because a skeleton list in a message is a template to copy without reading one.
|
|
70
|
+
* @member {Object<String, String[]>} SKELETON_ANCHORS
|
|
71
|
+
*/
|
|
72
|
+
export const SKELETON_ANCHORS = Object.freeze({
|
|
73
|
+
'canonical': [
|
|
74
|
+
'# PR Review Summary', '### 🪜 Strategic-Fit Decision', '### 🧭 Patch-Blind Premise Snapshot',
|
|
75
|
+
'### 🕸️ Context & Graph Linking', '### 🔬 Depth Floor', '### 🧠 Graph Ingestion Notes',
|
|
76
|
+
'### 📋 Required Actions', '### 📊 Evaluation Metrics'
|
|
77
|
+
],
|
|
78
|
+
'follow-up': [
|
|
79
|
+
'# PR Review Follow-Up — exceptional verdicts only', '### 🧭 Patch-Blind Premise Snapshot',
|
|
80
|
+
'### 🪜 Strategic-Fit Decision', '### ⚓ Prior Review Anchor', '### 🔁 Delta Scope',
|
|
81
|
+
'### ✅ Previous Required Actions Audit', '### 🔬 Delta Depth Floor', '### 🔬 Premise Falsifiers',
|
|
82
|
+
'### 📊 Metrics Delta', '### 📋 Required Actions'
|
|
83
|
+
],
|
|
84
|
+
'micro': ['# PR Micro-Review', '**Class:**', '**Verdict:**', '**Glance:**', '- **Origin Session ID:**'],
|
|
85
|
+
'micro-delta': [
|
|
86
|
+
'# Pull Request Micro-Delta Review', '> **Context:**', '### State Vector', '- **Target SHA:**',
|
|
87
|
+
'- **Origin Session ID:**', '- **Current reviewDecision:**', '- **Semantic Status:**', '- **CI Status:**',
|
|
88
|
+
'- **Remaining Blocker Class:**', '- **Measured Discussion Cost:**', '### Micro-Delta Focus',
|
|
89
|
+
'*Only defects classified as `mechanical-hygiene` or `metadata-drift` are reviewed here.*', '### Verdict',
|
|
90
|
+
'**APPROVED**', '**COMMENTED CLOSURE**', '**MAINTAINER POLISH FAST PATH APPLIED**'
|
|
91
|
+
],
|
|
92
|
+
'round-2': ['# PR Review — Round 2 (disposition only)', '### ⚓ Anchor', '### 📋 Disposition', '### 🔚 Verdict']
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Body markers that select a follow-up (exceptional verdict) review instead of a full one.
|
|
97
|
+
* @member {String[]} FOLLOW_UP_HINTS
|
|
98
|
+
*/
|
|
99
|
+
export const FOLLOW_UP_HINTS = Object.freeze([
|
|
100
|
+
'# PR Review Follow-Up', '**Cycle:**', '### ⚓ Prior Review Anchor', '### 🔁 Delta Scope', '### Prior Review Anchor',
|
|
101
|
+
'### Delta Scope', '### ✅ Previous Required Actions Audit', '### Previous Required Actions Audit',
|
|
102
|
+
'### 🔬 Delta Depth Floor', '### Delta Depth Floor', '### 📊 Metrics Delta', '### Metrics Delta'
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Review states as the REST API reports them for a submitted review.
|
|
107
|
+
* @member {String[]} REVIEW_STATES
|
|
108
|
+
*/
|
|
109
|
+
export const REVIEW_STATES = Object.freeze(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED']);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What each failure code asks of the reviewer. The CLI and the corrective comment print the same line.
|
|
113
|
+
* @member {Object<String, String>} REMEDIES
|
|
114
|
+
*/
|
|
115
|
+
export const REMEDIES = Object.freeze({
|
|
116
|
+
BUDGET_ACTIVATION_UNPROVABLE: 'The review-budget activation history could not be read completely, so this review\'s cohort is unknown.',
|
|
117
|
+
BUDGET_COHORT_UNKNOWN : 'The pull request has no valid creation time, so its review-budget cohort is unknown.',
|
|
118
|
+
BUDGET_OVERRIDE_UNMANAGED : '`[review-budget-override]` is valid only on a review the managed path submitted.',
|
|
119
|
+
BUDGET_PROVENANCE_MISSING : 'A Request Changes after the review-budget activation goes through `manage_pr_review`, or discloses the bypass on a `[review-budget-bypass] reason: …` line.',
|
|
120
|
+
COMMENT_ACTION_PACKET : 'A COMMENT dispositions existing actions and raises no new ones. A first demand round is a Request Changes.',
|
|
121
|
+
COMMENTED_CLOSURE_STATE : 'A COMMENTED CLOSURE is submitted as a COMMENT; it cannot be another Request Changes.',
|
|
122
|
+
DROP_SUPERSEDE_INCOMPLETE : 'A Drop+Supersede carries its Status and Decision lines, a disposition and every contract field of pr-review §9, on the full or follow-up template.',
|
|
123
|
+
DROP_SUPERSEDE_STATE : 'A terminal Drop+Supersede is submitted as a Request Changes.',
|
|
124
|
+
ROUND_2_STATE_INCOHERENT : 'A Round 2 states one legal `**Status:**` (Approved, Approve+Follow-Up, Comment), and any STILL_OPEN row makes it Comment.',
|
|
125
|
+
TEMPLATE_INCOMPLETE : 'The body does not follow the template its shape selects.'
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const
|
|
129
|
+
ORIGIN_SESSION = /^\s*[*-]\s+\*\*Origin Session ID:\*\*\s+[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\s*$/im,
|
|
130
|
+
ROUND_2_H1 = /^#[ \t]+PR Review — Round 2\b/m,
|
|
131
|
+
ROUND_2_STATUS = '**Status:**',
|
|
132
|
+
ROUND_2_LEGAL_STATUSES = ['approved', 'approve+follow-up', 'comment'],
|
|
133
|
+
// Every line end that `^` and `$` honour under the `m` flag, so a line scan reads the lines a regex would
|
|
134
|
+
LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/,
|
|
135
|
+
ROUND_2_VERBS = ['ADDRESSED', 'DEFENDED', 'STILL_OPEN'],
|
|
136
|
+
ROUND_2_ROW = /\|[^|\n]*\b(ADDRESSED|DEFENDED|STILL_OPEN)\b[^|\n]*\|/,
|
|
137
|
+
ROUND_1_REFERENCE = /\*\*Round-1 Review ID:\*\*[ \t]*(?!\[[^\]\n]*\](?!\())[^\s·]/,
|
|
138
|
+
CHECKLIST_ITEM = /^[ \t]*[-*][ \t]+\[[ x]\]/im,
|
|
139
|
+
MICRO_CLASS = /(?:^|[^\w-])(micro|contained|mechanical)(?:$|[^\w-])/i,
|
|
140
|
+
BLOCKER_CLASS = /(?:^|[^\w-])(mechanical-hygiene|metadata-drift)(?:$|[^\w-])/i,
|
|
141
|
+
COMMENTED_CLOSURE = /^\s*-\s*\[[xX]\]\s*\*\*COMMENTED CLOSURE\*\*/m,
|
|
142
|
+
CLOSURE_PACKET_FIELDS = ['Consumer sweep', 'Falsifier/property matrix', 'Carried-vs-new census', 'Truth-fold', 'Semantic-surface freeze'],
|
|
143
|
+
DROP_SUPERSEDE_FIELDS = ['Source-coordinate falsifiers', 'Salvage map', 'Successor landing pad', 'Successor map citation'],
|
|
144
|
+
DROP_SUPERSEDE_KINDS = ['implementation-off', 'ticket-prescription-off', 'ticket-premise-dead'],
|
|
145
|
+
FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/,
|
|
146
|
+
HEADING = /^[ \t]{0,3}#{1,6}[ \t]/,
|
|
147
|
+
ACTION_HEADING = /Required\s+Actions?\b/i,
|
|
148
|
+
ACTION_ITEM = /^[ \t]*[-*][ \t]+\[[ \t]*\][ \t]*\S/,
|
|
149
|
+
ACTION_RA = /^[ \t]*(?:[-*][ \t]+)?\*{0,2}RA[-_ ]?\d+\*{0,2}[ \t]*[:.)—-][ \t]*\S/i,
|
|
150
|
+
BUDGET_MANAGED = '[review-budget-managed]',
|
|
151
|
+
BUDGET_OVERRIDE = '[review-budget-override]',
|
|
152
|
+
BUDGET_BYPASS = /^\[review-budget-bypass\]\s+reason:\s*\S.*$/im,
|
|
153
|
+
// The template a failure points at, when the code decides it rather than the body's shape.
|
|
154
|
+
CODE_TEMPLATE = {COMMENTED_CLOSURE_STATE: 'micro-delta', DROP_SUPERSEDE_INCOMPLETE: 'follow-up', DROP_SUPERSEDE_STATE: 'follow-up', ROUND_2_STATE_INCOHERENT: 'round-2'};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @summary The body's lines outside fenced code blocks, split on CR/LF only.
|
|
158
|
+
* @param {String} body
|
|
159
|
+
* @returns {String[]}
|
|
160
|
+
*/
|
|
161
|
+
function unfencedLines(body) {
|
|
162
|
+
const lines = [];
|
|
163
|
+
let fence = null;
|
|
164
|
+
|
|
165
|
+
for (const line of String(body).split(/\r\n|[\n\r]/)) {
|
|
166
|
+
const opener = FENCE.exec(line);
|
|
167
|
+
|
|
168
|
+
if (opener) {
|
|
169
|
+
if (fence === null) fence = opener[1][0];
|
|
170
|
+
else if (opener[1][0] === fence) fence = null;
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fence === null && lines.push(line)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return lines
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @summary The Required Actions items a body demands: unchecked boxes and `RA-N` lines under that heading.
|
|
182
|
+
*
|
|
183
|
+
* Scoped to the section, because a Micro-Delta verdict block is a list of unchecked OPTIONS and a body-wide scan
|
|
184
|
+
* would read each as a demand. Blockquoted carried actions fail the item shape, and fenced examples are skipped.
|
|
185
|
+
* @param {String} body
|
|
186
|
+
* @returns {String[]} Demanded item lines, trimmed.
|
|
187
|
+
*/
|
|
188
|
+
export function demandedActionItems(body) {
|
|
189
|
+
const items = [];
|
|
190
|
+
let inSection = false;
|
|
191
|
+
|
|
192
|
+
for (const line of unfencedLines(body)) {
|
|
193
|
+
if (HEADING.test(line)) { inSection = ACTION_HEADING.test(line); continue }
|
|
194
|
+
|
|
195
|
+
inSection && (ACTION_ITEM.test(line) || ACTION_RA.test(line)) && items.push(line.trim())
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return items
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {String} body
|
|
203
|
+
* @returns {Boolean} Whether the body is a Micro-Delta review.
|
|
204
|
+
*/
|
|
205
|
+
function isMicroDelta(body) {
|
|
206
|
+
return body.includes('# Pull Request Micro-Delta Review') || (body.includes('### State Vector') && body.includes('### Micro-Delta Focus'))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @summary The shape a body selects, in the managed gate's dispatch order.
|
|
211
|
+
*
|
|
212
|
+
* A micro-review is selected by a real H1 outside fences, so a full review that quotes the light form is not
|
|
213
|
+
* judged against the light floor. Round 2 is tested before the follow-up hints, whose vocabulary it shares.
|
|
214
|
+
* @param {String} body
|
|
215
|
+
* @returns {'micro'|'round-2'|'micro-delta'|'follow-up'|'canonical'}
|
|
216
|
+
*/
|
|
217
|
+
export function reviewShape(body) {
|
|
218
|
+
if (unfencedLines(body).some(line => /^#[ \t]+PR Micro-Review\b/.test(line))) return 'micro';
|
|
219
|
+
if (ROUND_2_H1.test(body)) return 'round-2';
|
|
220
|
+
if (isMicroDelta(body)) return 'micro-delta';
|
|
221
|
+
|
|
222
|
+
return FOLLOW_UP_HINTS.some(hint => body.includes(hint)) ? 'follow-up' : 'canonical'
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @summary A `- **Label:** value` field, or '' when absent.
|
|
227
|
+
* @param {String} body
|
|
228
|
+
* @param {String} label
|
|
229
|
+
* @returns {String}
|
|
230
|
+
*/
|
|
231
|
+
function fieldValue(body, label) {
|
|
232
|
+
const prefix = `- **${label}:**`,
|
|
233
|
+
line = body.split('\n').find(candidate => candidate.trim().startsWith(prefix));
|
|
234
|
+
|
|
235
|
+
return line ? line.trim().slice(prefix.length).trim() : ''
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {String} value
|
|
240
|
+
* @returns {Boolean} Whether a contract field is empty or still holds a placeholder.
|
|
241
|
+
*/
|
|
242
|
+
function isPlaceholder(value) {
|
|
243
|
+
return !value || /^\[.*\]$/.test(value) || /^(?:todo|tbd)$/i.test(value)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @summary A shape's template misses, split into what a diagnostic may name and what it never names.
|
|
248
|
+
* @param {String} body
|
|
249
|
+
* @param {String} shape From `reviewShape`.
|
|
250
|
+
* @returns {{visible: String[], invisible: String[]}}
|
|
251
|
+
*/
|
|
252
|
+
export function templateMisses(body, shape) {
|
|
253
|
+
const
|
|
254
|
+
visible = [],
|
|
255
|
+
invisible = SKELETON_ANCHORS[shape].filter(anchor => !body.includes(anchor)),
|
|
256
|
+
lineWith = label => body.split('\n').find(line => line.includes(label)) || '';
|
|
257
|
+
|
|
258
|
+
if (shape === 'canonical' || shape === 'follow-up') {
|
|
259
|
+
visible.push(...VISIBLE_METRIC_ANCHORS.filter(anchor => !body.includes(anchor)));
|
|
260
|
+
visible.push(...PREMISE_ANCHORS.filter(({token}) => !body.includes(token)).map(({label}) => label));
|
|
261
|
+
invisible.push(...INVISIBLE_STRUCTURE_ANCHORS.filter(anchor => !body.includes(anchor)))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// The light form names its origin line but has never required the UUID; every other shape does.
|
|
265
|
+
shape !== 'micro' && !ORIGIN_SESSION.test(body) && visible.push('Origin Session ID: a Memory Core UUID on its own line');
|
|
266
|
+
|
|
267
|
+
if (shape === 'micro') {
|
|
268
|
+
!MICRO_CLASS.test(lineWith('**Class:**')) && visible.push('Class: micro | contained | mechanical')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (shape === 'micro-delta') {
|
|
272
|
+
!BLOCKER_CLASS.test(lineWith('- **Remaining Blocker Class:**')) && visible.push('Remaining Blocker Class: mechanical-hygiene | metadata-drift');
|
|
273
|
+
|
|
274
|
+
if (COMMENTED_CLOSURE.test(body)) {
|
|
275
|
+
!body.includes('### RC2 Closure Packet') && visible.push('RC2 Closure Packet');
|
|
276
|
+
visible.push(...CLOSURE_PACKET_FIELDS.filter(label => isPlaceholder(fieldValue(body, label))))
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (shape === 'round-2') {
|
|
281
|
+
!ROUND_1_REFERENCE.test(body) && visible.push('Round-1 Review ID: the review this round dispositions');
|
|
282
|
+
!ROUND_2_ROW.test(body) && visible.push('a disposition row: ADDRESSED, DEFENDED or STILL_OPEN');
|
|
283
|
+
CHECKLIST_ITEM.test(body) && visible.push('no new action checklist: prior actions live in the disposition table')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {invisible, visible}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @summary Why a Round 2's declared Status contradicts its own disposition table, or null.
|
|
291
|
+
* @param {String} body
|
|
292
|
+
* @returns {String|null}
|
|
293
|
+
*/
|
|
294
|
+
export function round2Incoherence(body) {
|
|
295
|
+
// A line scan, not a `[ \t]*(.+?)[ \t]*$` capture, which backtracks quadratically on a run of tabs
|
|
296
|
+
const status = body.split(LINE_BREAK).find(line => line.startsWith(ROUND_2_STATUS) && line.length > ROUND_2_STATUS.length);
|
|
297
|
+
|
|
298
|
+
if (!status) return 'no `**Status:**` line';
|
|
299
|
+
|
|
300
|
+
const declared = status.slice(ROUND_2_STATUS.length).replace(/[*_`]/g, '').trim().toLowerCase();
|
|
301
|
+
|
|
302
|
+
if (!ROUND_2_LEGAL_STATUSES.includes(declared)) return 'a `**Status:**` outside the legal Round-2 set';
|
|
303
|
+
|
|
304
|
+
const stillOpen = body.split('\n')
|
|
305
|
+
.filter(line => line.trim().startsWith('|') && ROUND_2_VERBS.some(verb => line.includes(verb)))
|
|
306
|
+
.some(line => line.split('|').slice(1, -1).map(cell => cell.trim()).includes('STILL_OPEN'));
|
|
307
|
+
|
|
308
|
+
return stillOpen && declared !== 'comment' ? 'a STILL_OPEN row under a Status other than Comment' : null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @summary The earliest merge that activated the review budget, from a complete closing-PR history.
|
|
313
|
+
* @param {Object} activation
|
|
314
|
+
* @param {String} activation.base Branch whose merge activates the budget.
|
|
315
|
+
* @param {{totalCount: Number, hasNextPage: Boolean, nodes: Object[]}} activation.closingPullRequests
|
|
316
|
+
* @returns {{activatedMs: Number|null}|{code: String}} `activatedMs: null` means not activated yet.
|
|
317
|
+
*/
|
|
318
|
+
export function resolveActivation({base, closingPullRequests: history}) {
|
|
319
|
+
const nodes = history?.nodes;
|
|
320
|
+
|
|
321
|
+
// A history that may be partial cannot prove the earliest merge, so it fails closed rather than guessing.
|
|
322
|
+
if (!Array.isArray(nodes) || history.hasNextPage !== false || !Number.isInteger(history.totalCount) || history.totalCount !== nodes.length) {
|
|
323
|
+
return {code: 'BUDGET_ACTIVATION_UNPROVABLE'}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const times = nodes.filter(node => node?.state === 'MERGED' && node?.baseRefName === base).map(node => Date.parse(node?.mergedAt || ''));
|
|
327
|
+
|
|
328
|
+
if (!times.every(Number.isFinite)) return {code: 'BUDGET_ACTIVATION_UNPROVABLE'};
|
|
329
|
+
|
|
330
|
+
return {activatedMs: times.length ? Math.min(...times) : null}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @summary The provenance code a Request Changes fails on, or null when it owes none.
|
|
335
|
+
* @param {Object} envelope
|
|
336
|
+
* @returns {String|null}
|
|
337
|
+
*/
|
|
338
|
+
function budgetFailure({budgetActivation, pullRequest, review: {body}}) {
|
|
339
|
+
// No activation coordinate means the caller's whole history postdates the budget.
|
|
340
|
+
if (budgetActivation !== null) {
|
|
341
|
+
const resolved = resolveActivation(budgetActivation);
|
|
342
|
+
|
|
343
|
+
if (resolved.code) return resolved.code;
|
|
344
|
+
if (resolved.activatedMs === null) return null;
|
|
345
|
+
|
|
346
|
+
const createdMs = Date.parse(pullRequest.createdAt);
|
|
347
|
+
|
|
348
|
+
if (!Number.isFinite(createdMs)) return 'BUDGET_COHORT_UNKNOWN';
|
|
349
|
+
if (createdMs <= resolved.activatedMs) return null
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const managed = body.includes(BUDGET_MANAGED);
|
|
353
|
+
|
|
354
|
+
if (body.includes(BUDGET_OVERRIDE) && !managed) return 'BUDGET_OVERRIDE_UNMANAGED';
|
|
355
|
+
|
|
356
|
+
return managed || BUDGET_BYPASS.test(body) ? null : 'BUDGET_PROVENANCE_MISSING'
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @summary Throws unless the envelope carries every field the verdict reads, in its documented shape.
|
|
361
|
+
* @param {Object} envelope
|
|
362
|
+
* @throws {TypeError} Naming the first malformed field.
|
|
363
|
+
*/
|
|
364
|
+
export function assertEnvelope(envelope) {
|
|
365
|
+
const
|
|
366
|
+
fail = field => { throw new TypeError(`malformed review envelope: ${field}`) },
|
|
367
|
+
positive = value => Number.isInteger(value) && value > 0,
|
|
368
|
+
activation = envelope?.budgetActivation;
|
|
369
|
+
|
|
370
|
+
if (!envelope || typeof envelope !== 'object') fail('not an object');
|
|
371
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(envelope.repository ?? '')) fail('repository');
|
|
372
|
+
if (!positive(envelope.pullRequest?.number)) fail('pullRequest.number');
|
|
373
|
+
if (typeof envelope.pullRequest.createdAt !== 'string') fail('pullRequest.createdAt');
|
|
374
|
+
if (!positive(envelope.review?.id)) fail('review.id');
|
|
375
|
+
if (!REVIEW_STATES.includes(envelope.review.state)) fail('review.state');
|
|
376
|
+
if (typeof envelope.review.reviewer !== 'string' || !envelope.review.reviewer) fail('review.reviewer');
|
|
377
|
+
if (typeof envelope.review.body !== 'string') fail('review.body');
|
|
378
|
+
|
|
379
|
+
// Required even when there is none: an absent key is a wrapper that forgot the read, never "no budget".
|
|
380
|
+
if (!('budgetActivation' in envelope)) fail('budgetActivation');
|
|
381
|
+
|
|
382
|
+
if (activation !== null && (typeof activation !== 'object' || !positive(activation.issue) ||
|
|
383
|
+
typeof activation.base !== 'string' || !activation.base || typeof activation.closingPullRequests !== 'object')) {
|
|
384
|
+
fail('budgetActivation')
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @summary The verdict on one submitted review. Pure: the same envelope always yields the same verdict.
|
|
390
|
+
*
|
|
391
|
+
* The order is the reader's fix order: what the review IS, its state against its own declarations, before
|
|
392
|
+
* whether its body is complete.
|
|
393
|
+
* @param {Object} envelope Validated by `assertEnvelope`.
|
|
394
|
+
* @returns {{outcome: 'pass'|'fail'|'not-enforced', code: String, shape: String, finding: String|null}}
|
|
395
|
+
*/
|
|
396
|
+
export function judgeReview(envelope) {
|
|
397
|
+
const
|
|
398
|
+
{body, reviewer, state} = envelope.review,
|
|
399
|
+
shape = reviewShape(body),
|
|
400
|
+
verdict = (outcome, code, finding = null) => ({code, finding, outcome, shape}),
|
|
401
|
+
lines = body.split('\n').map(line => line.trim()),
|
|
402
|
+
dsStatus = lines.includes('**Status:** Drop+Supersede'),
|
|
403
|
+
dsDecided = lines.includes('- **Decision**: Drop+Supersede'),
|
|
404
|
+
closure = body.includes('# Pull Request Micro-Delta Review') && COMMENTED_CLOSURE.test(body),
|
|
405
|
+
round2 = ROUND_2_H1.test(body);
|
|
406
|
+
|
|
407
|
+
// Every agent seat carries the `neo-` prefix; a person's review owes no agent template.
|
|
408
|
+
if (!reviewer.startsWith('neo-')) return verdict('not-enforced', 'REVIEWER_NOT_AGENT');
|
|
409
|
+
if (state === 'DISMISSED') return verdict('not-enforced', 'REVIEW_DISMISSED');
|
|
410
|
+
|
|
411
|
+
if (dsStatus || dsDecided) {
|
|
412
|
+
if (state !== 'CHANGES_REQUESTED') return verdict('fail', 'DROP_SUPERSEDE_STATE');
|
|
413
|
+
|
|
414
|
+
const missing = [
|
|
415
|
+
...(dsStatus && dsDecided ? [] : ['the Status and Decision lines both read Drop+Supersede']),
|
|
416
|
+
...(DROP_SUPERSEDE_KINDS.includes(fieldValue(body, 'Disposition')) ? [] : [`Disposition: ${DROP_SUPERSEDE_KINDS.join(' | ')}`]),
|
|
417
|
+
...DROP_SUPERSEDE_FIELDS.filter(label => isPlaceholder(fieldValue(body, label))),
|
|
418
|
+
...(shape === 'micro-delta' ? ['the full or follow-up template, not the Micro-Delta one'] : [])
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
if (missing.length) return verdict('fail', 'DROP_SUPERSEDE_INCOMPLETE', missing[0])
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (closure && state !== 'COMMENTED') return verdict('fail', 'COMMENTED_CLOSURE_STATE');
|
|
425
|
+
|
|
426
|
+
const incoherence = round2 && round2Incoherence(body);
|
|
427
|
+
|
|
428
|
+
if (incoherence) return verdict('fail', 'ROUND_2_STATE_INCOHERENT', incoherence);
|
|
429
|
+
|
|
430
|
+
// A plain COMMENT owes no template. It may still not open a demand packet, in any budget state: otherwise the
|
|
431
|
+
// counted round stays avoidable by never choosing Request Changes.
|
|
432
|
+
if (state === 'COMMENTED' && !closure && !round2) {
|
|
433
|
+
const demanded = demandedActionItems(body).length;
|
|
434
|
+
|
|
435
|
+
return demanded
|
|
436
|
+
? verdict('fail', 'COMMENT_ACTION_PACKET', `${demanded} new required-action item(s)`)
|
|
437
|
+
: verdict('pass', 'COMMENT_SUPPLEMENTARY')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const budget = state === 'CHANGES_REQUESTED' ? budgetFailure(envelope) : null;
|
|
441
|
+
|
|
442
|
+
if (budget) return verdict('fail', budget);
|
|
443
|
+
|
|
444
|
+
const {invisible, visible} = templateMisses(body, shape);
|
|
445
|
+
|
|
446
|
+
return visible.length || invisible.length
|
|
447
|
+
? verdict('fail', 'TEMPLATE_INCOMPLETE', visible[0] ?? null)
|
|
448
|
+
: verdict('pass', 'REVIEW_SHAPE_VALID')
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {Number} reviewId
|
|
453
|
+
* @returns {String} The hidden first line that makes a corrective comment one per review.
|
|
454
|
+
*/
|
|
455
|
+
export function commentMarker(reviewId) {
|
|
456
|
+
return `<!-- neo-agent-skills:pr-review-lint review=${reviewId} -->`
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* @summary The corrective comment for a failed review: one remedy and at most one finding, both this file's strings.
|
|
461
|
+
* @param {Object} envelope
|
|
462
|
+
* @param {Object} verdict A failing verdict from `judgeReview`.
|
|
463
|
+
* @returns {String} Markdown, opened by `commentMarker`.
|
|
464
|
+
*/
|
|
465
|
+
export function correctiveComment({pullRequest, repository, review}, {code, finding, shape}) {
|
|
466
|
+
const
|
|
467
|
+
url = `https://github.com/${repository}/pull/${pullRequest.number}#pullrequestreview-${review.id}`,
|
|
468
|
+
template = REVIEW_TEMPLATES[code === 'TEMPLATE_INCOMPLETE' ? shape : CODE_TEMPLATE[code]];
|
|
469
|
+
|
|
470
|
+
return [
|
|
471
|
+
commentMarker(review.id),
|
|
472
|
+
'## 🚨 Agent PR review lint',
|
|
473
|
+
'',
|
|
474
|
+
`@${review.reviewer}: your [review on #${pullRequest.number}](${url}) fails \`${code}\`. ${REMEDIES[code]}`,
|
|
475
|
+
...(finding ? ['', `First finding: ${finding}.`] : []),
|
|
476
|
+
'',
|
|
477
|
+
`**Required action:** read \`.agents/skills/pr-review/SKILL.md\`${template ? ` and \`${template}\`` : ''} before posting a corrective review.`,
|
|
478
|
+
'The check verifies more than this comment names; following the template is the reliable path.'
|
|
479
|
+
].join('\n')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @summary CLI entry: judges the `--envelope` file, prints the verdict, and writes the comment on a failure.
|
|
484
|
+
* @param {String[]} [argv]
|
|
485
|
+
* @param {Object} [options={}]
|
|
486
|
+
* @param {Function} [options.out=console.log]
|
|
487
|
+
* @param {Function} [options.error=console.error]
|
|
488
|
+
* @returns {Number} 0 pass or not enforced, 1 policy failure, 2 bad arguments or a malformed envelope.
|
|
489
|
+
*/
|
|
490
|
+
export function run(argv = process.argv.slice(2), {out = console.log, error = console.error} = {}) {
|
|
491
|
+
let envelope, values;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
({values} = parseArgs({
|
|
495
|
+
args : argv,
|
|
496
|
+
strict : true,
|
|
497
|
+
options: {'comment-file': {type: 'string'}, envelope: {type: 'string'}, json: {type: 'boolean', default: false}}
|
|
498
|
+
}));
|
|
499
|
+
|
|
500
|
+
if (!values.envelope) throw new TypeError('--envelope <file> is required');
|
|
501
|
+
|
|
502
|
+
envelope = JSON.parse(readFileSync(values.envelope, 'utf8'));
|
|
503
|
+
assertEnvelope(envelope)
|
|
504
|
+
} catch (cause) {
|
|
505
|
+
error(`check-pr-review-body: ${cause.message}`);
|
|
506
|
+
values?.json && out(JSON.stringify({code: 'ENVELOPE_INVALID', finding: null, outcome: 'error', shape: null}));
|
|
507
|
+
return 2
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const verdict = judgeReview(envelope),
|
|
511
|
+
failed = verdict.outcome === 'fail',
|
|
512
|
+
subject = `${verdict.shape} review by ${envelope.review.reviewer}`;
|
|
513
|
+
|
|
514
|
+
if (values.json) out(JSON.stringify(verdict));
|
|
515
|
+
else if (!failed) out(`✅ ${verdict.code} (${subject}).`);
|
|
516
|
+
else {
|
|
517
|
+
error(`❌ ${verdict.code} (${subject}). ${REMEDIES[verdict.code]}`);
|
|
518
|
+
verdict.finding && error(` First finding: ${verdict.finding}.`)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
failed && values['comment-file'] && writeFileSync(values['comment-file'], correctiveComment(envelope, verdict));
|
|
522
|
+
|
|
523
|
+
return failed ? 1 : 0
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Entrypoint guard, canonicalized on both sides so a symlinked bin still runs (see `check-pr-body.mjs`).
|
|
527
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
528
|
+
process.exit(run())
|
|
529
|
+
}
|