@effect-agent/pr-review 0.1.0-beta.6 → 0.1.0-beta.9

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,12 +33,19 @@ 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. */
31
42
  export class GitHubReviewTarget extends Context.Service<
32
43
  GitHubReviewTarget,
33
44
  {
34
45
  /** API root, e.g. `https://api.github.com` (no trailing slash). */
35
46
  readonly apiUrl: string;
47
+ /** GraphQL root, e.g. `https://api.github.com/graphql`. */
48
+ readonly graphqlUrl: string;
36
49
  /** `owner/name`. */
37
50
  readonly repository: string;
38
51
  readonly number: number;
@@ -42,11 +55,18 @@ export class GitHubReviewTarget extends Context.Service<
42
55
  >()("@effect-agent/pr-review/GitHubReviewTarget") {
43
56
  static layer(config: {
44
57
  readonly apiUrl: string;
58
+ readonly graphqlUrl?: string | undefined;
45
59
  readonly repository: string;
46
60
  readonly number: number;
47
61
  readonly token: Option.Option<Redacted.Redacted<string>>;
48
62
  }): Layer.Layer<GitHubReviewTarget> {
49
- return Layer.succeed(this, GitHubReviewTarget.of(config));
63
+ return Layer.succeed(
64
+ this,
65
+ GitHubReviewTarget.of({
66
+ ...config,
67
+ graphqlUrl: config.graphqlUrl ?? defaultGraphqlUrl(config.apiUrl),
68
+ }),
69
+ );
50
70
  }
51
71
  }
52
72
 
@@ -82,11 +102,54 @@ const GitHubFileWire = Schema.Struct({
82
102
 
83
103
  const GitHubFilesPageWire = Schema.Array(GitHubFileWire);
84
104
 
105
+ const GitHubActorWire = Schema.Struct({ node_id: Schema.String });
106
+
85
107
  const GitHubReviewWire = Schema.Struct({
86
108
  id: Schema.Int,
87
109
  html_url: Schema.String,
110
+ user: Schema.NullOr(GitHubActorWire),
111
+ submitted_at: Schema.NullOr(Schema.String),
112
+ });
113
+
114
+ const GitHubRetirableReviewWire = Schema.Struct({
115
+ id: Schema.Int,
116
+ body: Schema.NullOr(Schema.String),
117
+ commit_id: Schema.String,
118
+ user: Schema.NullOr(GitHubActorWire),
119
+ submitted_at: Schema.NullOr(Schema.String),
120
+ });
121
+ const GitHubRetirableReviewsPageWire = Schema.Array(GitHubRetirableReviewWire);
122
+
123
+ const GitHubReviewCommentWire = Schema.Struct({
124
+ node_id: Schema.String,
125
+ path: Schema.String,
126
+ body: Schema.String,
127
+ line: Schema.NullOr(Schema.Int),
128
+ original_line: Schema.NullOr(Schema.Int),
129
+ start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
130
+ original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
131
+ });
132
+ const GitHubReviewCommentsPageWire = Schema.Array(GitHubReviewCommentWire);
133
+
134
+ const GitHubMinimizeCommentWire = Schema.Struct({
135
+ data: Schema.optionalKey(
136
+ Schema.NullOr(
137
+ Schema.Struct({
138
+ minimizeComment: Schema.NullOr(
139
+ Schema.Struct({
140
+ minimizedComment: Schema.NullOr(Schema.Struct({ isMinimized: Schema.Boolean })),
141
+ }),
142
+ ),
143
+ }),
144
+ ),
145
+ ),
146
+ errors: Schema.optionalKey(Schema.Array(Schema.Struct({ message: Schema.String }))),
88
147
  });
89
148
 
149
+ /** Decode GitHub's external timestamp before it participates in mutation ordering. */
150
+ export const parseGitHubSubmittedAt = (value: string | null): DateTime.Utc | null =>
151
+ value === null ? null : Option.getOrNull(DateTime.make(value));
152
+
90
153
  /** The publication receipt callers report back to the operator. */
91
154
  export class PublishedReview extends Schema.Class<PublishedReview>(
92
155
  "@effect-agent/pr-review/PublishedReview",
@@ -95,6 +158,9 @@ export class PublishedReview extends Schema.Class<PublishedReview>(
95
158
  url: Schema.String,
96
159
  event: Schema.String,
97
160
  inlineComments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
161
+ /** Actor and ordering boundary returned by the create-review response. */
162
+ authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
163
+ submittedAt: Schema.NullOr(Schema.DateTimeUtc),
98
164
  }) {}
99
165
 
100
166
  /** Posts one planned review; the ONLY mutating operation in this package. */
@@ -335,12 +401,176 @@ export const gitHubReviewPublisherLayer: Layer.Layer<
335
401
  url: wire.html_url,
336
402
  event: plan.event,
337
403
  inlineComments: plan.comments.length,
404
+ authorNodeId: wire.user?.node_id ?? null,
405
+ submittedAt: parseGitHubSubmittedAt(wire.submitted_at),
338
406
  });
339
407
  }),
340
408
  });
341
409
  }),
342
410
  );
343
411
 
412
+ // --- Live ReviewRetirementHost ----------------------------------------------
413
+
414
+ const MAX_RETIREMENT_PAGES = 5;
415
+ const MINIMIZE_REVIEW_COMMENT_MUTATION = `mutation MinimizeReviewComment($subjectId: ID!) {
416
+ minimizeComment(input: { subjectId: $subjectId, classifier: OUTDATED }) {
417
+ minimizedComment { isMinimized }
418
+ }
419
+ }`;
420
+
421
+ /** GitHub-backed host operations for cosmetic retirement after publication. */
422
+ export const gitHubReviewRetirementHostLayer: Layer.Layer<
423
+ ReviewRetirementHost,
424
+ never,
425
+ GitHubReviewTarget | HttpClient.HttpClient
426
+ > = Layer.effect(ReviewRetirementHost)(
427
+ Effect.gen(function* () {
428
+ const target = yield* GitHubReviewTarget;
429
+ const client = yield* HttpClient.HttpClient;
430
+ const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
431
+ const asRetirementFailure =
432
+ (operation: string) =>
433
+ (error: { readonly _tag: string; readonly message?: string }): ReviewRetirementFailure =>
434
+ ReviewRetirementFailure.make({
435
+ operation,
436
+ reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
437
+ });
438
+ const executeRetirement = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
439
+ HttpClient.execute(request).pipe(
440
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
441
+ Effect.mapError(asRetirementFailure(operation)),
442
+ Effect.provideService(HttpClient.HttpClient, client),
443
+ );
444
+ const decodeRetirement = <S extends Schema.Top>(schema: S, operation: string) => {
445
+ const decode = Schema.decodeUnknownEffect(schema);
446
+ return (response: HttpClientResponse.HttpClientResponse) =>
447
+ response.json.pipe(
448
+ Effect.mapError(asRetirementFailure(operation)),
449
+ Effect.flatMap((body) =>
450
+ decode(body).pipe(Effect.mapError(asRetirementFailure(operation))),
451
+ ),
452
+ );
453
+ };
454
+ const listPaged = <A>(input: {
455
+ readonly operation: string;
456
+ readonly url: string;
457
+ readonly decode: (
458
+ response: HttpClientResponse.HttpClientResponse,
459
+ ) => Effect.Effect<ReadonlyArray<A>, ReviewRetirementFailure>;
460
+ }) =>
461
+ Effect.gen(function* () {
462
+ const values: Array<A> = [];
463
+ const perPage = 100;
464
+ for (let page = 1; page <= MAX_RETIREMENT_PAGES; page += 1) {
465
+ const response = yield* executeRetirement(
466
+ input.operation,
467
+ withCommonHeaders(
468
+ HttpClientRequest.get(input.url).pipe(
469
+ HttpClientRequest.acceptJson,
470
+ HttpClientRequest.setUrlParams({
471
+ per_page: String(perPage),
472
+ page: String(page),
473
+ }),
474
+ ),
475
+ target.token,
476
+ ),
477
+ );
478
+ const pageValues = yield* input.decode(response);
479
+ values.push(...pageValues);
480
+ if (pageValues.length < perPage) return values;
481
+ }
482
+ return yield* ReviewRetirementFailure.make({
483
+ operation: input.operation,
484
+ reason: `history exceeds the bounded ${MAX_RETIREMENT_PAGES * 100}-item lookup`,
485
+ });
486
+ });
487
+
488
+ return ReviewRetirementHost.of({
489
+ listReviews: listPaged({
490
+ operation: "listReviewsForRetirement",
491
+ url: `${prefix}/reviews`,
492
+ decode: decodeRetirement(GitHubRetirableReviewsPageWire, "listReviewsForRetirement"),
493
+ }).pipe(
494
+ Effect.map((reviews) =>
495
+ reviews.map((review) =>
496
+ RetirableReview.make({
497
+ reviewId: review.id,
498
+ body: review.body ?? "",
499
+ commitSha: review.commit_id,
500
+ authorNodeId: review.user?.node_id ?? null,
501
+ submittedAt: parseGitHubSubmittedAt(review.submitted_at),
502
+ }),
503
+ ),
504
+ ),
505
+ ),
506
+ listComments: (reviewId) =>
507
+ listPaged({
508
+ operation: "listReviewCommentsForRetirement",
509
+ url: `${prefix}/reviews/${reviewId}/comments`,
510
+ decode: decodeRetirement(GitHubReviewCommentsPageWire, "listReviewCommentsForRetirement"),
511
+ }).pipe(
512
+ Effect.map((comments) =>
513
+ comments.map((comment) => {
514
+ const endLine = comment.line ?? comment.original_line;
515
+ const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
516
+ return RetirableReviewComment.make({
517
+ nodeId: comment.node_id,
518
+ path: comment.path,
519
+ startLine,
520
+ endLine,
521
+ body: comment.body,
522
+ });
523
+ }),
524
+ ),
525
+ ),
526
+ updateBody: (reviewId, body) =>
527
+ executeRetirement(
528
+ "updateReview",
529
+ withCommonHeaders(
530
+ HttpClientRequest.put(`${prefix}/reviews/${reviewId}`).pipe(
531
+ HttpClientRequest.acceptJson,
532
+ HttpClientRequest.bodyJsonUnsafe({ body }),
533
+ ),
534
+ target.token,
535
+ ),
536
+ ).pipe(Effect.asVoid),
537
+ minimizeComment: (nodeId) =>
538
+ Effect.gen(function* () {
539
+ const response = yield* executeRetirement(
540
+ "minimizeComment",
541
+ withCommonHeaders(
542
+ HttpClientRequest.post(target.graphqlUrl).pipe(
543
+ HttpClientRequest.acceptJson,
544
+ HttpClientRequest.bodyJsonUnsafe({
545
+ query: MINIMIZE_REVIEW_COMMENT_MUTATION,
546
+ variables: { subjectId: nodeId },
547
+ }),
548
+ ),
549
+ target.token,
550
+ ),
551
+ );
552
+ const wire = yield* decodeRetirement(
553
+ GitHubMinimizeCommentWire,
554
+ "minimizeComment",
555
+ )(response);
556
+ if (
557
+ (wire.errors?.length ?? 0) > 0 ||
558
+ wire.data?.minimizeComment?.minimizedComment?.isMinimized !== true
559
+ ) {
560
+ return yield* ReviewRetirementFailure.make({
561
+ operation: "minimizeComment",
562
+ reason:
563
+ wire.errors
564
+ ?.map((error) => error.message)
565
+ .join("; ")
566
+ .slice(0, 2_048) ?? "GitHub did not confirm comment minimization",
567
+ });
568
+ }
569
+ }),
570
+ });
571
+ }),
572
+ );
573
+
344
574
  // --- Prior reviews (fingerprint deduplication) ---------------------------------
345
575
 
346
576
  /** Reading the pull request's previously posted reviews failed. */
@@ -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,9 +360,12 @@ export const defaultReviewPolicy = AgentPolicy.make({
360
360
  maxDuration: "8 minutes",
361
361
  toolConcurrency: 2,
362
362
  tokenBudget: 300_000,
363
- // Soft-landing adoption for the reviewer is the budget arc's S2/S3 rework;
364
- // until then the reviewer keeps its typed exhaustion failure.
365
- onExhaustion: "fail",
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",
366
369
  });
367
370
 
368
371
  // ---------------------------------------------------------------------------