@effect-agent/pr-review 0.1.0-beta.28 → 0.1.0-beta.30

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 (52) hide show
  1. package/README.md +11 -204
  2. package/dist/index.d.mts +92 -914
  3. package/dist/index.mjs +176 -71
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +3 -18
  6. package/src/index.ts +1 -25
  7. package/src/review.ts +244 -0
  8. package/dist/action.d.mts +0 -215
  9. package/dist/action.mjs +0 -505
  10. package/dist/action.mjs.map +0 -1
  11. package/dist/cli.d.mts +0 -1
  12. package/dist/cli.mjs +0 -106
  13. package/dist/cli.mjs.map +0 -1
  14. package/dist/fan-out-C3yG1cx3.d.mts +0 -1526
  15. package/dist/github-CCuLgyqb.mjs +0 -3437
  16. package/dist/github-CCuLgyqb.mjs.map +0 -1
  17. package/dist/logging-Q4j0oub-.mjs +0 -75
  18. package/dist/logging-Q4j0oub-.mjs.map +0 -1
  19. package/dist/providers-Br9FRn7j.mjs +0 -1349
  20. package/dist/providers-Br9FRn7j.mjs.map +0 -1
  21. package/dist/testing.d.mts +0 -86
  22. package/dist/testing.mjs +0 -184
  23. package/dist/testing.mjs.map +0 -1
  24. package/src/action.ts +0 -906
  25. package/src/cli.ts +0 -235
  26. package/src/internal/action-entry.ts +0 -45
  27. package/src/internal/adjudication.ts +0 -415
  28. package/src/internal/anchors.ts +0 -20
  29. package/src/internal/coverage.ts +0 -357
  30. package/src/internal/diff.ts +0 -193
  31. package/src/internal/effort.ts +0 -86
  32. package/src/internal/factory.ts +0 -357
  33. package/src/internal/fan-out-scripted.ts +0 -77
  34. package/src/internal/fan-out.ts +0 -1148
  35. package/src/internal/fingerprint.ts +0 -89
  36. package/src/internal/fixtures.ts +0 -148
  37. package/src/internal/github-env.ts +0 -164
  38. package/src/internal/github.ts +0 -1218
  39. package/src/internal/ignore.ts +0 -88
  40. package/src/internal/logging.ts +0 -124
  41. package/src/internal/profiles.ts +0 -91
  42. package/src/internal/progress.ts +0 -433
  43. package/src/internal/providers.ts +0 -133
  44. package/src/internal/render.ts +0 -819
  45. package/src/internal/retirement.ts +0 -337
  46. package/src/internal/review-agent.ts +0 -543
  47. package/src/internal/review-state.ts +0 -782
  48. package/src/internal/review-units.ts +0 -493
  49. package/src/internal/run.ts +0 -611
  50. package/src/internal/scripted.ts +0 -108
  51. package/src/internal/source.ts +0 -110
  52. package/src/testing.ts +0 -8
package/src/action.ts DELETED
@@ -1,906 +0,0 @@
1
- import { NodeRuntime, NodeServices } from "@effect/platform-node";
2
- import { Config, Console, Effect, FileSystem, Layer, Option, Redacted, Schema } from "effect";
3
- import { BudgetExceeded, UsageBudgetLimits } from "effect-agent";
4
- import { FetchHttpClient } from "effect/unstable/http";
5
-
6
- import { collectReviewAdjudications } from "./internal/adjudication.ts";
7
- import { splitCarriedScope } from "./internal/coverage.ts";
8
- import type { ChangedFile } from "./internal/diff.ts";
9
- import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./internal/effort.ts";
10
- import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
11
- import { readGitHubEvent, resolveReviewTarget, gitHubReviewLayers } from "./internal/github-env.ts";
12
- import { PriorReviews } from "./internal/github.ts";
13
- import { compactReviewLoggingLayer } from "./internal/logging.ts";
14
- import { ReviewProgressReporter } from "./internal/progress.ts";
15
- import {
16
- anthropicClientLayer,
17
- DEFAULT_PROVIDER,
18
- describeReviewModel,
19
- makeAnthropicReviewModel,
20
- makeOpenAiReviewModel,
21
- openAiClientLayer,
22
- validateReviewServiceTier,
23
- type OpenAiServiceTier,
24
- type ReviewProvider,
25
- } from "./internal/providers.ts";
26
- import { retireStaleReviews } from "./internal/retirement.ts";
27
- import {
28
- adjudicationIdentity,
29
- concernIdentity,
30
- findingIdentity,
31
- fullReviewSelection,
32
- ReviewExecutionContext,
33
- ReviewHeadComparison,
34
- ReviewStateAuthenticator,
35
- type ReviewTreeComparison,
36
- isLineageAncestor,
37
- type ReviewMode,
38
- type ReviewState,
39
- selectReviewRange,
40
- selectedPullRequestSourceLayer,
41
- unavailableReviewStateAuthenticatorLayer,
42
- validateReviewState,
43
- webCryptoReviewStateAuthenticatorLayer,
44
- } from "./internal/review-state.ts";
45
- import { fanOutReviewBudgetLimits, reviewBudgetLimits } from "./internal/run.ts";
46
- import type { ReviewRunOutcome } from "./internal/run.ts";
47
- import {
48
- normalizeRepoRelativePath,
49
- PullRequestSource,
50
- type PullRequestMetadata,
51
- } from "./internal/source.ts";
52
-
53
- // ---------------------------------------------------------------------------
54
- // The GitHub Actions entrypoint (deployment class E: one bounded ephemeral
55
- // run, no exactly-once posting). Bounded continuity state travels in GitHub
56
- // review bodies and is validated before reuse. Inputs arrive as
57
- // PR_REVIEW_* environment variables set by the action manifest; the target
58
- // pull request comes from the standard Actions event environment. A non-PR
59
- // or draft event is a typed skip — green job, nothing posted. Publication
60
- // happens only after the run settles, so a failed or truncated run posts
61
- // nothing.
62
- // ---------------------------------------------------------------------------
63
-
64
- export const ReviewCheckConclusion = Schema.Literals(["success", "blocking", "incomplete"]);
65
- export type ReviewCheckConclusion = typeof ReviewCheckConclusion.Type;
66
-
67
- /** The host-derived blocking or incomplete conclusion failed the check. */
68
- export class ReviewGateFailed extends Schema.TaggedError<ReviewGateFailed>()("ReviewGateFailed", {
69
- conclusion: Schema.Literals(["blocking", "incomplete"]),
70
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
71
- Schema.isMinLength(1),
72
- ),
73
- }) {
74
- override get message() {
75
- return `Review check concluded '${this.conclusion}': ${this.reasons.join("; ")}`;
76
- }
77
- }
78
-
79
- /** A configured max-duration that cannot bound a run; configuration faults fail loudly. */
80
- export class InvalidMaxDurationInput extends Schema.TaggedError<InvalidMaxDurationInput>()(
81
- "InvalidMaxDurationInput",
82
- {
83
- minutes: Schema.Int,
84
- },
85
- ) {
86
- override get message() {
87
- return `Invalid max-duration-minutes '${this.minutes}': expected a positive number of minutes.`;
88
- }
89
- }
90
-
91
- /** Everything the packaged action reads from its environment. */
92
- export interface ResolvedActionInputs {
93
- readonly provider: ReviewProvider;
94
- readonly model: string | undefined;
95
- readonly effort: EffortPosition | undefined;
96
- readonly serviceTier: OpenAiServiceTier | undefined;
97
- readonly post: boolean;
98
- readonly applyVerdict: boolean;
99
- readonly fanOut: boolean;
100
- readonly guidance: string | undefined;
101
- readonly guidanceFile: string | undefined;
102
- readonly ignore: ReadonlyArray<string>;
103
- readonly maxFindings: number | undefined;
104
- readonly maxDurationMinutes: number | undefined;
105
- readonly reviewMode: ReviewMode;
106
- readonly skipUnchanged: boolean;
107
- readonly retireStaleReviews: boolean;
108
- readonly progressComment: boolean;
109
- }
110
-
111
- /** Read the PR_REVIEW_* input surface (all optional, all defaulted). */
112
- export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* () {
113
- const provider = yield* Config.literals(["openai", "anthropic"], "PR_REVIEW_PROVIDER").pipe(
114
- Config.withDefault<ReviewProvider>(DEFAULT_PROVIDER),
115
- );
116
- const model = yield* Config.option(Config.nonEmptyString("PR_REVIEW_MODEL"));
117
- const effortRaw = yield* Config.option(Config.nonEmptyString("PR_REVIEW_EFFORT"));
118
- let effort: EffortPosition | undefined;
119
- if (Option.isSome(effortRaw)) {
120
- effort = parseEffortPosition(effortRaw.value);
121
- if (effort === undefined) {
122
- return yield* InvalidEffortInput.make({ input: effortRaw.value });
123
- }
124
- }
125
- const serviceTier = yield* validateReviewServiceTier(
126
- provider,
127
- Option.getOrUndefined(
128
- yield* Config.option(Config.literals(["fast"], "PR_REVIEW_SERVICE_TIER")),
129
- ),
130
- );
131
- const maxDurationMinutes = Option.getOrUndefined(
132
- yield* Config.option(Config.int("PR_REVIEW_MAX_DURATION_MINUTES")),
133
- );
134
- if (maxDurationMinutes !== undefined && maxDurationMinutes <= 0) {
135
- return yield* InvalidMaxDurationInput.make({ minutes: maxDurationMinutes });
136
- }
137
- const post = yield* Config.boolean("PR_REVIEW_POST").pipe(Config.withDefault(true));
138
- const applyVerdict = yield* Config.boolean("PR_REVIEW_APPLY_VERDICT").pipe(
139
- Config.withDefault(false),
140
- );
141
- const fanOut = yield* Config.boolean("PR_REVIEW_FAN_OUT").pipe(Config.withDefault(true));
142
- const guidance = yield* Config.option(Config.nonEmptyString("PR_REVIEW_GUIDANCE"));
143
- const guidanceFile = yield* Config.option(Config.nonEmptyString("PR_REVIEW_GUIDANCE_FILE"));
144
- const ignoreRaw = yield* Config.string("PR_REVIEW_IGNORE").pipe(Config.withDefault(""));
145
- const maxFindings = yield* Config.option(Config.int("PR_REVIEW_MAX_FINDINGS"));
146
- const reviewMode = yield* Config.literals(["incremental", "final"], "PR_REVIEW_MODE").pipe(
147
- Config.withDefault<ReviewMode>("incremental"),
148
- );
149
- const skipUnchanged = yield* Config.boolean("PR_REVIEW_SKIP_UNCHANGED").pipe(
150
- Config.withDefault(true),
151
- );
152
- const retireStaleReviews = yield* Config.boolean("PR_REVIEW_RETIRE_STALE_REVIEWS").pipe(
153
- Config.withDefault(true),
154
- );
155
- const progressComment = yield* Config.boolean("PR_REVIEW_PROGRESS_COMMENT").pipe(
156
- Config.withDefault(true),
157
- );
158
- return {
159
- provider,
160
- model: Option.getOrUndefined(model),
161
- effort,
162
- serviceTier,
163
- post,
164
- applyVerdict,
165
- fanOut,
166
- guidance: Option.getOrUndefined(guidance),
167
- guidanceFile: Option.getOrUndefined(guidanceFile),
168
- ignore: ignoreRaw
169
- .split(",")
170
- .map((pattern) => pattern.trim())
171
- .filter((pattern) => pattern.length > 0),
172
- maxFindings: Option.getOrUndefined(maxFindings),
173
- maxDurationMinutes,
174
- reviewMode,
175
- skipUnchanged,
176
- retireStaleReviews,
177
- progressComment,
178
- } satisfies ResolvedActionInputs;
179
- });
180
-
181
- /** A guidance file larger than this is refused, never silently truncated. */
182
- export const MAX_GUIDANCE_FILE_CHARS = 20_000;
183
-
184
- /** A configured guidance file could not be used; configuration faults fail loudly. */
185
- export class GuidanceFileUnreadable extends Schema.TaggedError<GuidanceFileUnreadable>()(
186
- "GuidanceFileUnreadable",
187
- {
188
- path: Schema.String,
189
- reason: Schema.String,
190
- },
191
- ) {
192
- override get message() {
193
- return `Cannot use guidance file '${this.path}': ${this.reason}`;
194
- }
195
- }
196
-
197
- /**
198
- * Resolve the effective review guidance: the committed guidance file (a
199
- * repository-owned review profile) first, with any inline guidance appended.
200
- * A configured-but-unreadable file fails typed — a review silently running
201
- * without its profile would be worse than a red job.
202
- */
203
- export const resolveGuidance = Effect.fn("resolveGuidance")(function* (inputs: {
204
- readonly guidance: string | undefined;
205
- readonly guidanceFile: string | undefined;
206
- }) {
207
- const filePath = inputs.guidanceFile;
208
- if (filePath === undefined) return inputs.guidance;
209
- // The profile path is operator configuration, but it stays fail-closed
210
- // like every other path in this package: workspace-relative only, and the
211
- // size bound is checked before the file is read into memory.
212
- const relative = yield* normalizeRepoRelativePath(filePath).pipe(
213
- Effect.mapError((violation) =>
214
- GuidanceFileUnreadable.make({ path: filePath, reason: violation.reason }),
215
- ),
216
- );
217
- const fs = yield* FileSystem.FileSystem;
218
- const unreadable = (error: { readonly _tag: string; readonly message: string }) =>
219
- GuidanceFileUnreadable.make({
220
- path: relative,
221
- reason: `${error._tag}: ${error.message}`.slice(0, 2_048),
222
- });
223
- const stat = yield* fs.stat(relative).pipe(Effect.mapError(unreadable));
224
- const oversized = GuidanceFileUnreadable.make({
225
- path: relative,
226
- reason: `File is larger than the ${MAX_GUIDANCE_FILE_CHARS}-character guidance bound.`,
227
- });
228
- if (stat.size > BigInt(MAX_GUIDANCE_FILE_CHARS) * 4n) {
229
- return yield* oversized;
230
- }
231
- const content = yield* fs.readFileString(relative).pipe(Effect.mapError(unreadable));
232
- if (content.length > MAX_GUIDANCE_FILE_CHARS) {
233
- return yield* oversized;
234
- }
235
- const combined = [content.trim(), inputs.guidance ?? ""]
236
- .filter((part) => part.length > 0)
237
- .join("\n");
238
- return combined.length > 0 ? combined : undefined;
239
- });
240
-
241
- /** One step-output line; values must be single-line by construction. */
242
- const outputLine = (name: string, value: string): string =>
243
- `${name}=${value.replaceAll("\n", " ").slice(0, 1_000)}\n`;
244
-
245
- /** Append step outputs to GITHUB_OUTPUT when present (no-op locally). */
246
- export const writeActionOutputs = Effect.fn("writeActionOutputs")(function* (
247
- entries: ReadonlyArray<readonly [name: string, value: string]>,
248
- ) {
249
- const outputPath = yield* Config.string("GITHUB_OUTPUT").pipe(Config.withDefault(""));
250
- if (outputPath === "") return;
251
- const fs = yield* FileSystem.FileSystem;
252
- yield* fs.writeFileString(
253
- outputPath,
254
- entries.map(([name, value]) => outputLine(name, value)).join(""),
255
- {
256
- flag: "a",
257
- },
258
- );
259
- });
260
-
261
- /** Append markdown to the GITHUB_STEP_SUMMARY report when present (no-op locally). */
262
- export const writeStepSummary = Effect.fn("writeStepSummary")(function* (
263
- lines: ReadonlyArray<string>,
264
- ) {
265
- const summaryPath = yield* Config.string("GITHUB_STEP_SUMMARY").pipe(Config.withDefault(""));
266
- if (summaryPath === "") return;
267
- const fs = yield* FileSystem.FileSystem;
268
- yield* fs.writeFileString(summaryPath, `${lines.join("\n")}\n`, { flag: "a" });
269
- });
270
-
271
- const outcomeOutputs = (
272
- outcome: ReviewRunOutcome,
273
- conclusion: ReviewCheckConclusion,
274
- ): ReadonlyArray<readonly [string, string]> => [
275
- ["skipped", "false"],
276
- ["conclusion", conclusion],
277
- ["verdict", outcome.review.verdict],
278
- ["input-coverage", outcome.inputCoverage.status],
279
- ["review-assurance", outcome.assurance.status],
280
- ["review-mode", outcome.reviewMode ?? "full"],
281
- ["review-reason", outcome.reviewReason ?? "direct full review"],
282
- ["inline-comments", String(outcome.plan.comments.length)],
283
- ["demoted-findings", String(outcome.plan.demoted.length)],
284
- ["concerns", String(outcome.review.concerns?.length ?? 0)],
285
- ["unreviewed-paths", String(outcome.unreviewedPaths.length)],
286
- ...(outcome.usage === undefined
287
- ? []
288
- : ([
289
- ["input-tokens", String(outcome.usage.inputTokens)],
290
- ["output-tokens", String(outcome.usage.outputTokens)],
291
- ] as const)),
292
- ["review-url", outcome.published?.url ?? ""],
293
- ];
294
-
295
- const outcomeSummary = (
296
- outcome: ReviewRunOutcome,
297
- modelLabel: string | undefined,
298
- conclusion: ReviewCheckConclusion,
299
- ): ReadonlyArray<string> => {
300
- const scope = splitCarriedScope(outcome);
301
- return [
302
- "### Pull-request review",
303
- `- Check conclusion: **${conclusion}**`,
304
- `- Verdict: **${outcome.review.verdict}**`,
305
- `- Input coverage: **${outcome.inputCoverage.status}** · scope: ${outcome.reviewMode ?? "full"}`,
306
- `- 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}`,
307
- `- Inline comments: ${outcome.plan.comments.length} · demoted findings: ${outcome.plan.demoted.length} · concerns: ${outcome.review.concerns?.length ?? 0}`,
308
- ...(scope.retryablePaths.length === 0
309
- ? []
310
- : [
311
- `- Carried forward: ${scope.retryablePaths.length} unreviewed path(s) retried automatically on the next run (reviewer-side gap, not a code defect)`,
312
- ]),
313
- ...(scope.undiffablePaths.length === 0
314
- ? []
315
- : [
316
- `- 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`,
317
- ]),
318
- ...(modelLabel === undefined ? [] : [`- Model: \`${modelLabel}\``]),
319
- ...(outcome.usage === undefined
320
- ? []
321
- : [`- Tokens: ${outcome.usage.inputTokens} in / ${outcome.usage.outputTokens} out`]),
322
- ...(outcome.published === undefined
323
- ? ["- Dry run: nothing posted"]
324
- : [`- Posted: ${outcome.published.url}`]),
325
- ];
326
- };
327
-
328
- /** The result of one action invocation: a typed skip or a settled review. */
329
- export type ReviewActionResult =
330
- | { readonly _tag: "Skipped"; readonly reason: string }
331
- | { readonly _tag: "Completed"; readonly outcome: ReviewRunOutcome };
332
-
333
- const skip = (reason: string) =>
334
- Effect.gen(function* () {
335
- yield* Console.log(`Skipping review: ${reason}`);
336
- yield* writeActionOutputs([
337
- ["skipped", "true"],
338
- ["skip-reason", reason],
339
- ]);
340
- yield* writeStepSummary(["### Pull-request review skipped", `- Reason: ${reason}`]);
341
- return { _tag: "Skipped", reason } satisfies ReviewActionResult;
342
- });
343
-
344
- /** The Actions run URL for the review footer, or undefined outside Actions. */
345
- const resolveRunUrl = Effect.fn("resolveRunUrl")(function* () {
346
- const runId = yield* Config.string("GITHUB_RUN_ID").pipe(Config.withDefault(""));
347
- const repository = yield* Config.string("GITHUB_REPOSITORY").pipe(Config.withDefault(""));
348
- if (runId === "" || repository === "") return undefined;
349
- const server = yield* Config.string("GITHUB_SERVER_URL").pipe(
350
- Config.withDefault("https://github.com"),
351
- );
352
- return `${server}/${repository}/actions/runs/${runId}`;
353
- });
354
-
355
- /**
356
- * The reviewer surface the action harness drives. `PrReview.make` and
357
- * `PrReview.makeFanOut` provide the state-selection effects. A fingerprint is
358
- * skip authority only when a profile fingerprint and authenticated review
359
- * state bind it to settled assurance.
360
- */
361
- interface HarnessedReviewerBase<E, R> {
362
- /** The action composition root always supplies the selected run context. */
363
- readonly run: (
364
- runOptions?: RunReviewOptions,
365
- ) => Effect.Effect<ReviewRunOutcome, E, R | ReviewExecutionContext>;
366
- }
367
-
368
- export type HarnessedReviewer<E, R, FingerprintE, FingerprintR> = HarnessedReviewerBase<E, R> &
369
- (
370
- | {
371
- readonly fingerprint?: undefined;
372
- readonly profileFingerprint?: undefined;
373
- readonly snapshot?: undefined;
374
- readonly filterFiles?: undefined;
375
- }
376
- | {
377
- /** Current effective changeset fingerprint; not standalone skip authority. */
378
- readonly fingerprint: Effect.Effect<string, FingerprintE, FingerprintR>;
379
- readonly profileFingerprint: Effect.Effect<string, FingerprintE, FingerprintR>;
380
- readonly snapshot: Effect.Effect<
381
- {
382
- readonly metadata: PullRequestMetadata;
383
- readonly files: ReadonlyArray<ChangedFile>;
384
- },
385
- FingerprintE,
386
- FingerprintR
387
- >;
388
- readonly filterFiles?:
389
- | ((files: ReadonlyArray<ChangedFile>) => ReadonlyArray<ChangedFile>)
390
- | undefined;
391
- }
392
- );
393
-
394
- const blockingReasons = (input: {
395
- readonly findings: ReadonlyArray<{ readonly severity: string; readonly title: string }>;
396
- readonly concerns: ReadonlyArray<{ readonly severity: string; readonly title: string }>;
397
- }): ReadonlyArray<string> => [
398
- ...input.findings
399
- .filter((finding) => finding.severity === "blocking")
400
- .map((finding) => `blocking finding: ${finding.title}`),
401
- ...input.concerns
402
- .filter((concern) => concern.severity === "blocking")
403
- .map((concern) => `blocking concern: ${concern.title}`),
404
- ];
405
-
406
- /**
407
- * Host-derived check conclusion; model verdict prose cannot weaken it.
408
- *
409
- * Blocking code findings outrank machinery gaps — they are the actionable
410
- * signal. A retryable machinery gap (failed passes, capacity overflow)
411
- * concludes `incomplete` with reasons that explicitly say the failure is
412
- * reviewer-side uncertainty carried forward for retry, never an invitation to
413
- * change code. Undiffable files are the deliberate exception: no retry can
414
- * settle them, so their reason instructs removal or ignore globs instead of
415
- * promising an automatic retry. The flat reviewer's constant `unverified`
416
- * assurance is not a gap.
417
- */
418
- export const concludeReviewOutcome = (
419
- outcome: ReviewRunOutcome,
420
- ): {
421
- readonly conclusion: ReviewCheckConclusion;
422
- readonly reasons: ReadonlyArray<string>;
423
- } => {
424
- const machinery: Array<string> = [];
425
- if (outcome.inputCoverage.status === "incomplete" || outcome.assurance.status === "incomplete") {
426
- const scope = splitCarriedScope(outcome);
427
- if (scope.retryableGap) {
428
- machinery.push(
429
- scope.retryablePaths.length > 0
430
- ? `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`
431
- : "review infrastructure did not settle — a reviewer-side gap, not a code defect",
432
- );
433
- }
434
- if (scope.undiffablePaths.length > 0) {
435
- machinery.push(
436
- `${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`,
437
- );
438
- }
439
- if (outcome.inputCoverage.status === "incomplete") {
440
- machinery.push(...outcome.inputCoverage.reasons);
441
- }
442
- if (outcome.assurance.status === "incomplete") {
443
- machinery.push(...outcome.assurance.reasons);
444
- }
445
- }
446
- const blocking = blockingReasons({
447
- findings: outcome.activeFindings,
448
- concerns: outcome.activeConcerns,
449
- });
450
- if (blocking.length > 0) {
451
- return { conclusion: "blocking", reasons: [...blocking, ...machinery] };
452
- }
453
- if (machinery.length > 0) return { conclusion: "incomplete", reasons: machinery };
454
- return { conclusion: "success", reasons: [] };
455
- };
456
-
457
- const concludeReviewState = (state: ReviewState, adjudicated: ReadonlySet<string>) => {
458
- const reasons = blockingReasons({
459
- findings: state.unresolvedFindings.filter(
460
- (finding) => !adjudicated.has(findingIdentity(finding)),
461
- ),
462
- concerns: state.unresolvedConcerns.filter(
463
- (concern) => !adjudicated.has(concernIdentity(concern)),
464
- ),
465
- });
466
- return reasons.length > 0
467
- ? ({ conclusion: "blocking", reasons } as const)
468
- : ({ conclusion: "success", reasons: [] } as const);
469
- };
470
-
471
- const skipCoveredReview = Effect.fn("skipCoveredReview")(function* (input: {
472
- readonly repository: string;
473
- readonly pullRequestNumber: number;
474
- readonly reason: string;
475
- readonly state: ReviewState;
476
- }) {
477
- // A maintainer adjudication must lift a preserved blocking conclusion
478
- // without a code push, so the skip path re-reads adjudications (fail-open;
479
- // stored ones survive a listing fault) before enforcing the stored state.
480
- const adjudications = yield* collectReviewAdjudications(input.state.adjudications ?? []);
481
- const result = concludeReviewState(input.state, new Set(adjudications.map(adjudicationIdentity)));
482
- yield* Console.log(
483
- `Skipping review of ${input.repository}#${input.pullRequestNumber}: ${input.reason}.`,
484
- );
485
- yield* writeActionOutputs([
486
- ["skipped", "true"],
487
- ["skip-reason", input.reason],
488
- ["conclusion", result.conclusion],
489
- ["input-coverage", "complete"],
490
- ["review-assurance", "settled"],
491
- ["review-mode", "incremental"],
492
- ]);
493
- yield* writeStepSummary([
494
- "### Pull-request review skipped",
495
- `- Reason: ${input.reason}`,
496
- `- Preserved check conclusion: **${result.conclusion}**`,
497
- ]);
498
- if (result.conclusion === "blocking") {
499
- return yield* ReviewGateFailed.make({
500
- conclusion: "blocking",
501
- reasons: result.reasons,
502
- });
503
- }
504
- return { _tag: "Skipped", reason: input.reason } satisfies ReviewActionResult;
505
- });
506
-
507
- /**
508
- * Harness one already-built reviewer inside the Actions environment: resolve
509
- * the target from the event, provide the GitHub source/publisher/prior
510
- * reviews, validate/select bounded continuity scope, write step outputs, and apply
511
- * the host-derived coverage/blocker gate. Draft and non-PR skips are values;
512
- * an unchanged reviewed head preserves and enforces its stored conclusion. The reviewer's
513
- * remaining requirements — its model client, any extra tool handlers — stay
514
- * visible in `R` for the caller.
515
- */
516
- export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never>(
517
- reviewer: HarnessedReviewer<E, R, FingerprintE, FingerprintR>,
518
- options: {
519
- readonly post?: boolean | undefined;
520
- /** Skip model execution when the current head already has complete stored coverage. */
521
- readonly skipUnchanged?: boolean | undefined;
522
- /** Incremental by default; `final` deliberately re-reviews the full PR diff. */
523
- readonly reviewMode?: ReviewMode | undefined;
524
- /** Model binding descriptor for the Actions step summary. */
525
- readonly modelLabel?: string | undefined;
526
- /** Explicit test/custom-host history override; GitHub owns the default adapter. */
527
- readonly priorReviews?: PriorReviews["Service"] | undefined;
528
- /** Retire marker-bearing prior reviews after a successful post (default true). */
529
- readonly retireStaleReviews?: boolean | undefined;
530
- /**
531
- * Maintain one sticky "review in progress" issue comment, updated in
532
- * place when the run settles. Default false here for custom-harness
533
- * compatibility; the packaged action enables it by default. Dry runs
534
- * (`post: false`) never post progress.
535
- */
536
- readonly progressComment?: boolean | undefined;
537
- } = {},
538
- ) =>
539
- Effect.gen(function* () {
540
- const event = yield* readGitHubEvent();
541
- if (Option.isSome(event)) {
542
- if (event.value.pull_request === undefined) {
543
- return yield* skip("the triggering event carries no pull request");
544
- }
545
- if (event.value.pull_request.draft === true) {
546
- return yield* skip("the pull request is a draft");
547
- }
548
- }
549
- const target = yield* resolveReviewTarget({});
550
- // One layer build for state selection AND the run, so both observe
551
- // the same cached pull-request snapshot.
552
- return yield* Effect.gen(function* () {
553
- let selection: ReturnType<typeof selectReviewRange> | undefined;
554
- if (reviewer.profileFingerprint !== undefined) {
555
- const [snapshot, profileFingerprint, currentFingerprint] = yield* Effect.all([
556
- reviewer.snapshot,
557
- reviewer.profileFingerprint,
558
- reviewer.fingerprint.pipe(
559
- Effect.map((fingerprint): string | undefined => fingerprint),
560
- Effect.orElseSucceed(() => undefined),
561
- ),
562
- ]);
563
- const { metadata, files: fullFiles } = snapshot;
564
- const history = options.priorReviews ?? (yield* PriorReviews);
565
- const stateAuthenticator = yield* ReviewStateAuthenticator;
566
- const recovered =
567
- stateAuthenticator.status === "unavailable"
568
- ? {
569
- state: undefined,
570
- failure:
571
- stateAuthenticator.unavailableReason ??
572
- "an authenticated review-state secret is not configured",
573
- }
574
- : yield* history.latestState.pipe(
575
- Effect.match({
576
- onFailure: (failure) => ({ state: undefined, failure: failure.reason }),
577
- onSuccess: (state) => ({
578
- state: Option.getOrUndefined(state),
579
- failure: undefined,
580
- }),
581
- }),
582
- );
583
- const equivalentPatchState =
584
- (options.reviewMode ?? "incremental") === "incremental" &&
585
- options.skipUnchanged !== false &&
586
- recovered.state !== undefined &&
587
- // Only a fully settled run may be skipped over: an unsettled state
588
- // carries retryable scope the next run must actually retry.
589
- recovered.state.settled &&
590
- currentFingerprint !== undefined &&
591
- validateReviewState(recovered.state, metadata, profileFingerprint) === undefined &&
592
- recovered.state.settledScopeFingerprint === currentFingerprint
593
- ? recovered.state
594
- : undefined;
595
- if (equivalentPatchState !== undefined) {
596
- return yield* skipCoveredReview({
597
- repository: target.repository,
598
- pullRequestNumber: target.number,
599
- reason:
600
- equivalentPatchState.reviewedHeadSha === metadata.headSha
601
- ? "the current head already has settled stored review assurance"
602
- : "the effective pull-request patch is unchanged since the last settled review",
603
- state: equivalentPatchState,
604
- });
605
- }
606
- let comparison: ReviewHeadComparison | undefined;
607
- let baseComparison: ReviewHeadComparison | undefined;
608
- let contentComparison: ReviewTreeComparison | undefined;
609
- let contentComparisonFailure: string | undefined;
610
- if (
611
- (options.reviewMode ?? "incremental") === "incremental" &&
612
- recovered.state !== undefined
613
- ) {
614
- if (recovered.state.reviewedHeadSha === metadata.headSha) {
615
- comparison = ReviewHeadComparison.make({
616
- status: "identical",
617
- baseSha: metadata.headSha,
618
- headSha: metadata.headSha,
619
- mergeBaseSha: metadata.headSha,
620
- files: [],
621
- truncated: false,
622
- });
623
- } else {
624
- comparison = yield* history
625
- .compareHeads(recovered.state.reviewedHeadSha, metadata.headSha)
626
- .pipe(Effect.orElseSucceed(() => undefined));
627
- if (comparison !== undefined && reviewer.filterFiles !== undefined) {
628
- comparison = ReviewHeadComparison.make({
629
- ...comparison,
630
- files: reviewer.filterFiles(comparison.files),
631
- });
632
- }
633
- }
634
- if (metadata.baseSha !== undefined && recovered.state.baseSha !== metadata.baseSha) {
635
- baseComparison = yield* history
636
- .compareHeads(recovered.state.baseSha, metadata.baseSha)
637
- .pipe(Effect.orElseSucceed(() => undefined));
638
- if (baseComparison !== undefined && reviewer.filterFiles !== undefined) {
639
- baseComparison = ReviewHeadComparison.make({
640
- ...baseComparison,
641
- files: reviewer.filterFiles(baseComparison.files),
642
- });
643
- }
644
- }
645
- if (
646
- recovered.state.reviewedHeadSha !== metadata.headSha &&
647
- (comparison === undefined ||
648
- !isLineageAncestor(comparison, recovered.state, metadata.headSha))
649
- ) {
650
- const comparisonPaths = new Set(
651
- fullFiles.flatMap((file) =>
652
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
653
- ),
654
- );
655
- for (const finding of recovered.state.unresolvedFindings) {
656
- comparisonPaths.add(finding.path);
657
- }
658
- for (const concern of recovered.state.unresolvedConcerns) {
659
- for (const path of concern.evidencePaths ?? []) comparisonPaths.add(path);
660
- }
661
- for (const path of recovered.state.unreviewedPaths) comparisonPaths.add(path);
662
- const treeResult = yield* history
663
- .compareTrees(recovered.state.reviewedHeadSha, metadata.headSha, [...comparisonPaths])
664
- .pipe(
665
- Effect.match({
666
- onFailure: (failure) => ({
667
- comparison: undefined,
668
- failure: failure.reason,
669
- }),
670
- onSuccess: (treeComparison) => ({
671
- comparison: treeComparison,
672
- failure: undefined,
673
- }),
674
- }),
675
- );
676
- contentComparison = treeResult.comparison;
677
- contentComparisonFailure = treeResult.failure;
678
- }
679
- }
680
- selection = {
681
- ...selectReviewRange({
682
- requestedMode: options.reviewMode ?? "incremental",
683
- current: metadata,
684
- fullFiles,
685
- profileFingerprint,
686
- priorState: recovered.state,
687
- comparison,
688
- baseComparison,
689
- contentComparison,
690
- contentComparisonFailure,
691
- lookupFailure: recovered.failure,
692
- }),
693
- stateAuthenticator,
694
- };
695
- if (
696
- options.skipUnchanged !== false &&
697
- selection.mode === "incremental" &&
698
- selection.files.length === 0 &&
699
- selection.priorState !== undefined &&
700
- selection.priorState.settled
701
- ) {
702
- const reason = "no changed review scope since the last settled review head";
703
- return yield* skipCoveredReview({
704
- repository: target.repository,
705
- pullRequestNumber: target.number,
706
- reason,
707
- state: selection.priorState,
708
- });
709
- }
710
- }
711
- const executionContext =
712
- selection ??
713
- (yield* Effect.gen(function* () {
714
- const source = yield* PullRequestSource;
715
- const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
716
- return fullReviewSelection({
717
- reason: "explicit custom-reviewer full review without continuity selection",
718
- files,
719
- totalFiles: metadata.totalChangedFiles,
720
- });
721
- }));
722
- yield* Console.log(
723
- `Reviewing ${target.repository}#${target.number} (${options.post === false ? "dry run" : "posting"})...`,
724
- );
725
- const runUrl = yield* resolveRunUrl();
726
- // Progress is a cosmetic, fail-open narration surface: it says a run is
727
- // working the moment execution starts and is overwritten in place when
728
- // the run settles. It never gates or delays the review itself.
729
- const progress =
730
- options.progressComment === true && options.post !== false
731
- ? Option.some(yield* ReviewProgressReporter)
732
- : Option.none<ReviewProgressReporter["Service"]>();
733
- if (Option.isSome(progress)) {
734
- const source = yield* PullRequestSource;
735
- const headMetadata = yield* source.metadata.pipe(Effect.orElseSucceed(() => undefined));
736
- yield* progress.value.begin({
737
- headSha: headMetadata?.headSha,
738
- reviewMode: executionContext.mode,
739
- reviewReason: executionContext.reason,
740
- filesInScope: executionContext.files.length,
741
- modelLabel: options.modelLabel,
742
- runUrl,
743
- });
744
- }
745
- const runReview = reviewer.run({ post: options.post ?? true, runUrl });
746
- const reviewEffect = Option.isSome(progress)
747
- ? runReview.pipe(
748
- Effect.tapCause(() =>
749
- progress.value.settle({
750
- outcome: "failed",
751
- runUrl,
752
- modelLabel: options.modelLabel,
753
- }),
754
- ),
755
- )
756
- : runReview;
757
- const executionLayer = Layer.merge(
758
- Layer.succeed(ReviewExecutionContext)(executionContext),
759
- selectedPullRequestSourceLayer(executionContext),
760
- );
761
- const outcome = yield* reviewEffect.pipe(Effect.provide(executionLayer));
762
- yield* Console.log(
763
- `Review finished in ${outcome.turns} turn(s): verdict ${outcome.review.verdict}, ` +
764
- `${outcome.plan.comments.length} inline comment(s), ${outcome.plan.demoted.length} demoted finding(s).`,
765
- );
766
- if (outcome.published !== undefined) {
767
- yield* Console.log(`Posted ${outcome.published.event} review: ${outcome.published.url}`);
768
- if (options.retireStaleReviews !== false && outcome.state !== undefined) {
769
- if (outcome.published.authorNodeId === null || outcome.published.submittedAt === null) {
770
- yield* Console.warn(
771
- "Skipping stale-review retirement because GitHub did not return the posted review's actor and submission time.",
772
- );
773
- } else {
774
- const report = yield* retireStaleReviews({
775
- currentReviewId: outcome.published.reviewId,
776
- currentReviewUrl: outcome.published.url,
777
- currentAuthorNodeId: outcome.published.authorNodeId,
778
- currentSubmittedAt: outcome.published.submittedAt,
779
- currentState: outcome.state,
780
- });
781
- yield* Console.log(
782
- `Review retirement: ${report.reviewsRetired} prior review(s), ` +
783
- `${report.findingsResolved} resolved finding(s), ` +
784
- `${report.commentsMinimized} minimized inline comment(s), ` +
785
- `${report.failures} failure(s).`,
786
- );
787
- }
788
- }
789
- }
790
- const check = concludeReviewOutcome(outcome);
791
- if (Option.isSome(progress)) {
792
- yield* progress.value.settle({
793
- outcome: "reviewed",
794
- conclusion: check.conclusion,
795
- verdict: outcome.review.verdict,
796
- inlineComments: outcome.plan.comments.length,
797
- reviewUrl: outcome.published?.url,
798
- runUrl,
799
- modelLabel: options.modelLabel,
800
- });
801
- }
802
- yield* writeActionOutputs(outcomeOutputs(outcome, check.conclusion));
803
- yield* writeStepSummary(outcomeSummary(outcome, options.modelLabel, check.conclusion));
804
- if (check.conclusion !== "success") {
805
- return yield* ReviewGateFailed.make({
806
- conclusion: check.conclusion,
807
- reasons: check.reasons,
808
- });
809
- }
810
- return { _tag: "Completed", outcome } satisfies ReviewActionResult;
811
- }).pipe(Effect.provide(gitHubReviewLayers(target)));
812
- });
813
-
814
- /**
815
- * The packaged, environment-driven action program: inputs from PR_REVIEW_*,
816
- * reviewer built from the packaged factory, provider client from the
817
- * matching credential environment variable.
818
- */
819
- export const reviewActionProgram = Effect.gen(function* () {
820
- const inputs = yield* resolveActionInputs();
821
- const stateSecret = Option.map(
822
- yield* Config.option(Config.nonEmptyString("PR_REVIEW_STATE_SECRET")),
823
- Redacted.make,
824
- );
825
- const stateAuthenticatorLayer = Option.match(stateSecret, {
826
- onNone: () =>
827
- unavailableReviewStateAuthenticatorLayer(
828
- "an authenticated review-state secret is not configured",
829
- ),
830
- onSome: webCryptoReviewStateAuthenticatorLayer,
831
- });
832
- const guidance = yield* resolveGuidance(inputs);
833
- const modelLabel = describeReviewModel(
834
- inputs.provider,
835
- inputs.model,
836
- inputs.effort,
837
- inputs.serviceTier,
838
- );
839
- const defaults = inputs.fanOut ? fanOutReviewBudgetLimits : reviewBudgetLimits;
840
- const budget =
841
- inputs.maxDurationMinutes === undefined
842
- ? defaults
843
- : UsageBudgetLimits.make({
844
- ...defaults,
845
- maxDurationMillis: inputs.maxDurationMinutes * 60_000,
846
- });
847
- const shared = {
848
- guidance,
849
- ignore: inputs.ignore,
850
- maxFindings: inputs.maxFindings,
851
- applyVerdict: inputs.applyVerdict,
852
- modelLabel,
853
- budget,
854
- };
855
- const harness = {
856
- post: inputs.post,
857
- skipUnchanged: inputs.skipUnchanged,
858
- reviewMode: inputs.reviewMode,
859
- retireStaleReviews: inputs.retireStaleReviews,
860
- progressComment: inputs.progressComment,
861
- modelLabel,
862
- };
863
- if (inputs.provider === "anthropic") {
864
- const model = makeAnthropicReviewModel(inputs.model, inputs.effort);
865
- const reviewer = inputs.fanOut
866
- ? PrReview.makeFanOut({ ...shared, model })
867
- : PrReview.make({ ...shared, model });
868
- return yield* runReviewAction(reviewer, harness).pipe(
869
- Effect.provide(Layer.merge(stateAuthenticatorLayer, anthropicClientLayer)),
870
- );
871
- }
872
- const model = makeOpenAiReviewModel(inputs.model, inputs.effort, inputs.serviceTier);
873
- const reviewer = inputs.fanOut
874
- ? PrReview.makeFanOut({ ...shared, model })
875
- : PrReview.make({ ...shared, model });
876
- return yield* runReviewAction(reviewer, harness).pipe(
877
- Effect.provide(Layer.merge(stateAuthenticatorLayer, openAiClientLayer)),
878
- );
879
- });
880
-
881
- /** Run the packaged action program on Node; the bundled action entrypoint. */
882
- export const main = (): void =>
883
- NodeRuntime.runMain(
884
- reviewActionProgram.pipe(
885
- Effect.tapError((error) =>
886
- Console.error(
887
- Schema.is(BudgetExceeded)(error)
888
- ? `Budget exceeded: ${error.limit} observed ${error.observedValue}, limit ${error.limitValue}.`
889
- : String(error),
890
- ),
891
- ),
892
- Effect.scoped,
893
- Effect.provide(
894
- Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer, compactReviewLoggingLayer),
895
- ),
896
- ),
897
- { disableErrorReporting: true },
898
- );
899
-
900
- /** The packaged GitHub Actions surface. */
901
- export const PrReviewAction = {
902
- inputs: resolveActionInputs,
903
- run: runReviewAction,
904
- program: reviewActionProgram,
905
- main,
906
- } as const;