@mechanai/deepreview 2.12.0 → 2.14.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/.opencode/agents/deepreview-review-formatter.md +26 -0
- package/.opencode/agents/deepreview-synthesizer.md +37 -1
- package/.opencode/commands/deepreview-loop.md +46 -5
- package/.opencode/commands/deepreview-spec-loop.md +60 -23
- package/package.json +1 -1
- package/src/batch-retry.ts +62 -0
- package/src/build-prior-review-fetch.ts +1 -0
- package/src/build-prior-review.test.ts +61 -1
- package/src/build-prior-review.ts +3 -1
- package/src/diff-classifier.ts +6 -0
- package/src/load-pr.test.ts +75 -0
- package/src/load-pr.ts +44 -0
- package/src/parse-threads.test.ts +41 -0
- package/src/parse-threads.ts +31 -12
- package/src/post-review.test.ts +84 -1
- package/src/post-review.ts +124 -113
- package/src/review-api.ts +16 -0
|
@@ -24,6 +24,21 @@ Read all provided files.
|
|
|
24
24
|
|
|
25
25
|
When `prior-review.md` is provided, emit findings from **both** the synthesis and the prior review, but deduplicate: if a prior-review finding and a synthesis finding refer to the same file path and line (or overlapping line range) AND describe the same issue, keep only the synthesis version (it may have updated wording). When in doubt, keep both — false duplicates are worse than a missing dedup.
|
|
26
26
|
|
|
27
|
+
## Replying to existing threads
|
|
28
|
+
|
|
29
|
+
When a finding overlaps with an existing thread from the prior review (identified by its `[thread: ID]` tag), decide:
|
|
30
|
+
|
|
31
|
+
1. Does your finding add substantive value beyond what the thread already says?
|
|
32
|
+
- A concrete suggestion block the thread lacks: reply
|
|
33
|
+
- Additional analysis or a related issue the original missed: reply
|
|
34
|
+
- Just restating what's already there: omit the finding entirely
|
|
35
|
+
|
|
36
|
+
2. If replying, use `replyTo: <thread ID>` in the frontmatter instead of `startLine`. The body should be written as a reply — it appears below existing comments in the thread. Don't repeat context the reader can see above.
|
|
37
|
+
|
|
38
|
+
3. If the thread is marked `[resolved]` or `[outdated]` in the prior review, do NOT reply to it. Post as a new finding instead (or omit if redundant).
|
|
39
|
+
|
|
40
|
+
4. `replyTo` and `startLine` are mutually exclusive. When using `replyTo`, omit `startLine`.
|
|
41
|
+
|
|
27
42
|
## Prior-review-only mode
|
|
28
43
|
|
|
29
44
|
This mode activates when the orchestrator provides `prior-review.md` but no `synthesis.md` (synthesis failed or was skipped). In this mode:
|
|
@@ -74,6 +89,17 @@ line: <line number>
|
|
|
74
89
|
<markdown body of the comment>
|
|
75
90
|
```
|
|
76
91
|
|
|
92
|
+
If replying to an existing thread:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
---
|
|
96
|
+
path: <file path>
|
|
97
|
+
line: <line number>
|
|
98
|
+
replyTo: <thread ID from prior review>
|
|
99
|
+
---
|
|
100
|
+
<markdown body of the reply>
|
|
101
|
+
```
|
|
102
|
+
|
|
77
103
|
## Content rules
|
|
78
104
|
|
|
79
105
|
- One finding per document. Never bundle multiple issues.
|
|
@@ -21,6 +21,25 @@ If your prompt begins with a "Prior Findings" preamble, reviewers were instructe
|
|
|
21
21
|
- **New findings only**: Your synthesis should contain only genuinely new findings. Do not re-synthesize issues from the prior review preamble.
|
|
22
22
|
- **Regression detection**: If a reviewer flags something in a region that was previously fixed (per the preamble), treat it as a potential regression and flag it at warning severity or higher.
|
|
23
23
|
|
|
24
|
+
## Novelty classification (iter2+ loop context only)
|
|
25
|
+
|
|
26
|
+
If your prompt includes a "## Prior Findings for Novelty Classification" preamble, classify each
|
|
27
|
+
finding in the current iteration:
|
|
28
|
+
|
|
29
|
+
- **[NEW]**: mechanism not present in any prior finding (different file + different mechanism, or same file but genuinely different issue)
|
|
30
|
+
- **[RECURRING]**: same file + same mechanism as a prior finding (even if reworded or at a slightly different line)
|
|
31
|
+
- **[REGRESSION]**: found in code/spec modified by a previous iteration's applied fix AND the mechanism is directly related to the change made by the fix
|
|
32
|
+
|
|
33
|
+
Classification process (after deduplication, before ranking):
|
|
34
|
+
|
|
35
|
+
1. Check "Applied Fixes" — if the finding is in a region modified by a fix AND the mechanism is directly related to the change, classify as [REGRESSION].
|
|
36
|
+
2. Check prior findings — if a finding matches an existing mechanism (same file + similar problem, even if differently worded), classify as [RECURRING].
|
|
37
|
+
3. Everything else is [NEW].
|
|
38
|
+
|
|
39
|
+
Prefix each finding entry with its classification tag: `[NEW]`, `[RECURRING]`, or `[REGRESSION]`.
|
|
40
|
+
|
|
41
|
+
If no "Prior Findings for Novelty Classification" preamble is present, skip classification entirely — do not emit tags or the Iteration Metrics section.
|
|
42
|
+
|
|
24
43
|
## Process
|
|
25
44
|
|
|
26
45
|
1. Read all validated review files
|
|
@@ -50,6 +69,12 @@ Write your synthesis to the output path provided. Use this structure:
|
|
|
50
69
|
## Overall Assessment
|
|
51
70
|
[2-3 sentences: is this safe to merge, what is the biggest concern, overall quality]
|
|
52
71
|
|
|
72
|
+
## Iteration Metrics
|
|
73
|
+
Iteration N: X findings (Y new, Z recurring, W regression)
|
|
74
|
+
- Convergence: [converging|deadlocked|diverging]
|
|
75
|
+
|
|
76
|
+
[Omit this section entirely if no novelty classification was performed (iter1 or single-pass)]
|
|
77
|
+
|
|
53
78
|
## Critical Issues (must fix before merge)
|
|
54
79
|
[All critical severity items, deduplicated and ranked by confidence]
|
|
55
80
|
|
|
@@ -74,6 +99,17 @@ The following doc/comment updates were identified (suggestion-level):
|
|
|
74
99
|
|
|
75
100
|
Be concise. No preamble or filler.
|
|
76
101
|
|
|
102
|
+
Convergence value for the Iteration Metrics section:
|
|
103
|
+
|
|
104
|
+
- `converging`: 0 new findings, or fewer new findings than the prior iteration
|
|
105
|
+
- `deadlocked`: 0 new findings but recurring findings persist
|
|
106
|
+
- `diverging`: more new findings than the prior iteration
|
|
107
|
+
|
|
77
108
|
## Response contract
|
|
78
109
|
|
|
79
|
-
After writing your synthesis file, your ONLY response must be the absolute path to your output file and a single stats line
|
|
110
|
+
After writing your synthesis file, your ONLY response must be the absolute path to your output file and a single stats line. Format:
|
|
111
|
+
|
|
112
|
+
- Without novelty classification: `"3 critical, 5 warnings, 2 suggestions"`
|
|
113
|
+
- With novelty classification: `"3 critical, 5 warnings, 2 suggestions | 4 new, 5 recurring, 1 regression"`
|
|
114
|
+
|
|
115
|
+
Do not summarize findings. Do not include any other text.
|
|
@@ -16,6 +16,7 @@ Parse "$ARGUMENTS" the same way as /deepreview:
|
|
|
16
16
|
|
|
17
17
|
Set ITERATION=1
|
|
18
18
|
Set PRIOR_CONTEXT="" (empty — built up across iterations; holds both design context and prior findings)
|
|
19
|
+
Set CONSECUTIVE_ZERO_NEW=0 (tracks consecutive iterations with 0 new findings for deadlock detection)
|
|
19
20
|
Set ALL_SESSION_DIRS=[] (list of all session directories used, in order)
|
|
20
21
|
|
|
21
22
|
Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
@@ -53,10 +54,38 @@ Run the full deepreview pipeline (Stages 1-5 from the deepreview command):
|
|
|
53
54
|
Record the stats from the synthesis return: count of critical, warning, and suggestion findings.
|
|
54
55
|
|
|
55
56
|
STEP 3: CHECK EXIT CONDITION
|
|
56
|
-
DEADLOCK CHECK (iter 2+ only):
|
|
57
|
-
Compare this iteration's findings (file:line + issue title) against the previous iteration's findings. If two consecutive iterations produce the SAME findings, this indicates a deadlock — the applier is making changes that don't resolve the issue, or the reviewer keeps flagging the same thing.
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
Parse the stats line from the synthesizer. If it contains novelty metrics (the `| N new, N recurring, N regression` suffix), use NOVELTY MODE. Otherwise use LEGACY MODE.
|
|
59
|
+
|
|
60
|
+
NOVELTY MODE (iter2+ only):
|
|
61
|
+
|
|
62
|
+
A) CONVERGENCE EXIT: If `0 new AND 0 regression`:
|
|
63
|
+
|
|
64
|
+
- Tell the user: "deepreview-loop converged after $ITERATION iteration(s). No new findings detected."
|
|
65
|
+
- STOP.
|
|
66
|
+
|
|
67
|
+
B) DEADLOCK (synthesizer signal): If `0 new AND N recurring (N > 0) AND 0 regression` for 2 consecutive iterations:
|
|
68
|
+
|
|
69
|
+
- Tell the user: "Deadlock detected: $N recurring findings persist with no new issues found across 2 iterations:"
|
|
70
|
+
- List the recurring findings from the synthesis.
|
|
71
|
+
- Ask: "How would you like to resolve these? Options: skip these findings, provide guidance, or stop the loop."
|
|
72
|
+
- Follow the user's instruction.
|
|
73
|
+
|
|
74
|
+
C) DEADLOCK (orchestrator fallback): Compare this iteration's findings (file:line + issue title) against the previous iteration's findings. If they match BUT the synthesizer did NOT signal deadlock (i.e., it classified some as [NEW]):
|
|
75
|
+
|
|
76
|
+
- Log warning: "Note: deterministic check found matching findings, but synthesizer classified them as new. Trusting synthesizer classification."
|
|
77
|
+
- Do NOT trigger deadlock. Continue.
|
|
78
|
+
|
|
79
|
+
D) Otherwise: proceed to STEP 4.
|
|
80
|
+
|
|
81
|
+
Tracking: If `0 new`, increment CONSECUTIVE_ZERO_NEW. If `> 0 new`, reset CONSECUTIVE_ZERO_NEW to 0. Deadlock (B) triggers when CONSECUTIVE_ZERO_NEW >= 2 AND recurring > 0.
|
|
82
|
+
|
|
83
|
+
LEGACY MODE (fallback when synthesizer omits novelty metrics):
|
|
84
|
+
|
|
85
|
+
Warn the user: "Synthesizer did not return novelty metrics — falling back to legacy convergence detection."
|
|
86
|
+
|
|
87
|
+
DEADLOCK CHECK (iter 2+ only):
|
|
88
|
+
Compare this iteration's findings (file:line + issue title) against the previous iteration's findings. If two consecutive iterations produce the SAME findings:
|
|
60
89
|
|
|
61
90
|
- Tell the user: "Deadlock detected: the following findings persist across iterations:"
|
|
62
91
|
- List the repeated findings.
|
|
@@ -65,7 +94,7 @@ When deadlock is detected:
|
|
|
65
94
|
|
|
66
95
|
If the synthesis/review has 0 critical AND 0 warning AND 0 suggestion findings:
|
|
67
96
|
|
|
68
|
-
- Tell the user: "
|
|
97
|
+
- Tell the user: "deepreview-loop complete after $ITERATION iteration(s). No findings remain."
|
|
69
98
|
- STOP.
|
|
70
99
|
|
|
71
100
|
STEP 4: APPLY ALL FIXES
|
|
@@ -249,8 +278,20 @@ Task 14 — Use the Task tool with subagent_type="deepreview-validator":
|
|
|
249
278
|
Wait for all 7 to return.
|
|
250
279
|
|
|
251
280
|
Stage 3 — DISPATCH SYNTHESIZER:
|
|
281
|
+
Extract PRIOR_FINDINGS_SECTION from PRIOR_CONTEXT: include only the "## Prior Findings" and "## Applied Fixes" sections (not "Known Issue Locations" or "Covered Regions" — those are for reviewers only).
|
|
282
|
+
|
|
283
|
+
If PRIOR_FINDINGS_SECTION is non-empty, include the novelty classification header in the synthesizer prompt. If empty (helper returned malformed context), omit the header — the synthesizer will operate in standard mode and the orchestrator MUST use LEGACY MODE for this iteration's exit check.
|
|
284
|
+
|
|
252
285
|
Task 15 — Use the Task tool with subagent_type="deepreview-synthesizer":
|
|
253
|
-
|
|
286
|
+
|
|
287
|
+
- If PRIOR_FINDINGS_SECTION is non-empty:
|
|
288
|
+
"## Prior Findings for Novelty Classification
|
|
289
|
+
$PRIOR_FINDINGS_SECTION
|
|
290
|
+
|
|
291
|
+
Read the validated reviews at: $SESSION_DIR/validated-correctness.md, $SESSION_DIR/validated-security.md, $SESSION_DIR/validated-architecture.md, $SESSION_DIR/validated-docs.md, $SESSION_DIR/validated-compatibility.md, $SESSION_DIR/validated-performance.md, $SESSION_DIR/validated-maintainability.md (skip any that don't exist). Write the synthesis to $SESSION_DIR/synthesis.md."
|
|
292
|
+
|
|
293
|
+
- If PRIOR_FINDINGS_SECTION is empty:
|
|
294
|
+
"Read the validated reviews at: $SESSION_DIR/validated-correctness.md, $SESSION_DIR/validated-security.md, $SESSION_DIR/validated-architecture.md, $SESSION_DIR/validated-docs.md, $SESSION_DIR/validated-compatibility.md, $SESSION_DIR/validated-performance.md, $SESSION_DIR/validated-maintainability.md (skip any that don't exist). Write the synthesis to $SESSION_DIR/synthesis.md."
|
|
254
295
|
|
|
255
296
|
Record the stats line.
|
|
256
297
|
|
|
@@ -12,6 +12,7 @@ STEP 1: DETERMINE INPUT
|
|
|
12
12
|
- Set FILES="$ARGUMENTS"
|
|
13
13
|
- Set ITERATION=1
|
|
14
14
|
- Set PRIOR_CONTEXT="" (empty — built up across iterations; holds both design context and prior findings)
|
|
15
|
+
- Set CONSECUTIVE_ZERO_NEW=0 (tracks consecutive iterations with 0 new findings for deadlock detection)
|
|
15
16
|
- Set ALL_SESSION_DIRS=[] (list of all session directories used, in order)
|
|
16
17
|
- Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
17
18
|
`REPO_ROOT=$(realpath "$(git rev-parse --git-common-dir)" | sed 's|/\.git$||')`
|
|
@@ -47,6 +48,47 @@ Run the full deepreview-spec pipeline (Stages 1-5 from the deepreview-spec comma
|
|
|
47
48
|
Record the stats from the synthesis return: count of critical, warning, and suggestion findings.
|
|
48
49
|
|
|
49
50
|
STEP 3: CHECK EXIT CONDITIONS
|
|
51
|
+
|
|
52
|
+
Parse the stats line from the synthesizer. If it contains novelty metrics (the `| N new, N recurring, N regression` suffix), use NOVELTY MODE. Otherwise use LEGACY MODE.
|
|
53
|
+
|
|
54
|
+
NOVELTY MODE (iter2+ only):
|
|
55
|
+
|
|
56
|
+
A) CLEAN EXIT: If 0 critical AND 0 warning AND 0 suggestion:
|
|
57
|
+
|
|
58
|
+
- Tell the user: "deepreview-spec-loop complete after $ITERATION iteration(s). No findings remain."
|
|
59
|
+
- STOP.
|
|
60
|
+
|
|
61
|
+
B) CONVERGENCE EXIT: If `0 new AND 0 regression`:
|
|
62
|
+
|
|
63
|
+
- Tell the user: "deepreview-spec-loop converged after $ITERATION iteration(s). No new findings detected. Remaining recurring findings (if any) reflect reviewer opinion differences."
|
|
64
|
+
- STOP.
|
|
65
|
+
|
|
66
|
+
C) DEADLOCK EXIT: If `0 new AND N recurring (N > 0) AND 0 regression` for 2 consecutive iterations:
|
|
67
|
+
|
|
68
|
+
- Tell the user: "Deadlock detected: $N recurring findings persist with no new issues found across 2 iterations:"
|
|
69
|
+
- List the recurring findings.
|
|
70
|
+
- Ask: "How would you like to resolve these? Options: skip these findings, provide guidance, or stop the loop."
|
|
71
|
+
- Follow the user's instruction.
|
|
72
|
+
|
|
73
|
+
D) DIVERGENCE CHECK (iter2+ only): If total findings INCREASE from the previous iteration:
|
|
74
|
+
|
|
75
|
+
- If all additional findings are classified [RECURRING] (none are [NEW]):
|
|
76
|
+
Treat as deadlock, not divergence. Tell the user: "Apparent divergence is actually recurring findings being re-reported. Treating as deadlock."
|
|
77
|
+
Trigger deadlock prompt (same as C).
|
|
78
|
+
- Otherwise (genuinely new findings increasing):
|
|
79
|
+
Tell the user: "Divergence detected: findings increased from N to M. The review is not converging — fixes are introducing new issues or reviewers are finding new stylistic concerns."
|
|
80
|
+
Show the iteration-over-iteration stats.
|
|
81
|
+
Ask: "Accept current state, revert last iteration's changes, or continue with only critical/warning fixes (ignore suggestions)?"
|
|
82
|
+
Follow the user's instruction.
|
|
83
|
+
|
|
84
|
+
E) Otherwise: proceed to STEP 4.
|
|
85
|
+
|
|
86
|
+
Tracking: If `0 new`, increment CONSECUTIVE_ZERO_NEW. If `> 0 new`, reset CONSECUTIVE_ZERO_NEW to 0. Deadlock (C) triggers when CONSECUTIVE_ZERO_NEW >= 2 AND recurring > 0.
|
|
87
|
+
|
|
88
|
+
LEGACY MODE (fallback when synthesizer omits novelty metrics):
|
|
89
|
+
|
|
90
|
+
Warn the user: "Synthesizer did not return novelty metrics — falling back to legacy convergence detection."
|
|
91
|
+
|
|
50
92
|
Track the total finding count (critical + warning + suggestion) for each iteration in a list: HISTORY.
|
|
51
93
|
|
|
52
94
|
A) CLEAN EXIT: If 0 critical AND 0 warning AND 0 suggestion:
|
|
@@ -54,11 +96,11 @@ A) CLEAN EXIT: If 0 critical AND 0 warning AND 0 suggestion:
|
|
|
54
96
|
- Tell the user: "deepreview-spec-loop complete after $ITERATION iteration(s). No findings remain."
|
|
55
97
|
- STOP.
|
|
56
98
|
|
|
57
|
-
B) PLATEAU EXIT: If ITERATION >= 3 and the total has not decreased compared to the minimum of any previous iteration for 2 consecutive iterations
|
|
99
|
+
B) PLATEAU EXIT: If ITERATION >= 3 and the total has not decreased compared to the minimum of any previous iteration for 2 consecutive iterations:
|
|
58
100
|
|
|
59
|
-
- Tell the user: "deepreview-spec-loop plateau after $ITERATION iteration(s). Findings are oscillating (history: [list totals]) and not converging.
|
|
60
|
-
- Show the latest stats breakdown
|
|
61
|
-
- STOP.
|
|
101
|
+
- Tell the user: "deepreview-spec-loop plateau after $ITERATION iteration(s). Findings are oscillating (history: [list totals]) and not converging."
|
|
102
|
+
- Show the latest stats breakdown.
|
|
103
|
+
- STOP.
|
|
62
104
|
|
|
63
105
|
STEP 4: APPLY ALL FIXES
|
|
64
106
|
Dispatch the applier automatically — do NOT ask the user for permission.
|
|
@@ -74,7 +116,7 @@ Set ITERATION = ITERATION + 1
|
|
|
74
116
|
|
|
75
117
|
If ITERATION > 7:
|
|
76
118
|
|
|
77
|
-
- Tell the user: "deepreview-spec-loop hit iteration limit (7). This should not normally happen —
|
|
119
|
+
- Tell the user: "deepreview-spec-loop hit iteration limit (7). This should not normally happen — convergence or deadlock detection should have stopped earlier."
|
|
78
120
|
- Show the latest stats.
|
|
79
121
|
- STOP.
|
|
80
122
|
|
|
@@ -177,8 +219,20 @@ Note: Validators intentionally do NOT receive PRIOR_CONTEXT. They filter on obje
|
|
|
177
219
|
Wait for all 5.
|
|
178
220
|
|
|
179
221
|
Stage 3 — DISPATCH SYNTHESIZER:
|
|
222
|
+
Extract PRIOR_FINDINGS_SECTION from PRIOR_CONTEXT: include only the "## Prior Findings" and "## Applied Fixes" sections (not "Known Issue Locations" or "Covered Regions" — those are for reviewers only).
|
|
223
|
+
|
|
224
|
+
If PRIOR_FINDINGS_SECTION is non-empty, include the novelty classification header in the synthesizer prompt. If empty (helper returned malformed context), omit the header — the synthesizer will operate in standard mode and the orchestrator MUST use LEGACY MODE for this iteration's exit check.
|
|
225
|
+
|
|
180
226
|
Task 11 — Use the Task tool with subagent_type="deepreview-synthesizer":
|
|
181
|
-
|
|
227
|
+
|
|
228
|
+
- If PRIOR_FINDINGS_SECTION is non-empty:
|
|
229
|
+
"## Prior Findings for Novelty Classification
|
|
230
|
+
$PRIOR_FINDINGS_SECTION
|
|
231
|
+
|
|
232
|
+
Read the validated reviews at: $SESSION_DIR/validated-completeness.md, $SESSION_DIR/validated-consistency.md, $SESSION_DIR/validated-feasibility.md, $SESSION_DIR/validated-docs.md, $SESSION_DIR/validated-architecture.md (skip any that don't exist). Write the synthesis to $SESSION_DIR/synthesis.md."
|
|
233
|
+
|
|
234
|
+
- If PRIOR_FINDINGS_SECTION is empty:
|
|
235
|
+
"Read the validated reviews at: $SESSION_DIR/validated-completeness.md, $SESSION_DIR/validated-consistency.md, $SESSION_DIR/validated-feasibility.md, $SESSION_DIR/validated-docs.md, $SESSION_DIR/validated-architecture.md (skip any that don't exist). Write the synthesis to $SESSION_DIR/synthesis.md."
|
|
182
236
|
|
|
183
237
|
Record the stats line.
|
|
184
238
|
|
|
@@ -196,23 +250,6 @@ If this task fails, emit a warning: "Plan validation failed — applying unvalid
|
|
|
196
250
|
|
|
197
251
|
Go to STEP 3.
|
|
198
252
|
|
|
199
|
-
STEP 6: DIVERGENCE AND DEADLOCK DETECTION
|
|
200
|
-
Track finding counts across iterations. Detect TWO failure modes:
|
|
201
|
-
|
|
202
|
-
A) DIVERGENCE: If total findings INCREASE from one iteration to the next:
|
|
203
|
-
|
|
204
|
-
- Tell the user: "Divergence detected: findings increased from N to M. The review is not converging — fixes are introducing new issues or reviewers are finding new stylistic concerns."
|
|
205
|
-
- Show the iteration-over-iteration stats.
|
|
206
|
-
- Ask: "Accept current state, revert last iteration's changes, or continue with only critical/warning fixes (ignore suggestions)?"
|
|
207
|
-
- Follow the user's instruction.
|
|
208
|
-
|
|
209
|
-
B) DEADLOCK: If two consecutive iterations produce the same findings (same location, same issue title):
|
|
210
|
-
|
|
211
|
-
- Tell the user: "Deadlock detected: the following findings persist across iterations:"
|
|
212
|
-
- List the repeated findings.
|
|
213
|
-
- Ask: "How would you like to resolve these? Options: skip these findings, provide guidance, or stop the loop."
|
|
214
|
-
- Follow the user's instruction.
|
|
215
|
-
|
|
216
253
|
IMPORTANT RULES:
|
|
217
254
|
|
|
218
255
|
- Do NOT read any review/synthesis/plan files yourself. Ever.
|
package/package.json
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isRateLimitError } from "./review-helpers.ts";
|
|
2
|
+
|
|
3
|
+
const BATCH_CONCURRENCY = 2;
|
|
4
|
+
const BATCH_DELAY_MS = 1000;
|
|
5
|
+
const RATE_LIMIT_DELAY_MS = 60_000;
|
|
6
|
+
const RATE_LIMIT_JITTER_MS = 10_000;
|
|
7
|
+
const MAX_RATE_LIMIT_RETRIES = 2;
|
|
8
|
+
|
|
9
|
+
export interface BatchOptions {
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
delayMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Process items in batches with concurrency and inter-batch delay. */
|
|
15
|
+
export async function mapBatch<T, R>(
|
|
16
|
+
items: T[],
|
|
17
|
+
fn: (item: T) => Promise<R>,
|
|
18
|
+
opts: BatchOptions = {},
|
|
19
|
+
): Promise<R[]> {
|
|
20
|
+
const concurrency = opts.concurrency ?? BATCH_CONCURRENCY;
|
|
21
|
+
const delayMs = opts.delayMs ?? BATCH_DELAY_MS;
|
|
22
|
+
const results: R[] = [];
|
|
23
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
24
|
+
if (i > 0) await Bun.sleep(delayMs);
|
|
25
|
+
results.push(...(await Promise.all(items.slice(i, i + concurrency).map(fn))));
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Retry an async operation with rate-limit-aware backoff. Returns true on success. */
|
|
31
|
+
export async function withRateLimitRetry(
|
|
32
|
+
fn: () => Promise<void>,
|
|
33
|
+
label: string,
|
|
34
|
+
maxRetries: number = MAX_RATE_LIMIT_RETRIES,
|
|
35
|
+
): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await fn();
|
|
38
|
+
return true;
|
|
39
|
+
} catch (err: unknown) {
|
|
40
|
+
if (!isRateLimitError(err)) {
|
|
41
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
42
|
+
console.error(`FAIL: ${label} — ${message}`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
46
|
+
console.warn(`Rate limited on ${label}. Waiting 60s (retry ${attempt + 1}/${maxRetries})...`);
|
|
47
|
+
await Bun.sleep(RATE_LIMIT_DELAY_MS + Math.floor(Math.random() * RATE_LIMIT_JITTER_MS));
|
|
48
|
+
try {
|
|
49
|
+
await fn();
|
|
50
|
+
return true;
|
|
51
|
+
} catch (retryErr: unknown) {
|
|
52
|
+
if (!isRateLimitError(retryErr)) {
|
|
53
|
+
const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
54
|
+
console.error(`FAIL: ${label} — ${message}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
console.error(`FAIL: ${label} — rate limited after retries`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -82,6 +82,7 @@ function classifyAuthorType(
|
|
|
82
82
|
/** Map raw GraphQL thread nodes to domain ReviewThread objects with author classification. */
|
|
83
83
|
export function mapGraphQLThreads(nodes: GQLThreadNode[]): ReviewThread[] {
|
|
84
84
|
return nodes.map((node) => ({
|
|
85
|
+
id: node.id,
|
|
85
86
|
path: node.path,
|
|
86
87
|
startLine: node.startLine,
|
|
87
88
|
line: node.line,
|
|
@@ -14,9 +14,10 @@ function makeThread(
|
|
|
14
14
|
path: string,
|
|
15
15
|
line: number,
|
|
16
16
|
comments: { login: string; body: string }[],
|
|
17
|
-
opts?: { startLine?: number; isResolved?: boolean; isOutdated?: boolean },
|
|
17
|
+
opts?: { id?: string; startLine?: number; isResolved?: boolean; isOutdated?: boolean },
|
|
18
18
|
): ReviewThread {
|
|
19
19
|
return {
|
|
20
|
+
id: opts?.id ?? `PRT_${path}_${line}`,
|
|
20
21
|
path,
|
|
21
22
|
startLine: opts?.startLine ?? null,
|
|
22
23
|
line,
|
|
@@ -54,10 +55,12 @@ describe("formatPriorReview: sections", () => {
|
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
// oxlint-disable-next-line max-lines-per-function -- Why: two inline thread fixtures with full comment arrays are required to test multi-thread grouping and line-range formatting; extracting them would obscure the test intent
|
|
57
59
|
describe("formatPriorReview: thread formatting", () => {
|
|
58
60
|
it("formats threads grouped by file with line numbers", () => {
|
|
59
61
|
const threads: ReviewThread[] = [
|
|
60
62
|
{
|
|
63
|
+
id: "PRT_1",
|
|
61
64
|
path: "src/foo.ts",
|
|
62
65
|
startLine: 10,
|
|
63
66
|
line: 15,
|
|
@@ -79,6 +82,7 @@ describe("formatPriorReview: thread formatting", () => {
|
|
|
79
82
|
],
|
|
80
83
|
},
|
|
81
84
|
{
|
|
85
|
+
id: "PRT_2",
|
|
82
86
|
path: "src/foo.ts",
|
|
83
87
|
startLine: null,
|
|
84
88
|
line: 42,
|
|
@@ -248,6 +252,31 @@ describe("mapGraphQLThreads", () => {
|
|
|
248
252
|
assert.equal(result[0].comments[0].authorType, "bot");
|
|
249
253
|
});
|
|
250
254
|
|
|
255
|
+
it("preserves thread id from GraphQL node", () => {
|
|
256
|
+
const nodes = [
|
|
257
|
+
{
|
|
258
|
+
id: "PRT_kwDOABC123",
|
|
259
|
+
path: "src/foo.ts",
|
|
260
|
+
startLine: null,
|
|
261
|
+
line: 10,
|
|
262
|
+
isResolved: false,
|
|
263
|
+
isOutdated: false,
|
|
264
|
+
comments: {
|
|
265
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
266
|
+
nodes: [
|
|
267
|
+
{
|
|
268
|
+
author: { login: "alice", __typename: "User" },
|
|
269
|
+
body: "Fix this",
|
|
270
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
const result = mapGraphQLThreads(nodes);
|
|
277
|
+
assert.equal(result[0].id, "PRT_kwDOABC123");
|
|
278
|
+
});
|
|
279
|
+
|
|
251
280
|
it("detects deepreview by finding ID HTML comment in body", () => {
|
|
252
281
|
const nodes = [
|
|
253
282
|
{
|
|
@@ -279,6 +308,7 @@ describe("buildPriorReviewContent: basic behavior", () => {
|
|
|
279
308
|
it("keeps all content when under budget", () => {
|
|
280
309
|
const threads: ReviewThread[] = [
|
|
281
310
|
{
|
|
311
|
+
id: "PRT_basic",
|
|
282
312
|
path: "a.ts",
|
|
283
313
|
startLine: null,
|
|
284
314
|
line: 1,
|
|
@@ -316,6 +346,7 @@ describe("buildPriorReview (integration shape)", () => {
|
|
|
316
346
|
describe("buildPriorReviewContent: truncation", () => {
|
|
317
347
|
it("drops oldest threads first when over budget", () => {
|
|
318
348
|
const oldThread: ReviewThread = {
|
|
349
|
+
id: "PRT_old",
|
|
319
350
|
path: "old.ts",
|
|
320
351
|
startLine: null,
|
|
321
352
|
line: 1,
|
|
@@ -331,6 +362,7 @@ describe("buildPriorReviewContent: truncation", () => {
|
|
|
331
362
|
],
|
|
332
363
|
};
|
|
333
364
|
const newThread: ReviewThread = {
|
|
365
|
+
id: "PRT_new",
|
|
334
366
|
path: "new.ts",
|
|
335
367
|
startLine: null,
|
|
336
368
|
line: 1,
|
|
@@ -354,6 +386,7 @@ describe("buildPriorReviewContent: truncation", () => {
|
|
|
354
386
|
|
|
355
387
|
it("preserves PR description and manual content even when threads are large", () => {
|
|
356
388
|
const bigThread: ReviewThread = {
|
|
389
|
+
id: "PRT_big",
|
|
357
390
|
path: "big.ts",
|
|
358
391
|
startLine: null,
|
|
359
392
|
line: 1,
|
|
@@ -383,6 +416,7 @@ describe("formatPriorReview: formatCommentBody paths", () => {
|
|
|
383
416
|
it("renders multi-line body as indented blockquote", () => {
|
|
384
417
|
const threads: ReviewThread[] = [
|
|
385
418
|
{
|
|
419
|
+
id: "PRT_multi",
|
|
386
420
|
path: "src/multi.ts",
|
|
387
421
|
startLine: null,
|
|
388
422
|
line: 1,
|
|
@@ -409,6 +443,7 @@ describe("formatPriorReview: formatCommentBody paths", () => {
|
|
|
409
443
|
it("renders single-line body in quotes", () => {
|
|
410
444
|
const threads: ReviewThread[] = [
|
|
411
445
|
{
|
|
446
|
+
id: "PRT_single",
|
|
412
447
|
path: "src/single.ts",
|
|
413
448
|
startLine: null,
|
|
414
449
|
line: 1,
|
|
@@ -433,6 +468,7 @@ describe("formatPriorReview: formatLineRef paths", () => {
|
|
|
433
468
|
it("renders startLine only when line is null", () => {
|
|
434
469
|
const threads: ReviewThread[] = [
|
|
435
470
|
{
|
|
471
|
+
id: "PRT_start_only",
|
|
436
472
|
path: "src/start-only.ts",
|
|
437
473
|
startLine: 42,
|
|
438
474
|
line: null,
|
|
@@ -457,6 +493,7 @@ describe("formatPriorReview: formatLineRef paths", () => {
|
|
|
457
493
|
it("renders file-level when both startLine and line are null", () => {
|
|
458
494
|
const threads: ReviewThread[] = [
|
|
459
495
|
{
|
|
496
|
+
id: "PRT_file_level",
|
|
460
497
|
path: "src/file-level.ts",
|
|
461
498
|
startLine: null,
|
|
462
499
|
line: null,
|
|
@@ -481,6 +518,7 @@ describe("formatPriorReview: formatSourceTag paths", () => {
|
|
|
481
518
|
it("renders deepreview author type in source tag", () => {
|
|
482
519
|
const threads: ReviewThread[] = [
|
|
483
520
|
{
|
|
521
|
+
id: "PRT_dr",
|
|
484
522
|
path: "src/dr.ts",
|
|
485
523
|
startLine: null,
|
|
486
524
|
line: 5,
|
|
@@ -503,6 +541,7 @@ describe("formatPriorReview: formatSourceTag paths", () => {
|
|
|
503
541
|
it("renders both resolved and outdated simultaneously", () => {
|
|
504
542
|
const threads: ReviewThread[] = [
|
|
505
543
|
{
|
|
544
|
+
id: "PRT_both",
|
|
506
545
|
path: "src/both.ts",
|
|
507
546
|
startLine: null,
|
|
508
547
|
line: 10,
|
|
@@ -523,10 +562,29 @@ describe("formatPriorReview: formatSourceTag paths", () => {
|
|
|
523
562
|
});
|
|
524
563
|
});
|
|
525
564
|
|
|
565
|
+
describe("formatPriorReview: thread ID tags", () => {
|
|
566
|
+
it("includes thread ID tag in formatted output", () => {
|
|
567
|
+
const thread = makeThread("src/foo.ts", 10, [{ login: "alice", body: "Fix this" }], {
|
|
568
|
+
id: "PRT_kwDOABC123",
|
|
569
|
+
});
|
|
570
|
+
const result = formatPriorReview("", [thread], null);
|
|
571
|
+
assert.ok(result.includes("[thread: PRT_kwDOABC123]"));
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("places thread ID after source tag", () => {
|
|
575
|
+
const thread = makeThread("src/foo.ts", 10, [{ login: "alice", body: "Fix this" }], {
|
|
576
|
+
id: "PRT_kwDOXYZ789",
|
|
577
|
+
});
|
|
578
|
+
const result = formatPriorReview("", [thread], null);
|
|
579
|
+
assert.match(result, /\[source: [^\]]+\] \[thread: PRT_kwDOXYZ789\]/u);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
526
583
|
describe("formatPriorReview: formatThread empty comments", () => {
|
|
527
584
|
it("returns empty output for thread with no comments", () => {
|
|
528
585
|
const threads: ReviewThread[] = [
|
|
529
586
|
{
|
|
587
|
+
id: "PRT_empty",
|
|
530
588
|
path: "src/empty.ts",
|
|
531
589
|
startLine: null,
|
|
532
590
|
line: 1,
|
|
@@ -585,6 +643,7 @@ describe("buildPriorReviewContent: edge cases", () => {
|
|
|
585
643
|
const hugeThreadBody = "z".repeat(40_000);
|
|
586
644
|
const threads: ReviewThread[] = [
|
|
587
645
|
{
|
|
646
|
+
id: "PRT_big1",
|
|
588
647
|
path: "big1.ts",
|
|
589
648
|
startLine: null,
|
|
590
649
|
line: 1,
|
|
@@ -600,6 +659,7 @@ describe("buildPriorReviewContent: edge cases", () => {
|
|
|
600
659
|
],
|
|
601
660
|
},
|
|
602
661
|
{
|
|
662
|
+
id: "PRT_big2",
|
|
603
663
|
path: "big2.ts",
|
|
604
664
|
startLine: null,
|
|
605
665
|
line: 1,
|
|
@@ -15,6 +15,7 @@ export interface ThreadComment {
|
|
|
15
15
|
|
|
16
16
|
/** A review thread attached to a file path and line range. */
|
|
17
17
|
export interface ReviewThread {
|
|
18
|
+
id: string;
|
|
18
19
|
path: string;
|
|
19
20
|
startLine: number | null;
|
|
20
21
|
line: number | null;
|
|
@@ -54,7 +55,8 @@ function formatThread(thread: ReviewThread): string {
|
|
|
54
55
|
const lines: string[] = [];
|
|
55
56
|
const lineRef = formatLineRef(thread.startLine, thread.line);
|
|
56
57
|
const tag = formatSourceTag(first, thread);
|
|
57
|
-
|
|
58
|
+
const threadTag = `[thread: ${thread.id}]`;
|
|
59
|
+
lines.push(`- ${lineRef} ${tag} ${threadTag}: ${formatCommentBody(first.body, " ")}`);
|
|
58
60
|
for (const reply of replies) {
|
|
59
61
|
lines.push(` - @${reply.authorLogin} replied: ${formatCommentBody(reply.body, " ")}`);
|
|
60
62
|
}
|
package/src/diff-classifier.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface Finding {
|
|
|
5
5
|
line: number;
|
|
6
6
|
startLine?: number;
|
|
7
7
|
body: string;
|
|
8
|
+
replyTo?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface ClassifiedFinding extends Finding {
|
|
@@ -13,6 +14,11 @@ export interface ClassifiedFinding extends Finding {
|
|
|
13
14
|
renderedBody?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/** A classified finding that targets an existing thread for reply. */
|
|
18
|
+
export interface ReplyFinding extends ClassifiedFinding {
|
|
19
|
+
replyTo: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
interface FileHunks {
|
|
17
23
|
hunks: { newStart: number; newLines: number }[];
|
|
18
24
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { writeFile, mkdir, rm, symlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadAndValidatePr } from "./load-pr.ts";
|
|
6
|
+
|
|
7
|
+
const TMP_DIR = "/tmp/opencode/load-pr-test";
|
|
8
|
+
|
|
9
|
+
describe("loadAndValidatePr — path validation", () => {
|
|
10
|
+
it("rejects paths with null bytes", async () => {
|
|
11
|
+
await assert.rejects(
|
|
12
|
+
loadAndValidatePr("threads\x00.md", 1, undefined, TMP_DIR),
|
|
13
|
+
/Invalid threadsPath/u,
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects absolute paths", async () => {
|
|
18
|
+
await assert.rejects(
|
|
19
|
+
loadAndValidatePr("/etc/passwd", 1, undefined, TMP_DIR),
|
|
20
|
+
/Invalid threadsPath/u,
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects paths with directory traversal", async () => {
|
|
25
|
+
await assert.rejects(
|
|
26
|
+
loadAndValidatePr("../../../etc/passwd", 1, undefined, TMP_DIR),
|
|
27
|
+
/Invalid threadsPath/u,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects empty path", async () => {
|
|
32
|
+
await assert.rejects(loadAndValidatePr("", 1, undefined, TMP_DIR), /Invalid threadsPath/u);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("loadAndValidatePr — file system", () => {
|
|
37
|
+
it("throws when file does not exist", async () => {
|
|
38
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
39
|
+
await assert.rejects(
|
|
40
|
+
loadAndValidatePr("nonexistent.md", 1, undefined, TMP_DIR),
|
|
41
|
+
/Threads file not found/u,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("throws when resolved path escapes working directory via symlink", async () => {
|
|
46
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
47
|
+
const linkPath = join(TMP_DIR, "escape.md");
|
|
48
|
+
try {
|
|
49
|
+
await rm(linkPath);
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore if not present
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await symlink("/etc/hostname", linkPath);
|
|
55
|
+
} catch {
|
|
56
|
+
// symlink creation may fail on some systems — skip test
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
loadAndValidatePr("escape.md", 1, undefined, TMP_DIR),
|
|
62
|
+
/escapes working directory/u,
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(linkPath);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when threads file has no findings", async () => {
|
|
70
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
71
|
+
await writeFile(join(TMP_DIR, "empty.md"), "");
|
|
72
|
+
const result = await loadAndValidatePr("empty.md", 1, undefined, TMP_DIR);
|
|
73
|
+
assert.equal(result, null);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/load-pr.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parseThreads } from "./parse-threads.ts";
|
|
4
|
+
import { type Finding } from "./diff-classifier.ts";
|
|
5
|
+
import { type PrInfo, getPrInfo } from "./graphql.ts";
|
|
6
|
+
import { isValidPath } from "./review-helpers.ts";
|
|
7
|
+
|
|
8
|
+
/** Load and parse a threads file, validate the PR is open, and verify the head SHA matches. */
|
|
9
|
+
export async function loadAndValidatePr(
|
|
10
|
+
threadsPath: string,
|
|
11
|
+
prNumber: number,
|
|
12
|
+
expectedSha: string | undefined,
|
|
13
|
+
cwd: string | undefined,
|
|
14
|
+
): Promise<{ findings: Finding[]; prInfo: PrInfo; summary?: string } | null> {
|
|
15
|
+
if (!isValidPath(threadsPath)) {
|
|
16
|
+
throw new Error(`Invalid threadsPath: ${threadsPath}`);
|
|
17
|
+
}
|
|
18
|
+
const base = await realpath(resolve(cwd ?? process.cwd()));
|
|
19
|
+
const candidatePath = resolve(base, threadsPath);
|
|
20
|
+
let resolved: string;
|
|
21
|
+
try {
|
|
22
|
+
resolved = await realpath(candidatePath);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Threads file not found: ${candidatePath}`);
|
|
25
|
+
}
|
|
26
|
+
if (!resolved.startsWith(base + "/") && resolved !== base) {
|
|
27
|
+
throw new Error(`threadsPath escapes working directory: ${threadsPath}`);
|
|
28
|
+
}
|
|
29
|
+
const content = await readFile(resolved, "utf8");
|
|
30
|
+
const { findings, summary } = parseThreads(content);
|
|
31
|
+
if (findings.length === 0 && (summary === undefined || summary === "")) return null;
|
|
32
|
+
|
|
33
|
+
const prInfo = await getPrInfo(prNumber, { cwd });
|
|
34
|
+
if (prInfo.state !== "OPEN") {
|
|
35
|
+
throw new Error(`PR is ${prInfo.state}. Aborting.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const resolvedSha = expectedSha ?? process.env.PR_HEAD_SHA;
|
|
39
|
+
if (resolvedSha !== undefined && resolvedSha !== "" && resolvedSha !== prInfo.headOid) {
|
|
40
|
+
throw new Error(`ABORT: PR head moved (expected ${resolvedSha}, got ${prInfo.headOid}).`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { findings, prInfo, summary };
|
|
44
|
+
}
|
|
@@ -125,3 +125,44 @@ describe("parseThreads findings", () => {
|
|
|
125
125
|
assert.equal(result.findings.length, 0);
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("parseThreads replyTo", () => {
|
|
130
|
+
it("extracts replyTo from frontmatter", () => {
|
|
131
|
+
const input = [
|
|
132
|
+
"---",
|
|
133
|
+
"path: src/foo.ts",
|
|
134
|
+
"line: 42",
|
|
135
|
+
"replyTo: PRT_kwDOABC123",
|
|
136
|
+
"---",
|
|
137
|
+
"This adds a suggestion.",
|
|
138
|
+
].join("\n");
|
|
139
|
+
|
|
140
|
+
const result = parseThreads(input);
|
|
141
|
+
assert.equal(result.findings.length, 1);
|
|
142
|
+
assert.equal(result.findings[0].replyTo, "PRT_kwDOABC123");
|
|
143
|
+
assert.equal(result.findings[0].startLine, undefined);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined replyTo when not present", () => {
|
|
147
|
+
const input = ["---", "path: src/foo.ts", "line: 42", "---", "Normal finding."].join("\n");
|
|
148
|
+
|
|
149
|
+
const result = parseThreads(input);
|
|
150
|
+
assert.equal(result.findings[0].replyTo, undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("replyTo and startLine are mutually exclusive — replyTo wins", () => {
|
|
154
|
+
const input = [
|
|
155
|
+
"---",
|
|
156
|
+
"path: src/foo.ts",
|
|
157
|
+
"startLine: 40",
|
|
158
|
+
"line: 45",
|
|
159
|
+
"replyTo: PRT_kwDOABC123",
|
|
160
|
+
"---",
|
|
161
|
+
"Reply body.",
|
|
162
|
+
].join("\n");
|
|
163
|
+
|
|
164
|
+
const result = parseThreads(input);
|
|
165
|
+
assert.equal(result.findings[0].replyTo, "PRT_kwDOABC123");
|
|
166
|
+
assert.equal(result.findings[0].startLine, undefined);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/parse-threads.ts
CHANGED
|
@@ -21,6 +21,36 @@ const safeYamlEngine = (s: string): Record<string, unknown> => {
|
|
|
21
21
|
};
|
|
22
22
|
const matterOptions = { engines: { yaml: safeYamlEngine } };
|
|
23
23
|
|
|
24
|
+
/** Build a Finding from parsed frontmatter data and body content. */
|
|
25
|
+
function buildFinding(
|
|
26
|
+
data: Record<string, unknown>,
|
|
27
|
+
path: string,
|
|
28
|
+
lineNum: number,
|
|
29
|
+
body: string,
|
|
30
|
+
): Finding {
|
|
31
|
+
const rawStartLine: unknown = data.startLine;
|
|
32
|
+
const startLineNum =
|
|
33
|
+
rawStartLine !== null && rawStartLine !== undefined ? Number(rawStartLine) : undefined;
|
|
34
|
+
|
|
35
|
+
const rawReplyTo: unknown = data.replyTo;
|
|
36
|
+
const replyTo = typeof rawReplyTo === "string" && rawReplyTo.length > 0 ? rawReplyTo : undefined;
|
|
37
|
+
|
|
38
|
+
// Replies don't use startLine; only set it when present and valid
|
|
39
|
+
const validStartLine =
|
|
40
|
+
replyTo === undefined &&
|
|
41
|
+
startLineNum !== undefined &&
|
|
42
|
+
startLineNum > 0 &&
|
|
43
|
+
Number.isFinite(startLineNum);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
path,
|
|
47
|
+
line: lineNum,
|
|
48
|
+
startLine: validStartLine ? startLineNum : undefined,
|
|
49
|
+
body,
|
|
50
|
+
replyTo,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
/**
|
|
25
55
|
* Parse a threads.md file into findings and an optional summary.
|
|
26
56
|
* The file uses --- separators between documents, each with YAML frontmatter.
|
|
@@ -60,18 +90,7 @@ function parseThreads(content: string): ParsedThreads {
|
|
|
60
90
|
continue;
|
|
61
91
|
}
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
const startLineNum =
|
|
65
|
-
rawStartLine !== null && rawStartLine !== undefined ? Number(rawStartLine) : undefined;
|
|
66
|
-
findings.push({
|
|
67
|
-
path,
|
|
68
|
-
line: lineNum,
|
|
69
|
-
startLine:
|
|
70
|
-
startLineNum !== undefined && startLineNum > 0 && Number.isFinite(startLineNum)
|
|
71
|
-
? startLineNum
|
|
72
|
-
: undefined,
|
|
73
|
-
body: parsed.content.trim(),
|
|
74
|
-
});
|
|
93
|
+
findings.push(buildFinding(data, path, lineNum, parsed.content.trim()));
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
return { findings, summary };
|
package/src/post-review.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it } from "bun:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { parseThreads } from "./parse-threads.ts";
|
|
4
|
-
import { classifyFindings } from "./diff-classifier.ts";
|
|
4
|
+
import { classifyFindings, type ReplyFinding } from "./diff-classifier.ts";
|
|
5
5
|
import { buildReviewBody } from "./review-helpers.ts";
|
|
6
6
|
|
|
7
7
|
describe("buildReviewBody", () => {
|
|
@@ -33,6 +33,59 @@ describe("buildReviewBody", () => {
|
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
describe("postReview reply routing", () => {
|
|
37
|
+
it("classifies findings with replyTo separately from new findings", () => {
|
|
38
|
+
const threadsContent = [
|
|
39
|
+
"---",
|
|
40
|
+
"path: src/foo.ts",
|
|
41
|
+
"line: 10",
|
|
42
|
+
"replyTo: PRT_kwDOABC123",
|
|
43
|
+
"---",
|
|
44
|
+
"Reply body here.",
|
|
45
|
+
"---",
|
|
46
|
+
"path: src/bar.ts",
|
|
47
|
+
"line: 20",
|
|
48
|
+
"---",
|
|
49
|
+
"New finding body.",
|
|
50
|
+
].join("\n");
|
|
51
|
+
|
|
52
|
+
const { findings } = parseThreads(threadsContent);
|
|
53
|
+
const replyFindings = findings.filter((f) => f.replyTo !== undefined);
|
|
54
|
+
const newFindings = findings.filter((f) => f.replyTo === undefined);
|
|
55
|
+
|
|
56
|
+
assert.equal(replyFindings.length, 1);
|
|
57
|
+
assert.equal(replyFindings[0].replyTo, "PRT_kwDOABC123");
|
|
58
|
+
assert.equal(newFindings.length, 1);
|
|
59
|
+
assert.equal(newFindings[0].path, "src/bar.ts");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("postReview dry-run with replyTo", () => {
|
|
64
|
+
it("includes reply findings in dry-run count", () => {
|
|
65
|
+
const threadsContent = [
|
|
66
|
+
"---",
|
|
67
|
+
"path: src/foo.ts",
|
|
68
|
+
"line: 10",
|
|
69
|
+
"replyTo: PRT_kwDOABC123",
|
|
70
|
+
"---",
|
|
71
|
+
"Reply finding.",
|
|
72
|
+
"---",
|
|
73
|
+
"path: src/bar.ts",
|
|
74
|
+
"startLine: 5",
|
|
75
|
+
"line: 10",
|
|
76
|
+
"---",
|
|
77
|
+
"New finding.",
|
|
78
|
+
].join("\n");
|
|
79
|
+
|
|
80
|
+
const { findings } = parseThreads(threadsContent);
|
|
81
|
+
assert.equal(findings.length, 2);
|
|
82
|
+
assert.equal(findings[0].replyTo, "PRT_kwDOABC123");
|
|
83
|
+
assert.equal(findings[0].startLine, undefined);
|
|
84
|
+
assert.equal(findings[1].replyTo, undefined);
|
|
85
|
+
assert.equal(findings[1].startLine, 5);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
36
89
|
describe("integration: parse → classify", () => {
|
|
37
90
|
const threadsContent = [
|
|
38
91
|
"---",
|
|
@@ -80,3 +133,33 @@ describe("integration: parse → classify", () => {
|
|
|
80
133
|
assert.equal(classified[2].tier, 3);
|
|
81
134
|
});
|
|
82
135
|
});
|
|
136
|
+
|
|
137
|
+
describe("ReplyFinding type narrowing", () => {
|
|
138
|
+
it("filter produces ReplyFinding[] when checking replyTo", () => {
|
|
139
|
+
const { findings } = parseThreads(
|
|
140
|
+
[
|
|
141
|
+
"---",
|
|
142
|
+
"path: src/foo.ts",
|
|
143
|
+
"line: 10",
|
|
144
|
+
"replyTo: PRT_kwDOABC123",
|
|
145
|
+
"---",
|
|
146
|
+
"Reply body.",
|
|
147
|
+
"---",
|
|
148
|
+
"path: src/bar.ts",
|
|
149
|
+
"line: 20",
|
|
150
|
+
"---",
|
|
151
|
+
"New body.",
|
|
152
|
+
].join("\n"),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const replies: ReplyFinding[] = findings
|
|
156
|
+
.filter((f): f is ReplyFinding => f.replyTo !== undefined)
|
|
157
|
+
.map((f) => ({ ...f, tier: 1 as const }));
|
|
158
|
+
|
|
159
|
+
assert.equal(replies.length, 1);
|
|
160
|
+
assert.equal(replies[0].replyTo, "PRT_kwDOABC123");
|
|
161
|
+
// Type check: replyTo is string (not string | undefined)
|
|
162
|
+
const threadId: string = replies[0].replyTo;
|
|
163
|
+
assert.equal(threadId, "PRT_kwDOABC123");
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/post-review.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { parseThreads } from "./parse-threads.ts";
|
|
4
|
-
import { type Finding, type ClassifiedFinding } from "./diff-classifier.ts";
|
|
5
|
-
import { type PrInfo, getPrInfo, execFileAsync } from "./graphql.ts";
|
|
1
|
+
import { type ClassifiedFinding, type ReplyFinding } from "./diff-classifier.ts";
|
|
2
|
+
import { type PrInfo, execFileAsync } from "./graphql.ts";
|
|
6
3
|
import {
|
|
7
4
|
type PendingReview,
|
|
8
5
|
findPendingReview,
|
|
@@ -11,16 +8,17 @@ import {
|
|
|
11
8
|
addLineThread,
|
|
12
9
|
addFileThread,
|
|
13
10
|
updateReviewComment,
|
|
11
|
+
replyToThread,
|
|
14
12
|
} from "./review-api.ts";
|
|
15
13
|
import {
|
|
16
|
-
isValidPath,
|
|
17
|
-
isRateLimitError,
|
|
18
14
|
findingId,
|
|
19
15
|
embedFindingId,
|
|
20
16
|
extractFindingId,
|
|
21
17
|
buildReviewBody,
|
|
22
18
|
classifyAndLog,
|
|
23
19
|
} from "./review-helpers.ts";
|
|
20
|
+
import { mapBatch, withRateLimitRetry } from "./batch-retry.ts";
|
|
21
|
+
import { loadAndValidatePr } from "./load-pr.ts";
|
|
24
22
|
|
|
25
23
|
export interface PostReviewOptions {
|
|
26
24
|
threadsPath: string;
|
|
@@ -59,17 +57,10 @@ async function updateExistingThreads(
|
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await Promise.all(
|
|
67
|
-
batch.map(async ({ finding, commentId, id }) => {
|
|
68
|
-
const body = embedFindingId(finding.renderedBody ?? finding.body, id);
|
|
69
|
-
await updateReviewComment(commentId, body);
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
60
|
+
await mapBatch(toUpdate, async ({ finding, commentId, id }) => {
|
|
61
|
+
const body = embedFindingId(finding.renderedBody ?? finding.body, id);
|
|
62
|
+
await updateReviewComment(commentId, body);
|
|
63
|
+
});
|
|
73
64
|
|
|
74
65
|
console.log(`Updated ${toUpdate.length} existing threads.`);
|
|
75
66
|
return new Set(toUpdate.map(({ id }) => id));
|
|
@@ -92,71 +83,65 @@ async function postWithRetry(
|
|
|
92
83
|
finding: ClassifiedFinding,
|
|
93
84
|
body: string,
|
|
94
85
|
): Promise<boolean> {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!isRateLimitError(err)) {
|
|
100
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
-
console.error(`FAIL: ${finding.path}:${finding.line} — ${message}`);
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
105
|
-
console.warn(
|
|
106
|
-
`Rate limited on ${finding.path}:${finding.line}. Waiting 60s (retry ${attempt + 1}/2)...`,
|
|
107
|
-
);
|
|
108
|
-
await Bun.sleep(60_000 + Math.floor(Math.random() * 10_000));
|
|
109
|
-
try {
|
|
110
|
-
await postThread(reviewId, finding, body);
|
|
111
|
-
return true;
|
|
112
|
-
} catch (retryErr: unknown) {
|
|
113
|
-
if (!isRateLimitError(retryErr)) {
|
|
114
|
-
const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
115
|
-
console.error(`FAIL: ${finding.path}:${finding.line} — ${message}`);
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
console.error(`FAIL: ${finding.path}:${finding.line} — rate limited after retries`);
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
86
|
+
return withRateLimitRetry(
|
|
87
|
+
async () => postThread(reviewId, finding, body),
|
|
88
|
+
`${finding.path}:${finding.line}`,
|
|
89
|
+
);
|
|
123
90
|
}
|
|
124
91
|
|
|
125
92
|
async function postInlineThreads(
|
|
126
93
|
reviewId: string,
|
|
127
94
|
inlineFindings: ClassifiedFinding[],
|
|
128
95
|
updatedIds: Set<string>,
|
|
96
|
+
maxThreads: number = MAX_INLINE_FINDINGS,
|
|
129
97
|
): Promise<string[]> {
|
|
130
|
-
const CONCURRENCY = 2;
|
|
131
|
-
const failedFindings: string[] = [];
|
|
132
98
|
const pending = inlineFindings.filter(
|
|
133
99
|
(f) => !updatedIds.has(findingId(f.path, f.startLine, f.line, f.body)),
|
|
134
100
|
);
|
|
135
101
|
|
|
136
|
-
if (pending.length >
|
|
102
|
+
if (pending.length > maxThreads) {
|
|
137
103
|
console.warn(
|
|
138
|
-
`WARN: ${pending.length} inline findings exceed limit of ${
|
|
104
|
+
`WARN: ${pending.length} inline findings exceed limit of ${maxThreads}. Truncating.`,
|
|
139
105
|
);
|
|
140
106
|
}
|
|
141
|
-
const capped = pending.slice(0,
|
|
107
|
+
const capped = pending.slice(0, maxThreads);
|
|
142
108
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
109
|
+
const results = await mapBatch(capped, async (f) => {
|
|
110
|
+
const id = findingId(f.path, f.startLine, f.line, f.body);
|
|
111
|
+
const body = embedFindingId(f.renderedBody ?? f.body, id);
|
|
112
|
+
const ok = await postWithRetry(reviewId, f, body);
|
|
113
|
+
return ok ? null : id;
|
|
114
|
+
});
|
|
115
|
+
return results.filter((id): id is string => id !== null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function postReplyThreads(
|
|
119
|
+
replyFindings: ReplyFinding[],
|
|
120
|
+
reviewId: string,
|
|
121
|
+
inlineBudgetRemaining: number,
|
|
122
|
+
): Promise<{ failedIds: string[]; fallbackCount: number }> {
|
|
123
|
+
let fallbackCount = 0;
|
|
124
|
+
const results = await mapBatch(replyFindings, async (f) => {
|
|
125
|
+
const id = findingId(f.path, f.startLine, f.line, f.body);
|
|
126
|
+
const body = embedFindingId(f.renderedBody ?? f.body, id);
|
|
127
|
+
const replied = await withRateLimitRetry(
|
|
128
|
+
async () => replyToThread(f.replyTo, body),
|
|
129
|
+
`reply to ${f.replyTo}`,
|
|
154
130
|
);
|
|
155
|
-
|
|
156
|
-
|
|
131
|
+
if (replied) return null;
|
|
132
|
+
// Fallback: post as new thread, subject to inline cap
|
|
133
|
+
if (fallbackCount >= inlineBudgetRemaining) {
|
|
134
|
+
console.warn(
|
|
135
|
+
`WARN: Reply to thread ${f.replyTo} failed. Inline cap reached — skipping fallback.`,
|
|
136
|
+
);
|
|
137
|
+
return id;
|
|
157
138
|
}
|
|
158
|
-
|
|
159
|
-
|
|
139
|
+
console.warn(`WARN: Reply to thread ${f.replyTo} failed. Falling back to new thread.`);
|
|
140
|
+
fallbackCount++;
|
|
141
|
+
const ok = await postWithRetry(reviewId, f, body);
|
|
142
|
+
return ok ? null : id;
|
|
143
|
+
});
|
|
144
|
+
return { failedIds: results.filter((id): id is string => id !== null), fallbackCount };
|
|
160
145
|
}
|
|
161
146
|
|
|
162
147
|
async function ensureReview(
|
|
@@ -188,44 +173,32 @@ async function ensureReview(
|
|
|
188
173
|
return { reviewId, updatedFindings: new Set() };
|
|
189
174
|
}
|
|
190
175
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const base = await realpath(resolve(cwd ?? process.cwd()));
|
|
202
|
-
const candidatePath = resolve(base, threadsPath);
|
|
203
|
-
let resolved: string;
|
|
204
|
-
try {
|
|
205
|
-
resolved = await realpath(candidatePath);
|
|
206
|
-
} catch {
|
|
207
|
-
throw new Error(`Threads file not found: ${candidatePath}`);
|
|
208
|
-
}
|
|
209
|
-
if (!resolved.startsWith(base + "/") && resolved !== base) {
|
|
210
|
-
throw new Error(`threadsPath escapes working directory: ${threadsPath}`);
|
|
211
|
-
}
|
|
212
|
-
const content = await readFile(resolved, "utf8");
|
|
213
|
-
const { findings, summary } = parseThreads(content);
|
|
214
|
-
if (findings.length === 0 && (summary === undefined || summary === "")) return null;
|
|
215
|
-
|
|
216
|
-
const prInfo = await getPrInfo(prNumber, { cwd });
|
|
217
|
-
if (prInfo.state !== "OPEN") {
|
|
218
|
-
throw new Error(`PR is ${prInfo.state}. Aborting.`);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const resolvedSha = expectedSha ?? process.env.PR_HEAD_SHA;
|
|
222
|
-
if (resolvedSha !== undefined && resolvedSha !== "" && resolvedSha !== prInfo.headOid) {
|
|
223
|
-
throw new Error(`ABORT: PR head moved (expected ${resolvedSha}, got ${prInfo.headOid}).`);
|
|
224
|
-
}
|
|
176
|
+
interface StatusCounts {
|
|
177
|
+
posted: number;
|
|
178
|
+
total: number;
|
|
179
|
+
replies: number;
|
|
180
|
+
upToDate: number;
|
|
181
|
+
skipped: number;
|
|
182
|
+
failed: number;
|
|
183
|
+
truncated: number;
|
|
184
|
+
tier3Count: number;
|
|
185
|
+
}
|
|
225
186
|
|
|
226
|
-
|
|
187
|
+
function formatStatusSummary(counts: StatusCounts): string {
|
|
188
|
+
const parts: string[] = [];
|
|
189
|
+
if (counts.replies > 0) parts.push(`${counts.replies} replies`);
|
|
190
|
+
if (counts.upToDate > 0) parts.push(`${counts.upToDate} up-to-date`);
|
|
191
|
+
if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`);
|
|
192
|
+
if (counts.failed > 0) parts.push(`${counts.failed} failed`);
|
|
193
|
+
if (counts.truncated > 0) parts.push(`${counts.truncated} truncated`);
|
|
194
|
+
const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
|
|
195
|
+
return (
|
|
196
|
+
`Posted ${counts.posted}/${counts.total} inline threads${detail}. ` +
|
|
197
|
+
`${counts.tier3Count} findings in review body.`
|
|
198
|
+
);
|
|
227
199
|
}
|
|
228
200
|
|
|
201
|
+
// oxlint-disable-next-line max-lines-per-function -- Why: orchestration function that coordinates reply/new posting and computes stats; splitting further would fragment the logic
|
|
229
202
|
async function postFindings(
|
|
230
203
|
tier1: ClassifiedFinding[],
|
|
231
204
|
tier2: ClassifiedFinding[],
|
|
@@ -247,26 +220,57 @@ async function postFindings(
|
|
|
247
220
|
);
|
|
248
221
|
|
|
249
222
|
const skipSet = new Set(skipIds);
|
|
250
|
-
const
|
|
223
|
+
const allInline = [...tier1, ...tier2].filter((f) => {
|
|
251
224
|
const id = findingId(f.path, f.startLine, f.line, f.body);
|
|
252
225
|
return !skipSet.has(id);
|
|
253
226
|
});
|
|
254
227
|
|
|
255
|
-
|
|
256
|
-
const
|
|
228
|
+
// Split into reply findings and new findings
|
|
229
|
+
const replyFindings = allInline.filter(
|
|
230
|
+
(f): f is ReplyFinding =>
|
|
231
|
+
f.replyTo !== undefined &&
|
|
232
|
+
!updatedFindings.has(findingId(f.path, f.startLine, f.line, f.body)),
|
|
233
|
+
);
|
|
234
|
+
const newFindings = allInline.filter((f) => f.replyTo === undefined);
|
|
235
|
+
|
|
236
|
+
// Post replies first (fallbacks count against inline cap)
|
|
237
|
+
const inlineBudget = MAX_INLINE_FINDINGS;
|
|
238
|
+
const { failedIds: replyFailedIds, fallbackCount } = await postReplyThreads(
|
|
239
|
+
replyFindings,
|
|
240
|
+
reviewId,
|
|
241
|
+
inlineBudget,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Post new threads with remaining budget after reply fallbacks
|
|
245
|
+
const newFailedIds = await postInlineThreads(
|
|
246
|
+
reviewId,
|
|
247
|
+
newFindings,
|
|
248
|
+
updatedFindings,
|
|
249
|
+
inlineBudget - fallbackCount,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const failedIds = [...replyFailedIds, ...newFailedIds];
|
|
253
|
+
const skipped = newFindings.filter((f) =>
|
|
257
254
|
updatedFindings.has(findingId(f.path, f.startLine, f.line, f.body)),
|
|
258
255
|
).length;
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
const
|
|
256
|
+
const pendingNew = newFindings.length - skipped;
|
|
257
|
+
const postedNew = Math.min(pendingNew, inlineBudget - fallbackCount) - newFailedIds.length;
|
|
258
|
+
const postedReplies = replyFindings.length - replyFailedIds.length;
|
|
262
259
|
const totalFindings = [...tier1, ...tier2].length;
|
|
263
|
-
const
|
|
264
|
-
const statusSummary =
|
|
265
|
-
`Posted ${posted}/${totalFindings} inline threads (${skipped} up-to-date, ${skippedByUser} skipped, ${failedIds.length} failed${truncatedCount > 0 ? `, ${truncatedCount} truncated` : ""}). ` +
|
|
266
|
-
`${tier3.length} findings in review body.`;
|
|
260
|
+
const truncated = Math.max(0, pendingNew - (inlineBudget - fallbackCount));
|
|
267
261
|
|
|
268
|
-
|
|
262
|
+
const statusSummary = formatStatusSummary({
|
|
263
|
+
posted: postedNew + postedReplies,
|
|
264
|
+
total: totalFindings,
|
|
265
|
+
replies: postedReplies,
|
|
266
|
+
upToDate: skipped,
|
|
267
|
+
skipped: totalFindings - allInline.length,
|
|
268
|
+
failed: failedIds.length,
|
|
269
|
+
truncated,
|
|
270
|
+
tier3Count: tier3.length,
|
|
271
|
+
});
|
|
269
272
|
|
|
273
|
+
console.log(statusSummary);
|
|
270
274
|
return { summary: statusSummary, failed: failedIds };
|
|
271
275
|
}
|
|
272
276
|
|
|
@@ -291,8 +295,15 @@ export async function postReview(opts: PostReviewOptions): Promise<PostReviewRes
|
|
|
291
295
|
const { tier1, tier2, tier3 } = classifyAndLog(findings, diff);
|
|
292
296
|
|
|
293
297
|
if (dryRun) {
|
|
298
|
+
const replyCount = findings.filter((f) => f.replyTo !== undefined).length;
|
|
299
|
+
const parts = [
|
|
300
|
+
`${tier1.length} line-level`,
|
|
301
|
+
`${tier2.length} file-level`,
|
|
302
|
+
`${tier3.length} review-body`,
|
|
303
|
+
];
|
|
304
|
+
if (replyCount > 0) parts.push(`${replyCount} replies`);
|
|
294
305
|
return {
|
|
295
|
-
summary: `Dry run: ${
|
|
306
|
+
summary: `Dry run: ${parts.join(", ")} findings.`,
|
|
296
307
|
failed: [],
|
|
297
308
|
};
|
|
298
309
|
}
|
package/src/review-api.ts
CHANGED
|
@@ -259,3 +259,19 @@ export async function updateReviewComment(commentId: string, body: string): Prom
|
|
|
259
259
|
{ input: { pullRequestReviewCommentId: commentId, body } },
|
|
260
260
|
);
|
|
261
261
|
}
|
|
262
|
+
|
|
263
|
+
/** Post a reply comment on an existing review thread. */
|
|
264
|
+
export async function replyToThread(threadId: string, body: string): Promise<void> {
|
|
265
|
+
await graphql(
|
|
266
|
+
`
|
|
267
|
+
mutation ($input: AddPullRequestReviewThreadReplyInput!) {
|
|
268
|
+
addPullRequestReviewThreadReply(input: $input) {
|
|
269
|
+
comment {
|
|
270
|
+
id
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
`,
|
|
275
|
+
{ input: { pullRequestReviewThreadId: threadId, body } },
|
|
276
|
+
);
|
|
277
|
+
}
|