@effect-agent/pr-review 0.0.1-beta.0 → 0.1.0-beta.11

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.
@@ -1,10 +1,16 @@
1
1
  import type { Redacted } from "effect";
2
- import { Context, Effect, Layer, Option, Schema } from "effect";
2
+ import { Context, DateTime, Effect, Layer, Option, Schema } from "effect";
3
3
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
4
 
5
5
  import { ChangedFile } from "./diff.ts";
6
6
  import { extractFingerprint } from "./fingerprint.ts";
7
7
  import type { ReviewPublicationPlan } from "./render.ts";
8
+ import {
9
+ RetirableReview,
10
+ RetirableReviewComment,
11
+ ReviewRetirementFailure,
12
+ ReviewRetirementHost,
13
+ } from "./retirement.ts";
8
14
  import {
9
15
  ReviewHeadComparison,
10
16
  ReviewStateAuthenticator,
@@ -27,26 +33,46 @@ import {
27
33
  // GitHubApiFailure instead of an untyped defect.
28
34
  // ---------------------------------------------------------------------------
29
35
 
36
+ const defaultGraphqlUrl = (apiUrl: string): string =>
37
+ apiUrl === "https://api.github.com"
38
+ ? "https://api.github.com/graphql"
39
+ : apiUrl.replace(/\/api\/v3$/, "/api/graphql");
40
+
30
41
  /** Which pull request to review and how to reach the API. */
42
+ export const DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN = "github-actions[bot]";
43
+
31
44
  export class GitHubReviewTarget extends Context.Service<
32
45
  GitHubReviewTarget,
33
46
  {
34
47
  /** API root, e.g. `https://api.github.com` (no trailing slash). */
35
48
  readonly apiUrl: string;
49
+ /** GraphQL root, e.g. `https://api.github.com/graphql`. */
50
+ readonly graphqlUrl: string;
36
51
  /** `owner/name`. */
37
52
  readonly repository: string;
38
53
  readonly number: number;
39
54
  /** Absent token means unauthenticated reads (public repositories only). */
40
55
  readonly token: Option.Option<Redacted.Redacted<string>>;
56
+ /** Bot login expected to author reviews posted with this target's token. */
57
+ readonly reviewAuthorLogin?: string | undefined;
41
58
  }
42
59
  >()("@effect-agent/pr-review/GitHubReviewTarget") {
43
60
  static layer(config: {
44
61
  readonly apiUrl: string;
62
+ readonly graphqlUrl?: string | undefined;
45
63
  readonly repository: string;
46
64
  readonly number: number;
47
65
  readonly token: Option.Option<Redacted.Redacted<string>>;
66
+ readonly reviewAuthorLogin?: string | undefined;
48
67
  }): Layer.Layer<GitHubReviewTarget> {
49
- return Layer.succeed(this, GitHubReviewTarget.of(config));
68
+ return Layer.succeed(
69
+ this,
70
+ GitHubReviewTarget.of({
71
+ ...config,
72
+ graphqlUrl: config.graphqlUrl ?? defaultGraphqlUrl(config.apiUrl),
73
+ reviewAuthorLogin: config.reviewAuthorLogin ?? DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN,
74
+ }),
75
+ );
50
76
  }
51
77
  }
52
78
 
@@ -82,11 +108,54 @@ const GitHubFileWire = Schema.Struct({
82
108
 
83
109
  const GitHubFilesPageWire = Schema.Array(GitHubFileWire);
84
110
 
111
+ const GitHubActorWire = Schema.Struct({ node_id: Schema.String });
112
+
85
113
  const GitHubReviewWire = Schema.Struct({
86
114
  id: Schema.Int,
87
115
  html_url: Schema.String,
116
+ user: Schema.NullOr(GitHubActorWire),
117
+ submitted_at: Schema.NullOr(Schema.String),
118
+ });
119
+
120
+ const GitHubRetirableReviewWire = Schema.Struct({
121
+ id: Schema.Int,
122
+ body: Schema.NullOr(Schema.String),
123
+ commit_id: Schema.String,
124
+ user: Schema.NullOr(GitHubActorWire),
125
+ submitted_at: Schema.NullOr(Schema.String),
126
+ });
127
+ const GitHubRetirableReviewsPageWire = Schema.Array(GitHubRetirableReviewWire);
128
+
129
+ const GitHubReviewCommentWire = Schema.Struct({
130
+ node_id: Schema.String,
131
+ path: Schema.String,
132
+ body: Schema.String,
133
+ line: Schema.NullOr(Schema.Int),
134
+ original_line: Schema.NullOr(Schema.Int),
135
+ start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
136
+ original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
137
+ });
138
+ const GitHubReviewCommentsPageWire = Schema.Array(GitHubReviewCommentWire);
139
+
140
+ const GitHubMinimizeCommentWire = Schema.Struct({
141
+ data: Schema.optionalKey(
142
+ Schema.NullOr(
143
+ Schema.Struct({
144
+ minimizeComment: Schema.NullOr(
145
+ Schema.Struct({
146
+ minimizedComment: Schema.NullOr(Schema.Struct({ isMinimized: Schema.Boolean })),
147
+ }),
148
+ ),
149
+ }),
150
+ ),
151
+ ),
152
+ errors: Schema.optionalKey(Schema.Array(Schema.Struct({ message: Schema.String }))),
88
153
  });
89
154
 
155
+ /** Decode GitHub's external timestamp before it participates in mutation ordering. */
156
+ export const parseGitHubSubmittedAt = (value: string | null): DateTime.Utc | null =>
157
+ value === null ? null : Option.getOrNull(DateTime.make(value));
158
+
90
159
  /** The publication receipt callers report back to the operator. */
91
160
  export class PublishedReview extends Schema.Class<PublishedReview>(
92
161
  "@effect-agent/pr-review/PublishedReview",
@@ -95,6 +164,9 @@ export class PublishedReview extends Schema.Class<PublishedReview>(
95
164
  url: Schema.String,
96
165
  event: Schema.String,
97
166
  inlineComments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
167
+ /** Actor and ordering boundary returned by the create-review response. */
168
+ authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
169
+ submittedAt: Schema.NullOr(Schema.DateTimeUtc),
98
170
  }) {}
99
171
 
100
172
  /** Posts one planned review; the ONLY mutating operation in this package. */
@@ -335,12 +407,176 @@ export const gitHubReviewPublisherLayer: Layer.Layer<
335
407
  url: wire.html_url,
336
408
  event: plan.event,
337
409
  inlineComments: plan.comments.length,
410
+ authorNodeId: wire.user?.node_id ?? null,
411
+ submittedAt: parseGitHubSubmittedAt(wire.submitted_at),
338
412
  });
339
413
  }),
340
414
  });
341
415
  }),
342
416
  );
343
417
 
418
+ // --- Live ReviewRetirementHost ----------------------------------------------
419
+
420
+ const MAX_RETIREMENT_PAGES = 5;
421
+ const MINIMIZE_REVIEW_COMMENT_MUTATION = `mutation MinimizeReviewComment($subjectId: ID!) {
422
+ minimizeComment(input: { subjectId: $subjectId, classifier: OUTDATED }) {
423
+ minimizedComment { isMinimized }
424
+ }
425
+ }`;
426
+
427
+ /** GitHub-backed host operations for cosmetic retirement after publication. */
428
+ export const gitHubReviewRetirementHostLayer: Layer.Layer<
429
+ ReviewRetirementHost,
430
+ never,
431
+ GitHubReviewTarget | HttpClient.HttpClient
432
+ > = Layer.effect(ReviewRetirementHost)(
433
+ Effect.gen(function* () {
434
+ const target = yield* GitHubReviewTarget;
435
+ const client = yield* HttpClient.HttpClient;
436
+ const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
437
+ const asRetirementFailure =
438
+ (operation: string) =>
439
+ (error: { readonly _tag: string; readonly message?: string }): ReviewRetirementFailure =>
440
+ ReviewRetirementFailure.make({
441
+ operation,
442
+ reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
443
+ });
444
+ const executeRetirement = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
445
+ HttpClient.execute(request).pipe(
446
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
447
+ Effect.mapError(asRetirementFailure(operation)),
448
+ Effect.provideService(HttpClient.HttpClient, client),
449
+ );
450
+ const decodeRetirement = <S extends Schema.Top>(schema: S, operation: string) => {
451
+ const decode = Schema.decodeUnknownEffect(schema);
452
+ return (response: HttpClientResponse.HttpClientResponse) =>
453
+ response.json.pipe(
454
+ Effect.mapError(asRetirementFailure(operation)),
455
+ Effect.flatMap((body) =>
456
+ decode(body).pipe(Effect.mapError(asRetirementFailure(operation))),
457
+ ),
458
+ );
459
+ };
460
+ const listPaged = <A>(input: {
461
+ readonly operation: string;
462
+ readonly url: string;
463
+ readonly decode: (
464
+ response: HttpClientResponse.HttpClientResponse,
465
+ ) => Effect.Effect<ReadonlyArray<A>, ReviewRetirementFailure>;
466
+ }) =>
467
+ Effect.gen(function* () {
468
+ const values: Array<A> = [];
469
+ const perPage = 100;
470
+ for (let page = 1; page <= MAX_RETIREMENT_PAGES; page += 1) {
471
+ const response = yield* executeRetirement(
472
+ input.operation,
473
+ withCommonHeaders(
474
+ HttpClientRequest.get(input.url).pipe(
475
+ HttpClientRequest.acceptJson,
476
+ HttpClientRequest.setUrlParams({
477
+ per_page: String(perPage),
478
+ page: String(page),
479
+ }),
480
+ ),
481
+ target.token,
482
+ ),
483
+ );
484
+ const pageValues = yield* input.decode(response);
485
+ values.push(...pageValues);
486
+ if (pageValues.length < perPage) return values;
487
+ }
488
+ return yield* ReviewRetirementFailure.make({
489
+ operation: input.operation,
490
+ reason: `history exceeds the bounded ${MAX_RETIREMENT_PAGES * 100}-item lookup`,
491
+ });
492
+ });
493
+
494
+ return ReviewRetirementHost.of({
495
+ listReviews: listPaged({
496
+ operation: "listReviewsForRetirement",
497
+ url: `${prefix}/reviews`,
498
+ decode: decodeRetirement(GitHubRetirableReviewsPageWire, "listReviewsForRetirement"),
499
+ }).pipe(
500
+ Effect.map((reviews) =>
501
+ reviews.map((review) =>
502
+ RetirableReview.make({
503
+ reviewId: review.id,
504
+ body: review.body ?? "",
505
+ commitSha: review.commit_id,
506
+ authorNodeId: review.user?.node_id ?? null,
507
+ submittedAt: parseGitHubSubmittedAt(review.submitted_at),
508
+ }),
509
+ ),
510
+ ),
511
+ ),
512
+ listComments: (reviewId) =>
513
+ listPaged({
514
+ operation: "listReviewCommentsForRetirement",
515
+ url: `${prefix}/reviews/${reviewId}/comments`,
516
+ decode: decodeRetirement(GitHubReviewCommentsPageWire, "listReviewCommentsForRetirement"),
517
+ }).pipe(
518
+ Effect.map((comments) =>
519
+ comments.map((comment) => {
520
+ const endLine = comment.line ?? comment.original_line;
521
+ const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
522
+ return RetirableReviewComment.make({
523
+ nodeId: comment.node_id,
524
+ path: comment.path,
525
+ startLine,
526
+ endLine,
527
+ body: comment.body,
528
+ });
529
+ }),
530
+ ),
531
+ ),
532
+ updateBody: (reviewId, body) =>
533
+ executeRetirement(
534
+ "updateReview",
535
+ withCommonHeaders(
536
+ HttpClientRequest.put(`${prefix}/reviews/${reviewId}`).pipe(
537
+ HttpClientRequest.acceptJson,
538
+ HttpClientRequest.bodyJsonUnsafe({ body }),
539
+ ),
540
+ target.token,
541
+ ),
542
+ ).pipe(Effect.asVoid),
543
+ minimizeComment: (nodeId) =>
544
+ Effect.gen(function* () {
545
+ const response = yield* executeRetirement(
546
+ "minimizeComment",
547
+ withCommonHeaders(
548
+ HttpClientRequest.post(target.graphqlUrl).pipe(
549
+ HttpClientRequest.acceptJson,
550
+ HttpClientRequest.bodyJsonUnsafe({
551
+ query: MINIMIZE_REVIEW_COMMENT_MUTATION,
552
+ variables: { subjectId: nodeId },
553
+ }),
554
+ ),
555
+ target.token,
556
+ ),
557
+ );
558
+ const wire = yield* decodeRetirement(
559
+ GitHubMinimizeCommentWire,
560
+ "minimizeComment",
561
+ )(response);
562
+ if (
563
+ (wire.errors?.length ?? 0) > 0 ||
564
+ wire.data?.minimizeComment?.minimizedComment?.isMinimized !== true
565
+ ) {
566
+ return yield* ReviewRetirementFailure.make({
567
+ operation: "minimizeComment",
568
+ reason:
569
+ wire.errors
570
+ ?.map((error) => error.message)
571
+ .join("; ")
572
+ .slice(0, 2_048) ?? "GitHub did not confirm comment minimization",
573
+ });
574
+ }
575
+ }),
576
+ });
577
+ }),
578
+ );
579
+
344
580
  // --- Prior reviews (fingerprint deduplication) ---------------------------------
345
581
 
346
582
  /** Reading the pull request's previously posted reviews failed. */
@@ -412,6 +648,7 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
412
648
  const target = yield* GitHubReviewTarget;
413
649
  const client = yield* HttpClient.HttpClient;
414
650
  const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
651
+ const reviewAuthorLogin = target.reviewAuthorLogin ?? DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN;
415
652
  const decodePage = Schema.decodeUnknownEffect(GitHubPriorReviewsPageWire);
416
653
  const asLookupFailure = (error: { readonly _tag: string; readonly message?: string }) =>
417
654
  PriorReviewLookupFailure.make({
@@ -443,10 +680,16 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
443
680
  Effect.flatMap((body) => decodePage(body).pipe(Effect.mapError(asLookupFailure))),
444
681
  );
445
682
  for (const wire of wires) {
446
- // State controls what required scope may be omitted. The author gate
447
- // rejects user prose; the terminal marker is additionally HMAC
448
- // authenticated so another bot workflow or model text cannot forge it.
449
- if (wire.user?.login !== "github-actions[bot]" || wire.user.type !== "Bot") continue;
683
+ // State controls what required scope may be omitted. Match the bot
684
+ // identity that posts with this target's token; the terminal marker
685
+ // is additionally HMAC authenticated so another workflow or model
686
+ // text cannot forge it.
687
+ if (
688
+ wire.user?.login.toLowerCase() !== reviewAuthorLogin.toLowerCase() ||
689
+ wire.user.type !== "Bot"
690
+ ) {
691
+ continue;
692
+ }
450
693
  const fingerprint = extractFingerprint(wire.body ?? "");
451
694
  if (fingerprint !== undefined) latest = Option.some(fingerprint);
452
695
  if (Option.isSome(authenticator)) {
@@ -46,7 +46,10 @@ export const PROVIDER_EFFORT_RUNGS = {
46
46
  /** One OpenAI review model binding with the package's structured-output settings. */
47
47
  export const makeOpenAiReviewModel = (model?: string, effort?: EffortPosition) =>
48
48
  OpenAiLanguageModel.model(model ?? DEFAULT_MODEL.openai, {
49
- max_output_tokens: 8_000,
49
+ // OpenAI counts hidden reasoning tokens and visible answer tokens against
50
+ // this same ceiling. High-effort reviews can exhaust an 8k allowance
51
+ // after reading every file but before emitting their structured report.
52
+ max_output_tokens: 32_000,
50
53
  store: false,
51
54
  strictJsonSchema: true,
52
55
  ...(effort === undefined
@@ -0,0 +1,332 @@
1
+ import { Context, DateTime, Effect, Option, Schema } from "effect";
2
+
3
+ import {
4
+ ReviewStateAuthenticator,
5
+ type ReviewState,
6
+ type StoredReviewFinding,
7
+ } from "./review-state.ts";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Review retirement. GitHub mutations stay behind ReviewRetirementHost; this
11
+ // module owns only deterministic identity matching, body rewriting, and the
12
+ // fail-open orchestration that turns stale reviews into quiet history.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
16
+
17
+ /** One previously posted review as observed through the retirement host. */
18
+ export class RetirableReview extends Schema.Class<RetirableReview>(
19
+ "@effect-agent/pr-review/RetirableReview",
20
+ )({
21
+ reviewId: Schema.Int.check(Schema.isGreaterThan(0)),
22
+ body: Schema.String.check(Schema.isMaxLength(60_000)),
23
+ commitSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
24
+ authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
25
+ submittedAt: Schema.NullOr(Schema.DateTimeUtc),
26
+ }) {}
27
+
28
+ /** One inline comment attached to a previously posted review. */
29
+ export class RetirableReviewComment extends Schema.Class<RetirableReviewComment>(
30
+ "@effect-agent/pr-review/RetirableReviewComment",
31
+ )({
32
+ nodeId: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
33
+ path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
34
+ startLine: Schema.NullOr(PositiveLine),
35
+ endLine: Schema.NullOr(PositiveLine),
36
+ body: Schema.String.check(Schema.isMaxLength(65_536)),
37
+ }) {}
38
+
39
+ /** A GitHub retirement read or mutation failed. */
40
+ export class ReviewRetirementFailure extends Schema.TaggedError<ReviewRetirementFailure>()(
41
+ "ReviewRetirementFailure",
42
+ {
43
+ operation: Schema.String,
44
+ reason: Schema.String,
45
+ },
46
+ ) {
47
+ override get message() {
48
+ return `Review retirement operation '${this.operation}' failed: ${this.reason}`;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Host-side GitHub operations used by retirement. Domain code never reaches
54
+ * into REST or GraphQL directly, and deterministic tests substitute this port.
55
+ */
56
+ export class ReviewRetirementHost extends Context.Service<
57
+ ReviewRetirementHost,
58
+ {
59
+ readonly listReviews: Effect.Effect<ReadonlyArray<RetirableReview>, ReviewRetirementFailure>;
60
+ readonly listComments: (
61
+ reviewId: number,
62
+ ) => Effect.Effect<ReadonlyArray<RetirableReviewComment>, ReviewRetirementFailure>;
63
+ readonly updateBody: (
64
+ reviewId: number,
65
+ body: string,
66
+ ) => Effect.Effect<void, ReviewRetirementFailure>;
67
+ readonly minimizeComment: (nodeId: string) => Effect.Effect<void, ReviewRetirementFailure>;
68
+ }
69
+ >()("@effect-agent/pr-review/ReviewRetirementHost") {}
70
+
71
+ /** Observable cosmetic work completed by one fail-open retirement pass. */
72
+ export class ReviewRetirementReport extends Schema.Class<ReviewRetirementReport>(
73
+ "@effect-agent/pr-review/ReviewRetirementReport",
74
+ )({
75
+ reviewsRetired: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
76
+ findingsResolved: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
77
+ commentsMinimized: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
78
+ failures: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
79
+ }) {}
80
+
81
+ export interface ReviewRetirementInput {
82
+ readonly currentReviewId: number;
83
+ readonly currentReviewUrl: string;
84
+ readonly currentAuthorNodeId: string;
85
+ readonly currentSubmittedAt: DateTime.Utc;
86
+ readonly currentState: ReviewState;
87
+ }
88
+
89
+ export interface ReviewRetirementDecision {
90
+ readonly body: string;
91
+ readonly resolvedFindings: ReadonlyArray<StoredReviewFinding>;
92
+ readonly priorFindingCount: number;
93
+ }
94
+
95
+ const findingIdentity = (finding: {
96
+ readonly path: string;
97
+ readonly startLine: number;
98
+ readonly endLine: number;
99
+ readonly title: string;
100
+ }): string =>
101
+ `${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.title}`;
102
+
103
+ const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
104
+ const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
105
+ const STATE_PATTERN =
106
+ /<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
107
+ const RETIRED_ORIGINAL_PATTERN =
108
+ /<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
109
+ const MACHINE_COMMENT_PATTERN = new RegExp(
110
+ `${REVIEW_METADATA_PATTERN.source}|${FINGERPRINT_PATTERN.source}|${STATE_PATTERN.source}`,
111
+ "g",
112
+ );
113
+ const VERDICT_CALLOUT_PATTERN =
114
+ /^(?:> \[!(?:CAUTION|IMPORTANT)\]\n> [^\n]*(?:\n> [^\n]*)*|> (?:ℹ️|✅)[^\n]*)\n*/;
115
+ const INLINE_FINDING_TITLE_PATTERN = /^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit)\] ([^\n]+)\*\*$/;
116
+ const MAX_REVIEW_BODY_CHARS = 60_000;
117
+
118
+ /** The host-authored metadata marker is the authority gate for any edit. */
119
+ export const hasReviewMetadataMarker = (body: string): boolean =>
120
+ /<!-- effect-agent-pr-review metadata\n/.test(body);
121
+
122
+ const machineComments = (body: string): ReadonlyArray<string> =>
123
+ Array.from(body.matchAll(MACHINE_COMMENT_PATTERN), (match) => match[0]);
124
+
125
+ const originalVisibleBody = (body: string): string => {
126
+ const retired = RETIRED_ORIGINAL_PATTERN.exec(body)?.[1];
127
+ if (retired !== undefined) return retired;
128
+ return body.replace(MACHINE_COMMENT_PATTERN, "").trim().replace(VERDICT_CALLOUT_PATTERN, "");
129
+ };
130
+
131
+ const findingLocation = (finding: StoredReviewFinding): string =>
132
+ `${finding.path}:${finding.startLine}${
133
+ finding.endLine === finding.startLine ? "" : `-${finding.endLine}`
134
+ }`;
135
+
136
+ const renderRetiredBody = (input: {
137
+ readonly priorBody: string;
138
+ readonly priorState: ReviewState;
139
+ readonly currentState: ReviewState;
140
+ readonly currentReviewUrl: string;
141
+ readonly resolvedFindings: ReadonlyArray<StoredReviewFinding>;
142
+ }): string => {
143
+ const shortSha = input.currentState.reviewedHeadSha.slice(0, 7);
144
+ const comments = machineComments(input.priorBody);
145
+ const original = originalVisibleBody(input.priorBody);
146
+ const resolved =
147
+ input.resolvedFindings.length === 0
148
+ ? []
149
+ : [
150
+ "### Findings resolved by later review",
151
+ "",
152
+ ...input.resolvedFindings.map(
153
+ (finding) =>
154
+ `- \`${findingLocation(finding)}\` ~~${finding.title}~~ · resolved at \`${shortSha}\``,
155
+ ),
156
+ "",
157
+ ];
158
+ const prefix = [
159
+ `> ℹ️ Superseded — ${input.resolvedFindings.length} of ${input.priorState.unresolvedFindings.length} findings resolved at \`${shortSha}\`; see [the latest review](${input.currentReviewUrl}).`,
160
+ "",
161
+ "<details>",
162
+ "<summary>Previous review details</summary>",
163
+ "",
164
+ ...resolved,
165
+ "<!-- effect-agent-pr-review retired-original:start -->",
166
+ ];
167
+ const suffix = [
168
+ "<!-- effect-agent-pr-review retired-original:end -->",
169
+ "",
170
+ "</details>",
171
+ ...(comments.length === 0 ? [] : ["", ...comments]),
172
+ ];
173
+ const render = (visible: string) => [...prefix, visible, ...suffix].join("\n");
174
+ if (render(original).length <= MAX_REVIEW_BODY_CHARS) return render(original);
175
+ const truncationNotice = "\n\n_Original review content truncated during retirement._";
176
+ const budget = Math.max(0, MAX_REVIEW_BODY_CHARS - render(truncationNotice).length);
177
+ return render(`${original.slice(0, budget)}${truncationNotice}`);
178
+ };
179
+
180
+ /** Compute one prior review's resolved subset and deterministic retired body. */
181
+ export const decideReviewRetirement = (input: {
182
+ readonly priorBody: string;
183
+ readonly priorState: ReviewState;
184
+ readonly currentState: ReviewState;
185
+ readonly currentReviewUrl: string;
186
+ }): ReviewRetirementDecision => {
187
+ const current = new Set(input.currentState.unresolvedFindings.map(findingIdentity));
188
+ const resolvedFindings = input.priorState.unresolvedFindings.filter(
189
+ (finding) => !current.has(findingIdentity(finding)),
190
+ );
191
+ return {
192
+ body: renderRetiredBody({ ...input, resolvedFindings }),
193
+ resolvedFindings,
194
+ priorFindingCount: input.priorState.unresolvedFindings.length,
195
+ };
196
+ };
197
+
198
+ const inlineCommentIdentity = (comment: RetirableReviewComment): string | undefined => {
199
+ if (comment.startLine === null || comment.endLine === null) return undefined;
200
+ const firstLine = comment.body.split("\n", 1)[0] ?? "";
201
+ const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine)?.[1];
202
+ return title === undefined
203
+ ? undefined
204
+ : findingIdentity({
205
+ path: comment.path,
206
+ startLine: comment.startLine,
207
+ endLine: comment.endLine,
208
+ title,
209
+ });
210
+ };
211
+
212
+ const failOpen = <A, E, R>(
213
+ effect: Effect.Effect<A, E, R>,
214
+ fallback: A,
215
+ message: string,
216
+ ): Effect.Effect<A, never, R> =>
217
+ effect.pipe(
218
+ Effect.catch((error) =>
219
+ Effect.logWarning(`${message}: ${String(error)}`).pipe(Effect.as(fallback)),
220
+ ),
221
+ );
222
+
223
+ const isStrictlyOlderReview = (review: RetirableReview, input: ReviewRetirementInput): boolean => {
224
+ if (review.submittedAt === null) return false;
225
+ const submittedAt = DateTime.toEpochMillis(review.submittedAt);
226
+ const currentSubmittedAt = DateTime.toEpochMillis(input.currentSubmittedAt);
227
+ return (
228
+ submittedAt < currentSubmittedAt ||
229
+ (submittedAt === currentSubmittedAt && review.reviewId < input.currentReviewId)
230
+ );
231
+ };
232
+
233
+ /**
234
+ * Retire every marker-bearing prior review against the newest posted state.
235
+ * Every lookup, edit, and minimization is isolated: retirement is cosmetic
236
+ * and can never change the run or check outcome.
237
+ */
238
+ export const retireStaleReviews = Effect.fn("retireStaleReviews")(function* (
239
+ input: ReviewRetirementInput,
240
+ ) {
241
+ const host = yield* ReviewRetirementHost;
242
+ const authenticator = yield* ReviewStateAuthenticator;
243
+ if (authenticator.status !== "available") {
244
+ yield* Effect.logWarning(
245
+ "Skipping stale-review retirement because authenticated review state is unavailable.",
246
+ );
247
+ return ReviewRetirementReport.make({
248
+ reviewsRetired: 0,
249
+ findingsResolved: 0,
250
+ commentsMinimized: 0,
251
+ failures: 0,
252
+ });
253
+ }
254
+
255
+ let failures = 0;
256
+ let reviewsRetired = 0;
257
+ let findingsResolved = 0;
258
+ let commentsMinimized = 0;
259
+ const reviews = yield* failOpen(host.listReviews, undefined, "Could not list prior reviews");
260
+ if (reviews === undefined) {
261
+ return ReviewRetirementReport.make({
262
+ reviewsRetired,
263
+ findingsResolved,
264
+ commentsMinimized,
265
+ failures: 1,
266
+ });
267
+ }
268
+
269
+ for (const review of reviews) {
270
+ if (
271
+ review.authorNodeId !== input.currentAuthorNodeId ||
272
+ !isStrictlyOlderReview(review, input) ||
273
+ !hasReviewMetadataMarker(review.body)
274
+ ) {
275
+ continue;
276
+ }
277
+ const priorState = yield* failOpen(
278
+ authenticator.extract(review.body),
279
+ Option.none<ReviewState>(),
280
+ `Could not authenticate prior review ${review.reviewId}`,
281
+ );
282
+ if (Option.isNone(priorState)) continue;
283
+
284
+ const decision = decideReviewRetirement({
285
+ priorBody: review.body,
286
+ priorState: priorState.value,
287
+ currentState: input.currentState,
288
+ currentReviewUrl: input.currentReviewUrl,
289
+ });
290
+ const updated = yield* failOpen(
291
+ host.updateBody(review.reviewId, decision.body).pipe(Effect.as(true)),
292
+ false,
293
+ `Could not retire prior review ${review.reviewId}`,
294
+ );
295
+ if (updated) {
296
+ reviewsRetired += 1;
297
+ findingsResolved += decision.resolvedFindings.length;
298
+ } else {
299
+ failures += 1;
300
+ }
301
+
302
+ if (decision.resolvedFindings.length === 0) continue;
303
+ const comments = yield* failOpen(
304
+ host.listComments(review.reviewId),
305
+ undefined,
306
+ `Could not list inline comments for prior review ${review.reviewId}`,
307
+ );
308
+ if (comments === undefined) {
309
+ failures += 1;
310
+ continue;
311
+ }
312
+ const resolved = new Set(decision.resolvedFindings.map(findingIdentity));
313
+ for (const comment of comments) {
314
+ const identity = inlineCommentIdentity(comment);
315
+ if (identity === undefined || !resolved.has(identity)) continue;
316
+ const minimized = yield* failOpen(
317
+ host.minimizeComment(comment.nodeId).pipe(Effect.as(true)),
318
+ false,
319
+ `Could not minimize resolved inline comment ${comment.nodeId}`,
320
+ );
321
+ if (minimized) commentsMinimized += 1;
322
+ else failures += 1;
323
+ }
324
+ }
325
+
326
+ return ReviewRetirementReport.make({
327
+ reviewsRetired,
328
+ findingsResolved,
329
+ commentsMinimized,
330
+ failures,
331
+ });
332
+ });
@@ -360,6 +360,12 @@ export const defaultReviewPolicy = AgentPolicy.make({
360
360
  maxDuration: "8 minutes",
361
361
  toolConcurrency: 2,
362
362
  tokenBudget: 300_000,
363
+ // Keep enough output/summary headroom for the 200k-class provider window;
364
+ // tool-heavy histories prune before the engine spends a summarization call.
365
+ contextTokenLimit: 150_000,
366
+ // Budget soft landing (RUN-018): an exhausted reviewer returns its partial
367
+ // review on one final tool-free turn instead of failing the whole run.
368
+ onExhaustion: "final-answer",
363
369
  });
364
370
 
365
371
  // ---------------------------------------------------------------------------