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

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 (59) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +170 -168
  3. package/dist/Review.d.mts +294 -0
  4. package/dist/Review.mjs +702 -0
  5. package/dist/Review.mjs.map +1 -0
  6. package/dist/ReviewRepository-Wd_4qCaO.d.mts +71 -0
  7. package/dist/ReviewRepository.d.mts +2 -0
  8. package/dist/ReviewRepository.mjs +15 -0
  9. package/dist/ReviewRepository.mjs.map +1 -0
  10. package/dist/index.d.mts +3 -717
  11. package/dist/index.mjs +3 -66
  12. package/dist/repository-D7NN3225.mjs +101 -0
  13. package/dist/repository-D7NN3225.mjs.map +1 -0
  14. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  15. package/package.json +1 -54
  16. package/src/Review.ts +1052 -0
  17. package/src/ReviewRepository.ts +9 -0
  18. package/src/index.ts +2 -21
  19. package/src/internal/repository.ts +156 -0
  20. package/dist/action.d.mts +0 -190
  21. package/dist/action.mjs +0 -423
  22. package/dist/action.mjs.map +0 -1
  23. package/dist/cli.d.mts +0 -1
  24. package/dist/cli.mjs +0 -103
  25. package/dist/cli.mjs.map +0 -1
  26. package/dist/fan-out-C6gq3CFg.d.mts +0 -1081
  27. package/dist/github-Lfa_ox-u.mjs +0 -1673
  28. package/dist/github-Lfa_ox-u.mjs.map +0 -1
  29. package/dist/index.mjs.map +0 -1
  30. package/dist/providers-CaOnz7mK.mjs +0 -987
  31. package/dist/providers-CaOnz7mK.mjs.map +0 -1
  32. package/dist/testing.d.mts +0 -131
  33. package/dist/testing.mjs +0 -230
  34. package/dist/testing.mjs.map +0 -1
  35. package/src/action.ts +0 -697
  36. package/src/cli.ts +0 -214
  37. package/src/internal/action-entry.ts +0 -42
  38. package/src/internal/coverage.ts +0 -245
  39. package/src/internal/diff.ts +0 -134
  40. package/src/internal/effort.ts +0 -86
  41. package/src/internal/factory.ts +0 -374
  42. package/src/internal/fan-out-scripted.ts +0 -164
  43. package/src/internal/fan-out.ts +0 -450
  44. package/src/internal/fingerprint.ts +0 -74
  45. package/src/internal/fixtures.ts +0 -131
  46. package/src/internal/github-env.ts +0 -142
  47. package/src/internal/github.ts +0 -761
  48. package/src/internal/ignore.ts +0 -88
  49. package/src/internal/profiles.ts +0 -79
  50. package/src/internal/providers.ts +0 -91
  51. package/src/internal/render.ts +0 -428
  52. package/src/internal/retirement.ts +0 -332
  53. package/src/internal/review-agent.ts +0 -385
  54. package/src/internal/review-state.ts +0 -488
  55. package/src/internal/review-units.ts +0 -167
  56. package/src/internal/run.ts +0 -397
  57. package/src/internal/scripted.ts +0 -108
  58. package/src/internal/source.ts +0 -110
  59. package/src/testing.ts +0 -8
package/src/action.ts DELETED
@@ -1,697 +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 { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./internal/effort.ts";
7
- import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
8
- import { readGitHubEvent, resolveReviewTarget, gitHubReviewLayers } from "./internal/github-env.ts";
9
- import { PriorReviews } from "./internal/github.ts";
10
- import {
11
- anthropicClientLayer,
12
- DEFAULT_PROVIDER,
13
- describeReviewModel,
14
- makeAnthropicReviewModel,
15
- makeOpenAiReviewModel,
16
- openAiClientLayer,
17
- type ReviewProvider,
18
- } from "./internal/providers.ts";
19
- import { retireStaleReviews } from "./internal/retirement.ts";
20
- import {
21
- ReviewExecutionContext,
22
- ReviewHeadComparison,
23
- ReviewStateAuthenticator,
24
- type ReviewMode,
25
- type ReviewState,
26
- selectReviewRange,
27
- selectedPullRequestSourceLayer,
28
- unavailableReviewStateAuthenticatorLayer,
29
- webCryptoReviewStateAuthenticatorLayer,
30
- } from "./internal/review-state.ts";
31
- import { fanOutReviewBudgetLimits, reviewBudgetLimits } from "./internal/run.ts";
32
- import type { ReviewRunOutcome } from "./internal/run.ts";
33
- import { normalizeRepoRelativePath, PullRequestSource } from "./internal/source.ts";
34
-
35
- // ---------------------------------------------------------------------------
36
- // The GitHub Actions entrypoint (deployment class E: one bounded ephemeral
37
- // run, no exactly-once posting). Bounded continuity state travels in GitHub
38
- // review bodies and is validated before reuse. Inputs arrive as
39
- // PR_REVIEW_* environment variables set by the action manifest; the target
40
- // pull request comes from the standard Actions event environment. A non-PR
41
- // or draft event is a typed skip — green job, nothing posted. Publication
42
- // happens only after the run settles, so a failed or truncated run posts
43
- // nothing.
44
- // ---------------------------------------------------------------------------
45
-
46
- /** Deprecated compatibility input; host-derived conclusions are unconditional. */
47
- export const FailOnPolicy = Schema.Literals(["never", "request-changes"]);
48
- export type FailOnPolicy = typeof FailOnPolicy.Type;
49
-
50
- export const ReviewCheckConclusion = Schema.Literals(["success", "blocking", "incomplete"]);
51
- export type ReviewCheckConclusion = typeof ReviewCheckConclusion.Type;
52
-
53
- /** The host-derived blocking or incomplete conclusion failed the check. */
54
- export class ReviewGateFailed extends Schema.TaggedError<ReviewGateFailed>()("ReviewGateFailed", {
55
- conclusion: Schema.Literals(["blocking", "incomplete"]),
56
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
57
- Schema.isMinLength(1),
58
- ),
59
- }) {
60
- override get message() {
61
- return `Review check concluded '${this.conclusion}': ${this.reasons.join("; ")}`;
62
- }
63
- }
64
-
65
- /** A configured max-duration that cannot bound a run; configuration faults fail loudly. */
66
- export class InvalidMaxDurationInput extends Schema.TaggedError<InvalidMaxDurationInput>()(
67
- "InvalidMaxDurationInput",
68
- {
69
- minutes: Schema.Int,
70
- },
71
- ) {
72
- override get message() {
73
- return `Invalid max-duration-minutes '${this.minutes}': expected a positive number of minutes.`;
74
- }
75
- }
76
-
77
- /** Everything the packaged action reads from its environment. */
78
- export interface ResolvedActionInputs {
79
- readonly provider: ReviewProvider;
80
- readonly model: string | undefined;
81
- readonly effort: EffortPosition | undefined;
82
- readonly post: boolean;
83
- readonly applyVerdict: boolean;
84
- readonly fanOut: boolean;
85
- readonly guidance: string | undefined;
86
- readonly guidanceFile: string | undefined;
87
- readonly ignore: ReadonlyArray<string>;
88
- readonly maxFindings: number | undefined;
89
- readonly maxDurationMinutes: number | undefined;
90
- readonly reviewMode: ReviewMode;
91
- /** Deprecated compatibility input; conclusions are always conservative. */
92
- readonly failOn: FailOnPolicy;
93
- readonly skipUnchanged: boolean;
94
- readonly retireStaleReviews: boolean;
95
- }
96
-
97
- /** Read the PR_REVIEW_* input surface (all optional, all defaulted). */
98
- export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* () {
99
- const provider = yield* Config.literals(["openai", "anthropic"], "PR_REVIEW_PROVIDER").pipe(
100
- Config.withDefault<ReviewProvider>(DEFAULT_PROVIDER),
101
- );
102
- const model = yield* Config.option(Config.nonEmptyString("PR_REVIEW_MODEL"));
103
- const effortRaw = yield* Config.option(Config.nonEmptyString("PR_REVIEW_EFFORT"));
104
- let effort: EffortPosition | undefined;
105
- if (Option.isSome(effortRaw)) {
106
- effort = parseEffortPosition(effortRaw.value);
107
- if (effort === undefined) {
108
- return yield* InvalidEffortInput.make({ input: effortRaw.value });
109
- }
110
- }
111
- const maxDurationMinutes = Option.getOrUndefined(
112
- yield* Config.option(Config.int("PR_REVIEW_MAX_DURATION_MINUTES")),
113
- );
114
- if (maxDurationMinutes !== undefined && maxDurationMinutes <= 0) {
115
- return yield* InvalidMaxDurationInput.make({ minutes: maxDurationMinutes });
116
- }
117
- const post = yield* Config.boolean("PR_REVIEW_POST").pipe(Config.withDefault(true));
118
- const applyVerdict = yield* Config.boolean("PR_REVIEW_APPLY_VERDICT").pipe(
119
- Config.withDefault(false),
120
- );
121
- const fanOut = yield* Config.boolean("PR_REVIEW_FAN_OUT").pipe(Config.withDefault(false));
122
- const guidance = yield* Config.option(Config.nonEmptyString("PR_REVIEW_GUIDANCE"));
123
- const guidanceFile = yield* Config.option(Config.nonEmptyString("PR_REVIEW_GUIDANCE_FILE"));
124
- const ignoreRaw = yield* Config.string("PR_REVIEW_IGNORE").pipe(Config.withDefault(""));
125
- const maxFindings = yield* Config.option(Config.int("PR_REVIEW_MAX_FINDINGS"));
126
- const reviewMode = yield* Config.literals(["incremental", "final"], "PR_REVIEW_MODE").pipe(
127
- Config.withDefault<ReviewMode>("incremental"),
128
- );
129
- const failOn = yield* Config.literals(["never", "request-changes"], "PR_REVIEW_FAIL_ON").pipe(
130
- Config.withDefault<FailOnPolicy>("never"),
131
- );
132
- const skipUnchanged = yield* Config.boolean("PR_REVIEW_SKIP_UNCHANGED").pipe(
133
- Config.withDefault(true),
134
- );
135
- const retireStaleReviews = yield* Config.boolean("PR_REVIEW_RETIRE_STALE_REVIEWS").pipe(
136
- Config.withDefault(true),
137
- );
138
- return {
139
- provider,
140
- model: Option.getOrUndefined(model),
141
- effort,
142
- post,
143
- applyVerdict,
144
- fanOut,
145
- guidance: Option.getOrUndefined(guidance),
146
- guidanceFile: Option.getOrUndefined(guidanceFile),
147
- ignore: ignoreRaw
148
- .split(",")
149
- .map((pattern) => pattern.trim())
150
- .filter((pattern) => pattern.length > 0),
151
- maxFindings: Option.getOrUndefined(maxFindings),
152
- maxDurationMinutes,
153
- reviewMode,
154
- failOn,
155
- skipUnchanged,
156
- retireStaleReviews,
157
- } satisfies ResolvedActionInputs;
158
- });
159
-
160
- /** A guidance file larger than this is refused, never silently truncated. */
161
- export const MAX_GUIDANCE_FILE_CHARS = 20_000;
162
-
163
- /** A configured guidance file could not be used; configuration faults fail loudly. */
164
- export class GuidanceFileUnreadable extends Schema.TaggedError<GuidanceFileUnreadable>()(
165
- "GuidanceFileUnreadable",
166
- {
167
- path: Schema.String,
168
- reason: Schema.String,
169
- },
170
- ) {
171
- override get message() {
172
- return `Cannot use guidance file '${this.path}': ${this.reason}`;
173
- }
174
- }
175
-
176
- /**
177
- * Resolve the effective review guidance: the committed guidance file (a
178
- * repository-owned review profile) first, with any inline guidance appended.
179
- * A configured-but-unreadable file fails typed — a review silently running
180
- * without its profile would be worse than a red job.
181
- */
182
- export const resolveGuidance = Effect.fn("resolveGuidance")(function* (inputs: {
183
- readonly guidance: string | undefined;
184
- readonly guidanceFile: string | undefined;
185
- }) {
186
- const filePath = inputs.guidanceFile;
187
- if (filePath === undefined) return inputs.guidance;
188
- // The profile path is operator configuration, but it stays fail-closed
189
- // like every other path in this package: workspace-relative only, and the
190
- // size bound is checked before the file is read into memory.
191
- const relative = yield* normalizeRepoRelativePath(filePath).pipe(
192
- Effect.mapError((violation) =>
193
- GuidanceFileUnreadable.make({ path: filePath, reason: violation.reason }),
194
- ),
195
- );
196
- const fs = yield* FileSystem.FileSystem;
197
- const unreadable = (error: { readonly _tag: string; readonly message: string }) =>
198
- GuidanceFileUnreadable.make({
199
- path: relative,
200
- reason: `${error._tag}: ${error.message}`.slice(0, 2_048),
201
- });
202
- const stat = yield* fs.stat(relative).pipe(Effect.mapError(unreadable));
203
- const oversized = GuidanceFileUnreadable.make({
204
- path: relative,
205
- reason: `File is larger than the ${MAX_GUIDANCE_FILE_CHARS}-character guidance bound.`,
206
- });
207
- if (stat.size > BigInt(MAX_GUIDANCE_FILE_CHARS) * 4n) {
208
- return yield* oversized;
209
- }
210
- const content = yield* fs.readFileString(relative).pipe(Effect.mapError(unreadable));
211
- if (content.length > MAX_GUIDANCE_FILE_CHARS) {
212
- return yield* oversized;
213
- }
214
- const combined = [content.trim(), inputs.guidance ?? ""]
215
- .filter((part) => part.length > 0)
216
- .join("\n");
217
- return combined.length > 0 ? combined : undefined;
218
- });
219
-
220
- /** One step-output line; values must be single-line by construction. */
221
- const outputLine = (name: string, value: string): string =>
222
- `${name}=${value.replaceAll("\n", " ").slice(0, 1_000)}\n`;
223
-
224
- /** Append step outputs to GITHUB_OUTPUT when present (no-op locally). */
225
- export const writeActionOutputs = Effect.fn("writeActionOutputs")(function* (
226
- entries: ReadonlyArray<readonly [name: string, value: string]>,
227
- ) {
228
- const outputPath = yield* Config.string("GITHUB_OUTPUT").pipe(Config.withDefault(""));
229
- if (outputPath === "") return;
230
- const fs = yield* FileSystem.FileSystem;
231
- yield* fs.writeFileString(
232
- outputPath,
233
- entries.map(([name, value]) => outputLine(name, value)).join(""),
234
- {
235
- flag: "a",
236
- },
237
- );
238
- });
239
-
240
- /** Append markdown to the GITHUB_STEP_SUMMARY report when present (no-op locally). */
241
- export const writeStepSummary = Effect.fn("writeStepSummary")(function* (
242
- lines: ReadonlyArray<string>,
243
- ) {
244
- const summaryPath = yield* Config.string("GITHUB_STEP_SUMMARY").pipe(Config.withDefault(""));
245
- if (summaryPath === "") return;
246
- const fs = yield* FileSystem.FileSystem;
247
- yield* fs.writeFileString(summaryPath, `${lines.join("\n")}\n`, { flag: "a" });
248
- });
249
-
250
- const outcomeOutputs = (
251
- outcome: ReviewRunOutcome,
252
- conclusion: ReviewCheckConclusion,
253
- ): ReadonlyArray<readonly [string, string]> => [
254
- ["skipped", "false"],
255
- ["conclusion", conclusion],
256
- ["verdict", outcome.review.verdict],
257
- ["coverage", outcome.coverage.status],
258
- ["review-mode", outcome.reviewMode ?? "full"],
259
- ["review-reason", outcome.reviewReason ?? "direct full review"],
260
- ["inline-comments", String(outcome.plan.comments.length)],
261
- ["demoted-findings", String(outcome.plan.demoted.length)],
262
- ["concerns", String(outcome.review.concerns?.length ?? 0)],
263
- ...(outcome.usage === undefined
264
- ? []
265
- : ([
266
- ["input-tokens", String(outcome.usage.inputTokens)],
267
- ["output-tokens", String(outcome.usage.outputTokens)],
268
- ] as const)),
269
- ["review-url", outcome.published?.url ?? ""],
270
- ];
271
-
272
- const outcomeSummary = (
273
- outcome: ReviewRunOutcome,
274
- modelLabel: string | undefined,
275
- conclusion: ReviewCheckConclusion,
276
- ): ReadonlyArray<string> => [
277
- "### Pull-request review",
278
- `- Check conclusion: **${conclusion}**`,
279
- `- Verdict: **${outcome.review.verdict}**`,
280
- `- Coverage: **${outcome.coverage.status}** · scope: ${outcome.reviewMode ?? "full"}`,
281
- `- Inline comments: ${outcome.plan.comments.length} · demoted findings: ${outcome.plan.demoted.length} · concerns: ${outcome.review.concerns?.length ?? 0}`,
282
- ...(modelLabel === undefined ? [] : [`- Model: \`${modelLabel}\``]),
283
- ...(outcome.usage === undefined
284
- ? []
285
- : [
286
- `- Tokens: ${outcome.usage.inputTokens} in / ${outcome.usage.outputTokens} out${
287
- outcome.usageScope === "coordinator" ? " (coordinator)" : ""
288
- }`,
289
- ]),
290
- ...(outcome.published === undefined
291
- ? ["- Dry run: nothing posted"]
292
- : [`- Posted: ${outcome.published.url}`]),
293
- ];
294
-
295
- /** The result of one action invocation: a typed skip or a settled review. */
296
- export type ReviewActionResult =
297
- | { readonly _tag: "Skipped"; readonly reason: string }
298
- | { readonly _tag: "Completed"; readonly outcome: ReviewRunOutcome };
299
-
300
- const skip = (reason: string) =>
301
- Effect.gen(function* () {
302
- yield* Console.log(`Skipping review: ${reason}`);
303
- yield* writeActionOutputs([
304
- ["skipped", "true"],
305
- ["skip-reason", reason],
306
- ]);
307
- yield* writeStepSummary(["### Pull-request review skipped", `- Reason: ${reason}`]);
308
- return { _tag: "Skipped", reason } satisfies ReviewActionResult;
309
- });
310
-
311
- /** The Actions run URL for the review footer, or undefined outside Actions. */
312
- const resolveRunUrl = Effect.fn("resolveRunUrl")(function* () {
313
- const runId = yield* Config.string("GITHUB_RUN_ID").pipe(Config.withDefault(""));
314
- const repository = yield* Config.string("GITHUB_REPOSITORY").pipe(Config.withDefault(""));
315
- if (runId === "" || repository === "") return undefined;
316
- const server = yield* Config.string("GITHUB_SERVER_URL").pipe(
317
- Config.withDefault("https://github.com"),
318
- );
319
- return `${server}/${repository}/actions/runs/${runId}`;
320
- });
321
-
322
- /** The reviewer surface the action harness drives. `PrReview.make` and
323
- * `PrReview.makeFanOut` provide the state-selection effects; the legacy
324
- * fingerprint field remains for source compatibility with custom harnesses. */
325
- export interface HarnessedReviewer<E, R, FingerprintE, FingerprintR> {
326
- readonly run: (runOptions?: RunReviewOptions) => Effect.Effect<ReviewRunOutcome, E, R>;
327
- readonly fingerprint?: Effect.Effect<string, FingerprintE, FingerprintR> | undefined;
328
- readonly profileFingerprint?: Effect.Effect<string, FingerprintE, FingerprintR> | undefined;
329
- readonly snapshot?:
330
- | Effect.Effect<
331
- {
332
- readonly metadata: import("./internal/source.ts").PullRequestMetadata;
333
- readonly files: ReadonlyArray<import("./internal/diff.ts").ChangedFile>;
334
- },
335
- FingerprintE,
336
- FingerprintR
337
- >
338
- | undefined;
339
- readonly filterFiles?:
340
- | ((
341
- files: ReadonlyArray<import("./internal/diff.ts").ChangedFile>,
342
- ) => ReadonlyArray<import("./internal/diff.ts").ChangedFile>)
343
- | undefined;
344
- }
345
-
346
- const blockingReasons = (input: {
347
- readonly findings: ReadonlyArray<{ readonly severity: string; readonly title: string }>;
348
- readonly concerns: ReadonlyArray<{ readonly severity: string; readonly title: string }>;
349
- }): ReadonlyArray<string> => [
350
- ...input.findings
351
- .filter((finding) => finding.severity === "blocking")
352
- .map((finding) => `blocking finding: ${finding.title}`),
353
- ...input.concerns
354
- .filter((concern) => concern.severity === "blocking")
355
- .map((concern) => `blocking concern: ${concern.title}`),
356
- ];
357
-
358
- /** Host-derived check conclusion; model verdict prose cannot weaken it. */
359
- export const concludeReviewOutcome = (
360
- outcome: ReviewRunOutcome,
361
- ): {
362
- readonly conclusion: ReviewCheckConclusion;
363
- readonly reasons: ReadonlyArray<string>;
364
- } => {
365
- if (outcome.coverage.status === "incomplete") {
366
- return { conclusion: "incomplete", reasons: outcome.coverage.reasons };
367
- }
368
- const reasons = blockingReasons({
369
- findings: outcome.activeFindings,
370
- concerns: outcome.activeConcerns,
371
- });
372
- return reasons.length > 0
373
- ? { conclusion: "blocking", reasons }
374
- : { conclusion: "success", reasons };
375
- };
376
-
377
- const concludeReviewState = (state: ReviewState) => {
378
- const reasons = blockingReasons({
379
- findings: state.unresolvedFindings,
380
- concerns: state.unresolvedConcerns,
381
- });
382
- return reasons.length > 0
383
- ? ({ conclusion: "blocking", reasons } as const)
384
- : ({ conclusion: "success", reasons: [] } as const);
385
- };
386
-
387
- /**
388
- * Harness one already-built reviewer inside the Actions environment: resolve
389
- * the target from the event, provide the GitHub source/publisher/prior
390
- * reviews, validate/select bounded continuity scope, write step outputs, and apply
391
- * the host-derived coverage/blocker gate. Draft and non-PR skips are values;
392
- * an unchanged reviewed head preserves and enforces its stored conclusion. The reviewer's
393
- * remaining requirements — its model client, any extra tool handlers — stay
394
- * visible in `R` for the caller.
395
- */
396
- export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never>(
397
- reviewer: HarnessedReviewer<E, R, FingerprintE, FingerprintR>,
398
- options: {
399
- readonly post?: boolean | undefined;
400
- readonly failOn?: FailOnPolicy | undefined;
401
- /** Skip model execution when the current head already has complete stored coverage. */
402
- readonly skipUnchanged?: boolean | undefined;
403
- /** Incremental by default; `final` deliberately re-reviews the full PR diff. */
404
- readonly reviewMode?: ReviewMode | undefined;
405
- /** Model binding descriptor for the Actions step summary. */
406
- readonly modelLabel?: string | undefined;
407
- /** Explicit test/custom-host history override; GitHub owns the default adapter. */
408
- readonly priorReviews?: PriorReviews["Service"] | undefined;
409
- /** Retire marker-bearing prior reviews after a successful post (default true). */
410
- readonly retireStaleReviews?: boolean | undefined;
411
- } = {},
412
- ) =>
413
- Effect.gen(function* () {
414
- const event = yield* readGitHubEvent();
415
- if (Option.isSome(event)) {
416
- if (event.value.pull_request === undefined) {
417
- return yield* skip("the triggering event carries no pull request");
418
- }
419
- if (event.value.pull_request.draft === true) {
420
- return yield* skip("the pull request is a draft");
421
- }
422
- }
423
- const target = yield* resolveReviewTarget({});
424
- // One layer build for state selection AND the run, so both observe
425
- // the same cached pull-request snapshot.
426
- return yield* Effect.gen(function* () {
427
- let selection: ReturnType<typeof selectReviewRange> | undefined;
428
- if (reviewer.profileFingerprint !== undefined) {
429
- const source = yield* PullRequestSource;
430
- const [snapshot, profileFingerprint] = yield* Effect.all([
431
- reviewer.snapshot ?? Effect.all({ metadata: source.metadata, files: source.anchorFiles }),
432
- reviewer.profileFingerprint,
433
- ]);
434
- const { metadata, files: fullFiles } = snapshot;
435
- const history = options.priorReviews ?? (yield* PriorReviews);
436
- const stateAuthenticator = yield* ReviewStateAuthenticator;
437
- const recovered =
438
- stateAuthenticator.status === "unavailable"
439
- ? {
440
- state: undefined,
441
- failure:
442
- stateAuthenticator.unavailableReason ??
443
- "an authenticated review-state secret is not configured",
444
- }
445
- : yield* history.latestState.pipe(
446
- Effect.match({
447
- onFailure: (failure) => ({ state: undefined, failure: failure.reason }),
448
- onSuccess: (state) => ({
449
- state: Option.getOrUndefined(state),
450
- failure: undefined,
451
- }),
452
- }),
453
- );
454
- let comparison: ReviewHeadComparison | undefined;
455
- let baseComparison: ReviewHeadComparison | undefined;
456
- if (
457
- (options.reviewMode ?? "incremental") === "incremental" &&
458
- recovered.state !== undefined
459
- ) {
460
- if (recovered.state.reviewedHeadSha === metadata.headSha) {
461
- comparison = ReviewHeadComparison.make({
462
- status: "identical",
463
- baseSha: metadata.headSha,
464
- headSha: metadata.headSha,
465
- mergeBaseSha: metadata.headSha,
466
- files: [],
467
- truncated: false,
468
- });
469
- } else {
470
- comparison = yield* history
471
- .compareHeads(recovered.state.reviewedHeadSha, metadata.headSha)
472
- .pipe(Effect.orElseSucceed(() => undefined));
473
- if (comparison !== undefined && reviewer.filterFiles !== undefined) {
474
- comparison = ReviewHeadComparison.make({
475
- ...comparison,
476
- files: reviewer.filterFiles(comparison.files),
477
- });
478
- }
479
- }
480
- if (metadata.baseSha !== undefined && recovered.state.baseSha !== metadata.baseSha) {
481
- baseComparison = yield* history
482
- .compareHeads(recovered.state.baseSha, metadata.baseSha)
483
- .pipe(Effect.orElseSucceed(() => undefined));
484
- if (baseComparison !== undefined && reviewer.filterFiles !== undefined) {
485
- baseComparison = ReviewHeadComparison.make({
486
- ...baseComparison,
487
- files: reviewer.filterFiles(baseComparison.files),
488
- });
489
- }
490
- }
491
- }
492
- selection = {
493
- ...selectReviewRange({
494
- requestedMode: options.reviewMode ?? "incremental",
495
- current: metadata,
496
- fullFiles,
497
- profileFingerprint,
498
- priorState: recovered.state,
499
- comparison,
500
- baseComparison,
501
- lookupFailure: recovered.failure,
502
- }),
503
- stateAuthenticator,
504
- };
505
- if (
506
- options.skipUnchanged !== false &&
507
- selection.mode === "incremental" &&
508
- selection.files.length === 0 &&
509
- selection.priorState !== undefined
510
- ) {
511
- const result = concludeReviewState(selection.priorState);
512
- const reason = "no changed review scope since the last successfully reviewed head";
513
- yield* Console.log(
514
- `Skipping review of ${target.repository}#${target.number}: ${reason}.`,
515
- );
516
- yield* writeActionOutputs([
517
- ["skipped", "true"],
518
- ["skip-reason", reason],
519
- ["conclusion", result.conclusion],
520
- ["coverage", "complete"],
521
- ["review-mode", "incremental"],
522
- ]);
523
- yield* writeStepSummary([
524
- "### Pull-request review skipped",
525
- `- Reason: ${reason}`,
526
- `- Preserved check conclusion: **${result.conclusion}**`,
527
- ]);
528
- if (result.conclusion === "blocking") {
529
- return yield* ReviewGateFailed.make({
530
- conclusion: "blocking",
531
- reasons: result.reasons,
532
- });
533
- }
534
- return { _tag: "Skipped", reason } satisfies ReviewActionResult;
535
- }
536
- } else if (options.skipUnchanged !== false && reviewer.fingerprint !== undefined) {
537
- // Preserve the pre-state custom harness contract explicitly. The
538
- // packaged reviewer always takes the authenticated state path above.
539
- const current = yield* reviewer.fingerprint;
540
- const history = options.priorReviews ?? (yield* PriorReviews);
541
- const latest = yield* history.latestFingerprint.pipe(
542
- Effect.orElseSucceed(() => Option.none<string>()),
543
- );
544
- if (Option.isSome(latest) && latest.value === current) {
545
- const reason = "changeset unchanged since the last review";
546
- yield* Console.log(
547
- `Skipping review of ${target.repository}#${target.number}: ${reason}.`,
548
- );
549
- yield* writeActionOutputs([
550
- ["skipped", "true"],
551
- ["skip-reason", reason],
552
- ["fingerprint", current],
553
- ["conclusion", "success"],
554
- ["coverage", "complete"],
555
- ]);
556
- yield* writeStepSummary(["### Pull-request review skipped", `- Reason: ${reason}`]);
557
- return { _tag: "Skipped", reason } satisfies ReviewActionResult;
558
- }
559
- }
560
- yield* Console.log(
561
- `Reviewing ${target.repository}#${target.number} (${options.post === false ? "dry run" : "posting"})...`,
562
- );
563
- const runUrl = yield* resolveRunUrl();
564
- const reviewEffect = reviewer.run({ post: options.post ?? true, runUrl });
565
- const outcome = yield* selection === undefined
566
- ? reviewEffect
567
- : reviewEffect.pipe(
568
- Effect.provide(selectedPullRequestSourceLayer(selection)),
569
- Effect.provideService(ReviewExecutionContext, selection),
570
- );
571
- yield* Console.log(
572
- `Review finished in ${outcome.turns} turn(s): verdict ${outcome.review.verdict}, ` +
573
- `${outcome.plan.comments.length} inline comment(s), ${outcome.plan.demoted.length} demoted finding(s).`,
574
- );
575
- if (outcome.published !== undefined) {
576
- yield* Console.log(`Posted ${outcome.published.event} review: ${outcome.published.url}`);
577
- if (options.retireStaleReviews !== false && outcome.state !== undefined) {
578
- if (outcome.published.authorNodeId === null || outcome.published.submittedAt === null) {
579
- yield* Console.warn(
580
- "Skipping stale-review retirement because GitHub did not return the posted review's actor and submission time.",
581
- );
582
- } else {
583
- const report = yield* retireStaleReviews({
584
- currentReviewId: outcome.published.reviewId,
585
- currentReviewUrl: outcome.published.url,
586
- currentAuthorNodeId: outcome.published.authorNodeId,
587
- currentSubmittedAt: outcome.published.submittedAt,
588
- currentState: outcome.state,
589
- });
590
- yield* Console.log(
591
- `Review retirement: ${report.reviewsRetired} prior review(s), ` +
592
- `${report.findingsResolved} resolved finding(s), ` +
593
- `${report.commentsMinimized} minimized inline comment(s), ` +
594
- `${report.failures} failure(s).`,
595
- );
596
- }
597
- }
598
- }
599
- const check = concludeReviewOutcome(outcome);
600
- yield* writeActionOutputs(outcomeOutputs(outcome, check.conclusion));
601
- yield* writeStepSummary(outcomeSummary(outcome, options.modelLabel, check.conclusion));
602
- if (check.conclusion !== "success") {
603
- return yield* ReviewGateFailed.make({
604
- conclusion: check.conclusion,
605
- reasons: check.reasons,
606
- });
607
- }
608
- return { _tag: "Completed", outcome } satisfies ReviewActionResult;
609
- }).pipe(Effect.provide(gitHubReviewLayers(target)));
610
- });
611
-
612
- /**
613
- * The packaged, environment-driven action program: inputs from PR_REVIEW_*,
614
- * reviewer built from the packaged factory, provider client from the
615
- * matching credential environment variable.
616
- */
617
- export const reviewActionProgram = Effect.gen(function* () {
618
- const inputs = yield* resolveActionInputs();
619
- const stateSecret = Option.map(
620
- yield* Config.option(Config.nonEmptyString("PR_REVIEW_STATE_SECRET")),
621
- Redacted.make,
622
- );
623
- const stateAuthenticatorLayer = Option.match(stateSecret, {
624
- onNone: () =>
625
- unavailableReviewStateAuthenticatorLayer(
626
- "an authenticated review-state secret is not configured",
627
- ),
628
- onSome: webCryptoReviewStateAuthenticatorLayer,
629
- });
630
- const guidance = yield* resolveGuidance(inputs);
631
- const modelLabel = describeReviewModel(inputs.provider, inputs.model, inputs.effort);
632
- const defaults = inputs.fanOut ? fanOutReviewBudgetLimits : reviewBudgetLimits;
633
- const budget =
634
- inputs.maxDurationMinutes === undefined
635
- ? defaults
636
- : UsageBudgetLimits.make({
637
- ...defaults,
638
- maxDurationMillis: inputs.maxDurationMinutes * 60_000,
639
- });
640
- const shared = {
641
- guidance,
642
- ignore: inputs.ignore,
643
- maxFindings: inputs.maxFindings,
644
- applyVerdict: inputs.applyVerdict,
645
- modelLabel,
646
- budget,
647
- };
648
- const harness = {
649
- post: inputs.post,
650
- failOn: inputs.failOn,
651
- skipUnchanged: inputs.skipUnchanged,
652
- reviewMode: inputs.reviewMode,
653
- retireStaleReviews: inputs.retireStaleReviews,
654
- modelLabel,
655
- };
656
- if (inputs.provider === "anthropic") {
657
- const model = makeAnthropicReviewModel(inputs.model, inputs.effort);
658
- const reviewer = inputs.fanOut
659
- ? PrReview.makeFanOut({ ...shared, model })
660
- : PrReview.make({ ...shared, model });
661
- return yield* runReviewAction(reviewer, harness).pipe(
662
- Effect.provide(Layer.merge(stateAuthenticatorLayer, anthropicClientLayer)),
663
- );
664
- }
665
- const model = makeOpenAiReviewModel(inputs.model, inputs.effort);
666
- const reviewer = inputs.fanOut
667
- ? PrReview.makeFanOut({ ...shared, model })
668
- : PrReview.make({ ...shared, model });
669
- return yield* runReviewAction(reviewer, harness).pipe(
670
- Effect.provide(Layer.merge(stateAuthenticatorLayer, openAiClientLayer)),
671
- );
672
- });
673
-
674
- /** Run the packaged action program on Node; the bundled action entrypoint. */
675
- export const main = (): void =>
676
- NodeRuntime.runMain(
677
- reviewActionProgram.pipe(
678
- Effect.tapError((error) =>
679
- Console.error(
680
- Schema.is(BudgetExceeded)(error)
681
- ? `Budget exceeded: ${error.limit} observed ${error.observedValue}, limit ${error.limitValue}.`
682
- : String(error),
683
- ),
684
- ),
685
- Effect.scoped,
686
- Effect.provide(Layer.merge(NodeServices.layer, FetchHttpClient.layer)),
687
- ),
688
- { disableErrorReporting: true },
689
- );
690
-
691
- /** The packaged GitHub Actions surface. */
692
- export const PrReviewAction = {
693
- inputs: resolveActionInputs,
694
- run: runReviewAction,
695
- program: reviewActionProgram,
696
- main,
697
- } as const;