@effect-agent/pr-review 0.1.0-beta.23 → 0.1.0-beta.25

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.
Files changed (37) hide show
  1. package/README.md +83 -195
  2. package/dist/action.d.mts +27 -18
  3. package/dist/action.mjs +60 -39
  4. package/dist/action.mjs.map +1 -1
  5. package/dist/cli.mjs +3 -3
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/{fan-out-BJBTAYuh.d.mts → fan-out-CMEsbFLk.d.mts} +455 -177
  8. package/dist/{github-BbwYzNrC.mjs → github-NjgxGqwM.mjs} +2163 -1518
  9. package/dist/github-NjgxGqwM.mjs.map +1 -0
  10. package/dist/index.d.mts +30 -20
  11. package/dist/index.mjs +3 -3
  12. package/dist/{providers-NyP-4rS6.mjs → providers-CODZQCmL.mjs} +202 -80
  13. package/dist/providers-CODZQCmL.mjs.map +1 -0
  14. package/dist/testing.d.mts +3 -1
  15. package/dist/testing.mjs +3 -2
  16. package/dist/testing.mjs.map +1 -1
  17. package/package.json +2 -2
  18. package/src/action.ts +141 -78
  19. package/src/cli.ts +6 -1
  20. package/src/index.ts +1 -0
  21. package/src/internal/adjudication.ts +415 -0
  22. package/src/internal/coverage.ts +41 -60
  23. package/src/internal/factory.ts +4 -4
  24. package/src/internal/fan-out.ts +208 -14
  25. package/src/internal/fingerprint.ts +16 -10
  26. package/src/internal/fixtures.ts +6 -0
  27. package/src/internal/github-env.ts +9 -0
  28. package/src/internal/github.ts +243 -7
  29. package/src/internal/progress.ts +1 -1
  30. package/src/internal/render.ts +186 -42
  31. package/src/internal/retirement.ts +16 -17
  32. package/src/internal/review-agent.ts +39 -4
  33. package/src/internal/review-state.ts +315 -105
  34. package/src/internal/review-units.ts +10 -9
  35. package/src/internal/run.ts +197 -63
  36. package/dist/github-BbwYzNrC.mjs.map +0 -1
  37. package/dist/providers-NyP-4rS6.mjs.map +0 -1
package/src/action.ts CHANGED
@@ -3,6 +3,8 @@ import { Config, Console, Effect, FileSystem, Layer, Option, Redacted, Schema }
3
3
  import { BudgetExceeded, UsageBudgetLimits } from "effect-agent";
4
4
  import { FetchHttpClient } from "effect/unstable/http";
5
5
 
6
+ import { collectReviewAdjudications } from "./internal/adjudication.ts";
7
+ import { splitCarriedScope } from "./internal/coverage.ts";
6
8
  import type { ChangedFile } from "./internal/diff.ts";
7
9
  import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./internal/effort.ts";
8
10
  import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
@@ -21,9 +23,14 @@ import {
21
23
  } from "./internal/providers.ts";
22
24
  import { retireStaleReviews } from "./internal/retirement.ts";
23
25
  import {
26
+ adjudicationIdentity,
27
+ concernIdentity,
28
+ findingIdentity,
29
+ fullReviewSelection,
24
30
  ReviewExecutionContext,
25
31
  ReviewHeadComparison,
26
32
  ReviewStateAuthenticator,
33
+ isLineageAncestor,
27
34
  type ReviewMode,
28
35
  type ReviewState,
29
36
  selectReviewRange,
@@ -259,8 +266,6 @@ const outcomeOutputs = (
259
266
  ["verdict", outcome.review.verdict],
260
267
  ["input-coverage", outcome.inputCoverage.status],
261
268
  ["review-assurance", outcome.assurance.status],
262
- // Compatibility output: the precise outputs above own new integrations.
263
- ["coverage", outcome.coverage.status],
264
269
  ["review-mode", outcome.reviewMode ?? "full"],
265
270
  ["review-reason", outcome.reviewReason ?? "direct full review"],
266
271
  ["inline-comments", String(outcome.plan.comments.length)],
@@ -280,26 +285,34 @@ const outcomeSummary = (
280
285
  outcome: ReviewRunOutcome,
281
286
  modelLabel: string | undefined,
282
287
  conclusion: ReviewCheckConclusion,
283
- ): ReadonlyArray<string> => [
284
- "### Pull-request review",
285
- `- Check conclusion: **${conclusion}**`,
286
- `- Verdict: **${outcome.review.verdict}**`,
287
- `- Input coverage: **${outcome.inputCoverage.status}** · scope: ${outcome.reviewMode ?? "full"}`,
288
- `- Review assurance: **${outcome.assurance.status}** · general discovery ${outcome.assurance.completedGeneralDiscoveryPasses}/${outcome.assurance.requiredGeneralDiscoveryPasses} · specialist ${outcome.assurance.completedSpecialistPasses}/${outcome.assurance.requiredSpecialistPasses} · verification ${outcome.assurance.completedVerificationPasses}/${outcome.assurance.requiredVerificationPasses}`,
289
- `- Inline comments: ${outcome.plan.comments.length} · demoted findings: ${outcome.plan.demoted.length} · concerns: ${outcome.review.concerns?.length ?? 0}`,
290
- ...(outcome.unreviewedPaths.length === 0
291
- ? []
292
- : [
293
- `- Carried forward: ${outcome.unreviewedPaths.length} unreviewed path(s) retried automatically on the next run (reviewer-side gap, not a code defect)`,
294
- ]),
295
- ...(modelLabel === undefined ? [] : [`- Model: \`${modelLabel}\``]),
296
- ...(outcome.usage === undefined
297
- ? []
298
- : [`- Tokens: ${outcome.usage.inputTokens} in / ${outcome.usage.outputTokens} out`]),
299
- ...(outcome.published === undefined
300
- ? ["- Dry run: nothing posted"]
301
- : [`- Posted: ${outcome.published.url}`]),
302
- ];
288
+ ): ReadonlyArray<string> => {
289
+ const scope = splitCarriedScope(outcome);
290
+ return [
291
+ "### Pull-request review",
292
+ `- Check conclusion: **${conclusion}**`,
293
+ `- Verdict: **${outcome.review.verdict}**`,
294
+ `- Input coverage: **${outcome.inputCoverage.status}** · scope: ${outcome.reviewMode ?? "full"}`,
295
+ `- Review assurance: **${outcome.assurance.status}** · general discovery ${outcome.assurance.completedGeneralDiscoveryPasses}/${outcome.assurance.requiredGeneralDiscoveryPasses} · specialist ${outcome.assurance.completedSpecialistPasses}/${outcome.assurance.requiredSpecialistPasses} · verification ${outcome.assurance.completedVerificationPasses}/${outcome.assurance.requiredVerificationPasses}`,
296
+ `- Inline comments: ${outcome.plan.comments.length} · demoted findings: ${outcome.plan.demoted.length} · concerns: ${outcome.review.concerns?.length ?? 0}`,
297
+ ...(scope.retryablePaths.length === 0
298
+ ? []
299
+ : [
300
+ `- Carried forward: ${scope.retryablePaths.length} unreviewed path(s) retried automatically on the next run (reviewer-side gap, not a code defect)`,
301
+ ]),
302
+ ...(scope.undiffablePaths.length === 0
303
+ ? []
304
+ : [
305
+ `- Unreviewable: ${scope.undiffablePaths.length} path(s) with no reviewable diff (binary or oversized) — remove them from the pull request or exclude them with ignore globs`,
306
+ ]),
307
+ ...(modelLabel === undefined ? [] : [`- Model: \`${modelLabel}\``]),
308
+ ...(outcome.usage === undefined
309
+ ? []
310
+ : [`- Tokens: ${outcome.usage.inputTokens} in / ${outcome.usage.outputTokens} out`]),
311
+ ...(outcome.published === undefined
312
+ ? ["- Dry run: nothing posted"]
313
+ : [`- Posted: ${outcome.published.url}`]),
314
+ ];
315
+ };
303
316
 
304
317
  /** The result of one action invocation: a typed skip or a settled review. */
305
318
  export type ReviewActionResult =
@@ -332,30 +345,41 @@ const resolveRunUrl = Effect.fn("resolveRunUrl")(function* () {
332
345
  * The reviewer surface the action harness drives. `PrReview.make` and
333
346
  * `PrReview.makeFanOut` provide the state-selection effects. A fingerprint is
334
347
  * skip authority only when a profile fingerprint and authenticated review
335
- * state bind it to settled assurance; a fingerprint-only custom harness
336
- * remains source-compatible but deliberately runs instead of claiming green
337
- * assurance from unauthenticated prior-review text.
348
+ * state bind it to settled assurance.
338
349
  */
339
- export interface HarnessedReviewer<E, R, FingerprintE, FingerprintR> {
340
- readonly run: (runOptions?: RunReviewOptions) => Effect.Effect<ReviewRunOutcome, E, R>;
341
- /** Current effective changeset fingerprint; not standalone skip authority. */
342
- readonly fingerprint?: Effect.Effect<string, FingerprintE, FingerprintR> | undefined;
343
- readonly profileFingerprint?: Effect.Effect<string, FingerprintE, FingerprintR> | undefined;
344
- readonly snapshot?:
345
- | Effect.Effect<
346
- {
347
- readonly metadata: PullRequestMetadata;
348
- readonly files: ReadonlyArray<ChangedFile>;
349
- },
350
- FingerprintE,
351
- FingerprintR
352
- >
353
- | undefined;
354
- readonly filterFiles?:
355
- | ((files: ReadonlyArray<ChangedFile>) => ReadonlyArray<ChangedFile>)
356
- | undefined;
350
+ interface HarnessedReviewerBase<E, R> {
351
+ /** The action composition root always supplies the selected run context. */
352
+ readonly run: (
353
+ runOptions?: RunReviewOptions,
354
+ ) => Effect.Effect<ReviewRunOutcome, E, R | ReviewExecutionContext>;
357
355
  }
358
356
 
357
+ export type HarnessedReviewer<E, R, FingerprintE, FingerprintR> = HarnessedReviewerBase<E, R> &
358
+ (
359
+ | {
360
+ readonly fingerprint?: undefined;
361
+ readonly profileFingerprint?: undefined;
362
+ readonly snapshot?: undefined;
363
+ readonly filterFiles?: undefined;
364
+ }
365
+ | {
366
+ /** Current effective changeset fingerprint; not standalone skip authority. */
367
+ readonly fingerprint: Effect.Effect<string, FingerprintE, FingerprintR>;
368
+ readonly profileFingerprint: Effect.Effect<string, FingerprintE, FingerprintR>;
369
+ readonly snapshot: Effect.Effect<
370
+ {
371
+ readonly metadata: PullRequestMetadata;
372
+ readonly files: ReadonlyArray<ChangedFile>;
373
+ },
374
+ FingerprintE,
375
+ FingerprintR
376
+ >;
377
+ readonly filterFiles?:
378
+ | ((files: ReadonlyArray<ChangedFile>) => ReadonlyArray<ChangedFile>)
379
+ | undefined;
380
+ }
381
+ );
382
+
359
383
  const blockingReasons = (input: {
360
384
  readonly findings: ReadonlyArray<{ readonly severity: string; readonly title: string }>;
361
385
  readonly concerns: ReadonlyArray<{ readonly severity: string; readonly title: string }>;
@@ -372,10 +396,13 @@ const blockingReasons = (input: {
372
396
  * Host-derived check conclusion; model verdict prose cannot weaken it.
373
397
  *
374
398
  * Blocking code findings outrank machinery gaps — they are the actionable
375
- * signal. A machinery gap (incomplete input, unsettled passes) concludes
376
- * `incomplete` with reasons that explicitly say the failure is reviewer-side
377
- * uncertainty carried forward for retry, never an invitation to change code.
378
- * The flat reviewer's constant `unverified` assurance is not a gap.
399
+ * signal. A retryable machinery gap (failed passes, capacity overflow)
400
+ * concludes `incomplete` with reasons that explicitly say the failure is
401
+ * reviewer-side uncertainty carried forward for retry, never an invitation to
402
+ * change code. Undiffable files are the deliberate exception: no retry can
403
+ * settle them, so their reason instructs removal or ignore globs instead of
404
+ * promising an automatic retry. The flat reviewer's constant `unverified`
405
+ * assurance is not a gap.
379
406
  */
380
407
  export const concludeReviewOutcome = (
381
408
  outcome: ReviewRunOutcome,
@@ -385,11 +412,19 @@ export const concludeReviewOutcome = (
385
412
  } => {
386
413
  const machinery: Array<string> = [];
387
414
  if (outcome.inputCoverage.status === "incomplete" || outcome.assurance.status === "incomplete") {
388
- machinery.push(
389
- outcome.unreviewedPaths.length > 0
390
- ? `review infrastructure did not settle — a reviewer-side gap, not a code defect; ${outcome.unreviewedPaths.length} path(s) are carried forward and retried automatically on the next run`
391
- : "review infrastructure did not settle — a reviewer-side gap, not a code defect",
392
- );
415
+ const scope = splitCarriedScope(outcome);
416
+ if (scope.retryableGap) {
417
+ machinery.push(
418
+ scope.retryablePaths.length > 0
419
+ ? `review infrastructure did not settle — a reviewer-side gap, not a code defect; ${scope.retryablePaths.length} path(s) are carried forward and retried automatically on the next run`
420
+ : "review infrastructure did not settle — a reviewer-side gap, not a code defect",
421
+ );
422
+ }
423
+ if (scope.undiffablePaths.length > 0) {
424
+ machinery.push(
425
+ `${scope.undiffablePaths.length} path(s) have no reviewable diff (binary or oversized) and no retry can settle them — remove them from the pull request or exclude them with ignore globs`,
426
+ );
427
+ }
393
428
  if (outcome.inputCoverage.status === "incomplete") {
394
429
  machinery.push(...outcome.inputCoverage.reasons);
395
430
  }
@@ -408,10 +443,14 @@ export const concludeReviewOutcome = (
408
443
  return { conclusion: "success", reasons: [] };
409
444
  };
410
445
 
411
- const concludeReviewState = (state: ReviewState) => {
446
+ const concludeReviewState = (state: ReviewState, adjudicated: ReadonlySet<string>) => {
412
447
  const reasons = blockingReasons({
413
- findings: state.unresolvedFindings,
414
- concerns: state.unresolvedConcerns,
448
+ findings: state.unresolvedFindings.filter(
449
+ (finding) => !adjudicated.has(findingIdentity(finding)),
450
+ ),
451
+ concerns: state.unresolvedConcerns.filter(
452
+ (concern) => !adjudicated.has(concernIdentity(concern)),
453
+ ),
415
454
  });
416
455
  return reasons.length > 0
417
456
  ? ({ conclusion: "blocking", reasons } as const)
@@ -423,20 +462,21 @@ const skipCoveredReview = Effect.fn("skipCoveredReview")(function* (input: {
423
462
  readonly pullRequestNumber: number;
424
463
  readonly reason: string;
425
464
  readonly state: ReviewState;
426
- readonly fingerprint?: string | undefined;
427
465
  }) {
428
- const result = concludeReviewState(input.state);
466
+ // A maintainer adjudication must lift a preserved blocking conclusion
467
+ // without a code push, so the skip path re-reads adjudications (fail-open;
468
+ // stored ones survive a listing fault) before enforcing the stored state.
469
+ const adjudications = yield* collectReviewAdjudications(input.state.adjudications ?? []);
470
+ const result = concludeReviewState(input.state, new Set(adjudications.map(adjudicationIdentity)));
429
471
  yield* Console.log(
430
472
  `Skipping review of ${input.repository}#${input.pullRequestNumber}: ${input.reason}.`,
431
473
  );
432
474
  yield* writeActionOutputs([
433
475
  ["skipped", "true"],
434
476
  ["skip-reason", input.reason],
435
- ...(input.fingerprint === undefined ? [] : ([["fingerprint", input.fingerprint]] as const)),
436
477
  ["conclusion", result.conclusion],
437
478
  ["input-coverage", "complete"],
438
479
  ["review-assurance", "settled"],
439
- ["coverage", "complete"],
440
480
  ["review-mode", "incremental"],
441
481
  ]);
442
482
  yield* writeStepSummary([
@@ -501,16 +541,13 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
501
541
  return yield* Effect.gen(function* () {
502
542
  let selection: ReturnType<typeof selectReviewRange> | undefined;
503
543
  if (reviewer.profileFingerprint !== undefined) {
504
- const source = yield* PullRequestSource;
505
544
  const [snapshot, profileFingerprint, currentFingerprint] = yield* Effect.all([
506
- reviewer.snapshot ?? Effect.all({ metadata: source.metadata, files: source.anchorFiles }),
545
+ reviewer.snapshot,
507
546
  reviewer.profileFingerprint,
508
- reviewer.fingerprint === undefined
509
- ? Effect.succeed(undefined)
510
- : reviewer.fingerprint.pipe(
511
- Effect.map((fingerprint): string | undefined => fingerprint),
512
- Effect.orElseSucceed(() => undefined),
513
- ),
547
+ reviewer.fingerprint.pipe(
548
+ Effect.map((fingerprint): string | undefined => fingerprint),
549
+ Effect.orElseSucceed(() => undefined),
550
+ ),
514
551
  ]);
515
552
  const { metadata, files: fullFiles } = snapshot;
516
553
  const history = options.priorReviews ?? (yield* PriorReviews);
@@ -541,7 +578,7 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
541
578
  recovered.state.settled &&
542
579
  currentFingerprint !== undefined &&
543
580
  validateReviewState(recovered.state, metadata, profileFingerprint) === undefined &&
544
- recovered.state.acceptedScopeFingerprint === currentFingerprint
581
+ recovered.state.settledScopeFingerprint === currentFingerprint
545
582
  ? recovered.state
546
583
  : undefined;
547
584
  if (equivalentPatchState !== undefined) {
@@ -553,11 +590,11 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
553
590
  ? "the current head already has settled stored review assurance"
554
591
  : "the effective pull-request patch is unchanged since the last settled review",
555
592
  state: equivalentPatchState,
556
- fingerprint: currentFingerprint,
557
593
  });
558
594
  }
559
595
  let comparison: ReviewHeadComparison | undefined;
560
596
  let baseComparison: ReviewHeadComparison | undefined;
597
+ let contentComparison: ReviewHeadComparison | undefined;
561
598
  if (
562
599
  (options.reviewMode ?? "incremental") === "incremental" &&
563
600
  recovered.state !== undefined
@@ -593,6 +630,21 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
593
630
  });
594
631
  }
595
632
  }
633
+ if (
634
+ recovered.state.reviewedHeadSha !== metadata.headSha &&
635
+ (comparison === undefined ||
636
+ !isLineageAncestor(comparison, recovered.state, metadata.headSha))
637
+ ) {
638
+ contentComparison = yield* history
639
+ .compareTrees(recovered.state.reviewedHeadSha, metadata.headSha)
640
+ .pipe(Effect.orElseSucceed(() => undefined));
641
+ if (contentComparison !== undefined && reviewer.filterFiles !== undefined) {
642
+ contentComparison = ReviewHeadComparison.make({
643
+ ...contentComparison,
644
+ files: reviewer.filterFiles(contentComparison.files),
645
+ });
646
+ }
647
+ }
596
648
  }
597
649
  selection = {
598
650
  ...selectReviewRange({
@@ -603,6 +655,7 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
603
655
  priorState: recovered.state,
604
656
  comparison,
605
657
  baseComparison,
658
+ contentComparison,
606
659
  lookupFailure: recovered.failure,
607
660
  }),
608
661
  stateAuthenticator,
@@ -623,6 +676,17 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
623
676
  });
624
677
  }
625
678
  }
679
+ const executionContext =
680
+ selection ??
681
+ (yield* Effect.gen(function* () {
682
+ const source = yield* PullRequestSource;
683
+ const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
684
+ return fullReviewSelection({
685
+ reason: "explicit custom-reviewer full review without continuity selection",
686
+ files,
687
+ totalFiles: metadata.totalChangedFiles,
688
+ });
689
+ }));
626
690
  yield* Console.log(
627
691
  `Reviewing ${target.repository}#${target.number} (${options.post === false ? "dry run" : "posting"})...`,
628
692
  );
@@ -639,9 +703,9 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
639
703
  const headMetadata = yield* source.metadata.pipe(Effect.orElseSucceed(() => undefined));
640
704
  yield* progress.value.begin({
641
705
  headSha: headMetadata?.headSha,
642
- reviewMode: selection?.mode,
643
- reviewReason: selection?.reason,
644
- filesInScope: selection?.files.length,
706
+ reviewMode: executionContext.mode,
707
+ reviewReason: executionContext.reason,
708
+ filesInScope: executionContext.files.length,
645
709
  modelLabel: options.modelLabel,
646
710
  runUrl,
647
711
  });
@@ -658,12 +722,11 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
658
722
  ),
659
723
  )
660
724
  : runReview;
661
- const outcome = yield* selection === undefined
662
- ? reviewEffect
663
- : reviewEffect.pipe(
664
- Effect.provide(selectedPullRequestSourceLayer(selection)),
665
- Effect.provideService(ReviewExecutionContext, selection),
666
- );
725
+ const executionLayer = Layer.merge(
726
+ Layer.succeed(ReviewExecutionContext)(executionContext),
727
+ selectedPullRequestSourceLayer(executionContext),
728
+ );
729
+ const outcome = yield* reviewEffect.pipe(Effect.provide(executionLayer));
667
730
  yield* Console.log(
668
731
  `Review finished in ${outcome.turns} turn(s): verdict ${outcome.review.verdict}, ` +
669
732
  `${outcome.plan.comments.length} inline comment(s), ${outcome.plan.demoted.length} demoted finding(s).`,
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type ReviewProvider,
20
20
  } from "./internal/providers.ts";
21
21
  import { ReviewPublicationPlan } from "./internal/render.ts";
22
+ import { fullReviewExecutionContextLayer } from "./internal/review-state.ts";
22
23
  import type { ReviewRunOutcome } from "./internal/run.ts";
23
24
 
24
25
  // ---------------------------------------------------------------------------
@@ -158,7 +159,11 @@ const command = CliCommand.make(
158
159
  return Option.none<ReviewRunOutcome>();
159
160
  }
160
161
  }
161
- return Option.some(yield* reviewer.run({ post: flags.post }));
162
+ return Option.some(
163
+ yield* reviewer
164
+ .run({ post: flags.post })
165
+ .pipe(Effect.provide(fullReviewExecutionContextLayer("explicit CLI full review"))),
166
+ );
162
167
  });
163
168
 
164
169
  let result: Option.Option<ReviewRunOutcome>;
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The @effect-agent/pr-review public surface. Platform-free: host entrypoints
2
2
  // live behind the "./action" and "./cli" subpaths, deterministic test helpers
3
3
  // behind "./testing".
4
+ export * from "./internal/adjudication.ts";
4
5
  export * from "./internal/anchors.ts";
5
6
  export * from "./internal/diff.ts";
6
7
  export * from "./internal/coverage.ts";