pi-orchestrator-config 4.15.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -807,8 +807,9 @@ PI_PIDIFF_ENABLE=false pi
|
|
|
807
807
|
| `myk-pi-tools pr claude-md` | Fetch `CLAUDE.md` and `AGENTS.md` content for a PR's repository |
|
|
808
808
|
| `myk-pi-tools release create` | Create a GitHub release with changelog |
|
|
809
809
|
| `myk-pi-tools db` | Review database query commands |
|
|
810
|
-
| `myk-pi-tools reviews` | Review handling commands (fetch, poll, address, post, store)
|
|
810
|
+
| `myk-pi-tools reviews` | Review handling commands (fetch, poll, address, post, store, store-session) |
|
|
811
811
|
| `myk-pi-tools reviews address` | Apply replies/statuses to a fetched reviews JSON, then post and store it (see below) |
|
|
812
|
+
| `myk-pi-tools reviews store-session` | Store Qodo pr-review-session findings JSON into the reviews DB (idempotent; run at loop convergence) |
|
|
812
813
|
| `myk-pi-tools memory` | Project memory commands — persistent per-repo learning |
|
|
813
814
|
| `myk-pi-tools ai-cli` | AI CLI commands (cursor, claude, gemini) |
|
|
814
815
|
| `myk-pi-tools coderabbit` | CodeRabbit commands (check, trigger, retry) |
|
|
@@ -821,13 +822,21 @@ myk-pi-tools reviews address [--json <reviews.json>] [--replies <mapping.txt>] [
|
|
|
821
822
|
```
|
|
822
823
|
|
|
823
824
|
- `--json`: reviews JSON to process (default: newest `pr-*-reviews.json` in `.pi/tmp`)
|
|
824
|
-
- `--replies`: mapping file, one rule per line —
|
|
825
|
-
|
|
825
|
+
- `--replies`: mapping file, one rule per line — full syntax:
|
|
826
|
+
- `<path>:<line>|<reply>` — matches the finding's full `[line, end_line]` span
|
|
827
|
+
- `<path>:<line>~<fragment>|<reply>` — discriminator: additionally requires the
|
|
828
|
+
case-insensitive `fragment` in the finding's body (targets one of two findings
|
|
829
|
+
whose spans overlap at the same line)
|
|
830
|
+
- `<path>:*|<reply>` — every finding in that file gets the reply (per-file wildcard)
|
|
831
|
+
- `*|<reply>` — matches all pending findings
|
|
832
|
+
- A finding whose span contains another finding's span (nested findings) matches
|
|
833
|
+
only its own reported line; `#` comments and blank lines ignored
|
|
826
834
|
- `--set`: target status for matched findings — `addressed` (default), `skipped`, `not_addressed`, or `failed`
|
|
827
835
|
- Without `--replies`, pending findings that already carry a reply only get their status flipped (status-only mode)
|
|
828
836
|
- Exit code `0` = replies posted and stored; non-zero = nothing matched, validation failed, or posting failed
|
|
829
837
|
(the JSON is kept for retry — re-running with the same or a corrected mapping resumes unposted entries)
|
|
830
|
-
- Validation failures change nothing: a rule matching several distinct findings
|
|
838
|
+
- Validation failures change nothing: a rule matching several distinct findings (overlapping
|
|
839
|
+
spans — disambiguate with `<path>:<line>~<fragment>`), or a status `post`
|
|
831
840
|
rejects for Qodo sticky findings (`not_addressed`/`failed`; `skipped` only with an issue-spec-update reply),
|
|
832
841
|
exits non-zero before the JSON is saved
|
|
833
842
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* recall them at write time (issue #918 Parts A and B).
|
|
4
4
|
*
|
|
5
5
|
* Part A — a `tool_result` watcher recognizes `myk-pi-tools reviews address`
|
|
6
|
-
* completions in main-session bash, then (fire-and-forget — scheduled via
|
|
6
|
+
* AND `myk-pi-tools reviews store-session` completions in main-session bash, then (fire-and-forget — scheduled via
|
|
7
7
|
* setImmediate so the handler returns before any synchronous I/O starts)
|
|
8
8
|
* distills the ADDRESSED findings of the review that command stored from
|
|
9
9
|
* `.pi/data/reviews.db` into one-line `lesson` memory entries via the internal
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* (`derivedFrom="reviews.db:<comment-id>;<owner>/<repo>"`).
|
|
12
12
|
*
|
|
13
13
|
* Review selection: the addressed PR is identified from the command's
|
|
14
|
-
* `--json` file
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `.pi/data/review-learning-state.json`, so re-fires
|
|
18
|
-
* runs, partial runs
|
|
14
|
+
* address `--json` file or the store-session JSON's `pr_url`. For a known
|
|
15
|
+
* PR, ALL stored review rows of that PR distill — session loops store one
|
|
16
|
+
* row per commit (Qodo 16d84431). Lessons already successfully written are
|
|
17
|
+
* tracked per review in `.pi/data/review-learning-state.json`, so re-fires
|
|
18
|
+
* (multi-group address runs, partial runs, every store-session round) never
|
|
19
|
+
* re-write them, and the per-fire lesson cap walks forward to the remaining
|
|
20
|
+
* findings — evidence only grows
|
|
19
21
|
* when a NEW review stores a NEW comment row for the same issue (a real
|
|
20
22
|
* recurrence, via addMemoryEntry's duplicate detection).
|
|
21
23
|
*
|
|
@@ -91,6 +93,17 @@ const MAX_TRACKED_REVIEWS = 200;
|
|
|
91
93
|
/** `reviews address` as a subcommand invocation (not `reviews address-foo`). */
|
|
92
94
|
const REVIEWS_ADDRESS_RE = /(?:^|[\s;&|])reviews\s+address(?=[\s;&|]|$)/i;
|
|
93
95
|
|
|
96
|
+
/** `reviews store-session` as a subcommand invocation (not `store-session-foo`). */
|
|
97
|
+
const REVIEWS_STORE_SESSION_RE = /(?:^|[\s;&|])reviews\s+store-session(?=[\s;&|]|$)/i;
|
|
98
|
+
|
|
99
|
+
/** Positional JSON path of a `reviews store-session <path>` invocation —
|
|
100
|
+
* stops at shell separators so a chained command
|
|
101
|
+
* (`store-session /tmp/x.json; echo done`) does not capture the `;`. */
|
|
102
|
+
const REVIEWS_STORE_SESSION_PATH_RE = /(?:^|[\s;&|])reviews\s+store-session\s+([^;\s&|]+)/i;
|
|
103
|
+
|
|
104
|
+
/** owner/repo/pr from a GitHub PR URL (`https://github.com/o/r/pull/N`). */
|
|
105
|
+
const PR_URL_PARTS_RE = /^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i;
|
|
106
|
+
|
|
94
107
|
/**
|
|
95
108
|
* True when a bash command invokes `myk-pi-tools reviews address`.
|
|
96
109
|
* Tolerates `uv run ... myk-pi-tools ... reviews address` and `uv run --group X ...`
|
|
@@ -103,6 +116,19 @@ export function isReviewsAddressCommand(command: unknown): boolean {
|
|
|
103
116
|
return REVIEWS_ADDRESS_RE.test(normalized);
|
|
104
117
|
}
|
|
105
118
|
|
|
119
|
+
/**
|
|
120
|
+
* True when a bash command invokes `myk-pi-tools reviews store-session` —
|
|
121
|
+
* the command a Qodo pr-review-session loop runs after every completed
|
|
122
|
+
* round (issue #942/#944 coupling: session-stored reviews must distill the
|
|
123
|
+
* same way address-stored ones do). `reviews store` alone never matches.
|
|
124
|
+
*/
|
|
125
|
+
export function isReviewsStoreSessionCommand(command: unknown): boolean {
|
|
126
|
+
if (typeof command !== "string") return false;
|
|
127
|
+
const normalized = command.trim();
|
|
128
|
+
if (!/\bmyk-pi-tools\b/i.test(normalized)) return false;
|
|
129
|
+
return REVIEWS_STORE_SESSION_RE.test(normalized);
|
|
130
|
+
}
|
|
131
|
+
|
|
106
132
|
// ── Repo resolution ────────────────────────────────────────────────────────
|
|
107
133
|
|
|
108
134
|
export interface RepoInfo {
|
|
@@ -203,6 +229,36 @@ export function getLatestReview(dbPath: string, owner: string, repo: string, prN
|
|
|
203
229
|
}
|
|
204
230
|
}
|
|
205
231
|
|
|
232
|
+
/**
|
|
233
|
+
* All review rows for owner/repo/PR, oldest first — a session loop stores
|
|
234
|
+
* ONE review row per commit (issue #942), so a PR's findings live in MANY
|
|
235
|
+
* rows; distillation must process every group, not just the highest id
|
|
236
|
+
* (Qodo 16d84431).
|
|
237
|
+
*/
|
|
238
|
+
export function getReviewsForPr(dbPath: string, owner: string, repo: string, prNumber: number): ReviewRow[] {
|
|
239
|
+
const db = openReviewsDb(dbPath);
|
|
240
|
+
if (!db) return [];
|
|
241
|
+
try {
|
|
242
|
+
const rows = db
|
|
243
|
+
.prepare(
|
|
244
|
+
"SELECT id, pr_number, commit_sha, owner, repo FROM reviews WHERE owner=? AND repo=? AND pr_number=? ORDER BY id ASC",
|
|
245
|
+
)
|
|
246
|
+
.all(owner, repo, prNumber) as any[];
|
|
247
|
+
return rows.map((row) => ({
|
|
248
|
+
id: Number(row.id),
|
|
249
|
+
prNumber: Number(row.pr_number),
|
|
250
|
+
commitSha: String(row.commit_sha),
|
|
251
|
+
owner: String(row.owner),
|
|
252
|
+
repo: String(row.repo),
|
|
253
|
+
}));
|
|
254
|
+
} catch (error: any) {
|
|
255
|
+
log.debug("reviews_db_query_failed", { dbPath, reason: String(error?.message ?? error).slice(0, 200) });
|
|
256
|
+
return [];
|
|
257
|
+
} finally {
|
|
258
|
+
db.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
206
262
|
/**
|
|
207
263
|
* Addressed findings for one review id — the review the address command
|
|
208
264
|
* stored, not every review sharing its commit SHA (skipped rows excluded by
|
|
@@ -308,7 +364,12 @@ function writeStateFile(dataDir: string, distilledReviews: number[], reviewLesso
|
|
|
308
364
|
/** Record a review id as distilled (bounded — oldest entries drop off). Fail-soft. */
|
|
309
365
|
export function markReviewDistilled(dataDir: string, reviewId: number): void {
|
|
310
366
|
try {
|
|
311
|
-
|
|
367
|
+
// Dedupe: the PR walk re-marks already-distilled reviews on every fire
|
|
368
|
+
// (their completed lessons make it a no-op walk), which would append the
|
|
369
|
+
// same id once per fire and grow the state file without bound.
|
|
370
|
+
const distilled = [...new Set(loadDistilledReviews(dataDir))];
|
|
371
|
+
if (!distilled.includes(reviewId)) distilled.push(reviewId);
|
|
372
|
+
writeStateFile(dataDir, distilled, loadReviewLessons(dataDir));
|
|
312
373
|
} catch (error: any) {
|
|
313
374
|
log.debug("distilled_state_write_failed", { reason: String(error?.message ?? error).slice(0, 200) });
|
|
314
375
|
}
|
|
@@ -330,23 +391,136 @@ export function recordReviewLessons(dataDir: string, reviewId: number, texts: st
|
|
|
330
391
|
// ── Addressed-PR identification ────────────────────────────────────────────
|
|
331
392
|
|
|
332
393
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
394
|
+
* Expand `$VAR` / `${VAR}` shell expressions in a captured command path and
|
|
395
|
+
* strip surrounding shell quotes. Bash expands these before the tool runs,
|
|
396
|
+
* so the hook normally sees the literal path — but a command recorded with
|
|
397
|
+
* the variable expression or quoted path (a combined poll+store line
|
|
398
|
+
* submitted verbatim, `store-session "${PROJECT_TMP_DIR}/x.json"`) must
|
|
399
|
+
* not read a nonexistent literal `"${...}"` path (Qodo 657c5633,
|
|
400
|
+
* bda69aff). Unset variables expand to the empty string, matching shell
|
|
401
|
+
* semantics.
|
|
402
|
+
*/
|
|
403
|
+
function expandEnvVars(value: string): string {
|
|
404
|
+
const expanded = value.replace(
|
|
405
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
406
|
+
(_match, braced: string | undefined, plain: string | undefined) => process.env[braced ?? plain ?? ""] ?? "",
|
|
407
|
+
);
|
|
408
|
+
return expanded.replace(/^["']+|["']+$/g, "");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* True when a captured `--json` token is a shell redirection (e.g.
|
|
413
|
+
* `2>/dev/null`), not a file path — the documented Qodo poll pipes
|
|
414
|
+
* `--json 2>/dev/null`, and treating the redirect as a path makes the read
|
|
415
|
+
* fail and lose the PR identity (Qodo beb11c94).
|
|
416
|
+
*/
|
|
417
|
+
function isRedirectionToken(token: string): boolean {
|
|
418
|
+
return /^\d*>>?\S/.test(token) || token.startsWith(">") || token.includes("/dev/null");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* PR number of the review an address or store-session command targeted —
|
|
423
|
+
* ONLY from the command's explicit inputs (the address `--json` file or the
|
|
424
|
+
* store-session session JSON's `pr_url`).
|
|
425
|
+
*
|
|
426
|
+
* Order and failure semantics (Qodo beb11c94/86f1855b):
|
|
427
|
+
* - A readable address `--json` file wins (partial runs keep it on disk).
|
|
428
|
+
* - An unreadable/invalid `--json` token (redirection capture, deleted file
|
|
429
|
+
* on the final storing run) FALLS THROUGH to the store-session path
|
|
430
|
+
* instead of failing the whole lookup — a combined poll+store command
|
|
431
|
+
* still resolves its PR from the session JSON.
|
|
432
|
+
* - An unreadable store-session JSON is logged (warn) — the caller decides
|
|
433
|
+
* not to fall back to the repository's newest review, which would distill
|
|
434
|
+
* a DIFFERENT PR's findings.
|
|
435
|
+
* - No explicit input at all → null — callers then select the newest review
|
|
436
|
+
* for the repo (final storing runs), never a different explicit PR.
|
|
340
437
|
*/
|
|
341
438
|
export function prNumberFromCommand(command: string | undefined, cwd: string): number | null {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
439
|
+
const cmd = command ?? "";
|
|
440
|
+
const m = /(?:^|\s)--json(?:\s+|=)(\S+)/.exec(cmd);
|
|
441
|
+
if (m && !isRedirectionToken(m[1]!)) {
|
|
442
|
+
try {
|
|
443
|
+
const pr = Number(JSON.parse(readFileSync(resolve(cwd, expandEnvVars(m[1]!)), "utf-8"))?.metadata?.pr_number);
|
|
444
|
+
if (Number.isInteger(pr) && pr > 0) return pr;
|
|
445
|
+
log.debug("pr_from_address_json_invalid", { token: m[1]!.slice(0, 120) });
|
|
446
|
+
} catch (error: any) {
|
|
447
|
+
// Deleted file (final storing run) or bad token — fall through to the
|
|
448
|
+
// store-session path; a null here would trigger the repo-newest
|
|
449
|
+
// fallback and could distill another PR.
|
|
450
|
+
log.debug("pr_from_address_json_unreadable", {
|
|
451
|
+
token: m[1]!.slice(0, 120),
|
|
452
|
+
reason: String(error?.message ?? error).slice(0, 120),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// `reviews store-session <path>`: the session JSON identifies its PR via
|
|
457
|
+
// pr_url (issue #944) — the positional path replaces address's --json.
|
|
458
|
+
const s = REVIEWS_STORE_SESSION_PATH_RE.exec(cmd);
|
|
459
|
+
if (s) {
|
|
460
|
+
try {
|
|
461
|
+
const prUrl: unknown = JSON.parse(readFileSync(resolve(cwd, expandEnvVars(s[1]!)), "utf-8"))?.pr_url;
|
|
462
|
+
if (typeof prUrl === "string") {
|
|
463
|
+
const pr = Number(/\/pull\/(\d+)/.exec(prUrl)?.[1]);
|
|
464
|
+
if (Number.isInteger(pr) && pr > 0) return pr;
|
|
465
|
+
log.debug("pr_from_session_json_invalid", { prUrl: prUrl.slice(0, 120) });
|
|
466
|
+
} else {
|
|
467
|
+
log.debug("pr_from_session_json_no_pr_url", { path: s[1]!.slice(0, 200) });
|
|
468
|
+
}
|
|
469
|
+
} catch (error: any) {
|
|
470
|
+
// The session JSON is never deleted by the loop — unreadable means a
|
|
471
|
+
// real error (bad path, race). Loud, because the caller must NOT fall
|
|
472
|
+
// back to the newest review: that would distill another PR's findings
|
|
473
|
+
// (Qodo 86f1855b).
|
|
474
|
+
log.warn("pr_from_session_json_unreadable", {
|
|
475
|
+
path: s[1]!.slice(0, 200),
|
|
476
|
+
reason: String(error?.message ?? error).slice(0, 200),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The FULL review target a command names — owner/repo/prNumber from the
|
|
485
|
+
* address `--json` metadata or the store-session pr_url — or null when the
|
|
486
|
+
* command carries no readable target. Used by distill() to query the repo
|
|
487
|
+
* the rows were actually STORED under: a fork-hosted session stores rows
|
|
488
|
+
* under the pr_url's upstream repo while origin names the fork, and a
|
|
489
|
+
* PR-number-only lookup against origin would find nothing (Qodo befc495a).
|
|
490
|
+
*/
|
|
491
|
+
export function prTargetFromCommand(
|
|
492
|
+
command: string | undefined,
|
|
493
|
+
cwd: string,
|
|
494
|
+
): { owner: string; repo: string; prNumber: number } | null {
|
|
495
|
+
const cmd = command ?? "";
|
|
496
|
+
const m = /(?:^|\s)--json(?:\s+|=)(\S+)/.exec(cmd);
|
|
497
|
+
if (m && !isRedirectionToken(m[1]!)) {
|
|
498
|
+
try {
|
|
499
|
+
const meta = JSON.parse(readFileSync(resolve(cwd, expandEnvVars(m[1]!)), "utf-8"))?.metadata;
|
|
500
|
+
const owner = typeof meta?.owner === "string" ? meta.owner : null;
|
|
501
|
+
const repo = typeof meta?.repo === "string" ? meta.repo : null;
|
|
502
|
+
const pr = Number(meta?.pr_number);
|
|
503
|
+
if (owner && repo && Number.isInteger(pr) && pr > 0) return { owner, repo, prNumber: pr };
|
|
504
|
+
} catch {
|
|
505
|
+
// unreadable --json — try the store-session path
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const s = REVIEWS_STORE_SESSION_PATH_RE.exec(cmd);
|
|
509
|
+
if (s) {
|
|
510
|
+
try {
|
|
511
|
+
const prUrl: unknown = JSON.parse(readFileSync(resolve(cwd, expandEnvVars(s[1]!)), "utf-8"))?.pr_url;
|
|
512
|
+
if (typeof prUrl === "string") {
|
|
513
|
+
const parts = PR_URL_PARTS_RE.exec(prUrl.trim());
|
|
514
|
+
if (parts) {
|
|
515
|
+
const pr = Number(parts[3]);
|
|
516
|
+
if (Number.isInteger(pr) && pr > 0) return { owner: parts[1]!, repo: parts[2]!, prNumber: pr };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// unreadable session JSON — prNumberFromCommand already warned
|
|
521
|
+
}
|
|
349
522
|
}
|
|
523
|
+
return null;
|
|
350
524
|
}
|
|
351
525
|
|
|
352
526
|
// ── Distillation (script-only engine) ──────────────────────────────────────
|
|
@@ -469,8 +643,21 @@ function findingIdentity(f: AddressedFinding): string | null {
|
|
|
469
643
|
* provenance lookups can distinguish new occurrences from re-fires. The
|
|
470
644
|
* dedupe key is the UNTRUNCATED identity (two findings whose titles first
|
|
471
645
|
* differ past the 100-char display cap stay distinct lessons).
|
|
646
|
+
*
|
|
647
|
+
* `completedTexts` (lesson texts already written for this review, from the
|
|
648
|
+
* persisted reviewLessons state) are excluded BEFORE the cap — a review with
|
|
649
|
+
* more findings than the per-fire cap distills its remaining lessons on the
|
|
650
|
+
* NEXT fire instead of re-picking the same top slice forever (Qodo
|
|
651
|
+
* 16d84431): session loops re-fire store-session every round, so the walk
|
|
652
|
+
* completes across rounds. It also makes findings APPENDED to an
|
|
653
|
+
* already-processed review row distill: their lessons are not in the
|
|
654
|
+
* completed set, so they surface as pending work.
|
|
472
655
|
*/
|
|
473
|
-
export function distillLessons(
|
|
656
|
+
export function distillLessons(
|
|
657
|
+
findings: AddressedFinding[],
|
|
658
|
+
completedTexts: Set<string> = new Set(),
|
|
659
|
+
cap: number = MAX_FINDINGS_PER_FIRE,
|
|
660
|
+
): DistilledLesson[] {
|
|
474
661
|
const byIdentity = new Map<string, DistilledLesson>();
|
|
475
662
|
for (const finding of rankFindings(findings)) {
|
|
476
663
|
const text = lessonFromFinding(finding);
|
|
@@ -480,7 +667,10 @@ export function distillLessons(findings: AddressedFinding[]): DistilledLesson[]
|
|
|
480
667
|
if (existing) existing.findingIds.push(finding.id); // merged duplicate keeps its identity
|
|
481
668
|
else byIdentity.set(key, { findingIds: [finding.id], text });
|
|
482
669
|
}
|
|
483
|
-
|
|
670
|
+
const pool = completedTexts.size > 0
|
|
671
|
+
? [...byIdentity.values()].filter((lesson) => !completedTexts.has(lesson.text))
|
|
672
|
+
: [...byIdentity.values()];
|
|
673
|
+
return pool.slice(0, Math.max(0, cap));
|
|
484
674
|
}
|
|
485
675
|
|
|
486
676
|
// ── Memory write with provenance dedupe ────────────────────────────────────
|
|
@@ -931,19 +1121,22 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
|
|
|
931
1121
|
};
|
|
932
1122
|
|
|
933
1123
|
/**
|
|
934
|
-
* Full distillation pipeline for one address fire.
|
|
935
|
-
* addressed PR (
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
* whose
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
1124
|
+
* Full distillation pipeline for one address or store-session fire.
|
|
1125
|
+
* `command` identifies the addressed PR (address `--json` file →
|
|
1126
|
+
* metadata.pr_number, or the store-session JSON's pr_url — no
|
|
1127
|
+
* newest-JSON fallback, which could target a leftover file from another
|
|
1128
|
+
* PR after the final run deletes its own). Selection is PR-SCOPED when
|
|
1129
|
+
* the PR is known: ALL review rows of that PR are processed — session
|
|
1130
|
+
* loops store one row per commit, so earlier groups and findings appended
|
|
1131
|
+
* to already-processed rows must distill too (Qodo 16d84431). A store-
|
|
1132
|
+
* session command whose PR cannot be resolved is a NO-OP (never the
|
|
1133
|
+
* repo-newest fallback — that would distill another PR, Qodo 86f1855b).
|
|
1134
|
+
* Repo-wide newest selection happens ONLY when no explicit target exists
|
|
1135
|
+
* (prNumber null — the final storing run whose JSON was just deleted).
|
|
1136
|
+
* A review whose lesson writes were REJECTED stays retryable, but only its
|
|
1137
|
+
* UNWRITTEN lessons are re-attempted (completed lessons are persisted per
|
|
1138
|
+
* review — re-processing them would inflate evidence without a new
|
|
1139
|
+
* occurrence). Settings-gated, fail-soft.
|
|
947
1140
|
*/
|
|
948
1141
|
const distill = async (cwd: string, command?: string): Promise<void> => {
|
|
949
1142
|
if (!getSetting(cwd, "review_learning")) {
|
|
@@ -959,88 +1152,171 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
|
|
|
959
1152
|
const dbPath = join(dataDir, "reviews.db");
|
|
960
1153
|
const distilled = loadDistilledReviews(dataDir);
|
|
961
1154
|
const prNumber = prNumberFromCommand(command, cwd);
|
|
962
|
-
|
|
1155
|
+
if (prNumber === null && command !== undefined && isReviewsStoreSessionCommand(command)) {
|
|
1156
|
+
// The session JSON is the loop's own record and is never deleted — an
|
|
1157
|
+
// unresolved PR means the read failed; falling back to the newest
|
|
1158
|
+
// review would distill a DIFFERENT PR's findings.
|
|
1159
|
+
log.warn("skipped_store_session_pr_unresolved", {
|
|
1160
|
+
cwd,
|
|
1161
|
+
command: String(command).slice(0, 120),
|
|
1162
|
+
});
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
let reviews: ReviewRow[];
|
|
1166
|
+
/** The repo the matched rows are stored under — lesson provenance must
|
|
1167
|
+
* name it, not the checkout origin: a fork session stores rows (and
|
|
1168
|
+
* recalls them) under the pr_url repo, and provenance keyed to the
|
|
1169
|
+
* fork would never match at write time (Qodo 66cc6bfe). */
|
|
1170
|
+
let rowsRepo: RepoInfo = repo;
|
|
963
1171
|
if (prNumber !== null) {
|
|
964
|
-
// Explicit target:
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1172
|
+
// Explicit target: every review row of the PR, oldest first. The
|
|
1173
|
+
// command-named repo (address --json metadata or session pr_url) is
|
|
1174
|
+
// AUTHORITATIVE — rows are always stored under the repo the fetch/
|
|
1175
|
+
// store ran as, so a target with no rows is a no-op, NEVER a fall
|
|
1176
|
+
// back to origin: an unrelated same-numbered PR there would distill
|
|
1177
|
+
// the wrong repository's findings (Qodo 1c8fc091). A fork checkout
|
|
1178
|
+
// (origin = fork, rows under the upstream pr_url repo) still finds
|
|
1179
|
+
// its rows through the target.
|
|
1180
|
+
const target = prTargetFromCommand(command, cwd);
|
|
1181
|
+
if (target) {
|
|
1182
|
+
rowsRepo = { owner: target.owner, repo: target.repo };
|
|
1183
|
+
reviews = getReviewsForPr(dbPath, target.owner, target.repo, prNumber);
|
|
1184
|
+
} else {
|
|
1185
|
+
reviews = getReviewsForPr(dbPath, repo.owner, repo.repo, prNumber);
|
|
969
1186
|
}
|
|
970
|
-
if (
|
|
971
|
-
log.debug("
|
|
1187
|
+
if (reviews.length === 0) {
|
|
1188
|
+
log.debug("skipped_no_review_row", {
|
|
1189
|
+
owner: rowsRepo.owner, repo: rowsRepo.repo, prNumber,
|
|
1190
|
+
origin: `${repo.owner}/${repo.repo}`,
|
|
1191
|
+
});
|
|
972
1192
|
return;
|
|
973
1193
|
}
|
|
974
1194
|
} else {
|
|
975
1195
|
// No explicit target (final storing run deleted its JSON): the newest
|
|
976
|
-
//
|
|
977
|
-
|
|
978
|
-
|
|
1196
|
+
// review for the repo is exactly the just-stored row. Already
|
|
1197
|
+
// distilled → no-op (nothing can have been appended without a PR).
|
|
1198
|
+
const latest = getLatestReview(dbPath, repo.owner, repo.repo);
|
|
1199
|
+
if (!latest) {
|
|
979
1200
|
log.debug("skipped_no_review_row", { owner: repo.owner, repo: repo.repo, prNumber: null });
|
|
980
1201
|
return;
|
|
981
1202
|
}
|
|
982
|
-
if (distilled.has(
|
|
983
|
-
log.debug("skipped_review_already_distilled", { reviewId:
|
|
1203
|
+
if (distilled.has(latest.id)) {
|
|
1204
|
+
log.debug("skipped_review_already_distilled", { reviewId: latest.id, prNumber: latest.prNumber });
|
|
984
1205
|
return;
|
|
985
1206
|
}
|
|
1207
|
+
reviews = [latest];
|
|
986
1208
|
}
|
|
987
|
-
// Per-
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
|
|
1209
|
+
// Per-FIRE write budget across ALL reviews of the PR (Qodo 062e8e28):
|
|
1210
|
+
// the docs' "~5 lessons per fire" cap is per distill() call, not per
|
|
1211
|
+
// review row — a multi-round PR must not dump 5×N lessons into memory
|
|
1212
|
+
// at once. Remaining lessons distill on the next fire (completed-aware
|
|
1213
|
+
// lesson selection walks forward).
|
|
1214
|
+
let budget = MAX_MEMORY_WRITES_PER_FIRE;
|
|
1215
|
+
let writes = 0;
|
|
1216
|
+
let remainingLessons = 0;
|
|
1217
|
+
for (const review of reviews) {
|
|
1218
|
+
if (budget <= 0) {
|
|
1219
|
+
log.debug("distill_budget_exhausted", { reviewId: review.id, prNumber: review.prNumber });
|
|
1220
|
+
remainingLessons += 1; // at least this review still has pending work
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
const result = await distillWithLock(cwd, review, dataDir, dbPath, rowsRepo, budget);
|
|
1224
|
+
budget -= result.consumed;
|
|
1225
|
+
writes += result.writes;
|
|
1226
|
+
remainingLessons += result.remaining;
|
|
1227
|
+
}
|
|
1228
|
+
if (remainingLessons > 0 && writes > 0) {
|
|
1229
|
+
// Lessons remain and this fire made progress — schedule the next
|
|
1230
|
+
// burst so a session's FINAL store still drains every lesson (no
|
|
1231
|
+
// later trigger exists after convergence, Qodo eceb273e). Progress
|
|
1232
|
+
// gating (writes > 0) prevents an infinite loop when every remaining
|
|
1233
|
+
// lesson is persistently rejected.
|
|
1234
|
+
log.debug("distill_continuation_scheduled", { remainingLessons, writes });
|
|
1235
|
+
setImmediate(() => {
|
|
1236
|
+
void distill(cwd, command).catch((error) =>
|
|
1237
|
+
log.warn("distill_unexpected_failure", { reason: String((error as any)?.message ?? error).slice(0, 300) }),
|
|
1238
|
+
);
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Per-review serialization: overlapping address fires (two bash results in
|
|
1245
|
+
* one turn, a retried command) would otherwise both read the completed-
|
|
1246
|
+
* lesson state before either async write finishes and double-write the
|
|
1247
|
+
* same lessons — inflating counts and evidence. The second fire waits for
|
|
1248
|
+
* the first, then re-reads state (the first may have just completed the
|
|
1249
|
+
* review) before doing any work. Returns the number of memory writes
|
|
1250
|
+
* consumed (bounded by `budget` — the per-fire cap is shared across all
|
|
1251
|
+
* reviews of the PR, Qodo 062e8e28).
|
|
1252
|
+
*/
|
|
1253
|
+
const distillWithLock = async (
|
|
1254
|
+
cwd: string,
|
|
1255
|
+
review: ReviewRow,
|
|
1256
|
+
dataDir: string,
|
|
1257
|
+
dbPath: string,
|
|
1258
|
+
repo: RepoInfo,
|
|
1259
|
+
budget: number,
|
|
1260
|
+
): Promise<{ consumed: number; writes: number; remaining: number }> => {
|
|
993
1261
|
const previous = distillLocks.get(review.id) ?? Promise.resolve();
|
|
994
1262
|
let release!: () => void;
|
|
995
1263
|
const current = new Promise<void>((resolve) => (release = resolve));
|
|
996
1264
|
distillLocks.set(review.id, current);
|
|
997
1265
|
try {
|
|
998
1266
|
await previous;
|
|
999
|
-
|
|
1000
|
-
log.debug("skipped_review_already_distilled", { reviewId: review.id, prNumber: review.prNumber, reason: "completed by a concurrent fire" });
|
|
1001
|
-
return;
|
|
1002
|
-
}
|
|
1003
|
-
await distillLockedReview(cwd, review, dataDir, dbPath, repo);
|
|
1267
|
+
return await distillLockedReview(cwd, review, dataDir, dbPath, repo, budget);
|
|
1004
1268
|
} finally {
|
|
1005
1269
|
release();
|
|
1006
1270
|
if (distillLocks.get(review.id) === current) distillLocks.delete(review.id);
|
|
1007
1271
|
}
|
|
1008
1272
|
};
|
|
1009
1273
|
|
|
1010
|
-
/**
|
|
1274
|
+
/**
|
|
1275
|
+
* The distill pipeline body — runs holding the per-review lock. Budget is
|
|
1276
|
+
* the fire's remaining write allowance; REJECTED lessons do not consume
|
|
1277
|
+
* it (a persistently rejected lesson must not starve the PR's later
|
|
1278
|
+
* reviews, Qodo 850bb32d — it is re-attempted on later fires for free).
|
|
1279
|
+
* A review whose lessons were cut short by the budget is not marked
|
|
1280
|
+
* distilled-error: the completed-aware lesson walk picks up its remainder
|
|
1281
|
+
* on the next fire.
|
|
1282
|
+
*/
|
|
1011
1283
|
const distillLockedReview = async (
|
|
1012
1284
|
cwd: string,
|
|
1013
1285
|
review: ReviewRow,
|
|
1014
1286
|
dataDir: string,
|
|
1015
1287
|
dbPath: string,
|
|
1016
1288
|
repo: RepoInfo,
|
|
1017
|
-
|
|
1289
|
+
budget: number,
|
|
1290
|
+
): Promise<{ consumed: number; writes: number; remaining: number }> => {
|
|
1018
1291
|
const findings = getAddressedComments(dbPath, review.id);
|
|
1019
1292
|
if (findings.length === 0) {
|
|
1020
1293
|
log.debug("skipped_no_addressed_findings", { reviewId: review.id });
|
|
1021
|
-
return;
|
|
1294
|
+
return { consumed: 0, writes: 0, remaining: 0 };
|
|
1022
1295
|
}
|
|
1023
1296
|
log.info("distill_start", {
|
|
1024
1297
|
owner: repo.owner, repo: repo.repo, prNumber: review.prNumber, reviewId: review.id, findings: findings.length,
|
|
1025
1298
|
});
|
|
1026
|
-
|
|
1299
|
+
// Per-lesson retry bookkeeping BEFORE the cap: lessons already written
|
|
1300
|
+
// for this review are excluded upstream of MAX_FINDINGS_PER_FIRE, so a
|
|
1301
|
+
// review with more findings than one fire's cap distills its remaining
|
|
1302
|
+
// lessons on the next fire (Qodo 16d84431).
|
|
1303
|
+
const completed = loadReviewLessons(dataDir).get(review.id) ?? new Set<string>();
|
|
1304
|
+
const lessons = distillLessons(findings, completed);
|
|
1027
1305
|
if (lessons.length === 0) {
|
|
1028
1306
|
log.info("distill_finish", { findings: findings.length, lessons: 0, reason: "no lessons distilled" });
|
|
1029
1307
|
markReviewDistilled(dataDir, review.id);
|
|
1030
|
-
return;
|
|
1308
|
+
return { consumed: 0, writes: 0, remaining: 0 };
|
|
1031
1309
|
}
|
|
1032
|
-
|
|
1033
|
-
const completed = loadReviewLessons(dataDir).get(review.id) ?? new Set<string>();
|
|
1034
|
-
const pending = lessons.filter((l) => !completed.has(l.text));
|
|
1310
|
+
const pending = lessons.slice(0, Math.max(0, budget));
|
|
1035
1311
|
if (pending.length === 0) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
markReviewDistilled(dataDir, review.id);
|
|
1039
|
-
log.info("distill_finish", { reviewId: review.id, findings: findings.length, lessons: lessons.length, reason: "all lessons already written" });
|
|
1040
|
-
return;
|
|
1312
|
+
log.debug("distill_budget_exhausted_before_review", { reviewId: review.id });
|
|
1313
|
+
return { consumed: 0, writes: 0, remaining: 1 };
|
|
1041
1314
|
}
|
|
1042
1315
|
const written = await writeLessons(cwd, pending, repo);
|
|
1043
1316
|
if (written.completed.length > 0) recordReviewLessons(dataDir, review.id, written.completed);
|
|
1317
|
+
// Only actual memory writes consume the budget — rejected lessons are
|
|
1318
|
+
// re-attempted on later fires without starving anyone (Qodo 850bb32d).
|
|
1319
|
+
const consumed = written.added + written.reinforced + written.skipped;
|
|
1044
1320
|
if (written.rejected > 0) {
|
|
1045
1321
|
// Rejected writes are retryable, not complete — the review stays
|
|
1046
1322
|
// unmarked; the next fire re-attempts ONLY the still-unwritten lessons.
|
|
@@ -1050,9 +1326,16 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
|
|
|
1050
1326
|
completed: written.completed.length,
|
|
1051
1327
|
pending: pending.length,
|
|
1052
1328
|
});
|
|
1053
|
-
return;
|
|
1054
1329
|
}
|
|
1055
|
-
|
|
1330
|
+
// The review is marked distilled only when every distinct lesson was
|
|
1331
|
+
// completed; a review cut short by the budget (or with rejected
|
|
1332
|
+
// lessons) stays unmarked — the next fire re-runs it (completed-aware)
|
|
1333
|
+
// and writes the remainder.
|
|
1334
|
+
const totalLessons = distillLessons(findings, new Set(), Number.MAX_SAFE_INTEGER).length;
|
|
1335
|
+
const doneAfter = completed.size + written.completed.length;
|
|
1336
|
+
if (doneAfter >= totalLessons) {
|
|
1337
|
+
markReviewDistilled(dataDir, review.id);
|
|
1338
|
+
}
|
|
1056
1339
|
log.info("distill_finish", {
|
|
1057
1340
|
reviewId: review.id,
|
|
1058
1341
|
findings: findings.length,
|
|
@@ -1061,7 +1344,9 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
|
|
|
1061
1344
|
reinforced: written.reinforced,
|
|
1062
1345
|
rejected: written.rejected,
|
|
1063
1346
|
skipped: written.skipped,
|
|
1347
|
+
budgetRemaining: budget - consumed,
|
|
1064
1348
|
});
|
|
1349
|
+
return { consumed, writes: consumed, remaining: doneAfter >= totalLessons ? 0 : 1 };
|
|
1065
1350
|
};
|
|
1066
1351
|
|
|
1067
1352
|
const register = (pi: ExtensionAPI): void => {
|
|
@@ -1089,8 +1374,8 @@ export function createReviewLearning(options: ReviewLearningOptions = {}) {
|
|
|
1089
1374
|
const tool = String(event?.toolName ?? "").toLowerCase();
|
|
1090
1375
|
if (tool !== "bash") return;
|
|
1091
1376
|
const command = event?.input?.command;
|
|
1092
|
-
if (!isReviewsAddressCommand(command)) return;
|
|
1093
|
-
log.debug("address_detected", { cwd: ctx?.cwd });
|
|
1377
|
+
if (!isReviewsAddressCommand(command) && !isReviewsStoreSessionCommand(command)) return;
|
|
1378
|
+
log.debug("address_detected", { cwd: ctx?.cwd, command: String(command).slice(0, 120) });
|
|
1094
1379
|
// Fire-and-forget, deferred past the handler's synchronous frame —
|
|
1095
1380
|
// the tool result must not wait for git exec, SQLite queries, or the
|
|
1096
1381
|
// provenance lookup (all sync I/O inside `distill` before its first
|
package/package.json
CHANGED
|
@@ -373,9 +373,12 @@ If any approved tasks weren't implemented, review with user.
|
|
|
373
373
|
|
|
374
374
|
### Phase 5: Prepare Reply Mappings
|
|
375
375
|
|
|
376
|
-
🚨 **NEVER hand-edit
|
|
377
|
-
`status`/`reply`
|
|
376
|
+
🚨 **NEVER hand-edit a reviews JSON's `status`/`reply` fields.** No python heredocs, no `jq`, no manual
|
|
377
|
+
`status`/`reply` mutation — those bypass posting and the database store. The
|
|
378
|
+
`myk-pi-tools reviews address` command applies
|
|
378
379
|
replies, flips statuses, posts, and stores in one step — you only prepare mapping files.
|
|
380
|
+
(The single exception: narrowing `line`/`end_line` for span disambiguation — see
|
|
381
|
+
span semantics below; that affects matching only, never posting.)
|
|
379
382
|
|
|
380
383
|
Write one mapping file per target status. Each line targets one finding:
|
|
381
384
|
|
|
@@ -386,11 +389,48 @@ Write one mapping file per target status. Each line targets one finding:
|
|
|
386
389
|
- `<path>:<line>` — the finding's fetched `path` and `line`. A thread finding and its
|
|
387
390
|
paired sticky finding are addressed together automatically (the command handles
|
|
388
391
|
dedupe and pairing — you never target stickies separately).
|
|
392
|
+
- `<path>:<line>~<fragment>` — **discriminator**: additionally requires `<fragment>`
|
|
393
|
+
(case-insensitive) in the finding's body — the way to target one of two findings
|
|
394
|
+
whose spans overlap at the same line.
|
|
395
|
+
- `<path>:*` — per-file wildcard: same reply for EVERY finding in that file.
|
|
389
396
|
- `*` — matches ALL pending findings (single-line mapping file).
|
|
390
397
|
- Group findings by their target status — Phase 8 runs one `reviews address` call per
|
|
391
398
|
status group.
|
|
392
399
|
- Posting happens in Phase 8, AFTER commit/push — do not post before the fixes are pushed.
|
|
393
400
|
|
|
401
|
+
**How a rule matches a finding (span semantics — issue #945):**
|
|
402
|
+
|
|
403
|
+
- A specific rule matches the finding's FULL `[line, end_line]` span — any line in
|
|
404
|
+
the span reaches the finding.
|
|
405
|
+
- When one finding's span CONTAINS another finding's span (nested findings, same
|
|
406
|
+
file), the containing finding matches only its own reported `line` — so a rule at
|
|
407
|
+
any inner line reaches the inner finding alone, and a rule at the outer finding's
|
|
408
|
+
reported line reaches the outer one alone. Equal spans: both findings match only
|
|
409
|
+
their own reported lines.
|
|
410
|
+
- A rule matching 2+ distinct findings is rejected ATOMICALLY — nothing is posted.
|
|
411
|
+
Two-pass addressing (reply to one, then the other) cannot work: ALL rules are
|
|
412
|
+
validated before anything posts. Disambiguate with `~<fragment>`, use
|
|
413
|
+
`<path>:*` / `*` when the same reply fits all, or narrow `line`/`end_line` in the
|
|
414
|
+
reviews JSON (the ONE sanctioned JSON edit — it affects matching only, never
|
|
415
|
+
posting).
|
|
416
|
+
- Sticky records (untitled duplicates of findings) participate in matching through
|
|
417
|
+
their cluster — you never target them separately, and a rule reaching the thread
|
|
418
|
+
reaches its sticky.
|
|
419
|
+
|
|
420
|
+
**Mapping cheat sheet (use this — never read the address source code):**
|
|
421
|
+
|
|
422
|
+
```text
|
|
423
|
+
src/a.py:10|Fixed in abc1234 # one finding (any line in its span)
|
|
424
|
+
src/a.py:10~backoff|Fixed the backoff in abc1234 # ONE of two findings overlapping at 10
|
|
425
|
+
src/a.py:*|Same reply for every finding in this file
|
|
426
|
+
*|All findings fixed in def5678 # every pending finding
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
When `reviews address` rejects a rule as ambiguous, re-read the finding bodies in
|
|
430
|
+
the reviews JSON, pick a unique word from the target finding's title, and re-run
|
|
431
|
+
with `<path>:<line>~<word>|<reply>`. Do NOT run the command twice to reach two
|
|
432
|
+
findings at one location — the second run would fail validation the same way.
|
|
433
|
+
|
|
394
434
|
**Valid status values (the `--set` flag for each mapping group):**
|
|
395
435
|
|
|
396
436
|
| Status | Behavior |
|
|
@@ -705,3 +745,52 @@ Each cycle displays a status update so the user knows the loop is active:
|
|
|
705
745
|
[auto] CodeRabbit rate-limited. Handling automatically via reviews poll --source coderabbit...
|
|
706
746
|
[auto] 🎉 {source} approved PR #{pr_number}. Displaying remaining findings summary.
|
|
707
747
|
```
|
|
748
|
+
|
|
749
|
+
## Qodo Review-Session Loops (Storage — MANDATORY)
|
|
750
|
+
|
|
751
|
+
PRs reviewed through the Qodo `pr-review-session` CLI (poll
|
|
752
|
+
`qodo read pr-review-session findings --pr-url <url> --json`, fix,
|
|
753
|
+
`qodo pr-review-session mark-implemented`) bypass GitHub review comments
|
|
754
|
+
until convergence, so `reviews fetch` sees nothing — every thread is
|
|
755
|
+
resolved by the time the loop converges, and the fetch default excludes
|
|
756
|
+
resolved threads. The reviews database therefore never hears about these
|
|
757
|
+
reviews unless you store them explicitly.
|
|
758
|
+
|
|
759
|
+
**When a pr-review-session loop runs, every completed round follows this
|
|
760
|
+
EXACT order — the store step runs AFTER the round's findings are resolved
|
|
761
|
+
(mark-implemented / dismissed), on a FRESH fetch, so stored statuses are
|
|
762
|
+
final, not poll-time snapshots:**
|
|
763
|
+
|
|
764
|
+
```bash
|
|
765
|
+
# 1. Poll — the loop's record (kept on disk, never deleted)
|
|
766
|
+
qodo read pr-review-session findings --pr-url <pr-url> --json 2>/dev/null | head -1 \
|
|
767
|
+
> ${PROJECT_TMP_DIR}/qodo-<pr>-<sha>.json
|
|
768
|
+
|
|
769
|
+
# 2. Fix the round's findings, then resolve them:
|
|
770
|
+
qodo pr-review-session mark-implemented --finding-ids <ids> --explanation "..." --json
|
|
771
|
+
|
|
772
|
+
# 3. Store on a FRESH fetch — statuses now reflect the resolution. The
|
|
773
|
+
# store is idempotent (per finding UUID) and REFRESHES the attribution
|
|
774
|
+
# on already-stored rows, so re-storing earlier rounds self-heals them.
|
|
775
|
+
qodo read pr-review-session findings --pr-url <pr-url> --json 2>/dev/null | head -1 \
|
|
776
|
+
> ${PROJECT_TMP_DIR}/qodo-<pr>-<sha>.json
|
|
777
|
+
myk-pi-tools reviews store-session ${PROJECT_TMP_DIR}/qodo-<pr>-<sha>.json
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
NEVER store the poll-time snapshot as the round's final state — findings
|
|
781
|
+
saved before mark-implemented carry pending attribution, and the status
|
|
782
|
+
report shows stale pendings. If a round was stored pre-resolution (e.g. a
|
|
783
|
+
crash between steps 2 and 3), the NEXT round's step-3 store refreshes it;
|
|
784
|
+
at convergence, run step 3 once more after the final mark-implemented.
|
|
785
|
+
|
|
786
|
+
The command converts the session findings JSON into reviews DB rows
|
|
787
|
+
(source `qodo`, status from each finding's `attribution_status`, grouped
|
|
788
|
+
by the commit the finding was raised on). It is idempotent — re-running
|
|
789
|
+
skips findings already stored (per session UUID) — and it does NOT delete
|
|
790
|
+
the JSON, so storing every round is safe and `reviews status --pr <n>`
|
|
791
|
+
stays current while the loop runs.
|
|
792
|
+
|
|
793
|
+
A clean convergence round is stored the same way — it is just the last
|
|
794
|
+
round. Without this step, `reviews status --pr <n>` renders an empty HTML
|
|
795
|
+
report for the PR mid-loop and after convergence, and the review analytics
|
|
796
|
+
lose the whole loop (issue #942).
|