@effect-agent/pr-review 0.1.0-beta.23 → 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.
@@ -25,6 +25,7 @@ import {
25
25
  WalkthroughEntry,
26
26
  } from "./review-agent.ts";
27
27
  import {
28
+ MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS,
28
29
  MAX_REVIEW_UNITS,
29
30
  MAX_UNIT_EVIDENCE_SHARDS,
30
31
  MAX_UNIT_FILES,
@@ -32,13 +33,13 @@ import {
32
33
  planReviewUnits,
33
34
  rankAndDedupeConcerns,
34
35
  rankAndDedupeFindings,
36
+ ReviewDiscoveryPass,
35
37
  ReviewEvidenceShardId,
36
38
  ReviewPassId,
37
39
  ReviewRiskCategory,
40
+ ReviewUnit,
38
41
  ReviewUnitId,
39
- type ReviewDiscoveryPass,
40
- type ReviewUnit,
41
- type ReviewUnitPlan,
42
+ ReviewUnitPlan,
42
43
  } from "./review-units.ts";
43
44
 
44
45
  // ---------------------------------------------------------------------------
@@ -361,6 +362,11 @@ export interface FanOutPipelineOutcome {
361
362
  readonly plan: ReviewUnitPlan;
362
363
  /** Paths of units with an unsettled pass — retryable scope for the next run. */
363
364
  readonly unreviewedPaths: ReadonlyArray<string>;
365
+ /** Failed stages paired with the leftover paths they still own. */
366
+ readonly unreviewedPasses: ReadonlyArray<{
367
+ readonly stage: FailedReviewPass["stage"];
368
+ readonly paths: ReadonlyArray<string>;
369
+ }>;
364
370
  /** Total settled child turns across every scheduled pass. */
365
371
  readonly turns: number;
366
372
  }
@@ -372,6 +378,16 @@ export interface FanOutPipelineInput {
372
378
  readonly maxFindings?: number | undefined;
373
379
  /** Shared run budget observed by every child pass. */
374
380
  readonly budget?: RunBudgetHook<BudgetExceeded | BudgetAdapterError> | undefined;
381
+ /**
382
+ * Unchanged leftover paths from a prior failed pass. Those units retry only
383
+ * the recorded stages — no second general discovery on files nobody touched.
384
+ */
385
+ readonly retry?:
386
+ | {
387
+ readonly paths: ReadonlyArray<string>;
388
+ readonly stages: ReadonlyArray<FailedReviewPass["stage"]>;
389
+ }
390
+ | undefined;
375
391
  }
376
392
 
377
393
  const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
@@ -587,6 +603,10 @@ interface UnitReviewOutcome {
587
603
  readonly requiredVerificationPasses: number;
588
604
  readonly completedVerificationPasses: number;
589
605
  readonly unreviewedPaths: ReadonlyArray<string>;
606
+ readonly unreviewedPasses: ReadonlyArray<{
607
+ readonly stage: FailedReviewPass["stage"];
608
+ readonly paths: ReadonlyArray<string>;
609
+ }>;
590
610
  }
591
611
 
592
612
  const reviewUnit = <Provider, ModelProvides, ModelRequires>(
@@ -697,6 +717,10 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
697
717
  requiredVerificationPasses,
698
718
  completedVerificationPasses,
699
719
  unreviewedPaths: failedPasses.length > 0 ? unit.paths : [],
720
+ unreviewedPasses: failedPasses.map((pass) => ({
721
+ stage: pass.stage,
722
+ paths: unit.paths,
723
+ })),
700
724
  } satisfies UnitReviewOutcome;
701
725
  });
702
726
 
@@ -737,6 +761,121 @@ const composeSummary = (plan: ReviewUnitPlan, assurance: ReviewAssurance): strin
737
761
  return parts.join(" ").slice(0, 4_000);
738
762
  };
739
763
 
764
+ const remapPlanUnitIds = (plan: ReviewUnitPlan, offset: number): ReviewUnitPlan => {
765
+ if (offset === 0) return plan;
766
+ const units = plan.units.map((unit, index) =>
767
+ ReviewUnit.make({
768
+ ...unit,
769
+ unitId: `unit-${String(offset + index + 1).padStart(3, "0")}`,
770
+ }),
771
+ );
772
+ const mappedIds = new Map<string, string>();
773
+ for (const [index, unit] of plan.units.entries()) {
774
+ const remapped = units[index];
775
+ if (remapped !== undefined) {
776
+ mappedIds.set(unit.unitId, remapped.unitId);
777
+ }
778
+ }
779
+ return ReviewUnitPlan.make({
780
+ ...plan,
781
+ units,
782
+ discoveryPasses: plan.discoveryPasses.map((pass) => {
783
+ const unitId = mappedIds.get(pass.unitId) ?? pass.unitId;
784
+ return ReviewDiscoveryPass.make({
785
+ ...pass,
786
+ unitId,
787
+ passId: `${unitId}${pass.passId.slice(pass.unitId.length)}`,
788
+ });
789
+ }),
790
+ });
791
+ };
792
+
793
+ const scheduleFanOutWork = (
794
+ input: FanOutPipelineInput,
795
+ ): {
796
+ readonly plan: ReviewUnitPlan;
797
+ readonly passesByUnit: Map<string, ReadonlyArray<ReviewDiscoveryPass>>;
798
+ readonly overflowRetryPaths: ReadonlyArray<string>;
799
+ } => {
800
+ const retryPathSet = new Set(input.retry?.paths ?? []);
801
+ const retryStages = new Set(input.retry?.stages ?? []);
802
+ if (retryPathSet.size === 0) {
803
+ const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
804
+ const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
805
+ for (const pass of plan.discoveryPasses) {
806
+ const passes = passesByUnit.get(pass.unitId) ?? [];
807
+ passes.push(pass);
808
+ passesByUnit.set(pass.unitId, passes);
809
+ }
810
+ return { plan, passesByUnit, overflowRetryPaths: [] };
811
+ }
812
+ const freshFiles = input.files.filter((file) => !retryPathSet.has(file.path));
813
+ const retryFiles = input.files.filter((file) => retryPathSet.has(file.path));
814
+ const freshPlan = planReviewUnits(freshFiles, { totalChangedFiles: input.totalChangedFiles });
815
+ const retryPlan = remapPlanUnitIds(
816
+ planReviewUnits(retryFiles, { totalChangedFiles: input.totalChangedFiles }),
817
+ freshPlan.units.length,
818
+ );
819
+ const acceptedFresh = freshPlan.units.slice(0, MAX_REVIEW_UNITS);
820
+ const acceptedRetry = retryPlan.units.slice(
821
+ 0,
822
+ Math.max(0, MAX_REVIEW_UNITS - acceptedFresh.length),
823
+ );
824
+ const overflowRetryPaths = retryPlan.units
825
+ .slice(acceptedRetry.length)
826
+ .flatMap((unit) => [...unit.paths]);
827
+ const retryPassFilter = (pass: ReviewDiscoveryPass): boolean => {
828
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
829
+ return retryStages.has(stage);
830
+ };
831
+ const acceptedRetryIds = new Set(acceptedRetry.map((unit) => unit.unitId));
832
+ let retryPasses = retryPlan.discoveryPasses.filter(
833
+ (pass) => acceptedRetryIds.has(pass.unitId) && retryPassFilter(pass),
834
+ );
835
+ if (
836
+ retryStages.has("verification") &&
837
+ !retryStages.has("discovery") &&
838
+ !retryStages.has("specialist")
839
+ ) {
840
+ retryPasses = retryPlan.discoveryPasses.filter((pass) => acceptedRetryIds.has(pass.unitId));
841
+ }
842
+ const acceptedFreshIds = new Set(acceptedFresh.map((unit) => unit.unitId));
843
+ const freshPasses = freshPlan.discoveryPasses.filter((pass) => acceptedFreshIds.has(pass.unitId));
844
+ const discoveryPasses = [...freshPasses, ...retryPasses];
845
+ const plan = ReviewUnitPlan.make({
846
+ totalFiles: input.files.length,
847
+ truncated: freshPlan.truncated || retryPlan.truncated,
848
+ units: [...acceptedFresh, ...acceptedRetry],
849
+ discoveryPasses,
850
+ undiffablePaths: [
851
+ ...new Set([...freshPlan.undiffablePaths, ...retryPlan.undiffablePaths]),
852
+ ].sort(),
853
+ partialEvidencePaths: [
854
+ ...new Set([...freshPlan.partialEvidencePaths, ...retryPlan.partialEvidencePaths]),
855
+ ].sort(),
856
+ unassignedEvidenceShardCount:
857
+ freshPlan.unassignedEvidenceShardCount + retryPlan.unassignedEvidenceShardCount,
858
+ unassignedEvidenceShardIds: [
859
+ ...freshPlan.unassignedEvidenceShardIds,
860
+ ...retryPlan.unassignedEvidenceShardIds,
861
+ ].slice(0, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS),
862
+ unassignedPaths: [
863
+ ...new Set([
864
+ ...freshPlan.unassignedPaths,
865
+ ...retryPlan.unassignedPaths,
866
+ ...overflowRetryPaths,
867
+ ]),
868
+ ].sort(),
869
+ });
870
+ const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
871
+ for (const pass of discoveryPasses) {
872
+ const passes = passesByUnit.get(pass.unitId) ?? [];
873
+ passes.push(pass);
874
+ passesByUnit.set(pass.unitId, passes);
875
+ }
876
+ return { plan, passesByUnit, overflowRetryPaths };
877
+ };
878
+
740
879
  /**
741
880
  * Run the complete host-scheduled fan-out pipeline over one selected
742
881
  * changeset snapshot: plan, independent discovery, exact verification, and a
@@ -748,13 +887,7 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
748
887
  input: FanOutPipelineInput,
749
888
  ) =>
750
889
  Effect.gen(function* () {
751
- const plan = planReviewUnits(input.files, { totalChangedFiles: input.totalChangedFiles });
752
- const passesByUnit = new Map<string, Array<ReviewDiscoveryPass>>();
753
- for (const pass of plan.discoveryPasses) {
754
- const passes = passesByUnit.get(pass.unitId) ?? [];
755
- passes.push(pass);
756
- passesByUnit.set(pass.unitId, passes);
757
- }
890
+ const { plan, passesByUnit, overflowRetryPaths } = scheduleFanOutWork(input);
758
891
  const outcomes = yield* Effect.forEach(
759
892
  plan.units,
760
893
  (unit) => reviewUnit(binding, unit, passesByUnit.get(unit.unitId) ?? [], input),
@@ -865,6 +998,15 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
865
998
  ...plan.undiffablePaths,
866
999
  ]),
867
1000
  ].sort(),
1001
+ unreviewedPasses: [
1002
+ ...outcomes.flatMap((outcome) => outcome.unreviewedPasses),
1003
+ ...(overflowRetryPaths.length === 0
1004
+ ? []
1005
+ : (input.retry?.stages.length
1006
+ ? input.retry.stages
1007
+ : (["discovery", "specialist", "verification"] as const)
1008
+ ).map((stage) => ({ stage, paths: overflowRetryPaths }))),
1009
+ ],
868
1010
  turns: outcomes.reduce((total, outcome) => total + outcome.turns, 0),
869
1011
  } satisfies FanOutPipelineOutcome;
870
1012
  });
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Crypto, Effect, Encoding } from "effect";
2
2
 
3
3
  import type { ChangedFile } from "./diff.ts";
4
4
 
@@ -36,14 +36,14 @@ export const extractFingerprint = (body: string): string | undefined => {
36
36
  return last;
37
37
  };
38
38
 
39
- /** WebCrypto SHA-256; unavailable crypto is a defect, not an expected failure. */
40
- const sha256Hex = (text: string): Effect.Effect<string> =>
41
- Effect.promise(async () => {
42
- const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
43
- return Array.from(new Uint8Array(digest))
44
- .map((byte) => byte.toString(16).padStart(2, "0"))
45
- .join("");
46
- });
39
+ /** SHA-256 via `Crypto.Crypto`; unavailable crypto is a defect, not an expected failure. */
40
+ const sha256Hex = Effect.fn("sha256Hex")(function* (
41
+ text: string,
42
+ ): Effect.fn.Return<string, never, Crypto.Crypto> {
43
+ const crypto = yield* Crypto.Crypto;
44
+ const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.orDie);
45
+ return Encoding.encodeHex(digest);
46
+ });
47
47
 
48
48
  const FIELD = "\u0000";
49
49
  const RECORD = "\u0001";
@@ -80,4 +80,10 @@ const canonicalChangeset = (files: ReadonlyArray<ChangedFile>): string =>
80
80
  export const computeChangesetFingerprint = (
81
81
  files: ReadonlyArray<ChangedFile>,
82
82
  signature: string,
83
- ): Effect.Effect<string> => sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
83
+ ): Effect.Effect<string, never, Crypto.Crypto> =>
84
+ sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
85
+
86
+ /** Profile fingerprints are SHA-256 over configuration-only signatures. */
87
+ export const computeProfileFingerprint = (
88
+ signature: string,
89
+ ): Effect.Effect<string, never, Crypto.Crypto> => sha256Hex(signature);
@@ -120,6 +120,7 @@ export const staticPriorReviews = (
120
120
  options: {
121
121
  readonly state?: Option.Option<ReviewState> | undefined;
122
122
  readonly comparison?: ReviewHeadComparison | undefined;
123
+ readonly treeComparison?: ReviewHeadComparison | undefined;
123
124
  } = {},
124
125
  ): PriorReviews["Service"] =>
125
126
  PriorReviews.of({
@@ -129,6 +130,10 @@ export const staticPriorReviews = (
129
130
  options.comparison === undefined
130
131
  ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture comparison" }))
131
132
  : Effect.succeed(options.comparison),
133
+ compareTrees: () =>
134
+ options.treeComparison === undefined
135
+ ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture tree comparison" }))
136
+ : Effect.succeed(options.treeComparison),
132
137
  });
133
138
 
134
139
  /** Layer form for consumers whose Effect explicitly requires `PriorReviews`. */
@@ -137,6 +142,7 @@ export const staticPriorReviewsLayer = (
137
142
  options: {
138
143
  readonly state?: Option.Option<ReviewState> | undefined;
139
144
  readonly comparison?: ReviewHeadComparison | undefined;
145
+ readonly treeComparison?: ReviewHeadComparison | undefined;
140
146
  } = {},
141
147
  ): Layer.Layer<PriorReviews> =>
142
148
  Layer.succeed(PriorReviews)(staticPriorReviews(fingerprint, options));
@@ -130,8 +130,9 @@ const GitHubReviewCommentWire = Schema.Struct({
130
130
  node_id: Schema.String,
131
131
  path: Schema.String,
132
132
  body: Schema.String,
133
- line: Schema.NullOr(Schema.Int),
134
- original_line: Schema.NullOr(Schema.Int),
133
+ // Outdated comments omit `line` entirely instead of sending null.
134
+ line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
135
+ original_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
135
136
  start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
136
137
  original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
137
138
  });
@@ -578,8 +579,11 @@ export const gitHubReviewRetirementHostLayer: Layer.Layer<
578
579
  }).pipe(
579
580
  Effect.map((comments) =>
580
581
  comments.map((comment) => {
581
- const endLine = comment.line ?? comment.original_line;
582
- const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
582
+ const positiveLine = (value: number | null | undefined): number | null =>
583
+ value !== undefined && value !== null && value > 0 ? value : null;
584
+ const endLine = positiveLine(comment.line ?? comment.original_line);
585
+ const startLine =
586
+ positiveLine(comment.start_line ?? comment.original_start_line) ?? endLine;
583
587
  return RetirableReviewComment.make({
584
588
  nodeId: comment.node_id,
585
589
  path: comment.path,
@@ -672,6 +676,15 @@ export class PriorReviews extends Context.Service<
672
676
  baseSha: string,
673
677
  headSha: string,
674
678
  ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
679
+ /**
680
+ * Two-dot tree comparison (`base..head`). Used when the reviewed head is
681
+ * not a git ancestor so a rebase or amend can still name the paths whose
682
+ * blob contents actually changed.
683
+ */
684
+ readonly compareTrees: (
685
+ baseSha: string,
686
+ headSha: string,
687
+ ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
675
688
  }
676
689
  >()("@effect-agent/pr-review/PriorReviews") {}
677
690
 
@@ -775,12 +788,12 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
775
788
  }
776
789
  return { latestFingerprint: latest, latestState };
777
790
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
778
- const compareHeads = (baseSha: string, headSha: string) =>
791
+ const compareCommits = (baseSha: string, headSha: string, separator: "..." | "..") =>
779
792
  Effect.gen(function* () {
780
793
  const response = yield* HttpClient.execute(
781
794
  withCommonHeaders(
782
795
  HttpClientRequest.get(
783
- `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
796
+ `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}${separator}${encodeURIComponent(headSha)}`,
784
797
  ).pipe(HttpClientRequest.acceptJson),
785
798
  target.token,
786
799
  ),
@@ -803,6 +816,21 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
803
816
  truncated: files.length >= MAX_CHANGED_FILES,
804
817
  });
805
818
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
819
+ const compareTrees = (baseSha: string, headSha: string) =>
820
+ compareCommits(baseSha, headSha, "..").pipe(
821
+ Effect.map((comparison) =>
822
+ ReviewHeadComparison.make({
823
+ // This is a content snapshot, not a lineage claim. Selection
824
+ // intersects these files with the current PR path set.
825
+ status: comparison.status === "identical" ? "identical" : "ahead",
826
+ baseSha,
827
+ headSha,
828
+ mergeBaseSha: baseSha,
829
+ files: comparison.files,
830
+ truncated: comparison.truncated,
831
+ }),
832
+ ),
833
+ );
806
834
  return PriorReviews.of({
807
835
  latestFingerprint: readMarkers(Option.none()).pipe(
808
836
  Effect.map((markers) => markers.latestFingerprint),
@@ -813,7 +841,8 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
813
841
  Effect.map((markers) => markers.latestState),
814
842
  );
815
843
  }),
816
- compareHeads,
844
+ compareHeads: (baseSha, headSha) => compareCommits(baseSha, headSha, "..."),
845
+ compareTrees,
817
846
  });
818
847
  }),
819
848
  );