@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.
- package/NOTICE +26 -0
- package/README.md +170 -168
- package/dist/Review.d.mts +294 -0
- package/dist/Review.mjs +702 -0
- package/dist/Review.mjs.map +1 -0
- package/dist/ReviewRepository-Wd_4qCaO.d.mts +71 -0
- package/dist/ReviewRepository.d.mts +2 -0
- package/dist/ReviewRepository.mjs +15 -0
- package/dist/ReviewRepository.mjs.map +1 -0
- package/dist/index.d.mts +3 -717
- package/dist/index.mjs +3 -66
- package/dist/repository-D7NN3225.mjs +101 -0
- package/dist/repository-D7NN3225.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -54
- package/src/Review.ts +1052 -0
- package/src/ReviewRepository.ts +9 -0
- package/src/index.ts +2 -21
- package/src/internal/repository.ts +156 -0
- package/dist/action.d.mts +0 -190
- package/dist/action.mjs +0 -423
- package/dist/action.mjs.map +0 -1
- package/dist/cli.d.mts +0 -1
- package/dist/cli.mjs +0 -103
- package/dist/cli.mjs.map +0 -1
- package/dist/fan-out-C6gq3CFg.d.mts +0 -1081
- package/dist/github-Lfa_ox-u.mjs +0 -1673
- package/dist/github-Lfa_ox-u.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/providers-CaOnz7mK.mjs +0 -987
- package/dist/providers-CaOnz7mK.mjs.map +0 -1
- package/dist/testing.d.mts +0 -131
- package/dist/testing.mjs +0 -230
- package/dist/testing.mjs.map +0 -1
- package/src/action.ts +0 -697
- package/src/cli.ts +0 -214
- package/src/internal/action-entry.ts +0 -42
- package/src/internal/coverage.ts +0 -245
- package/src/internal/diff.ts +0 -134
- package/src/internal/effort.ts +0 -86
- package/src/internal/factory.ts +0 -374
- package/src/internal/fan-out-scripted.ts +0 -164
- package/src/internal/fan-out.ts +0 -450
- package/src/internal/fingerprint.ts +0 -74
- package/src/internal/fixtures.ts +0 -131
- package/src/internal/github-env.ts +0 -142
- package/src/internal/github.ts +0 -761
- package/src/internal/ignore.ts +0 -88
- package/src/internal/profiles.ts +0 -79
- package/src/internal/providers.ts +0 -91
- package/src/internal/render.ts +0 -428
- package/src/internal/retirement.ts +0 -332
- package/src/internal/review-agent.ts +0 -385
- package/src/internal/review-state.ts +0 -488
- package/src/internal/review-units.ts +0 -167
- package/src/internal/run.ts +0 -397
- package/src/internal/scripted.ts +0 -108
- package/src/internal/source.ts +0 -110
- package/src/testing.ts +0 -8
package/src/internal/github.ts
DELETED
|
@@ -1,761 +0,0 @@
|
|
|
1
|
-
import type { Redacted } from "effect";
|
|
2
|
-
import { Context, DateTime, 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
|
-
RetirableReview,
|
|
10
|
-
RetirableReviewComment,
|
|
11
|
-
ReviewRetirementFailure,
|
|
12
|
-
ReviewRetirementHost,
|
|
13
|
-
} from "./retirement.ts";
|
|
14
|
-
import {
|
|
15
|
-
ReviewHeadComparison,
|
|
16
|
-
ReviewStateAuthenticator,
|
|
17
|
-
type ReviewState,
|
|
18
|
-
} from "./review-state.ts";
|
|
19
|
-
import {
|
|
20
|
-
MAX_CHANGED_FILES,
|
|
21
|
-
MAX_FILE_CHARS,
|
|
22
|
-
normalizeRepoRelativePath,
|
|
23
|
-
PullRequestMetadata,
|
|
24
|
-
PullRequestSource,
|
|
25
|
-
PullRequestSourceFailure,
|
|
26
|
-
ReviewInputViolation,
|
|
27
|
-
} from "./source.ts";
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// GitHub REST adapters for the PullRequestSource port and the ReviewPublisher.
|
|
31
|
-
// Wire payloads are decoded through minimal Schemas — never asserted — and
|
|
32
|
-
// every upstream fault becomes the typed PullRequestSourceFailure /
|
|
33
|
-
// GitHubApiFailure instead of an untyped defect.
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
const defaultGraphqlUrl = (apiUrl: string): string =>
|
|
37
|
-
apiUrl === "https://api.github.com"
|
|
38
|
-
? "https://api.github.com/graphql"
|
|
39
|
-
: apiUrl.replace(/\/api\/v3$/, "/api/graphql");
|
|
40
|
-
|
|
41
|
-
/** Which pull request to review and how to reach the API. */
|
|
42
|
-
export class GitHubReviewTarget extends Context.Service<
|
|
43
|
-
GitHubReviewTarget,
|
|
44
|
-
{
|
|
45
|
-
/** API root, e.g. `https://api.github.com` (no trailing slash). */
|
|
46
|
-
readonly apiUrl: string;
|
|
47
|
-
/** GraphQL root, e.g. `https://api.github.com/graphql`. */
|
|
48
|
-
readonly graphqlUrl: string;
|
|
49
|
-
/** `owner/name`. */
|
|
50
|
-
readonly repository: string;
|
|
51
|
-
readonly number: number;
|
|
52
|
-
/** Absent token means unauthenticated reads (public repositories only). */
|
|
53
|
-
readonly token: Option.Option<Redacted.Redacted<string>>;
|
|
54
|
-
}
|
|
55
|
-
>()("@effect-agent/pr-review/GitHubReviewTarget") {
|
|
56
|
-
static layer(config: {
|
|
57
|
-
readonly apiUrl: string;
|
|
58
|
-
readonly graphqlUrl?: string | undefined;
|
|
59
|
-
readonly repository: string;
|
|
60
|
-
readonly number: number;
|
|
61
|
-
readonly token: Option.Option<Redacted.Redacted<string>>;
|
|
62
|
-
}): Layer.Layer<GitHubReviewTarget> {
|
|
63
|
-
return Layer.succeed(
|
|
64
|
-
this,
|
|
65
|
-
GitHubReviewTarget.of({
|
|
66
|
-
...config,
|
|
67
|
-
graphqlUrl: config.graphqlUrl ?? defaultGraphqlUrl(config.apiUrl),
|
|
68
|
-
}),
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** A GitHub API call failed: transport, status, or payload decode. */
|
|
74
|
-
export class GitHubApiFailure extends Schema.TaggedError<GitHubApiFailure>()("GitHubApiFailure", {
|
|
75
|
-
operation: Schema.String,
|
|
76
|
-
reason: Schema.String,
|
|
77
|
-
}) {
|
|
78
|
-
override get message() {
|
|
79
|
-
return `GitHub API operation '${this.operation}' failed: ${this.reason}`;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// --- Wire schemas (decode-only, minimal fields) ------------------------------
|
|
84
|
-
|
|
85
|
-
const GitHubPullRequestWire = Schema.Struct({
|
|
86
|
-
number: Schema.Int,
|
|
87
|
-
title: Schema.String,
|
|
88
|
-
body: Schema.NullOr(Schema.String),
|
|
89
|
-
changed_files: Schema.Int,
|
|
90
|
-
base: Schema.Struct({ ref: Schema.String, sha: Schema.String }),
|
|
91
|
-
head: Schema.Struct({ ref: Schema.String, sha: Schema.String }),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const GitHubFileWire = Schema.Struct({
|
|
95
|
-
filename: Schema.String,
|
|
96
|
-
status: Schema.String,
|
|
97
|
-
additions: Schema.Int,
|
|
98
|
-
deletions: Schema.Int,
|
|
99
|
-
patch: Schema.optionalKey(Schema.String),
|
|
100
|
-
previous_filename: Schema.optionalKey(Schema.String),
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const GitHubFilesPageWire = Schema.Array(GitHubFileWire);
|
|
104
|
-
|
|
105
|
-
const GitHubActorWire = Schema.Struct({ node_id: Schema.String });
|
|
106
|
-
|
|
107
|
-
const GitHubReviewWire = Schema.Struct({
|
|
108
|
-
id: Schema.Int,
|
|
109
|
-
html_url: Schema.String,
|
|
110
|
-
user: Schema.NullOr(GitHubActorWire),
|
|
111
|
-
submitted_at: Schema.NullOr(Schema.String),
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const GitHubRetirableReviewWire = Schema.Struct({
|
|
115
|
-
id: Schema.Int,
|
|
116
|
-
body: Schema.NullOr(Schema.String),
|
|
117
|
-
commit_id: Schema.String,
|
|
118
|
-
user: Schema.NullOr(GitHubActorWire),
|
|
119
|
-
submitted_at: Schema.NullOr(Schema.String),
|
|
120
|
-
});
|
|
121
|
-
const GitHubRetirableReviewsPageWire = Schema.Array(GitHubRetirableReviewWire);
|
|
122
|
-
|
|
123
|
-
const GitHubReviewCommentWire = Schema.Struct({
|
|
124
|
-
node_id: Schema.String,
|
|
125
|
-
path: Schema.String,
|
|
126
|
-
body: Schema.String,
|
|
127
|
-
line: Schema.NullOr(Schema.Int),
|
|
128
|
-
original_line: Schema.NullOr(Schema.Int),
|
|
129
|
-
start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
130
|
-
original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
131
|
-
});
|
|
132
|
-
const GitHubReviewCommentsPageWire = Schema.Array(GitHubReviewCommentWire);
|
|
133
|
-
|
|
134
|
-
const GitHubMinimizeCommentWire = Schema.Struct({
|
|
135
|
-
data: Schema.optionalKey(
|
|
136
|
-
Schema.NullOr(
|
|
137
|
-
Schema.Struct({
|
|
138
|
-
minimizeComment: Schema.NullOr(
|
|
139
|
-
Schema.Struct({
|
|
140
|
-
minimizedComment: Schema.NullOr(Schema.Struct({ isMinimized: Schema.Boolean })),
|
|
141
|
-
}),
|
|
142
|
-
),
|
|
143
|
-
}),
|
|
144
|
-
),
|
|
145
|
-
),
|
|
146
|
-
errors: Schema.optionalKey(Schema.Array(Schema.Struct({ message: Schema.String }))),
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
/** Decode GitHub's external timestamp before it participates in mutation ordering. */
|
|
150
|
-
export const parseGitHubSubmittedAt = (value: string | null): DateTime.Utc | null =>
|
|
151
|
-
value === null ? null : Option.getOrNull(DateTime.make(value));
|
|
152
|
-
|
|
153
|
-
/** The publication receipt callers report back to the operator. */
|
|
154
|
-
export class PublishedReview extends Schema.Class<PublishedReview>(
|
|
155
|
-
"@effect-agent/pr-review/PublishedReview",
|
|
156
|
-
)({
|
|
157
|
-
reviewId: Schema.Int,
|
|
158
|
-
url: Schema.String,
|
|
159
|
-
event: Schema.String,
|
|
160
|
-
inlineComments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
161
|
-
/** Actor and ordering boundary returned by the create-review response. */
|
|
162
|
-
authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
|
|
163
|
-
submittedAt: Schema.NullOr(Schema.DateTimeUtc),
|
|
164
|
-
}) {}
|
|
165
|
-
|
|
166
|
-
/** Posts one planned review; the ONLY mutating operation in this package. */
|
|
167
|
-
export class ReviewPublisher extends Context.Service<
|
|
168
|
-
ReviewPublisher,
|
|
169
|
-
{
|
|
170
|
-
readonly publish: (
|
|
171
|
-
plan: ReviewPublicationPlan,
|
|
172
|
-
) => Effect.Effect<PublishedReview, GitHubApiFailure>;
|
|
173
|
-
}
|
|
174
|
-
>()("@effect-agent/pr-review/ReviewPublisher") {}
|
|
175
|
-
|
|
176
|
-
// --- Shared request plumbing -------------------------------------------------
|
|
177
|
-
|
|
178
|
-
const FILE_STATUSES = new Set([
|
|
179
|
-
"added",
|
|
180
|
-
"removed",
|
|
181
|
-
"modified",
|
|
182
|
-
"renamed",
|
|
183
|
-
"copied",
|
|
184
|
-
"changed",
|
|
185
|
-
"unchanged",
|
|
186
|
-
]);
|
|
187
|
-
|
|
188
|
-
const withCommonHeaders = (
|
|
189
|
-
request: HttpClientRequest.HttpClientRequest,
|
|
190
|
-
token: Option.Option<Redacted.Redacted<string>>,
|
|
191
|
-
): HttpClientRequest.HttpClientRequest => {
|
|
192
|
-
const base = request.pipe(
|
|
193
|
-
HttpClientRequest.setHeaders({
|
|
194
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
195
|
-
"User-Agent": "effect-agent-pr-review",
|
|
196
|
-
}),
|
|
197
|
-
);
|
|
198
|
-
return Option.isSome(token) ? base.pipe(HttpClientRequest.bearerToken(token.value)) : base;
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const failWith =
|
|
202
|
-
(operation: string) =>
|
|
203
|
-
(error: { readonly _tag: string; readonly message?: string }): PullRequestSourceFailure =>
|
|
204
|
-
PullRequestSourceFailure.make({
|
|
205
|
-
operation,
|
|
206
|
-
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
const decodeJsonBody = <S extends Schema.Top>(schema: S, operation: string) => {
|
|
210
|
-
const decode = Schema.decodeUnknownEffect(schema);
|
|
211
|
-
return (
|
|
212
|
-
response: HttpClientResponse.HttpClientResponse,
|
|
213
|
-
): Effect.Effect<S["Type"], PullRequestSourceFailure, S["DecodingServices"]> =>
|
|
214
|
-
response.json.pipe(
|
|
215
|
-
Effect.mapError(failWith(operation)),
|
|
216
|
-
Effect.flatMap((body) => decode(body).pipe(Effect.mapError(failWith(operation)))),
|
|
217
|
-
);
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const executeOk = (
|
|
221
|
-
operation: string,
|
|
222
|
-
request: HttpClientRequest.HttpClientRequest,
|
|
223
|
-
): Effect.Effect<
|
|
224
|
-
HttpClientResponse.HttpClientResponse,
|
|
225
|
-
PullRequestSourceFailure,
|
|
226
|
-
HttpClient.HttpClient
|
|
227
|
-
> =>
|
|
228
|
-
HttpClient.execute(request).pipe(
|
|
229
|
-
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
230
|
-
Effect.mapError(failWith(operation)),
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
const toChangedFile = (wire: typeof GitHubFileWire.Type): ChangedFile =>
|
|
234
|
-
ChangedFile.make({
|
|
235
|
-
path: wire.filename,
|
|
236
|
-
status: FILE_STATUSES.has(wire.status) ? (wire.status as ChangedFile["status"]) : "changed",
|
|
237
|
-
additions: wire.additions,
|
|
238
|
-
deletions: wire.deletions,
|
|
239
|
-
...(wire.previous_filename !== undefined ? { previousPath: wire.previous_filename } : {}),
|
|
240
|
-
...(wire.patch !== undefined ? { patch: wire.patch } : {}),
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
// --- Live PullRequestSource --------------------------------------------------
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* GitHub-backed PullRequestSource. Metadata and the changeset are fetched
|
|
247
|
-
* once per Layer build and cached: the pull request is reviewed as one
|
|
248
|
-
* consistent snapshot even if the branch moves mid-run.
|
|
249
|
-
*/
|
|
250
|
-
export const gitHubPullRequestSourceLayer: Layer.Layer<
|
|
251
|
-
PullRequestSource,
|
|
252
|
-
never,
|
|
253
|
-
GitHubReviewTarget | HttpClient.HttpClient
|
|
254
|
-
> = Layer.effect(PullRequestSource)(
|
|
255
|
-
Effect.gen(function* () {
|
|
256
|
-
const target = yield* GitHubReviewTarget;
|
|
257
|
-
const client = yield* HttpClient.HttpClient;
|
|
258
|
-
const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
|
|
259
|
-
|
|
260
|
-
const fetchMetadata = executeOk(
|
|
261
|
-
"getPullRequest",
|
|
262
|
-
withCommonHeaders(
|
|
263
|
-
HttpClientRequest.get(prefix).pipe(HttpClientRequest.acceptJson),
|
|
264
|
-
target.token,
|
|
265
|
-
),
|
|
266
|
-
).pipe(
|
|
267
|
-
Effect.flatMap(decodeJsonBody(GitHubPullRequestWire, "getPullRequest")),
|
|
268
|
-
Effect.map((wire) =>
|
|
269
|
-
PullRequestMetadata.make({
|
|
270
|
-
repository: target.repository,
|
|
271
|
-
number: wire.number,
|
|
272
|
-
title: wire.title.slice(0, 400),
|
|
273
|
-
body: (wire.body ?? "").slice(0, 20_000),
|
|
274
|
-
baseRef: wire.base.ref,
|
|
275
|
-
baseSha: wire.base.sha,
|
|
276
|
-
headRef: wire.head.ref,
|
|
277
|
-
headSha: wire.head.sha,
|
|
278
|
-
totalChangedFiles: wire.changed_files,
|
|
279
|
-
}),
|
|
280
|
-
),
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
const fetchFiles = Effect.gen(function* () {
|
|
284
|
-
const perPage = 100;
|
|
285
|
-
const all: Array<ChangedFile> = [];
|
|
286
|
-
for (let page = 1; page <= MAX_CHANGED_FILES / perPage; page += 1) {
|
|
287
|
-
const response = yield* executeOk(
|
|
288
|
-
"listChangedFiles",
|
|
289
|
-
withCommonHeaders(
|
|
290
|
-
HttpClientRequest.get(`${prefix}/files`).pipe(
|
|
291
|
-
HttpClientRequest.acceptJson,
|
|
292
|
-
HttpClientRequest.setUrlParams({
|
|
293
|
-
per_page: String(perPage),
|
|
294
|
-
page: String(page),
|
|
295
|
-
}),
|
|
296
|
-
),
|
|
297
|
-
target.token,
|
|
298
|
-
),
|
|
299
|
-
);
|
|
300
|
-
const wires = yield* decodeJsonBody(GitHubFilesPageWire, "listChangedFiles")(response);
|
|
301
|
-
all.push(...wires.map(toChangedFile));
|
|
302
|
-
if (wires.length < perPage) break;
|
|
303
|
-
}
|
|
304
|
-
return all as ReadonlyArray<ChangedFile>;
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
const metadata = yield* Effect.cached(
|
|
308
|
-
fetchMetadata.pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
309
|
-
);
|
|
310
|
-
const changedFiles = yield* Effect.cached(
|
|
311
|
-
fetchFiles.pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
312
|
-
);
|
|
313
|
-
|
|
314
|
-
const readFile = (path: string) =>
|
|
315
|
-
Effect.gen(function* () {
|
|
316
|
-
const relative = yield* normalizeRepoRelativePath(path);
|
|
317
|
-
const files = yield* changedFiles;
|
|
318
|
-
if (!files.some((file) => file.path === relative)) {
|
|
319
|
-
return yield* ReviewInputViolation.make({
|
|
320
|
-
input: relative,
|
|
321
|
-
reason: "Path is not part of this pull request's changeset.",
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
const head = yield* metadata;
|
|
325
|
-
const encodedPath = relative.split("/").map(encodeURIComponent).join("/");
|
|
326
|
-
const response = yield* executeOk(
|
|
327
|
-
"readFile",
|
|
328
|
-
withCommonHeaders(
|
|
329
|
-
HttpClientRequest.get(
|
|
330
|
-
`${target.apiUrl}/repos/${target.repository}/contents/${encodedPath}`,
|
|
331
|
-
).pipe(
|
|
332
|
-
HttpClientRequest.accept("application/vnd.github.raw+json"),
|
|
333
|
-
HttpClientRequest.setUrlParams({ ref: head.headSha }),
|
|
334
|
-
),
|
|
335
|
-
target.token,
|
|
336
|
-
),
|
|
337
|
-
).pipe(Effect.provideService(HttpClient.HttpClient, client));
|
|
338
|
-
const text = yield* response.text.pipe(Effect.mapError(failWith("readFile")));
|
|
339
|
-
if (text.length > MAX_FILE_CHARS) {
|
|
340
|
-
return yield* ReviewInputViolation.make({
|
|
341
|
-
input: relative,
|
|
342
|
-
reason: `File is larger than the ${MAX_FILE_CHARS}-character read bound.`,
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
return text;
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
return PullRequestSource.of({ metadata, changedFiles, anchorFiles: changedFiles, readFile });
|
|
349
|
-
}),
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
// --- Live ReviewPublisher ------------------------------------------------------
|
|
353
|
-
|
|
354
|
-
/** GitHub-backed publisher: one POST to the pull-request reviews endpoint. */
|
|
355
|
-
export const gitHubReviewPublisherLayer: Layer.Layer<
|
|
356
|
-
ReviewPublisher,
|
|
357
|
-
never,
|
|
358
|
-
GitHubReviewTarget | HttpClient.HttpClient
|
|
359
|
-
> = Layer.effect(ReviewPublisher)(
|
|
360
|
-
Effect.gen(function* () {
|
|
361
|
-
const target = yield* GitHubReviewTarget;
|
|
362
|
-
const client = yield* HttpClient.HttpClient;
|
|
363
|
-
return ReviewPublisher.of({
|
|
364
|
-
publish: (plan) =>
|
|
365
|
-
Effect.gen(function* () {
|
|
366
|
-
const payload = {
|
|
367
|
-
event: plan.event,
|
|
368
|
-
body: plan.body,
|
|
369
|
-
commit_id: plan.commitSha,
|
|
370
|
-
comments: plan.comments.map((comment) => ({
|
|
371
|
-
path: comment.path,
|
|
372
|
-
line: comment.line,
|
|
373
|
-
side: "RIGHT",
|
|
374
|
-
...(comment.startLine !== undefined
|
|
375
|
-
? { start_line: comment.startLine, start_side: "RIGHT" }
|
|
376
|
-
: {}),
|
|
377
|
-
body: comment.body,
|
|
378
|
-
})),
|
|
379
|
-
};
|
|
380
|
-
const request = withCommonHeaders(
|
|
381
|
-
HttpClientRequest.post(
|
|
382
|
-
`${target.apiUrl}/repos/${target.repository}/pulls/${target.number}/reviews`,
|
|
383
|
-
).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bodyJsonUnsafe(payload)),
|
|
384
|
-
target.token,
|
|
385
|
-
);
|
|
386
|
-
const wire = yield* HttpClient.execute(request).pipe(
|
|
387
|
-
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
388
|
-
Effect.flatMap((response) =>
|
|
389
|
-
response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(GitHubReviewWire))),
|
|
390
|
-
),
|
|
391
|
-
Effect.mapError((error) =>
|
|
392
|
-
GitHubApiFailure.make({
|
|
393
|
-
operation: "createReview",
|
|
394
|
-
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
395
|
-
}),
|
|
396
|
-
),
|
|
397
|
-
Effect.provideService(HttpClient.HttpClient, client),
|
|
398
|
-
);
|
|
399
|
-
return PublishedReview.make({
|
|
400
|
-
reviewId: wire.id,
|
|
401
|
-
url: wire.html_url,
|
|
402
|
-
event: plan.event,
|
|
403
|
-
inlineComments: plan.comments.length,
|
|
404
|
-
authorNodeId: wire.user?.node_id ?? null,
|
|
405
|
-
submittedAt: parseGitHubSubmittedAt(wire.submitted_at),
|
|
406
|
-
});
|
|
407
|
-
}),
|
|
408
|
-
});
|
|
409
|
-
}),
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
// --- Live ReviewRetirementHost ----------------------------------------------
|
|
413
|
-
|
|
414
|
-
const MAX_RETIREMENT_PAGES = 5;
|
|
415
|
-
const MINIMIZE_REVIEW_COMMENT_MUTATION = `mutation MinimizeReviewComment($subjectId: ID!) {
|
|
416
|
-
minimizeComment(input: { subjectId: $subjectId, classifier: OUTDATED }) {
|
|
417
|
-
minimizedComment { isMinimized }
|
|
418
|
-
}
|
|
419
|
-
}`;
|
|
420
|
-
|
|
421
|
-
/** GitHub-backed host operations for cosmetic retirement after publication. */
|
|
422
|
-
export const gitHubReviewRetirementHostLayer: Layer.Layer<
|
|
423
|
-
ReviewRetirementHost,
|
|
424
|
-
never,
|
|
425
|
-
GitHubReviewTarget | HttpClient.HttpClient
|
|
426
|
-
> = Layer.effect(ReviewRetirementHost)(
|
|
427
|
-
Effect.gen(function* () {
|
|
428
|
-
const target = yield* GitHubReviewTarget;
|
|
429
|
-
const client = yield* HttpClient.HttpClient;
|
|
430
|
-
const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
|
|
431
|
-
const asRetirementFailure =
|
|
432
|
-
(operation: string) =>
|
|
433
|
-
(error: { readonly _tag: string; readonly message?: string }): ReviewRetirementFailure =>
|
|
434
|
-
ReviewRetirementFailure.make({
|
|
435
|
-
operation,
|
|
436
|
-
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
437
|
-
});
|
|
438
|
-
const executeRetirement = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
439
|
-
HttpClient.execute(request).pipe(
|
|
440
|
-
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
441
|
-
Effect.mapError(asRetirementFailure(operation)),
|
|
442
|
-
Effect.provideService(HttpClient.HttpClient, client),
|
|
443
|
-
);
|
|
444
|
-
const decodeRetirement = <S extends Schema.Top>(schema: S, operation: string) => {
|
|
445
|
-
const decode = Schema.decodeUnknownEffect(schema);
|
|
446
|
-
return (response: HttpClientResponse.HttpClientResponse) =>
|
|
447
|
-
response.json.pipe(
|
|
448
|
-
Effect.mapError(asRetirementFailure(operation)),
|
|
449
|
-
Effect.flatMap((body) =>
|
|
450
|
-
decode(body).pipe(Effect.mapError(asRetirementFailure(operation))),
|
|
451
|
-
),
|
|
452
|
-
);
|
|
453
|
-
};
|
|
454
|
-
const listPaged = <A>(input: {
|
|
455
|
-
readonly operation: string;
|
|
456
|
-
readonly url: string;
|
|
457
|
-
readonly decode: (
|
|
458
|
-
response: HttpClientResponse.HttpClientResponse,
|
|
459
|
-
) => Effect.Effect<ReadonlyArray<A>, ReviewRetirementFailure>;
|
|
460
|
-
}) =>
|
|
461
|
-
Effect.gen(function* () {
|
|
462
|
-
const values: Array<A> = [];
|
|
463
|
-
const perPage = 100;
|
|
464
|
-
for (let page = 1; page <= MAX_RETIREMENT_PAGES; page += 1) {
|
|
465
|
-
const response = yield* executeRetirement(
|
|
466
|
-
input.operation,
|
|
467
|
-
withCommonHeaders(
|
|
468
|
-
HttpClientRequest.get(input.url).pipe(
|
|
469
|
-
HttpClientRequest.acceptJson,
|
|
470
|
-
HttpClientRequest.setUrlParams({
|
|
471
|
-
per_page: String(perPage),
|
|
472
|
-
page: String(page),
|
|
473
|
-
}),
|
|
474
|
-
),
|
|
475
|
-
target.token,
|
|
476
|
-
),
|
|
477
|
-
);
|
|
478
|
-
const pageValues = yield* input.decode(response);
|
|
479
|
-
values.push(...pageValues);
|
|
480
|
-
if (pageValues.length < perPage) return values;
|
|
481
|
-
}
|
|
482
|
-
return yield* ReviewRetirementFailure.make({
|
|
483
|
-
operation: input.operation,
|
|
484
|
-
reason: `history exceeds the bounded ${MAX_RETIREMENT_PAGES * 100}-item lookup`,
|
|
485
|
-
});
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
return ReviewRetirementHost.of({
|
|
489
|
-
listReviews: listPaged({
|
|
490
|
-
operation: "listReviewsForRetirement",
|
|
491
|
-
url: `${prefix}/reviews`,
|
|
492
|
-
decode: decodeRetirement(GitHubRetirableReviewsPageWire, "listReviewsForRetirement"),
|
|
493
|
-
}).pipe(
|
|
494
|
-
Effect.map((reviews) =>
|
|
495
|
-
reviews.map((review) =>
|
|
496
|
-
RetirableReview.make({
|
|
497
|
-
reviewId: review.id,
|
|
498
|
-
body: review.body ?? "",
|
|
499
|
-
commitSha: review.commit_id,
|
|
500
|
-
authorNodeId: review.user?.node_id ?? null,
|
|
501
|
-
submittedAt: parseGitHubSubmittedAt(review.submitted_at),
|
|
502
|
-
}),
|
|
503
|
-
),
|
|
504
|
-
),
|
|
505
|
-
),
|
|
506
|
-
listComments: (reviewId) =>
|
|
507
|
-
listPaged({
|
|
508
|
-
operation: "listReviewCommentsForRetirement",
|
|
509
|
-
url: `${prefix}/reviews/${reviewId}/comments`,
|
|
510
|
-
decode: decodeRetirement(GitHubReviewCommentsPageWire, "listReviewCommentsForRetirement"),
|
|
511
|
-
}).pipe(
|
|
512
|
-
Effect.map((comments) =>
|
|
513
|
-
comments.map((comment) => {
|
|
514
|
-
const endLine = comment.line ?? comment.original_line;
|
|
515
|
-
const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
|
|
516
|
-
return RetirableReviewComment.make({
|
|
517
|
-
nodeId: comment.node_id,
|
|
518
|
-
path: comment.path,
|
|
519
|
-
startLine,
|
|
520
|
-
endLine,
|
|
521
|
-
body: comment.body,
|
|
522
|
-
});
|
|
523
|
-
}),
|
|
524
|
-
),
|
|
525
|
-
),
|
|
526
|
-
updateBody: (reviewId, body) =>
|
|
527
|
-
executeRetirement(
|
|
528
|
-
"updateReview",
|
|
529
|
-
withCommonHeaders(
|
|
530
|
-
HttpClientRequest.put(`${prefix}/reviews/${reviewId}`).pipe(
|
|
531
|
-
HttpClientRequest.acceptJson,
|
|
532
|
-
HttpClientRequest.bodyJsonUnsafe({ body }),
|
|
533
|
-
),
|
|
534
|
-
target.token,
|
|
535
|
-
),
|
|
536
|
-
).pipe(Effect.asVoid),
|
|
537
|
-
minimizeComment: (nodeId) =>
|
|
538
|
-
Effect.gen(function* () {
|
|
539
|
-
const response = yield* executeRetirement(
|
|
540
|
-
"minimizeComment",
|
|
541
|
-
withCommonHeaders(
|
|
542
|
-
HttpClientRequest.post(target.graphqlUrl).pipe(
|
|
543
|
-
HttpClientRequest.acceptJson,
|
|
544
|
-
HttpClientRequest.bodyJsonUnsafe({
|
|
545
|
-
query: MINIMIZE_REVIEW_COMMENT_MUTATION,
|
|
546
|
-
variables: { subjectId: nodeId },
|
|
547
|
-
}),
|
|
548
|
-
),
|
|
549
|
-
target.token,
|
|
550
|
-
),
|
|
551
|
-
);
|
|
552
|
-
const wire = yield* decodeRetirement(
|
|
553
|
-
GitHubMinimizeCommentWire,
|
|
554
|
-
"minimizeComment",
|
|
555
|
-
)(response);
|
|
556
|
-
if (
|
|
557
|
-
(wire.errors?.length ?? 0) > 0 ||
|
|
558
|
-
wire.data?.minimizeComment?.minimizedComment?.isMinimized !== true
|
|
559
|
-
) {
|
|
560
|
-
return yield* ReviewRetirementFailure.make({
|
|
561
|
-
operation: "minimizeComment",
|
|
562
|
-
reason:
|
|
563
|
-
wire.errors
|
|
564
|
-
?.map((error) => error.message)
|
|
565
|
-
.join("; ")
|
|
566
|
-
.slice(0, 2_048) ?? "GitHub did not confirm comment minimization",
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
}),
|
|
570
|
-
});
|
|
571
|
-
}),
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
// --- Prior reviews (fingerprint deduplication) ---------------------------------
|
|
575
|
-
|
|
576
|
-
/** Reading the pull request's previously posted reviews failed. */
|
|
577
|
-
export class PriorReviewLookupFailure extends Schema.TaggedError<PriorReviewLookupFailure>()(
|
|
578
|
-
"PriorReviewLookupFailure",
|
|
579
|
-
{
|
|
580
|
-
reason: Schema.String,
|
|
581
|
-
},
|
|
582
|
-
) {
|
|
583
|
-
override get message() {
|
|
584
|
-
return `Prior-review lookup failed: ${this.reason}`;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Read-only view of this package's previously posted reviews on the target
|
|
590
|
-
* pull request — the deduplication state for unchanged-changeset skipping.
|
|
591
|
-
*/
|
|
592
|
-
export class PriorReviews extends Context.Service<
|
|
593
|
-
PriorReviews,
|
|
594
|
-
{
|
|
595
|
-
/** The fingerprint embedded in the most recent marker-bearing review. */
|
|
596
|
-
readonly latestFingerprint: Effect.Effect<Option.Option<string>, PriorReviewLookupFailure>;
|
|
597
|
-
/** The latest authenticated, successfully covered review state marker. */
|
|
598
|
-
readonly latestState: Effect.Effect<
|
|
599
|
-
Option.Option<ReviewState>,
|
|
600
|
-
PriorReviewLookupFailure,
|
|
601
|
-
ReviewStateAuthenticator
|
|
602
|
-
>;
|
|
603
|
-
/** Compare a previously reviewed head to the live current head. */
|
|
604
|
-
readonly compareHeads: (
|
|
605
|
-
baseSha: string,
|
|
606
|
-
headSha: string,
|
|
607
|
-
) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
|
|
608
|
-
}
|
|
609
|
-
>()("@effect-agent/pr-review/PriorReviews") {}
|
|
610
|
-
|
|
611
|
-
const GitHubPriorReviewWire = Schema.Struct({
|
|
612
|
-
body: Schema.NullOr(Schema.String),
|
|
613
|
-
commit_id: Schema.String,
|
|
614
|
-
user: Schema.optionalKey(
|
|
615
|
-
Schema.NullOr(
|
|
616
|
-
Schema.Struct({
|
|
617
|
-
login: Schema.String,
|
|
618
|
-
type: Schema.String,
|
|
619
|
-
}),
|
|
620
|
-
),
|
|
621
|
-
),
|
|
622
|
-
});
|
|
623
|
-
const GitHubPriorReviewsPageWire = Schema.Array(GitHubPriorReviewWire);
|
|
624
|
-
|
|
625
|
-
const GitHubCompareWire = Schema.Struct({
|
|
626
|
-
status: Schema.Literals(["ahead", "behind", "diverged", "identical"]),
|
|
627
|
-
base_commit: Schema.Struct({ sha: Schema.String }),
|
|
628
|
-
merge_base_commit: Schema.Struct({ sha: Schema.String }),
|
|
629
|
-
files: GitHubFilesPageWire,
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
/** Reviews are paged chronologically; scanning stays bounded. */
|
|
633
|
-
const MAX_PRIOR_REVIEW_PAGES = 5;
|
|
634
|
-
|
|
635
|
-
/** GitHub-backed PriorReviews over the pull-request reviews endpoint. */
|
|
636
|
-
export const gitHubPriorReviewsLayer: Layer.Layer<
|
|
637
|
-
PriorReviews,
|
|
638
|
-
never,
|
|
639
|
-
GitHubReviewTarget | HttpClient.HttpClient
|
|
640
|
-
> = Layer.effect(PriorReviews)(
|
|
641
|
-
Effect.gen(function* () {
|
|
642
|
-
const target = yield* GitHubReviewTarget;
|
|
643
|
-
const client = yield* HttpClient.HttpClient;
|
|
644
|
-
const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
|
|
645
|
-
const decodePage = Schema.decodeUnknownEffect(GitHubPriorReviewsPageWire);
|
|
646
|
-
const asLookupFailure = (error: { readonly _tag: string; readonly message?: string }) =>
|
|
647
|
-
PriorReviewLookupFailure.make({
|
|
648
|
-
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
649
|
-
});
|
|
650
|
-
const readMarkers = (authenticator: Option.Option<ReviewStateAuthenticator["Service"]>) =>
|
|
651
|
-
Effect.gen(function* () {
|
|
652
|
-
const perPage = 100;
|
|
653
|
-
let latest = Option.none<string>();
|
|
654
|
-
let latestState = Option.none<ReviewState>();
|
|
655
|
-
for (let page = 1; page <= MAX_PRIOR_REVIEW_PAGES; page += 1) {
|
|
656
|
-
const response = yield* HttpClient.execute(
|
|
657
|
-
withCommonHeaders(
|
|
658
|
-
HttpClientRequest.get(`${prefix}/reviews`).pipe(
|
|
659
|
-
HttpClientRequest.acceptJson,
|
|
660
|
-
HttpClientRequest.setUrlParams({
|
|
661
|
-
per_page: String(perPage),
|
|
662
|
-
page: String(page),
|
|
663
|
-
}),
|
|
664
|
-
),
|
|
665
|
-
target.token,
|
|
666
|
-
),
|
|
667
|
-
).pipe(
|
|
668
|
-
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
669
|
-
Effect.mapError(asLookupFailure),
|
|
670
|
-
);
|
|
671
|
-
const wires = yield* response.json.pipe(
|
|
672
|
-
Effect.mapError(asLookupFailure),
|
|
673
|
-
Effect.flatMap((body) => decodePage(body).pipe(Effect.mapError(asLookupFailure))),
|
|
674
|
-
);
|
|
675
|
-
for (const wire of wires) {
|
|
676
|
-
// State controls what required scope may be omitted. The author gate
|
|
677
|
-
// rejects user prose; the terminal marker is additionally HMAC
|
|
678
|
-
// authenticated so another bot workflow or model text cannot forge it.
|
|
679
|
-
if (wire.user?.login !== "github-actions[bot]" || wire.user.type !== "Bot") continue;
|
|
680
|
-
const fingerprint = extractFingerprint(wire.body ?? "");
|
|
681
|
-
if (fingerprint !== undefined) latest = Option.some(fingerprint);
|
|
682
|
-
if (Option.isSome(authenticator)) {
|
|
683
|
-
const state = yield* authenticator.value.extract(wire.body ?? "").pipe(
|
|
684
|
-
Effect.mapError((error) =>
|
|
685
|
-
PriorReviewLookupFailure.make({
|
|
686
|
-
reason: `${error._tag}: ${error.reason}`.slice(0, 2_048),
|
|
687
|
-
}),
|
|
688
|
-
),
|
|
689
|
-
);
|
|
690
|
-
if (Option.isSome(state) && state.value.reviewedHeadSha === wire.commit_id) {
|
|
691
|
-
latestState = state;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
if (wires.length < perPage) break;
|
|
696
|
-
if (page === MAX_PRIOR_REVIEW_PAGES) {
|
|
697
|
-
return yield* PriorReviewLookupFailure.make({
|
|
698
|
-
reason: `review history exceeds the bounded ${MAX_PRIOR_REVIEW_PAGES * perPage}-review lookup`,
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
return { latestFingerprint: latest, latestState };
|
|
703
|
-
}).pipe(Effect.provideService(HttpClient.HttpClient, client));
|
|
704
|
-
const compareHeads = (baseSha: string, headSha: string) =>
|
|
705
|
-
Effect.gen(function* () {
|
|
706
|
-
const response = yield* HttpClient.execute(
|
|
707
|
-
withCommonHeaders(
|
|
708
|
-
HttpClientRequest.get(
|
|
709
|
-
`${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
|
|
710
|
-
).pipe(HttpClientRequest.acceptJson),
|
|
711
|
-
target.token,
|
|
712
|
-
),
|
|
713
|
-
).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError(asLookupFailure));
|
|
714
|
-
const wire = yield* response.json.pipe(
|
|
715
|
-
Effect.mapError(asLookupFailure),
|
|
716
|
-
Effect.flatMap((body) =>
|
|
717
|
-
Schema.decodeUnknownEffect(GitHubCompareWire)(body).pipe(
|
|
718
|
-
Effect.mapError(asLookupFailure),
|
|
719
|
-
),
|
|
720
|
-
),
|
|
721
|
-
);
|
|
722
|
-
const files = wire.files.map(toChangedFile);
|
|
723
|
-
return ReviewHeadComparison.make({
|
|
724
|
-
status: wire.status,
|
|
725
|
-
baseSha: wire.base_commit.sha,
|
|
726
|
-
headSha,
|
|
727
|
-
mergeBaseSha: wire.merge_base_commit.sha,
|
|
728
|
-
files,
|
|
729
|
-
truncated: files.length >= MAX_CHANGED_FILES,
|
|
730
|
-
});
|
|
731
|
-
}).pipe(Effect.provideService(HttpClient.HttpClient, client));
|
|
732
|
-
return PriorReviews.of({
|
|
733
|
-
latestFingerprint: readMarkers(Option.none()).pipe(
|
|
734
|
-
Effect.map((markers) => markers.latestFingerprint),
|
|
735
|
-
),
|
|
736
|
-
latestState: Effect.gen(function* () {
|
|
737
|
-
const authenticator = yield* ReviewStateAuthenticator;
|
|
738
|
-
return yield* readMarkers(Option.some(authenticator)).pipe(
|
|
739
|
-
Effect.map((markers) => markers.latestState),
|
|
740
|
-
);
|
|
741
|
-
}),
|
|
742
|
-
compareHeads,
|
|
743
|
-
});
|
|
744
|
-
}),
|
|
745
|
-
);
|
|
746
|
-
|
|
747
|
-
/**
|
|
748
|
-
* Whether the current fingerprint matches the most recent posted review.
|
|
749
|
-
* Fails OPEN: a lookup fault means "not unchanged" — the review proceeds,
|
|
750
|
-
* which is the safe direction for a deduplication optimization.
|
|
751
|
-
*/
|
|
752
|
-
export const fingerprintUnchanged = (
|
|
753
|
-
current: string,
|
|
754
|
-
): Effect.Effect<boolean, never, PriorReviews> =>
|
|
755
|
-
Effect.gen(function* () {
|
|
756
|
-
const priorReviews = yield* PriorReviews;
|
|
757
|
-
const latest = yield* priorReviews.latestFingerprint.pipe(
|
|
758
|
-
Effect.orElseSucceed(() => Option.none<string>()),
|
|
759
|
-
);
|
|
760
|
-
return Option.isSome(latest) && latest.value === current;
|
|
761
|
-
});
|