@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.
- package/NOTICE +26 -0
- package/README.md +170 -158
- package/dist/Review.d.mts +295 -0
- package/dist/Review.mjs +704 -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 -716
- package/dist/index.mjs +3 -66
- package/dist/repository-BzSG74vX.mjs +101 -0
- package/dist/repository-BzSG74vX.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -54
- package/src/Review.ts +1058 -0
- package/src/ReviewRepository.ts +9 -0
- package/src/index.ts +2 -20
- package/src/internal/repository.ts +156 -0
- package/dist/action.d.mts +0 -185
- package/dist/action.mjs +0 -406
- package/dist/action.mjs.map +0 -1
- package/dist/cli.d.mts +0 -1
- package/dist/cli.mjs +0 -102
- package/dist/cli.mjs.map +0 -1
- package/dist/fan-out-BBEATQwc.d.mts +0 -997
- package/dist/github-BZNzmxao.mjs +0 -1372
- package/dist/github-BZNzmxao.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/providers-J6BKHyHe.mjs +0 -986
- package/dist/providers-J6BKHyHe.mjs.map +0 -1
- package/dist/testing.d.mts +0 -131
- package/dist/testing.mjs +0 -228
- package/dist/testing.mjs.map +0 -1
- package/src/action.ts +0 -666
- package/src/cli.ts +0 -213
- package/src/internal/action-entry.ts +0 -41
- 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 -127
- package/src/internal/github-env.ts +0 -128
- package/src/internal/github.ts +0 -531
- 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/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
|
@@ -1,488 +0,0 @@
|
|
|
1
|
-
import { Context, Effect, Encoding, Layer, Option, Redacted, Result, Schema } from "effect";
|
|
2
|
-
|
|
3
|
-
import { ChangedFile, ChangedPath } from "./diff.ts";
|
|
4
|
-
import { FindingSeverity, ReviewConcern, ReviewFinding, ReviewMission } from "./review-agent.ts";
|
|
5
|
-
import { PullRequestSource, ReviewInputViolation, type PullRequestMetadata } from "./source.ts";
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Bounded review continuity. The Action is still deployment class E, but every
|
|
9
|
-
// completed review can publish authenticated state inside its GitHub review body.
|
|
10
|
-
// A later Action run validates the state against the live PR/base lineage and
|
|
11
|
-
// uses a head-to-head comparison to select only newly affected scope.
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
export const ReviewMode = Schema.Literals(["incremental", "final"]);
|
|
15
|
-
export type ReviewMode = typeof ReviewMode.Type;
|
|
16
|
-
|
|
17
|
-
export const ReviewScopeMode = Schema.Literals(["incremental", "full"]);
|
|
18
|
-
export type ReviewScopeMode = typeof ReviewScopeMode.Type;
|
|
19
|
-
|
|
20
|
-
export const GitCommitSha = Schema.NonEmptyString.check(
|
|
21
|
-
Schema.isMaxLength(64),
|
|
22
|
-
Schema.isPattern(/^[0-9a-f]{40,64}$/),
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
const Fingerprint = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
|
|
26
|
-
const StoredText = Schema.NonEmptyString.check(Schema.isMaxLength(800));
|
|
27
|
-
|
|
28
|
-
/** A compact unresolved finding suitable for the bounded review-body marker. */
|
|
29
|
-
export class StoredReviewFinding extends Schema.Class<StoredReviewFinding>(
|
|
30
|
-
"@effect-agent/pr-review/StoredReviewFinding",
|
|
31
|
-
)({
|
|
32
|
-
path: ChangedPath,
|
|
33
|
-
startLine: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
34
|
-
endLine: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
35
|
-
severity: FindingSeverity,
|
|
36
|
-
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
37
|
-
body: StoredText,
|
|
38
|
-
}) {}
|
|
39
|
-
|
|
40
|
-
/** A compact unresolved non-anchored concern carried until a final audit. */
|
|
41
|
-
export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
|
|
42
|
-
"@effect-agent/pr-review/StoredReviewConcern",
|
|
43
|
-
)({
|
|
44
|
-
severity: FindingSeverity,
|
|
45
|
-
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
46
|
-
body: StoredText,
|
|
47
|
-
}) {}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Versioned state embedded in one successfully covered review. The reviewed
|
|
51
|
-
* head plus the full-scope fingerprint means every path not represented by an
|
|
52
|
-
* unresolved item is accepted at that head; storing hundreds of path strings
|
|
53
|
-
* separately would not fit GitHub's bounded review body in the worst case.
|
|
54
|
-
*/
|
|
55
|
-
export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
|
|
56
|
-
version: Schema.Literal(1),
|
|
57
|
-
repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
|
|
58
|
-
pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
59
|
-
baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
60
|
-
baseSha: GitCommitSha,
|
|
61
|
-
headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
62
|
-
reviewedHeadSha: GitCommitSha,
|
|
63
|
-
profileFingerprint: Fingerprint,
|
|
64
|
-
acceptedScopeFingerprint: Fingerprint,
|
|
65
|
-
reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
|
|
66
|
-
unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
|
|
67
|
-
unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
|
|
68
|
-
lastReviewMode: ReviewScopeMode,
|
|
69
|
-
}) {}
|
|
70
|
-
|
|
71
|
-
export const toStoredFinding = (finding: ReviewFinding): StoredReviewFinding =>
|
|
72
|
-
StoredReviewFinding.make({
|
|
73
|
-
path: finding.path,
|
|
74
|
-
startLine: finding.startLine,
|
|
75
|
-
endLine: finding.endLine,
|
|
76
|
-
severity: finding.severity,
|
|
77
|
-
title: finding.title,
|
|
78
|
-
body: finding.body.slice(0, 800),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
export const fromStoredFinding = (finding: StoredReviewFinding): ReviewFinding =>
|
|
82
|
-
ReviewFinding.make({
|
|
83
|
-
path: finding.path,
|
|
84
|
-
startLine: finding.startLine,
|
|
85
|
-
endLine: finding.endLine,
|
|
86
|
-
severity: finding.severity,
|
|
87
|
-
title: finding.title,
|
|
88
|
-
body: finding.body,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
|
|
92
|
-
StoredReviewConcern.make({
|
|
93
|
-
severity: concern.severity,
|
|
94
|
-
title: concern.title,
|
|
95
|
-
body: concern.body.slice(0, 800),
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
|
|
99
|
-
ReviewConcern.make({ severity: concern.severity, title: concern.title, body: concern.body });
|
|
100
|
-
|
|
101
|
-
const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
|
|
102
|
-
const STATE_MARKER_SUFFIX = " -->";
|
|
103
|
-
const STATE_MARKER_PATTERN =
|
|
104
|
-
/(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
|
|
105
|
-
const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\u0000";
|
|
106
|
-
export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
|
|
107
|
-
export const ReviewStateMarker = Schema.NonEmptyString.check(
|
|
108
|
-
Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
|
|
109
|
-
Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
|
|
110
|
-
).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
|
|
111
|
-
export type ReviewStateMarker = typeof ReviewStateMarker.Type;
|
|
112
|
-
|
|
113
|
-
export class ReviewStateAuthenticationFailure extends Schema.TaggedError<ReviewStateAuthenticationFailure>()(
|
|
114
|
-
"ReviewStateAuthenticationFailure",
|
|
115
|
-
{
|
|
116
|
-
operation: Schema.Literals(["sign", "verify"]),
|
|
117
|
-
reason: Schema.NonEmptyString.check(Schema.isMaxLength(2_048)),
|
|
118
|
-
},
|
|
119
|
-
) {}
|
|
120
|
-
|
|
121
|
-
export class ReviewStateMarkerTooLarge extends Schema.TaggedError<ReviewStateMarkerTooLarge>()(
|
|
122
|
-
"ReviewStateMarkerTooLarge",
|
|
123
|
-
{
|
|
124
|
-
observedChars: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
125
|
-
maximumChars: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
126
|
-
},
|
|
127
|
-
) {}
|
|
128
|
-
|
|
129
|
-
export class ReviewStateAuthenticator extends Context.Service<
|
|
130
|
-
ReviewStateAuthenticator,
|
|
131
|
-
{
|
|
132
|
-
readonly status: "available" | "unavailable";
|
|
133
|
-
readonly unavailableReason: string | undefined;
|
|
134
|
-
readonly render: (
|
|
135
|
-
state: ReviewState,
|
|
136
|
-
) => Effect.Effect<
|
|
137
|
-
ReviewStateMarker,
|
|
138
|
-
ReviewStateAuthenticationFailure | ReviewStateMarkerTooLarge
|
|
139
|
-
>;
|
|
140
|
-
readonly extract: (
|
|
141
|
-
body: string,
|
|
142
|
-
) => Effect.Effect<Option.Option<ReviewState>, ReviewStateAuthenticationFailure>;
|
|
143
|
-
}
|
|
144
|
-
>()("@effect-agent/pr-review/ReviewStateAuthenticator") {}
|
|
145
|
-
|
|
146
|
-
const authenticationFailure = (
|
|
147
|
-
operation: "sign" | "verify",
|
|
148
|
-
cause: unknown,
|
|
149
|
-
): ReviewStateAuthenticationFailure =>
|
|
150
|
-
ReviewStateAuthenticationFailure.make({
|
|
151
|
-
operation,
|
|
152
|
-
reason: String(cause).slice(0, 2_048),
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
const hmacKey = (secret: Redacted.Redacted<string>, operation: "sign" | "verify") =>
|
|
156
|
-
Effect.tryPromise({
|
|
157
|
-
try: () =>
|
|
158
|
-
globalThis.crypto.subtle.importKey(
|
|
159
|
-
"raw",
|
|
160
|
-
new TextEncoder().encode(Redacted.value(secret)),
|
|
161
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
162
|
-
false,
|
|
163
|
-
["sign", "verify"],
|
|
164
|
-
),
|
|
165
|
-
catch: (cause) => authenticationFailure(operation, cause),
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
const signatureBytes = (signature: string): ArrayBuffer => {
|
|
169
|
-
const pairs = signature.match(/../g) ?? [];
|
|
170
|
-
const buffer = new ArrayBuffer(pairs.length);
|
|
171
|
-
const bytes = new Uint8Array(buffer);
|
|
172
|
-
for (let index = 0; index < pairs.length; index += 1) {
|
|
173
|
-
bytes[index] = Number.parseInt(pairs[index] ?? "", 16);
|
|
174
|
-
}
|
|
175
|
-
return buffer;
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
/** Validated WebCrypto adapter selected at the Action composition root. */
|
|
179
|
-
export const webCryptoReviewStateAuthenticatorLayer = (
|
|
180
|
-
secret: Redacted.Redacted<string>,
|
|
181
|
-
): Layer.Layer<ReviewStateAuthenticator> =>
|
|
182
|
-
Layer.succeed(ReviewStateAuthenticator)(
|
|
183
|
-
ReviewStateAuthenticator.of({
|
|
184
|
-
status: "available",
|
|
185
|
-
unavailableReason: undefined,
|
|
186
|
-
render: (state) =>
|
|
187
|
-
Effect.gen(function* () {
|
|
188
|
-
const json = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(ReviewState))(
|
|
189
|
-
state,
|
|
190
|
-
).pipe(Effect.mapError((cause) => authenticationFailure("sign", cause)));
|
|
191
|
-
const payload = Encoding.encodeBase64(json);
|
|
192
|
-
const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
|
|
193
|
-
const key = yield* hmacKey(secret, "sign");
|
|
194
|
-
const signature = yield* Effect.tryPromise({
|
|
195
|
-
try: () => globalThis.crypto.subtle.sign("HMAC", key, message),
|
|
196
|
-
catch: (cause) => authenticationFailure("sign", cause),
|
|
197
|
-
});
|
|
198
|
-
const hex = Array.from(new Uint8Array(signature))
|
|
199
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
200
|
-
.join("");
|
|
201
|
-
const marker = `${STATE_MARKER_PREFIX}${payload}.${hex}${STATE_MARKER_SUFFIX}`;
|
|
202
|
-
if (marker.length > MAX_REVIEW_STATE_MARKER_CHARS) {
|
|
203
|
-
return yield* ReviewStateMarkerTooLarge.make({
|
|
204
|
-
observedChars: marker.length,
|
|
205
|
-
maximumChars: MAX_REVIEW_STATE_MARKER_CHARS,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
return yield* Schema.decodeUnknownEffect(ReviewStateMarker)(marker).pipe(
|
|
209
|
-
Effect.mapError((cause) => authenticationFailure("sign", cause)),
|
|
210
|
-
);
|
|
211
|
-
}),
|
|
212
|
-
extract: (body) => {
|
|
213
|
-
if (body.length > 60_000) return Effect.succeed(Option.none());
|
|
214
|
-
const match = STATE_MARKER_PATTERN.exec(body);
|
|
215
|
-
const payload = match?.[1];
|
|
216
|
-
const signature = match?.[2];
|
|
217
|
-
if (payload === undefined || signature === undefined) return Effect.succeed(Option.none());
|
|
218
|
-
const marker = `${STATE_MARKER_PREFIX}${payload}.${signature}${STATE_MARKER_SUFFIX}`;
|
|
219
|
-
if (!Schema.is(ReviewStateMarker)(marker)) return Effect.succeed(Option.none());
|
|
220
|
-
const json = Result.getOrUndefined(Encoding.decodeBase64String(payload));
|
|
221
|
-
if (json === undefined) return Effect.succeed(Option.none());
|
|
222
|
-
const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(ReviewState))(json);
|
|
223
|
-
if (Option.isNone(decoded)) return Effect.succeed(Option.none());
|
|
224
|
-
const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
|
|
225
|
-
return Effect.gen(function* () {
|
|
226
|
-
const key = yield* hmacKey(secret, "verify");
|
|
227
|
-
const valid = yield* Effect.tryPromise({
|
|
228
|
-
try: () =>
|
|
229
|
-
globalThis.crypto.subtle.verify("HMAC", key, signatureBytes(signature), message),
|
|
230
|
-
catch: (cause) => authenticationFailure("verify", cause),
|
|
231
|
-
});
|
|
232
|
-
return valid ? Option.some(decoded.value) : Option.none();
|
|
233
|
-
});
|
|
234
|
-
},
|
|
235
|
-
}),
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
/** Explicit no-state implementation for hosts without a stable authentication secret. */
|
|
239
|
-
export const unavailableReviewStateAuthenticatorLayer = (
|
|
240
|
-
reason: string,
|
|
241
|
-
): Layer.Layer<ReviewStateAuthenticator> => {
|
|
242
|
-
const safeReason = reason === "" ? "review-state authentication is unavailable" : reason;
|
|
243
|
-
return Layer.succeed(ReviewStateAuthenticator)(
|
|
244
|
-
ReviewStateAuthenticator.of({
|
|
245
|
-
status: "unavailable",
|
|
246
|
-
unavailableReason: safeReason.slice(0, 1_000),
|
|
247
|
-
render: () =>
|
|
248
|
-
Effect.fail(
|
|
249
|
-
ReviewStateAuthenticationFailure.make({
|
|
250
|
-
operation: "sign",
|
|
251
|
-
reason: safeReason.slice(0, 2_048),
|
|
252
|
-
}),
|
|
253
|
-
),
|
|
254
|
-
extract: () => Effect.succeed(Option.none()),
|
|
255
|
-
}),
|
|
256
|
-
);
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
/** The bounded result of GitHub's previous-head...current-head comparison. */
|
|
260
|
-
export class ReviewHeadComparison extends Schema.Class<ReviewHeadComparison>(
|
|
261
|
-
"@effect-agent/pr-review/ReviewHeadComparison",
|
|
262
|
-
)({
|
|
263
|
-
status: Schema.Literals(["ahead", "behind", "diverged", "identical"]),
|
|
264
|
-
baseSha: GitCommitSha,
|
|
265
|
-
headSha: GitCommitSha,
|
|
266
|
-
mergeBaseSha: GitCommitSha,
|
|
267
|
-
files: Schema.Array(ChangedFile).check(Schema.isMaxLength(300)),
|
|
268
|
-
/** GitHub caps compare-file payloads at 300; equality is conservatively truncated. */
|
|
269
|
-
truncated: Schema.Boolean,
|
|
270
|
-
}) {}
|
|
271
|
-
|
|
272
|
-
/** Internal review selection applied as a decorator over the full PR source. */
|
|
273
|
-
export interface ReviewSelection {
|
|
274
|
-
readonly mode: ReviewScopeMode;
|
|
275
|
-
readonly reason: string;
|
|
276
|
-
readonly files: ReadonlyArray<ChangedFile>;
|
|
277
|
-
/** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
|
|
278
|
-
readonly affectedPaths: ReadonlyArray<string>;
|
|
279
|
-
readonly totalFiles: number;
|
|
280
|
-
readonly baselineSha: string | undefined;
|
|
281
|
-
readonly priorState: ReviewState | undefined;
|
|
282
|
-
readonly profileFingerprint: string;
|
|
283
|
-
/** Action-owned authentication capability, constructed at the composition root. */
|
|
284
|
-
readonly stateAuthenticator?: ReviewStateAuthenticator["Service"] | undefined;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const fullSelection = (input: {
|
|
288
|
-
readonly reason: string;
|
|
289
|
-
readonly files: ReadonlyArray<ChangedFile>;
|
|
290
|
-
readonly totalFiles: number;
|
|
291
|
-
readonly profileFingerprint: string;
|
|
292
|
-
}): ReviewSelection => ({
|
|
293
|
-
mode: "full",
|
|
294
|
-
reason: input.reason,
|
|
295
|
-
files: input.files,
|
|
296
|
-
affectedPaths: input.files.flatMap((file) =>
|
|
297
|
-
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
|
|
298
|
-
),
|
|
299
|
-
totalFiles: input.totalFiles,
|
|
300
|
-
baselineSha: undefined,
|
|
301
|
-
priorState: undefined,
|
|
302
|
-
profileFingerprint: input.profileFingerprint,
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Validate that persisted state belongs to this exact PR/base lineage and the
|
|
307
|
-
* same review profile. A mismatch is a full-review reason, never an error that
|
|
308
|
-
* silently suppresses review work.
|
|
309
|
-
*/
|
|
310
|
-
export const validateReviewState = (
|
|
311
|
-
state: ReviewState,
|
|
312
|
-
current: PullRequestMetadata,
|
|
313
|
-
profileFingerprint: string,
|
|
314
|
-
): string | undefined => {
|
|
315
|
-
if (state.repository !== current.repository || state.pullRequestNumber !== current.number) {
|
|
316
|
-
return "stored state belongs to a different pull request";
|
|
317
|
-
}
|
|
318
|
-
if (current.baseSha === undefined) return "the current base commit is unavailable";
|
|
319
|
-
if (state.baseRef !== current.baseRef) return "the pull request base ref changed";
|
|
320
|
-
if (state.headRef !== current.headRef) return "the pull request head ref changed";
|
|
321
|
-
if (state.profileFingerprint !== profileFingerprint) {
|
|
322
|
-
return "the reviewer profile or model configuration changed";
|
|
323
|
-
}
|
|
324
|
-
return undefined;
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
/** Pure, deterministic range selection with conservative full-review fallbacks. */
|
|
328
|
-
export const selectReviewRange = (input: {
|
|
329
|
-
readonly requestedMode: ReviewMode;
|
|
330
|
-
readonly current: PullRequestMetadata;
|
|
331
|
-
readonly fullFiles: ReadonlyArray<ChangedFile>;
|
|
332
|
-
readonly profileFingerprint: string;
|
|
333
|
-
readonly priorState: ReviewState | undefined;
|
|
334
|
-
readonly comparison: ReviewHeadComparison | undefined;
|
|
335
|
-
readonly baseComparison?: ReviewHeadComparison | undefined;
|
|
336
|
-
readonly lookupFailure?: string | undefined;
|
|
337
|
-
}): ReviewSelection => {
|
|
338
|
-
const full = (reason: string) =>
|
|
339
|
-
fullSelection({
|
|
340
|
-
reason,
|
|
341
|
-
files: input.fullFiles,
|
|
342
|
-
totalFiles: input.current.totalChangedFiles,
|
|
343
|
-
profileFingerprint: input.profileFingerprint,
|
|
344
|
-
});
|
|
345
|
-
if (input.requestedMode === "final") return full("explicit final full-diff audit requested");
|
|
346
|
-
if (input.lookupFailure !== undefined) {
|
|
347
|
-
return full(`stored review state could not be recovered: ${input.lookupFailure}`);
|
|
348
|
-
}
|
|
349
|
-
if (input.priorState === undefined) return full("no compatible stored review state was found");
|
|
350
|
-
const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
|
|
351
|
-
if (invalid !== undefined) return full(invalid);
|
|
352
|
-
const comparison = input.comparison;
|
|
353
|
-
if (comparison === undefined) return full("the incremental head comparison was unavailable");
|
|
354
|
-
if (
|
|
355
|
-
comparison.baseSha !== input.priorState.reviewedHeadSha ||
|
|
356
|
-
comparison.headSha !== input.current.headSha ||
|
|
357
|
-
comparison.mergeBaseSha !== input.priorState.reviewedHeadSha ||
|
|
358
|
-
(comparison.status !== "ahead" && comparison.status !== "identical")
|
|
359
|
-
) {
|
|
360
|
-
return full("the prior reviewed head is not an ancestor of the current head");
|
|
361
|
-
}
|
|
362
|
-
if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
|
|
363
|
-
const affectedPaths = new Set(
|
|
364
|
-
comparison.files.flatMap((file) =>
|
|
365
|
-
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
|
|
366
|
-
),
|
|
367
|
-
);
|
|
368
|
-
let baseReason = "";
|
|
369
|
-
if (input.priorState.baseSha !== input.current.baseSha) {
|
|
370
|
-
const baseComparison = input.baseComparison;
|
|
371
|
-
if (baseComparison === undefined) {
|
|
372
|
-
return full("the pull request base changed and its lineage comparison was unavailable");
|
|
373
|
-
}
|
|
374
|
-
if (
|
|
375
|
-
baseComparison.baseSha !== input.priorState.baseSha ||
|
|
376
|
-
baseComparison.headSha !== input.current.baseSha ||
|
|
377
|
-
baseComparison.mergeBaseSha !== input.priorState.baseSha ||
|
|
378
|
-
(baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
|
|
379
|
-
baseComparison.truncated
|
|
380
|
-
) {
|
|
381
|
-
return full("the pull request base changed materially or exceeded the comparison bound");
|
|
382
|
-
}
|
|
383
|
-
for (const file of baseComparison.files) {
|
|
384
|
-
affectedPaths.add(file.path);
|
|
385
|
-
if (file.previousPath !== undefined) affectedPaths.add(file.previousPath);
|
|
386
|
-
}
|
|
387
|
-
baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
|
|
388
|
-
}
|
|
389
|
-
const currentPaths = new Set(
|
|
390
|
-
input.fullFiles.flatMap((file) =>
|
|
391
|
-
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
|
|
392
|
-
),
|
|
393
|
-
);
|
|
394
|
-
const selectedByPath = new Map<string, ChangedFile>();
|
|
395
|
-
for (const file of comparison.files) {
|
|
396
|
-
if (
|
|
397
|
-
currentPaths.has(file.path) ||
|
|
398
|
-
(file.previousPath !== undefined && currentPaths.has(file.previousPath))
|
|
399
|
-
) {
|
|
400
|
-
selectedByPath.set(file.path, file);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
if (input.priorState.baseSha !== input.current.baseSha) {
|
|
404
|
-
for (const file of input.fullFiles) {
|
|
405
|
-
if (
|
|
406
|
-
affectedPaths.has(file.path) ||
|
|
407
|
-
(file.previousPath !== undefined && affectedPaths.has(file.previousPath))
|
|
408
|
-
) {
|
|
409
|
-
selectedByPath.set(file.path, file);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
|
|
414
|
-
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
415
|
-
);
|
|
416
|
-
return {
|
|
417
|
-
mode: "incremental",
|
|
418
|
-
reason: `changes since successfully reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
|
|
419
|
-
files: selectedFiles,
|
|
420
|
-
affectedPaths: [...affectedPaths].sort(),
|
|
421
|
-
totalFiles: selectedFiles.length,
|
|
422
|
-
baselineSha: input.priorState.reviewedHeadSha,
|
|
423
|
-
priorState: input.priorState,
|
|
424
|
-
profileFingerprint: input.profileFingerprint,
|
|
425
|
-
};
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
/** Per-run context consumed by orchestration and publication, not by the model. */
|
|
429
|
-
export class ReviewExecutionContext extends Context.Service<
|
|
430
|
-
ReviewExecutionContext,
|
|
431
|
-
ReviewSelection
|
|
432
|
-
>()("@effect-agent/pr-review/ReviewExecutionContext") {}
|
|
433
|
-
|
|
434
|
-
/**
|
|
435
|
-
* Decorate the full source with the selected review range. Full anchor files
|
|
436
|
-
* remain available to host-side publication validation; model tools see only
|
|
437
|
-
* the selected delta and may read head context only for that delta's paths.
|
|
438
|
-
*/
|
|
439
|
-
export const selectedPullRequestSourceLayer = (
|
|
440
|
-
selection: ReviewSelection,
|
|
441
|
-
): Layer.Layer<PullRequestSource, never, PullRequestSource> =>
|
|
442
|
-
Layer.effect(PullRequestSource)(
|
|
443
|
-
Effect.gen(function* () {
|
|
444
|
-
const source = yield* PullRequestSource;
|
|
445
|
-
const selectedPaths = new Set(selection.files.map((file) => file.path));
|
|
446
|
-
return PullRequestSource.of({
|
|
447
|
-
metadata: source.metadata,
|
|
448
|
-
changedFiles: Effect.succeed(selection.files),
|
|
449
|
-
anchorFiles: source.anchorFiles,
|
|
450
|
-
readFile: (path) =>
|
|
451
|
-
selectedPaths.has(path)
|
|
452
|
-
? source.readFile(path)
|
|
453
|
-
: Effect.fail(
|
|
454
|
-
ReviewInputViolation.make({
|
|
455
|
-
input: path,
|
|
456
|
-
reason: "Path is outside this incremental review range.",
|
|
457
|
-
}),
|
|
458
|
-
),
|
|
459
|
-
});
|
|
460
|
-
}),
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
/** Profile fingerprints are SHA-256 over configuration-only signatures. */
|
|
464
|
-
export const computeProfileFingerprint = (signature: string): Effect.Effect<string> =>
|
|
465
|
-
Effect.promise(async () => {
|
|
466
|
-
const digest = await globalThis.crypto.subtle.digest(
|
|
467
|
-
"SHA-256",
|
|
468
|
-
new TextEncoder().encode(signature),
|
|
469
|
-
);
|
|
470
|
-
return Array.from(new Uint8Array(digest))
|
|
471
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
472
|
-
.join("");
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
/** Build the full-surface mission used only to resolve profile guidance. */
|
|
476
|
-
export const buildProfileMission = (
|
|
477
|
-
metadata: PullRequestMetadata,
|
|
478
|
-
files: ReadonlyArray<ChangedFile>,
|
|
479
|
-
): ReviewMission =>
|
|
480
|
-
ReviewMission.make({
|
|
481
|
-
repository: metadata.repository,
|
|
482
|
-
number: metadata.number,
|
|
483
|
-
title: metadata.title,
|
|
484
|
-
body: metadata.body,
|
|
485
|
-
baseRef: metadata.baseRef,
|
|
486
|
-
headRef: metadata.headRef,
|
|
487
|
-
changedFileCount: files.length,
|
|
488
|
-
});
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
|
|
3
|
-
import type { ChangedFile } from "./diff.ts";
|
|
4
|
-
import { ChangedPath } from "./diff.ts";
|
|
5
|
-
import type { FindingSeverity } from "./review-agent.ts";
|
|
6
|
-
import { ReviewFinding } from "./review-agent.ts";
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Pure, deterministic planning for the fan-out reviewer: group the changeset
|
|
10
|
-
// into bounded review units (the work one delegated child reviews) and merge
|
|
11
|
-
// the children's findings back into one bounded review. Both operations are
|
|
12
|
-
// plain functions so tests pin them directly and the coordinator's tool
|
|
13
|
-
// surface stays deterministic — grouping is an algorithm, not model prose.
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
/** The delegation fan-out bound: one parent Run spawns at most this many children. */
|
|
17
|
-
export const MAX_REVIEW_UNITS = 8;
|
|
18
|
-
|
|
19
|
-
/** A unit never carries more files than this, regardless of their size. */
|
|
20
|
-
export const MAX_UNIT_FILES = 12;
|
|
21
|
-
|
|
22
|
-
/** Soft changed-line budget per unit; a single oversized file still gets its own unit. */
|
|
23
|
-
export const UNIT_CHANGED_LINE_BUDGET = 800;
|
|
24
|
-
|
|
25
|
-
/** Flat per-file cost so many tiny files still spread across units. */
|
|
26
|
-
const FILE_OVERHEAD_LINES = 20;
|
|
27
|
-
|
|
28
|
-
/** The merged review never exceeds the `CodeReview` findings bound. */
|
|
29
|
-
export const MAX_MERGED_FINDINGS = 20;
|
|
30
|
-
|
|
31
|
-
export const ReviewUnitId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
|
|
32
|
-
|
|
33
|
-
/** One bounded slice of the changeset delegated to one child reviewer. */
|
|
34
|
-
export class ReviewUnit extends Schema.Class<ReviewUnit>("@effect-agent/pr-review/ReviewUnit")({
|
|
35
|
-
unitId: ReviewUnitId,
|
|
36
|
-
paths: Schema.Array(ChangedPath)
|
|
37
|
-
.check(Schema.isMinLength(1))
|
|
38
|
-
.check(Schema.isMaxLength(MAX_UNIT_FILES)),
|
|
39
|
-
/** additions + deletions across the unit's files, for honest sizing. */
|
|
40
|
-
changedLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
41
|
-
}) {}
|
|
42
|
-
|
|
43
|
-
/** The complete deterministic fan-out plan over one changeset. */
|
|
44
|
-
export class ReviewUnitPlan extends Schema.Class<ReviewUnitPlan>(
|
|
45
|
-
"@effect-agent/pr-review/ReviewUnitPlan",
|
|
46
|
-
)({
|
|
47
|
-
totalFiles: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
48
|
-
/** True when the source returned fewer files than the pull request has. */
|
|
49
|
-
truncated: Schema.Boolean,
|
|
50
|
-
units: Schema.Array(ReviewUnit).check(Schema.isMaxLength(MAX_REVIEW_UNITS)),
|
|
51
|
-
/** Changed files without a textual diff; no finding can anchor to them. */
|
|
52
|
-
undiffablePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
|
|
53
|
-
/**
|
|
54
|
-
* Diffable files beyond the fan-out capacity (MAX_REVIEW_UNITS units of
|
|
55
|
-
* MAX_UNIT_FILES files). Never silently dropped: the coordinator must name
|
|
56
|
-
* them as unreviewed in its summary.
|
|
57
|
-
*/
|
|
58
|
-
unassignedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
|
|
59
|
-
}) {}
|
|
60
|
-
|
|
61
|
-
const fileCost = (file: ChangedFile): number =>
|
|
62
|
-
file.additions + file.deletions + FILE_OVERHEAD_LINES;
|
|
63
|
-
|
|
64
|
-
const unitOf = (index: number, files: ReadonlyArray<ChangedFile>): ReviewUnit =>
|
|
65
|
-
ReviewUnit.make({
|
|
66
|
-
unitId: `unit-${String(index + 1).padStart(3, "0")}`,
|
|
67
|
-
paths: files.map((file) => file.path),
|
|
68
|
-
changedLines: files.reduce((total, file) => total + file.additions + file.deletions, 0),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Group the changeset into at most `MAX_REVIEW_UNITS` review units.
|
|
73
|
-
*
|
|
74
|
-
* Deterministic by construction: files are ordered by path (so files sharing
|
|
75
|
-
* a directory become neighbors — directory affinity without a heuristic),
|
|
76
|
-
* then packed greedily in that order under the soft changed-line budget and
|
|
77
|
-
* the hard per-unit file bound. Capacity is finite and explicit:
|
|
78
|
-
*
|
|
79
|
-
* - files without a textual diff are not delegated — no finding can anchor
|
|
80
|
-
* to them (anchor validation demands a parsed patch), so they surface in
|
|
81
|
-
* `undiffablePaths` instead of consuming a child's budget;
|
|
82
|
-
* - diffable files beyond `MAX_REVIEW_UNITS` full units surface in
|
|
83
|
-
* `unassignedPaths` so the review can report them as unreviewed, never
|
|
84
|
-
* silently truncated.
|
|
85
|
-
*/
|
|
86
|
-
export const planReviewUnits = (
|
|
87
|
-
files: ReadonlyArray<ChangedFile>,
|
|
88
|
-
options: { readonly totalChangedFiles: number },
|
|
89
|
-
): ReviewUnitPlan => {
|
|
90
|
-
const ordered = [...files].sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
91
|
-
const diffable = ordered.filter((file) => file.patch !== undefined);
|
|
92
|
-
const undiffable = ordered.filter((file) => file.patch === undefined);
|
|
93
|
-
|
|
94
|
-
const groups: Array<Array<ChangedFile>> = [];
|
|
95
|
-
const unassigned: Array<ChangedFile> = [];
|
|
96
|
-
let current: Array<ChangedFile> = [];
|
|
97
|
-
let currentCost = 0;
|
|
98
|
-
for (const file of diffable) {
|
|
99
|
-
const cost = fileCost(file);
|
|
100
|
-
const wouldOverflow =
|
|
101
|
-
current.length >= MAX_UNIT_FILES ||
|
|
102
|
-
(current.length > 0 && currentCost + cost > UNIT_CHANGED_LINE_BUDGET);
|
|
103
|
-
if (wouldOverflow) {
|
|
104
|
-
groups.push(current);
|
|
105
|
-
current = [];
|
|
106
|
-
currentCost = 0;
|
|
107
|
-
}
|
|
108
|
-
if (groups.length >= MAX_REVIEW_UNITS) {
|
|
109
|
-
unassigned.push(file);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
current.push(file);
|
|
113
|
-
currentCost += cost;
|
|
114
|
-
}
|
|
115
|
-
if (current.length > 0 && groups.length < MAX_REVIEW_UNITS) {
|
|
116
|
-
groups.push(current);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return ReviewUnitPlan.make({
|
|
120
|
-
totalFiles: files.length,
|
|
121
|
-
truncated: files.length < options.totalChangedFiles,
|
|
122
|
-
units: groups.map((group, index) => unitOf(index, group)),
|
|
123
|
-
undiffablePaths: undiffable.map((file) => file.path),
|
|
124
|
-
unassignedPaths: unassigned.map((file) => file.path),
|
|
125
|
-
});
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const severityRank: Record<FindingSeverity, number> = {
|
|
129
|
-
blocking: 0,
|
|
130
|
-
important: 1,
|
|
131
|
-
nit: 2,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const anchorKey = (finding: ReviewFinding): string =>
|
|
135
|
-
`${finding.path} ${finding.startLine} ${finding.endLine}`;
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Merge the children's findings into one bounded, deterministic list: dedupe
|
|
139
|
-
* findings sharing an anchor (path + line range) keeping the most severe —
|
|
140
|
-
* and, at equal severity, the first in declaration order — then rank by
|
|
141
|
-
* severity, path, and line, and cap at the `CodeReview` findings bound.
|
|
142
|
-
* This is the merge policy the coordinator's instructions state in prose;
|
|
143
|
-
* pinning it here keeps the policy itself deterministic and testable.
|
|
144
|
-
*/
|
|
145
|
-
export const rankAndDedupeFindings = (
|
|
146
|
-
findings: ReadonlyArray<ReviewFinding>,
|
|
147
|
-
): ReadonlyArray<ReviewFinding> => {
|
|
148
|
-
const byAnchor = new Map<string, ReviewFinding>();
|
|
149
|
-
for (const finding of findings) {
|
|
150
|
-
const key = anchorKey(finding);
|
|
151
|
-
const existing = byAnchor.get(key);
|
|
152
|
-
if (
|
|
153
|
-
existing === undefined ||
|
|
154
|
-
severityRank[finding.severity] < severityRank[existing.severity]
|
|
155
|
-
) {
|
|
156
|
-
byAnchor.set(key, finding);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return [...byAnchor.values()]
|
|
160
|
-
.sort((left, right) => {
|
|
161
|
-
const bySeverity = severityRank[left.severity] - severityRank[right.severity];
|
|
162
|
-
if (bySeverity !== 0) return bySeverity;
|
|
163
|
-
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
164
|
-
return left.startLine - right.startLine;
|
|
165
|
-
})
|
|
166
|
-
.slice(0, MAX_MERGED_FINDINGS);
|
|
167
|
-
};
|