@effect-agent/pr-review 0.1.0-beta.22 → 0.1.0-beta.24

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.
@@ -46,16 +46,37 @@ export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
46
46
  body: StoredText,
47
47
  }) {}
48
48
 
49
+ /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
50
+ export const MAX_STORED_UNREVIEWED_PATHS = 100;
51
+
52
+ /** Failed-pass records stored beside the leftover paths; one per unit stage. */
53
+ export const MAX_STORED_UNREVIEWED_PASSES = 24;
54
+
55
+ /** Stages a leftover path may need retried without a second general discovery. */
56
+ export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
57
+ export type UnreviewedStage = typeof UnreviewedStage.Type;
58
+
59
+ /** One failed fan-out pass whose paths should be retried, not rediscovered. */
60
+ export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
61
+ "@effect-agent/pr-review/StoredUnreviewedPass",
62
+ )({
63
+ stage: UnreviewedStage,
64
+ paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
65
+ }) {}
66
+
49
67
  /**
50
- * Versioned state embedded only after complete input assignment and settled
51
- * configured review assurance. The head plus full-scope fingerprint forms an
52
- * incremental baseline; an absent unresolved item never means the path is
53
- * defect-free. The `acceptedScopeFingerprint` name is retained for wire
54
- * compatibility. Storing hundreds of path strings separately would not fit
55
- * GitHub's bounded review body in the worst case.
68
+ * Versioned state embedded after EVERY completed run that can be signed. The
69
+ * head plus full-scope fingerprint forms an incremental baseline; an absent
70
+ * unresolved item never means the path is defect-free. `unreviewedPaths`
71
+ * carries retryable review gaps (failed passes) forward so the next
72
+ * incremental run re-reviews exactly them plus the new delta — the baseline
73
+ * advances monotonically instead of freezing on one flaky pass and reopening
74
+ * the whole post-baseline scope. The `acceptedScopeFingerprint` name is
75
+ * retained for wire compatibility. Storing hundreds of path strings
76
+ * separately would not fit GitHub's bounded review body in the worst case.
56
77
  */
57
78
  export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
58
- version: Schema.Literal(1),
79
+ version: Schema.Literal(2),
59
80
  repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
60
81
  pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
61
82
  baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
@@ -67,6 +88,22 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
67
88
  reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
68
89
  unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
69
90
  unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
91
+ /** Retryable review gaps carried into the next incremental run's scope. */
92
+ unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PATHS)),
93
+ /**
94
+ * Which failed pass produced those leftovers. Absent on state-v2 markers
95
+ * written before this field existed; those leftovers still re-enter scope
96
+ * but cannot skip rediscovery. Present (including empty) on new markers.
97
+ */
98
+ unreviewedPasses: Schema.optionalKey(
99
+ Schema.Array(StoredUnreviewedPass).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES)),
100
+ ),
101
+ /**
102
+ * True only when the producing run had complete input coverage, no
103
+ * unsettled pass, and nothing carried. Skip-unchanged authority: an
104
+ * unchanged patch may skip re-review only over a settled state.
105
+ */
106
+ settled: Schema.Boolean,
70
107
  lastReviewMode: ReviewScopeMode,
71
108
  }) {}
72
109
 
@@ -100,15 +137,15 @@ export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
100
137
  export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
101
138
  ReviewConcern.make({ severity: concern.severity, title: concern.title, body: concern.body });
102
139
 
103
- const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
140
+ const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v2:";
104
141
  const STATE_MARKER_SUFFIX = " -->";
105
142
  const STATE_MARKER_PATTERN =
106
- /(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
107
- const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\u0000";
143
+ /(?:^|\n)<!-- effect-agent-pr-review state-v2:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
144
+ const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v2\u0000";
108
145
  export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
109
146
  export const ReviewStateMarker = Schema.NonEmptyString.check(
110
147
  Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
111
- Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
148
+ Schema.isPattern(/^<!-- effect-agent-pr-review state-v2:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
112
149
  ).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
113
150
  export type ReviewStateMarker = typeof ReviewStateMarker.Type;
114
151
 
@@ -278,6 +315,12 @@ export interface ReviewSelection {
278
315
  readonly files: ReadonlyArray<ChangedFile>;
279
316
  /** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
280
317
  readonly affectedPaths: ReadonlyArray<string>;
318
+ /**
319
+ * Leftover paths whose contents did not change. Fan-out retries only the
320
+ * recorded failed stages on these paths and keeps their stored findings.
321
+ */
322
+ readonly retryPaths: ReadonlyArray<string>;
323
+ readonly retryStages: ReadonlyArray<UnreviewedStage>;
281
324
  readonly totalFiles: number;
282
325
  readonly baselineSha: string | undefined;
283
326
  readonly priorState: ReviewState | undefined;
@@ -298,12 +341,26 @@ const fullSelection = (input: {
298
341
  affectedPaths: input.files.flatMap((file) =>
299
342
  file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
300
343
  ),
344
+ retryPaths: [],
345
+ retryStages: [],
301
346
  totalFiles: input.totalFiles,
302
347
  baselineSha: undefined,
303
348
  priorState: undefined,
304
349
  profileFingerprint: input.profileFingerprint,
305
350
  });
306
351
 
352
+ /** Three-dot lineage from the reviewed head to the current head is usable. */
353
+ export const isLineageAncestor = (
354
+ comparison: ReviewHeadComparison,
355
+ priorState: ReviewState,
356
+ currentHeadSha: string,
357
+ ): boolean =>
358
+ comparison.baseSha === priorState.reviewedHeadSha &&
359
+ comparison.headSha === currentHeadSha &&
360
+ comparison.mergeBaseSha === priorState.reviewedHeadSha &&
361
+ !comparison.truncated &&
362
+ (comparison.status === "ahead" || comparison.status === "identical");
363
+
307
364
  /**
308
365
  * Validate that persisted state belongs to this exact PR/base lineage and the
309
366
  * same review profile. A mismatch is a full-review reason, never an error that
@@ -326,6 +383,96 @@ export const validateReviewState = (
326
383
  return undefined;
327
384
  };
328
385
 
386
+ const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
387
+ file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
388
+
389
+ const incrementalFromDelta = (input: {
390
+ readonly current: PullRequestMetadata;
391
+ readonly fullFiles: ReadonlyArray<ChangedFile>;
392
+ readonly profileFingerprint: string;
393
+ readonly priorState: ReviewState;
394
+ readonly deltaFiles: ReadonlyArray<ChangedFile>;
395
+ readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
396
+ readonly reason: string;
397
+ }): ReviewSelection => {
398
+ const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
399
+ const affectedPaths = new Set([
400
+ ...input.deltaFiles.flatMap(filePaths),
401
+ ...(input.extraAffectedPaths ?? []),
402
+ ]);
403
+ const selectedByPath = new Map<string, ChangedFile>();
404
+ for (const file of input.deltaFiles) {
405
+ if (
406
+ currentPaths.has(file.path) ||
407
+ (file.previousPath !== undefined && currentPaths.has(file.previousPath))
408
+ ) {
409
+ selectedByPath.set(file.path, file);
410
+ }
411
+ }
412
+ const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
413
+ const surgical = input.priorState.unreviewedPasses !== undefined;
414
+ const retryOnly = new Set<string>();
415
+ const retryStages = new Set<UnreviewedStage>();
416
+ for (const path of carriedPaths) {
417
+ if (affectedPaths.has(path)) continue;
418
+ retryOnly.add(path);
419
+ if (surgical) {
420
+ for (const pass of input.priorState.unreviewedPasses ?? []) {
421
+ if (pass.paths.includes(path)) retryStages.add(pass.stage);
422
+ }
423
+ } else {
424
+ affectedPaths.add(path);
425
+ }
426
+ }
427
+ if (
428
+ surgical &&
429
+ retryStages.has("verification") &&
430
+ !retryStages.has("discovery") &&
431
+ !retryStages.has("specialist")
432
+ ) {
433
+ retryStages.add("discovery");
434
+ retryStages.add("specialist");
435
+ }
436
+ if (surgical && retryOnly.size > 0 && retryStages.size === 0) {
437
+ for (const path of retryOnly) affectedPaths.add(path);
438
+ retryStages.add("discovery");
439
+ retryStages.add("specialist");
440
+ retryStages.add("verification");
441
+ }
442
+ if (carriedPaths.length > 0 || (input.extraAffectedPaths?.length ?? 0) > 0) {
443
+ for (const file of input.fullFiles) {
444
+ const needed =
445
+ affectedPaths.has(file.path) ||
446
+ (file.previousPath !== undefined && affectedPaths.has(file.previousPath)) ||
447
+ retryOnly.has(file.path) ||
448
+ (file.previousPath !== undefined && retryOnly.has(file.previousPath));
449
+ if (needed) selectedByPath.set(file.path, file);
450
+ }
451
+ }
452
+ const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
453
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
454
+ );
455
+ const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
456
+ const carriedReason =
457
+ leftoverCount > 0 && surgical
458
+ ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
459
+ : carriedPaths.length > 0
460
+ ? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
461
+ : "";
462
+ return {
463
+ mode: "incremental",
464
+ reason: `${input.reason}${carriedReason}`,
465
+ files: selectedFiles,
466
+ affectedPaths: [...affectedPaths].sort(),
467
+ retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
468
+ retryStages: [...retryStages].sort(),
469
+ totalFiles: selectedFiles.length,
470
+ baselineSha: input.priorState.reviewedHeadSha,
471
+ priorState: input.priorState,
472
+ profileFingerprint: input.profileFingerprint,
473
+ };
474
+ };
475
+
329
476
  /** Pure, deterministic range selection with conservative full-review fallbacks. */
330
477
  export const selectReviewRange = (input: {
331
478
  readonly requestedMode: ReviewMode;
@@ -335,6 +482,12 @@ export const selectReviewRange = (input: {
335
482
  readonly priorState: ReviewState | undefined;
336
483
  readonly comparison: ReviewHeadComparison | undefined;
337
484
  readonly baseComparison?: ReviewHeadComparison | undefined;
485
+ /**
486
+ * Two-dot tree comparison used when the reviewed head is not a git ancestor
487
+ * (rebase, amend, force-push). Intersected with the current PR path set so
488
+ * main-drift outside the pull request never re-enters scope.
489
+ */
490
+ readonly contentComparison?: ReviewHeadComparison | undefined;
338
491
  readonly lookupFailure?: string | undefined;
339
492
  }): ReviewSelection => {
340
493
  const full = (reason: string) =>
@@ -352,79 +505,53 @@ export const selectReviewRange = (input: {
352
505
  const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
353
506
  if (invalid !== undefined) return full(invalid);
354
507
  const comparison = input.comparison;
355
- if (comparison === undefined) return full("the incremental head comparison was unavailable");
356
508
  if (
357
- comparison.baseSha !== input.priorState.reviewedHeadSha ||
358
- comparison.headSha !== input.current.headSha ||
359
- comparison.mergeBaseSha !== input.priorState.reviewedHeadSha ||
360
- (comparison.status !== "ahead" && comparison.status !== "identical")
509
+ comparison !== undefined &&
510
+ isLineageAncestor(comparison, input.priorState, input.current.headSha)
361
511
  ) {
362
- return full("the prior reviewed head is not an ancestor of the current head");
363
- }
364
- if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
365
- const affectedPaths = new Set(
366
- comparison.files.flatMap((file) =>
367
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
368
- ),
369
- );
370
- let baseReason = "";
371
- if (input.priorState.baseSha !== input.current.baseSha) {
372
- const baseComparison = input.baseComparison;
373
- if (baseComparison === undefined) {
374
- return full("the pull request base changed and its lineage comparison was unavailable");
375
- }
376
- if (
377
- baseComparison.baseSha !== input.priorState.baseSha ||
378
- baseComparison.headSha !== input.current.baseSha ||
379
- baseComparison.mergeBaseSha !== input.priorState.baseSha ||
380
- (baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
381
- baseComparison.truncated
382
- ) {
383
- return full("the pull request base changed materially or exceeded the comparison bound");
384
- }
385
- for (const file of baseComparison.files) {
386
- affectedPaths.add(file.path);
387
- if (file.previousPath !== undefined) affectedPaths.add(file.previousPath);
388
- }
389
- baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
390
- }
391
- const currentPaths = new Set(
392
- input.fullFiles.flatMap((file) =>
393
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
394
- ),
395
- );
396
- const selectedByPath = new Map<string, ChangedFile>();
397
- for (const file of comparison.files) {
398
- if (
399
- currentPaths.has(file.path) ||
400
- (file.previousPath !== undefined && currentPaths.has(file.previousPath))
401
- ) {
402
- selectedByPath.set(file.path, file);
403
- }
404
- }
405
- if (input.priorState.baseSha !== input.current.baseSha) {
406
- for (const file of input.fullFiles) {
512
+ const extraAffected: Array<string> = [];
513
+ let baseReason = "";
514
+ if (input.priorState.baseSha !== input.current.baseSha) {
515
+ const baseComparison = input.baseComparison;
516
+ if (baseComparison === undefined) {
517
+ return full("the pull request base changed and its lineage comparison was unavailable");
518
+ }
407
519
  if (
408
- affectedPaths.has(file.path) ||
409
- (file.previousPath !== undefined && affectedPaths.has(file.previousPath))
520
+ baseComparison.baseSha !== input.priorState.baseSha ||
521
+ baseComparison.headSha !== input.current.baseSha ||
522
+ baseComparison.mergeBaseSha !== input.priorState.baseSha ||
523
+ (baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
524
+ baseComparison.truncated
410
525
  ) {
411
- selectedByPath.set(file.path, file);
526
+ return full("the pull request base changed materially or exceeded the comparison bound");
412
527
  }
528
+ for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
529
+ baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
413
530
  }
531
+ return incrementalFromDelta({
532
+ current: input.current,
533
+ fullFiles: input.fullFiles,
534
+ profileFingerprint: input.profileFingerprint,
535
+ priorState: input.priorState,
536
+ deltaFiles: comparison.files,
537
+ extraAffectedPaths: extraAffected,
538
+ reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
539
+ });
414
540
  }
415
- const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
416
- left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
417
- );
418
- return {
419
- mode: "incremental",
420
- reason: `changes since settled review head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
421
- files: selectedFiles,
422
- affectedPaths: [...affectedPaths].sort(),
423
- totalFiles: selectedFiles.length,
424
- baselineSha: input.priorState.reviewedHeadSha,
425
- priorState: input.priorState,
426
- profileFingerprint: input.profileFingerprint,
427
- };
541
+ const contentComparison = input.contentComparison;
542
+ if (contentComparison !== undefined && !contentComparison.truncated) {
543
+ return incrementalFromDelta({
544
+ current: input.current,
545
+ fullFiles: input.fullFiles,
546
+ profileFingerprint: input.profileFingerprint,
547
+ priorState: input.priorState,
548
+ deltaFiles: contentComparison.files,
549
+ reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
550
+ });
551
+ }
552
+ if (comparison === undefined) return full("the incremental head comparison was unavailable");
553
+ if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
554
+ return full("the prior reviewed head is not an ancestor of the current head");
428
555
  };
429
556
 
430
557
  /** Per-run context consumed by orchestration and publication, not by the model. */
@@ -482,18 +609,6 @@ export const selectedPullRequestSourceLayer = (
482
609
  }),
483
610
  );
484
611
 
485
- /** Profile fingerprints are SHA-256 over configuration-only signatures. */
486
- export const computeProfileFingerprint = (signature: string): Effect.Effect<string> =>
487
- Effect.promise(async () => {
488
- const digest = await globalThis.crypto.subtle.digest(
489
- "SHA-256",
490
- new TextEncoder().encode(signature),
491
- );
492
- return Array.from(new Uint8Array(digest))
493
- .map((byte) => byte.toString(16).padStart(2, "0"))
494
- .join("");
495
- });
496
-
497
612
  /** Build the full-surface mission used only to resolve profile guidance. */
498
613
  export const buildProfileMission = (
499
614
  metadata: PullRequestMetadata,
@@ -6,6 +6,7 @@ import {
6
6
  fileReviewEvidenceChunks,
7
7
  type FindingSeverity,
8
8
  MAX_PATCH_CHARS,
9
+ type ReviewConcern,
9
10
  type ReviewFinding,
10
11
  } from "./review-agent.ts";
11
12
 
@@ -465,3 +466,27 @@ export const rankAndDedupeFindings = (
465
466
  })
466
467
  .slice(0, MAX_MERGED_FINDINGS);
467
468
  };
469
+
470
+ /**
471
+ * The concern analogue of `rankAndDedupeFindings`: dedupe by exact content
472
+ * keeping the most severe duplicate, rank by severity, and cap at the
473
+ * `CodeReview` concerns bound.
474
+ */
475
+ export const rankAndDedupeConcerns = (
476
+ concerns: ReadonlyArray<ReviewConcern>,
477
+ ): ReadonlyArray<ReviewConcern> => {
478
+ const byContent = new Map<string, ReviewConcern>();
479
+ for (const concern of concerns) {
480
+ const key = `${concern.title}\u0000${concern.body}`;
481
+ const previous = byContent.get(key);
482
+ if (
483
+ previous === undefined ||
484
+ severityRank[concern.severity] < severityRank[previous.severity]
485
+ ) {
486
+ byContent.set(key, concern);
487
+ }
488
+ }
489
+ return [...byContent.values()]
490
+ .sort((left, right) => severityRank[left.severity] - severityRank[right.severity])
491
+ .slice(0, 10);
492
+ };