@effect-agent/pr-review 0.1.0-beta.8 → 0.1.0-beta.80

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 (58) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +170 -158
  3. package/dist/Review.d.mts +295 -0
  4. package/dist/Review.mjs +704 -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 -716
  11. package/dist/index.mjs +3 -66
  12. package/dist/repository-BzSG74vX.mjs +101 -0
  13. package/dist/repository-BzSG74vX.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 +1058 -0
  17. package/src/ReviewRepository.ts +9 -0
  18. package/src/index.ts +2 -20
  19. package/src/internal/repository.ts +156 -0
  20. package/dist/action.d.mts +0 -185
  21. package/dist/action.mjs +0 -406
  22. package/dist/action.mjs.map +0 -1
  23. package/dist/cli.d.mts +0 -1
  24. package/dist/cli.mjs +0 -102
  25. package/dist/cli.mjs.map +0 -1
  26. package/dist/fan-out-BBEATQwc.d.mts +0 -997
  27. package/dist/github-BZNzmxao.mjs +0 -1372
  28. package/dist/github-BZNzmxao.mjs.map +0 -1
  29. package/dist/index.mjs.map +0 -1
  30. package/dist/providers-J6BKHyHe.mjs +0 -986
  31. package/dist/providers-J6BKHyHe.mjs.map +0 -1
  32. package/dist/testing.d.mts +0 -131
  33. package/dist/testing.mjs +0 -228
  34. package/dist/testing.mjs.map +0 -1
  35. package/src/action.ts +0 -666
  36. package/src/cli.ts +0 -213
  37. package/src/internal/action-entry.ts +0 -41
  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 -127
  46. package/src/internal/github-env.ts +0 -128
  47. package/src/internal/github.ts +0 -531
  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/review-agent.ts +0 -385
  53. package/src/internal/review-state.ts +0 -488
  54. package/src/internal/review-units.ts +0 -167
  55. package/src/internal/run.ts +0 -397
  56. package/src/internal/scripted.ts +0 -108
  57. package/src/internal/source.ts +0 -110
  58. package/src/testing.ts +0 -8
@@ -1,128 +0,0 @@
1
- import { Config, Effect, FileSystem, Layer, Option, Schema } from "effect";
2
- import { FetchHttpClient } from "effect/unstable/http";
3
-
4
- import type { PriorReviews, ReviewPublisher } from "./github.ts";
5
- import {
6
- GitHubReviewTarget,
7
- gitHubPriorReviewsLayer,
8
- gitHubPullRequestSourceLayer,
9
- gitHubReviewPublisherLayer,
10
- } from "./github.ts";
11
- import type { PullRequestSource } from "./source.ts";
12
-
13
- // ---------------------------------------------------------------------------
14
- // GitHub Actions environment resolution: which pull request to review, from
15
- // explicit values first and the standard Actions environment second
16
- // (GITHUB_REPOSITORY, GITHUB_EVENT_PATH, GITHUB_API_URL, GITHUB_TOKEN).
17
- // Platform-free: FileSystem and Config are Effect services supplied by the
18
- // host entrypoint.
19
- // ---------------------------------------------------------------------------
20
-
21
- /** The pull request could not be resolved from options or the environment. */
22
- export class ReviewTargetUnresolved extends Schema.TaggedError<ReviewTargetUnresolved>()(
23
- "ReviewTargetUnresolved",
24
- {
25
- reason: Schema.String,
26
- },
27
- ) {
28
- override get message() {
29
- return this.reason;
30
- }
31
- }
32
-
33
- /** The slice of a GitHub Actions event payload this package understands. */
34
- export const GitHubEventWire = Schema.Struct({
35
- pull_request: Schema.optionalKey(
36
- Schema.Struct({
37
- number: Schema.Int,
38
- draft: Schema.optionalKey(Schema.Boolean),
39
- }),
40
- ),
41
- repository: Schema.optionalKey(Schema.Struct({ full_name: Schema.String })),
42
- });
43
- export type GitHubEventWire = typeof GitHubEventWire.Type;
44
-
45
- const decodeEvent = Schema.decodeUnknownEffect(Schema.fromJsonString(GitHubEventWire));
46
-
47
- /** Read and decode the GITHUB_EVENT_PATH payload, or none outside Actions. */
48
- export const readGitHubEvent = Effect.fn("readGitHubEvent")(function* () {
49
- const eventPath = yield* Config.string("GITHUB_EVENT_PATH").pipe(Config.withDefault(""));
50
- if (eventPath === "") return Option.none<GitHubEventWire>();
51
- const fs = yield* FileSystem.FileSystem;
52
- const raw = yield* fs
53
- .readFileString(eventPath)
54
- .pipe(
55
- Effect.mapError((error) =>
56
- ReviewTargetUnresolved.make({ reason: `Cannot read event payload: ${error.message}` }),
57
- ),
58
- );
59
- const event = yield* decodeEvent(raw).pipe(
60
- Effect.mapError((error) =>
61
- ReviewTargetUnresolved.make({ reason: `Cannot decode event payload: ${error.message}` }),
62
- ),
63
- );
64
- return Option.some(event);
65
- });
66
-
67
- export interface ResolvedReviewTarget {
68
- readonly repository: string;
69
- readonly number: number;
70
- }
71
-
72
- /**
73
- * Resolve the review target: explicit values win, then GITHUB_REPOSITORY and
74
- * the pull_request event payload. Fails typed when no target can be named.
75
- */
76
- export const resolveReviewTarget = Effect.fn("resolveReviewTarget")(function* (options: {
77
- readonly repository?: string | undefined;
78
- readonly number?: number | undefined;
79
- }) {
80
- let repository = options.repository ?? "";
81
- if (repository === "") {
82
- repository = yield* Config.string("GITHUB_REPOSITORY").pipe(Config.withDefault(""));
83
- }
84
- let number = options.number;
85
- if (number === undefined || repository === "") {
86
- const event = yield* readGitHubEvent();
87
- if (Option.isSome(event)) {
88
- number ??= event.value.pull_request?.number;
89
- if (repository === "") repository = event.value.repository?.full_name ?? "";
90
- }
91
- }
92
- if (repository === "" || number === undefined) {
93
- return yield* ReviewTargetUnresolved.make({
94
- reason:
95
- "No pull request to review: pass an explicit repository and number, or run inside a GitHub Actions pull_request event.",
96
- });
97
- }
98
- return { repository, number } satisfies ResolvedReviewTarget;
99
- });
100
-
101
- /**
102
- * Build the GitHub source and publisher Layers for one resolved target,
103
- * reading GITHUB_API_URL and GITHUB_TOKEN from configuration. The returned
104
- * Layer is the complete GitHub side of a review run.
105
- */
106
- export const gitHubReviewLayers = (
107
- target: ResolvedReviewTarget,
108
- ): Layer.Layer<PullRequestSource | ReviewPublisher | PriorReviews, Config.ConfigError> =>
109
- Layer.unwrap(
110
- Effect.gen(function* () {
111
- const apiUrl = yield* Config.string("GITHUB_API_URL").pipe(
112
- Config.withDefault("https://api.github.com"),
113
- );
114
- const token = yield* Config.option(Config.redacted("GITHUB_TOKEN"));
115
- const targetLayer = GitHubReviewTarget.layer({
116
- apiUrl,
117
- repository: target.repository,
118
- number: target.number,
119
- token,
120
- });
121
- const deps = Layer.merge(targetLayer, FetchHttpClient.layer);
122
- return Layer.mergeAll(
123
- gitHubPullRequestSourceLayer.pipe(Layer.provide(deps)),
124
- gitHubReviewPublisherLayer.pipe(Layer.provide(deps)),
125
- gitHubPriorReviewsLayer.pipe(Layer.provide(deps)),
126
- );
127
- }),
128
- );
@@ -1,531 +0,0 @@
1
- import type { Redacted } from "effect";
2
- import { Context, Effect, Layer, Option, Schema } from "effect";
3
- import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
-
5
- import { ChangedFile } from "./diff.ts";
6
- import { extractFingerprint } from "./fingerprint.ts";
7
- import type { ReviewPublicationPlan } from "./render.ts";
8
- import {
9
- ReviewHeadComparison,
10
- ReviewStateAuthenticator,
11
- type ReviewState,
12
- } from "./review-state.ts";
13
- import {
14
- MAX_CHANGED_FILES,
15
- MAX_FILE_CHARS,
16
- normalizeRepoRelativePath,
17
- PullRequestMetadata,
18
- PullRequestSource,
19
- PullRequestSourceFailure,
20
- ReviewInputViolation,
21
- } from "./source.ts";
22
-
23
- // ---------------------------------------------------------------------------
24
- // GitHub REST adapters for the PullRequestSource port and the ReviewPublisher.
25
- // Wire payloads are decoded through minimal Schemas — never asserted — and
26
- // every upstream fault becomes the typed PullRequestSourceFailure /
27
- // GitHubApiFailure instead of an untyped defect.
28
- // ---------------------------------------------------------------------------
29
-
30
- /** Which pull request to review and how to reach the API. */
31
- export class GitHubReviewTarget extends Context.Service<
32
- GitHubReviewTarget,
33
- {
34
- /** API root, e.g. `https://api.github.com` (no trailing slash). */
35
- readonly apiUrl: string;
36
- /** `owner/name`. */
37
- readonly repository: string;
38
- readonly number: number;
39
- /** Absent token means unauthenticated reads (public repositories only). */
40
- readonly token: Option.Option<Redacted.Redacted<string>>;
41
- }
42
- >()("@effect-agent/pr-review/GitHubReviewTarget") {
43
- static layer(config: {
44
- readonly apiUrl: string;
45
- readonly repository: string;
46
- readonly number: number;
47
- readonly token: Option.Option<Redacted.Redacted<string>>;
48
- }): Layer.Layer<GitHubReviewTarget> {
49
- return Layer.succeed(this, GitHubReviewTarget.of(config));
50
- }
51
- }
52
-
53
- /** A GitHub API call failed: transport, status, or payload decode. */
54
- export class GitHubApiFailure extends Schema.TaggedError<GitHubApiFailure>()("GitHubApiFailure", {
55
- operation: Schema.String,
56
- reason: Schema.String,
57
- }) {
58
- override get message() {
59
- return `GitHub API operation '${this.operation}' failed: ${this.reason}`;
60
- }
61
- }
62
-
63
- // --- Wire schemas (decode-only, minimal fields) ------------------------------
64
-
65
- const GitHubPullRequestWire = Schema.Struct({
66
- number: Schema.Int,
67
- title: Schema.String,
68
- body: Schema.NullOr(Schema.String),
69
- changed_files: Schema.Int,
70
- base: Schema.Struct({ ref: Schema.String, sha: Schema.String }),
71
- head: Schema.Struct({ ref: Schema.String, sha: Schema.String }),
72
- });
73
-
74
- const GitHubFileWire = Schema.Struct({
75
- filename: Schema.String,
76
- status: Schema.String,
77
- additions: Schema.Int,
78
- deletions: Schema.Int,
79
- patch: Schema.optionalKey(Schema.String),
80
- previous_filename: Schema.optionalKey(Schema.String),
81
- });
82
-
83
- const GitHubFilesPageWire = Schema.Array(GitHubFileWire);
84
-
85
- const GitHubReviewWire = Schema.Struct({
86
- id: Schema.Int,
87
- html_url: Schema.String,
88
- });
89
-
90
- /** The publication receipt callers report back to the operator. */
91
- export class PublishedReview extends Schema.Class<PublishedReview>(
92
- "@effect-agent/pr-review/PublishedReview",
93
- )({
94
- reviewId: Schema.Int,
95
- url: Schema.String,
96
- event: Schema.String,
97
- inlineComments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
98
- }) {}
99
-
100
- /** Posts one planned review; the ONLY mutating operation in this package. */
101
- export class ReviewPublisher extends Context.Service<
102
- ReviewPublisher,
103
- {
104
- readonly publish: (
105
- plan: ReviewPublicationPlan,
106
- ) => Effect.Effect<PublishedReview, GitHubApiFailure>;
107
- }
108
- >()("@effect-agent/pr-review/ReviewPublisher") {}
109
-
110
- // --- Shared request plumbing -------------------------------------------------
111
-
112
- const FILE_STATUSES = new Set([
113
- "added",
114
- "removed",
115
- "modified",
116
- "renamed",
117
- "copied",
118
- "changed",
119
- "unchanged",
120
- ]);
121
-
122
- const withCommonHeaders = (
123
- request: HttpClientRequest.HttpClientRequest,
124
- token: Option.Option<Redacted.Redacted<string>>,
125
- ): HttpClientRequest.HttpClientRequest => {
126
- const base = request.pipe(
127
- HttpClientRequest.setHeaders({
128
- "X-GitHub-Api-Version": "2022-11-28",
129
- "User-Agent": "effect-agent-pr-review",
130
- }),
131
- );
132
- return Option.isSome(token) ? base.pipe(HttpClientRequest.bearerToken(token.value)) : base;
133
- };
134
-
135
- const failWith =
136
- (operation: string) =>
137
- (error: { readonly _tag: string; readonly message?: string }): PullRequestSourceFailure =>
138
- PullRequestSourceFailure.make({
139
- operation,
140
- reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
141
- });
142
-
143
- const decodeJsonBody = <S extends Schema.Top>(schema: S, operation: string) => {
144
- const decode = Schema.decodeUnknownEffect(schema);
145
- return (
146
- response: HttpClientResponse.HttpClientResponse,
147
- ): Effect.Effect<S["Type"], PullRequestSourceFailure, S["DecodingServices"]> =>
148
- response.json.pipe(
149
- Effect.mapError(failWith(operation)),
150
- Effect.flatMap((body) => decode(body).pipe(Effect.mapError(failWith(operation)))),
151
- );
152
- };
153
-
154
- const executeOk = (
155
- operation: string,
156
- request: HttpClientRequest.HttpClientRequest,
157
- ): Effect.Effect<
158
- HttpClientResponse.HttpClientResponse,
159
- PullRequestSourceFailure,
160
- HttpClient.HttpClient
161
- > =>
162
- HttpClient.execute(request).pipe(
163
- Effect.flatMap(HttpClientResponse.filterStatusOk),
164
- Effect.mapError(failWith(operation)),
165
- );
166
-
167
- const toChangedFile = (wire: typeof GitHubFileWire.Type): ChangedFile =>
168
- ChangedFile.make({
169
- path: wire.filename,
170
- status: FILE_STATUSES.has(wire.status) ? (wire.status as ChangedFile["status"]) : "changed",
171
- additions: wire.additions,
172
- deletions: wire.deletions,
173
- ...(wire.previous_filename !== undefined ? { previousPath: wire.previous_filename } : {}),
174
- ...(wire.patch !== undefined ? { patch: wire.patch } : {}),
175
- });
176
-
177
- // --- Live PullRequestSource --------------------------------------------------
178
-
179
- /**
180
- * GitHub-backed PullRequestSource. Metadata and the changeset are fetched
181
- * once per Layer build and cached: the pull request is reviewed as one
182
- * consistent snapshot even if the branch moves mid-run.
183
- */
184
- export const gitHubPullRequestSourceLayer: Layer.Layer<
185
- PullRequestSource,
186
- never,
187
- GitHubReviewTarget | HttpClient.HttpClient
188
- > = Layer.effect(PullRequestSource)(
189
- Effect.gen(function* () {
190
- const target = yield* GitHubReviewTarget;
191
- const client = yield* HttpClient.HttpClient;
192
- const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
193
-
194
- const fetchMetadata = executeOk(
195
- "getPullRequest",
196
- withCommonHeaders(
197
- HttpClientRequest.get(prefix).pipe(HttpClientRequest.acceptJson),
198
- target.token,
199
- ),
200
- ).pipe(
201
- Effect.flatMap(decodeJsonBody(GitHubPullRequestWire, "getPullRequest")),
202
- Effect.map((wire) =>
203
- PullRequestMetadata.make({
204
- repository: target.repository,
205
- number: wire.number,
206
- title: wire.title.slice(0, 400),
207
- body: (wire.body ?? "").slice(0, 20_000),
208
- baseRef: wire.base.ref,
209
- baseSha: wire.base.sha,
210
- headRef: wire.head.ref,
211
- headSha: wire.head.sha,
212
- totalChangedFiles: wire.changed_files,
213
- }),
214
- ),
215
- );
216
-
217
- const fetchFiles = Effect.gen(function* () {
218
- const perPage = 100;
219
- const all: Array<ChangedFile> = [];
220
- for (let page = 1; page <= MAX_CHANGED_FILES / perPage; page += 1) {
221
- const response = yield* executeOk(
222
- "listChangedFiles",
223
- withCommonHeaders(
224
- HttpClientRequest.get(`${prefix}/files`).pipe(
225
- HttpClientRequest.acceptJson,
226
- HttpClientRequest.setUrlParams({
227
- per_page: String(perPage),
228
- page: String(page),
229
- }),
230
- ),
231
- target.token,
232
- ),
233
- );
234
- const wires = yield* decodeJsonBody(GitHubFilesPageWire, "listChangedFiles")(response);
235
- all.push(...wires.map(toChangedFile));
236
- if (wires.length < perPage) break;
237
- }
238
- return all as ReadonlyArray<ChangedFile>;
239
- });
240
-
241
- const metadata = yield* Effect.cached(
242
- fetchMetadata.pipe(Effect.provideService(HttpClient.HttpClient, client)),
243
- );
244
- const changedFiles = yield* Effect.cached(
245
- fetchFiles.pipe(Effect.provideService(HttpClient.HttpClient, client)),
246
- );
247
-
248
- const readFile = (path: string) =>
249
- Effect.gen(function* () {
250
- const relative = yield* normalizeRepoRelativePath(path);
251
- const files = yield* changedFiles;
252
- if (!files.some((file) => file.path === relative)) {
253
- return yield* ReviewInputViolation.make({
254
- input: relative,
255
- reason: "Path is not part of this pull request's changeset.",
256
- });
257
- }
258
- const head = yield* metadata;
259
- const encodedPath = relative.split("/").map(encodeURIComponent).join("/");
260
- const response = yield* executeOk(
261
- "readFile",
262
- withCommonHeaders(
263
- HttpClientRequest.get(
264
- `${target.apiUrl}/repos/${target.repository}/contents/${encodedPath}`,
265
- ).pipe(
266
- HttpClientRequest.accept("application/vnd.github.raw+json"),
267
- HttpClientRequest.setUrlParams({ ref: head.headSha }),
268
- ),
269
- target.token,
270
- ),
271
- ).pipe(Effect.provideService(HttpClient.HttpClient, client));
272
- const text = yield* response.text.pipe(Effect.mapError(failWith("readFile")));
273
- if (text.length > MAX_FILE_CHARS) {
274
- return yield* ReviewInputViolation.make({
275
- input: relative,
276
- reason: `File is larger than the ${MAX_FILE_CHARS}-character read bound.`,
277
- });
278
- }
279
- return text;
280
- });
281
-
282
- return PullRequestSource.of({ metadata, changedFiles, anchorFiles: changedFiles, readFile });
283
- }),
284
- );
285
-
286
- // --- Live ReviewPublisher ------------------------------------------------------
287
-
288
- /** GitHub-backed publisher: one POST to the pull-request reviews endpoint. */
289
- export const gitHubReviewPublisherLayer: Layer.Layer<
290
- ReviewPublisher,
291
- never,
292
- GitHubReviewTarget | HttpClient.HttpClient
293
- > = Layer.effect(ReviewPublisher)(
294
- Effect.gen(function* () {
295
- const target = yield* GitHubReviewTarget;
296
- const client = yield* HttpClient.HttpClient;
297
- return ReviewPublisher.of({
298
- publish: (plan) =>
299
- Effect.gen(function* () {
300
- const payload = {
301
- event: plan.event,
302
- body: plan.body,
303
- commit_id: plan.commitSha,
304
- comments: plan.comments.map((comment) => ({
305
- path: comment.path,
306
- line: comment.line,
307
- side: "RIGHT",
308
- ...(comment.startLine !== undefined
309
- ? { start_line: comment.startLine, start_side: "RIGHT" }
310
- : {}),
311
- body: comment.body,
312
- })),
313
- };
314
- const request = withCommonHeaders(
315
- HttpClientRequest.post(
316
- `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}/reviews`,
317
- ).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bodyJsonUnsafe(payload)),
318
- target.token,
319
- );
320
- const wire = yield* HttpClient.execute(request).pipe(
321
- Effect.flatMap(HttpClientResponse.filterStatusOk),
322
- Effect.flatMap((response) =>
323
- response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(GitHubReviewWire))),
324
- ),
325
- Effect.mapError((error) =>
326
- GitHubApiFailure.make({
327
- operation: "createReview",
328
- reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
329
- }),
330
- ),
331
- Effect.provideService(HttpClient.HttpClient, client),
332
- );
333
- return PublishedReview.make({
334
- reviewId: wire.id,
335
- url: wire.html_url,
336
- event: plan.event,
337
- inlineComments: plan.comments.length,
338
- });
339
- }),
340
- });
341
- }),
342
- );
343
-
344
- // --- Prior reviews (fingerprint deduplication) ---------------------------------
345
-
346
- /** Reading the pull request's previously posted reviews failed. */
347
- export class PriorReviewLookupFailure extends Schema.TaggedError<PriorReviewLookupFailure>()(
348
- "PriorReviewLookupFailure",
349
- {
350
- reason: Schema.String,
351
- },
352
- ) {
353
- override get message() {
354
- return `Prior-review lookup failed: ${this.reason}`;
355
- }
356
- }
357
-
358
- /**
359
- * Read-only view of this package's previously posted reviews on the target
360
- * pull request — the deduplication state for unchanged-changeset skipping.
361
- */
362
- export class PriorReviews extends Context.Service<
363
- PriorReviews,
364
- {
365
- /** The fingerprint embedded in the most recent marker-bearing review. */
366
- readonly latestFingerprint: Effect.Effect<Option.Option<string>, PriorReviewLookupFailure>;
367
- /** The latest authenticated, successfully covered review state marker. */
368
- readonly latestState: Effect.Effect<
369
- Option.Option<ReviewState>,
370
- PriorReviewLookupFailure,
371
- ReviewStateAuthenticator
372
- >;
373
- /** Compare a previously reviewed head to the live current head. */
374
- readonly compareHeads: (
375
- baseSha: string,
376
- headSha: string,
377
- ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
378
- }
379
- >()("@effect-agent/pr-review/PriorReviews") {}
380
-
381
- const GitHubPriorReviewWire = Schema.Struct({
382
- body: Schema.NullOr(Schema.String),
383
- commit_id: Schema.String,
384
- user: Schema.optionalKey(
385
- Schema.NullOr(
386
- Schema.Struct({
387
- login: Schema.String,
388
- type: Schema.String,
389
- }),
390
- ),
391
- ),
392
- });
393
- const GitHubPriorReviewsPageWire = Schema.Array(GitHubPriorReviewWire);
394
-
395
- const GitHubCompareWire = Schema.Struct({
396
- status: Schema.Literals(["ahead", "behind", "diverged", "identical"]),
397
- base_commit: Schema.Struct({ sha: Schema.String }),
398
- merge_base_commit: Schema.Struct({ sha: Schema.String }),
399
- files: GitHubFilesPageWire,
400
- });
401
-
402
- /** Reviews are paged chronologically; scanning stays bounded. */
403
- const MAX_PRIOR_REVIEW_PAGES = 5;
404
-
405
- /** GitHub-backed PriorReviews over the pull-request reviews endpoint. */
406
- export const gitHubPriorReviewsLayer: Layer.Layer<
407
- PriorReviews,
408
- never,
409
- GitHubReviewTarget | HttpClient.HttpClient
410
- > = Layer.effect(PriorReviews)(
411
- Effect.gen(function* () {
412
- const target = yield* GitHubReviewTarget;
413
- const client = yield* HttpClient.HttpClient;
414
- const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
415
- const decodePage = Schema.decodeUnknownEffect(GitHubPriorReviewsPageWire);
416
- const asLookupFailure = (error: { readonly _tag: string; readonly message?: string }) =>
417
- PriorReviewLookupFailure.make({
418
- reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
419
- });
420
- const readMarkers = (authenticator: Option.Option<ReviewStateAuthenticator["Service"]>) =>
421
- Effect.gen(function* () {
422
- const perPage = 100;
423
- let latest = Option.none<string>();
424
- let latestState = Option.none<ReviewState>();
425
- for (let page = 1; page <= MAX_PRIOR_REVIEW_PAGES; page += 1) {
426
- const response = yield* HttpClient.execute(
427
- withCommonHeaders(
428
- HttpClientRequest.get(`${prefix}/reviews`).pipe(
429
- HttpClientRequest.acceptJson,
430
- HttpClientRequest.setUrlParams({
431
- per_page: String(perPage),
432
- page: String(page),
433
- }),
434
- ),
435
- target.token,
436
- ),
437
- ).pipe(
438
- Effect.flatMap(HttpClientResponse.filterStatusOk),
439
- Effect.mapError(asLookupFailure),
440
- );
441
- const wires = yield* response.json.pipe(
442
- Effect.mapError(asLookupFailure),
443
- Effect.flatMap((body) => decodePage(body).pipe(Effect.mapError(asLookupFailure))),
444
- );
445
- for (const wire of wires) {
446
- // State controls what required scope may be omitted. The author gate
447
- // rejects user prose; the terminal marker is additionally HMAC
448
- // authenticated so another bot workflow or model text cannot forge it.
449
- if (wire.user?.login !== "github-actions[bot]" || wire.user.type !== "Bot") continue;
450
- const fingerprint = extractFingerprint(wire.body ?? "");
451
- if (fingerprint !== undefined) latest = Option.some(fingerprint);
452
- if (Option.isSome(authenticator)) {
453
- const state = yield* authenticator.value.extract(wire.body ?? "").pipe(
454
- Effect.mapError((error) =>
455
- PriorReviewLookupFailure.make({
456
- reason: `${error._tag}: ${error.reason}`.slice(0, 2_048),
457
- }),
458
- ),
459
- );
460
- if (Option.isSome(state) && state.value.reviewedHeadSha === wire.commit_id) {
461
- latestState = state;
462
- }
463
- }
464
- }
465
- if (wires.length < perPage) break;
466
- if (page === MAX_PRIOR_REVIEW_PAGES) {
467
- return yield* PriorReviewLookupFailure.make({
468
- reason: `review history exceeds the bounded ${MAX_PRIOR_REVIEW_PAGES * perPage}-review lookup`,
469
- });
470
- }
471
- }
472
- return { latestFingerprint: latest, latestState };
473
- }).pipe(Effect.provideService(HttpClient.HttpClient, client));
474
- const compareHeads = (baseSha: string, headSha: string) =>
475
- Effect.gen(function* () {
476
- const response = yield* HttpClient.execute(
477
- withCommonHeaders(
478
- HttpClientRequest.get(
479
- `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
480
- ).pipe(HttpClientRequest.acceptJson),
481
- target.token,
482
- ),
483
- ).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError(asLookupFailure));
484
- const wire = yield* response.json.pipe(
485
- Effect.mapError(asLookupFailure),
486
- Effect.flatMap((body) =>
487
- Schema.decodeUnknownEffect(GitHubCompareWire)(body).pipe(
488
- Effect.mapError(asLookupFailure),
489
- ),
490
- ),
491
- );
492
- const files = wire.files.map(toChangedFile);
493
- return ReviewHeadComparison.make({
494
- status: wire.status,
495
- baseSha: wire.base_commit.sha,
496
- headSha,
497
- mergeBaseSha: wire.merge_base_commit.sha,
498
- files,
499
- truncated: files.length >= MAX_CHANGED_FILES,
500
- });
501
- }).pipe(Effect.provideService(HttpClient.HttpClient, client));
502
- return PriorReviews.of({
503
- latestFingerprint: readMarkers(Option.none()).pipe(
504
- Effect.map((markers) => markers.latestFingerprint),
505
- ),
506
- latestState: Effect.gen(function* () {
507
- const authenticator = yield* ReviewStateAuthenticator;
508
- return yield* readMarkers(Option.some(authenticator)).pipe(
509
- Effect.map((markers) => markers.latestState),
510
- );
511
- }),
512
- compareHeads,
513
- });
514
- }),
515
- );
516
-
517
- /**
518
- * Whether the current fingerprint matches the most recent posted review.
519
- * Fails OPEN: a lookup fault means "not unchanged" — the review proceeds,
520
- * which is the safe direction for a deduplication optimization.
521
- */
522
- export const fingerprintUnchanged = (
523
- current: string,
524
- ): Effect.Effect<boolean, never, PriorReviews> =>
525
- Effect.gen(function* () {
526
- const priorReviews = yield* PriorReviews;
527
- const latest = yield* priorReviews.latestFingerprint.pipe(
528
- Effect.orElseSucceed(() => Option.none<string>()),
529
- );
530
- return Option.isSome(latest) && latest.value === current;
531
- });