@effect-agent/pr-review 0.1.0-beta.23 → 0.1.0-beta.25
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/README.md +83 -195
- package/dist/action.d.mts +27 -18
- package/dist/action.mjs +60 -39
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +3 -3
- package/dist/cli.mjs.map +1 -1
- package/dist/{fan-out-BJBTAYuh.d.mts → fan-out-CMEsbFLk.d.mts} +455 -177
- package/dist/{github-BbwYzNrC.mjs → github-NjgxGqwM.mjs} +2163 -1518
- package/dist/github-NjgxGqwM.mjs.map +1 -0
- package/dist/index.d.mts +30 -20
- package/dist/index.mjs +3 -3
- package/dist/{providers-NyP-4rS6.mjs → providers-CODZQCmL.mjs} +202 -80
- package/dist/providers-CODZQCmL.mjs.map +1 -0
- package/dist/testing.d.mts +3 -1
- package/dist/testing.mjs +3 -2
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +141 -78
- package/src/cli.ts +6 -1
- package/src/index.ts +1 -0
- package/src/internal/adjudication.ts +415 -0
- package/src/internal/coverage.ts +41 -60
- package/src/internal/factory.ts +4 -4
- package/src/internal/fan-out.ts +208 -14
- package/src/internal/fingerprint.ts +16 -10
- package/src/internal/fixtures.ts +6 -0
- package/src/internal/github-env.ts +9 -0
- package/src/internal/github.ts +243 -7
- package/src/internal/progress.ts +1 -1
- package/src/internal/render.ts +186 -42
- package/src/internal/retirement.ts +16 -17
- package/src/internal/review-agent.ts +39 -4
- package/src/internal/review-state.ts +315 -105
- package/src/internal/review-units.ts +10 -9
- package/src/internal/run.ts +197 -63
- package/dist/github-BbwYzNrC.mjs.map +0 -1
- package/dist/providers-NyP-4rS6.mjs.map +0 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { Context, DateTime, Effect, Layer, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { INLINE_FINDING_TITLE_PATTERN } from "./retirement.ts";
|
|
4
|
+
import {
|
|
5
|
+
adjudicationIdentity,
|
|
6
|
+
MAX_STORED_ADJUDICATIONS,
|
|
7
|
+
StoredAdjudication,
|
|
8
|
+
type AdjudicationDisposition,
|
|
9
|
+
type StoredReviewFinding,
|
|
10
|
+
} from "./review-state.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Maintainer adjudication. GitHub reads stay behind ReviewAdjudicationHost;
|
|
14
|
+
// this module owns only the deterministic verb grammar, fail-closed
|
|
15
|
+
// authorization, later-wins resolution, and prompt-context rendering. Only an
|
|
16
|
+
// explicit, authorized `/adjudicate` verb adjudicates — free-text rebuttals
|
|
17
|
+
// are deliberately never parsed, because only an explicit verb is auditable
|
|
18
|
+
// and fail-closed (model output and third-party comments are untrusted
|
|
19
|
+
// input, AGENTS.md rule 11).
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
|
|
23
|
+
|
|
24
|
+
/** Maximum authorized command candidates retained for one inline thread. */
|
|
25
|
+
export const MAX_THREAD_ADJUDICATION_COMMANDS = 100;
|
|
26
|
+
|
|
27
|
+
/** One reply or top-level comment observed through the adjudication host. */
|
|
28
|
+
export class AdjudicationComment extends Schema.Class<AdjudicationComment>(
|
|
29
|
+
"@effect-agent/pr-review/AdjudicationComment",
|
|
30
|
+
)({
|
|
31
|
+
body: Schema.String.check(Schema.isMaxLength(65_536)),
|
|
32
|
+
/** GitHub's author_association for the comment author, verbatim. */
|
|
33
|
+
authorAssociation: Schema.String.check(Schema.isMaxLength(40)),
|
|
34
|
+
authorLogin: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
|
|
35
|
+
/** Creation time; a comment without one loses every later-wins tie. */
|
|
36
|
+
createdAt: Schema.NullOr(Schema.DateTimeUtc),
|
|
37
|
+
/** Stable zero-based order in the source listing, before thread grouping. */
|
|
38
|
+
sourceOrder: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
39
|
+
}) {}
|
|
40
|
+
|
|
41
|
+
/** One of the action's own inline finding threads, replies in creation order. */
|
|
42
|
+
export class AdjudicableThread extends Schema.Class<AdjudicableThread>(
|
|
43
|
+
"@effect-agent/pr-review/AdjudicableThread",
|
|
44
|
+
)({
|
|
45
|
+
path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
|
|
46
|
+
startLine: Schema.NullOr(PositiveLine),
|
|
47
|
+
endLine: Schema.NullOr(PositiveLine),
|
|
48
|
+
/** The root comment's body; its first line carries the finding title. */
|
|
49
|
+
rootBody: Schema.String.check(Schema.isMaxLength(65_536)),
|
|
50
|
+
replies: Schema.Array(AdjudicationComment).check(
|
|
51
|
+
Schema.isMaxLength(MAX_THREAD_ADJUDICATION_COMMANDS),
|
|
52
|
+
),
|
|
53
|
+
}) {}
|
|
54
|
+
|
|
55
|
+
/** A GitHub adjudication read failed. */
|
|
56
|
+
export class ReviewAdjudicationFailure extends Schema.TaggedError<ReviewAdjudicationFailure>()(
|
|
57
|
+
"ReviewAdjudicationFailure",
|
|
58
|
+
{
|
|
59
|
+
operation: Schema.String,
|
|
60
|
+
reason: Schema.String,
|
|
61
|
+
},
|
|
62
|
+
) {
|
|
63
|
+
override get message() {
|
|
64
|
+
return `Review adjudication operation '${this.operation}' failed: ${this.reason}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Host-side GitHub reads used by adjudication. Domain code never reaches into
|
|
70
|
+
* REST directly, and deterministic tests substitute this port. Both listings
|
|
71
|
+
* return comments in creation order.
|
|
72
|
+
*/
|
|
73
|
+
export class ReviewAdjudicationHost extends Context.Service<
|
|
74
|
+
ReviewAdjudicationHost,
|
|
75
|
+
{
|
|
76
|
+
/** This action's own inline finding threads with their replies. */
|
|
77
|
+
readonly listFindingThreads: Effect.Effect<
|
|
78
|
+
ReadonlyArray<AdjudicableThread>,
|
|
79
|
+
ReviewAdjudicationFailure
|
|
80
|
+
>;
|
|
81
|
+
/** Top-level pull-request conversation comments. */
|
|
82
|
+
readonly listIssueComments: Effect.Effect<
|
|
83
|
+
ReadonlyArray<AdjudicationComment>,
|
|
84
|
+
ReviewAdjudicationFailure
|
|
85
|
+
>;
|
|
86
|
+
}
|
|
87
|
+
>()("@effect-agent/pr-review/ReviewAdjudicationHost") {}
|
|
88
|
+
|
|
89
|
+
/** Explicit program-edge adapter for runs that intentionally perform no host reads. */
|
|
90
|
+
export const noReviewAdjudicationHost = ReviewAdjudicationHost.of({
|
|
91
|
+
listFindingThreads: Effect.succeed([]),
|
|
92
|
+
listIssueComments: Effect.succeed([]),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Layer form of {@link noReviewAdjudicationHost}. */
|
|
96
|
+
export const noReviewAdjudicationHostLayer =
|
|
97
|
+
Layer.succeed(ReviewAdjudicationHost)(noReviewAdjudicationHost);
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Verb grammar. A body whose first line starts with `/adjudicate` is a
|
|
101
|
+
// command; a command that fails the grammar is malformed and ignored rather
|
|
102
|
+
// than guessed at. Fail-closed authorization: only OWNER, MEMBER, and
|
|
103
|
+
// COLLABORATOR authors may adjudicate.
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** author_associations allowed to adjudicate; everything else is ignored. */
|
|
107
|
+
export const AUTHORIZED_ADJUDICATION_ASSOCIATIONS: ReadonlySet<string> = new Set([
|
|
108
|
+
"OWNER",
|
|
109
|
+
"MEMBER",
|
|
110
|
+
"COLLABORATOR",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const AdjudicationDispositionSchema = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
|
|
114
|
+
|
|
115
|
+
const THREAD_COMMAND_PATTERN = /^\/adjudicate[ \t]+([a-z-]+)[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
|
|
116
|
+
const ISSUE_COMMAND_PATTERN =
|
|
117
|
+
/^\/adjudicate[ \t]+([a-z-]+)[ \t]+"([^"\n]+)"[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
|
|
118
|
+
|
|
119
|
+
export interface ParsedAdjudicationCommand {
|
|
120
|
+
readonly disposition: AdjudicationDisposition;
|
|
121
|
+
/** Present only for the issue-comment grammar's quoted target title. */
|
|
122
|
+
readonly title?: string | undefined;
|
|
123
|
+
readonly reason?: string | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const firstLine = (body: string): string => (body.split("\n", 1)[0] ?? "").trim();
|
|
127
|
+
|
|
128
|
+
const boundedReason = (raw: string | undefined): string | undefined => {
|
|
129
|
+
if (raw === undefined) return undefined;
|
|
130
|
+
const trimmed = raw.trim().slice(0, 300);
|
|
131
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse one inline-thread reply: `/adjudicate <disposition>(: <reason>)?`.
|
|
136
|
+
* The thread itself names the target identity. Returns undefined for a
|
|
137
|
+
* non-command body and "malformed" for a command that fails the grammar.
|
|
138
|
+
*/
|
|
139
|
+
export const parseThreadAdjudication = (
|
|
140
|
+
body: string,
|
|
141
|
+
): ParsedAdjudicationCommand | "malformed" | undefined => {
|
|
142
|
+
const line = firstLine(body);
|
|
143
|
+
if (!line.startsWith("/adjudicate")) return undefined;
|
|
144
|
+
const match = THREAD_COMMAND_PATTERN.exec(line);
|
|
145
|
+
const disposition = match?.[1];
|
|
146
|
+
if (disposition === undefined || !Schema.is(AdjudicationDispositionSchema)(disposition)) {
|
|
147
|
+
return "malformed";
|
|
148
|
+
}
|
|
149
|
+
return { disposition, reason: boundedReason(match?.[2]) };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse one top-level PR comment:
|
|
154
|
+
* `/adjudicate <disposition> "<exact title>"(: <reason>)?`. The quoted title
|
|
155
|
+
* is required because the conversation names no finding thread; it targets
|
|
156
|
+
* the title-alone identity of an unanchored concern.
|
|
157
|
+
*/
|
|
158
|
+
export const parseIssueAdjudication = (
|
|
159
|
+
body: string,
|
|
160
|
+
): ParsedAdjudicationCommand | "malformed" | undefined => {
|
|
161
|
+
const line = firstLine(body);
|
|
162
|
+
if (!line.startsWith("/adjudicate")) return undefined;
|
|
163
|
+
const match = ISSUE_COMMAND_PATTERN.exec(line);
|
|
164
|
+
const disposition = match?.[1];
|
|
165
|
+
const title = match?.[2];
|
|
166
|
+
if (
|
|
167
|
+
disposition === undefined ||
|
|
168
|
+
!Schema.is(AdjudicationDispositionSchema)(disposition) ||
|
|
169
|
+
title === undefined ||
|
|
170
|
+
title.length > 120
|
|
171
|
+
) {
|
|
172
|
+
return "malformed";
|
|
173
|
+
}
|
|
174
|
+
return { disposition, title, reason: boundedReason(match?.[3]) };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** The finding identity an inline thread names, or undefined when unparsable. */
|
|
178
|
+
export const threadFindingTarget = (
|
|
179
|
+
thread: AdjudicableThread,
|
|
180
|
+
):
|
|
181
|
+
| {
|
|
182
|
+
readonly path: string;
|
|
183
|
+
readonly startLine: number;
|
|
184
|
+
readonly endLine: number;
|
|
185
|
+
readonly title: string;
|
|
186
|
+
}
|
|
187
|
+
| undefined => {
|
|
188
|
+
if (thread.startLine === null || thread.endLine === null) return undefined;
|
|
189
|
+
const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine(thread.rootBody))?.[1];
|
|
190
|
+
if (title === undefined || title.length > 120) return undefined;
|
|
191
|
+
return {
|
|
192
|
+
path: thread.path,
|
|
193
|
+
startLine: thread.startLine,
|
|
194
|
+
endLine: thread.endLine,
|
|
195
|
+
title,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Deterministic derivation: authorization, later-wins, bounded storage.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
interface AdjudicationCandidate {
|
|
204
|
+
readonly adjudication: StoredAdjudication;
|
|
205
|
+
readonly epochMillis: number;
|
|
206
|
+
readonly sourceOrder: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface DerivedAdjudications {
|
|
210
|
+
readonly adjudications: ReadonlyArray<StoredAdjudication>;
|
|
211
|
+
/** Commands ignored fail-closed: unauthorized authors and malformed bodies. */
|
|
212
|
+
readonly ignored: ReadonlyArray<string>;
|
|
213
|
+
/** Later-wins winners dropped oldest-first at the storage bound. */
|
|
214
|
+
readonly droppedOldest: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Derive the standing adjudications from the host's listings. Every command
|
|
219
|
+
* is screened fail-closed (authorization, grammar, a parsable target); later
|
|
220
|
+
* adjudications of the same identity win by comment creation order; the
|
|
221
|
+
* result is capped at the ReviewState bound dropping the oldest winners.
|
|
222
|
+
*/
|
|
223
|
+
export const deriveAdjudications = (input: {
|
|
224
|
+
readonly threads: ReadonlyArray<AdjudicableThread>;
|
|
225
|
+
readonly issueComments: ReadonlyArray<AdjudicationComment>;
|
|
226
|
+
}): DerivedAdjudications => {
|
|
227
|
+
const candidates: Array<AdjudicationCandidate> = [];
|
|
228
|
+
const ignored: Array<string> = [];
|
|
229
|
+
const admit = (
|
|
230
|
+
comment: AdjudicationComment,
|
|
231
|
+
command: ParsedAdjudicationCommand,
|
|
232
|
+
target: {
|
|
233
|
+
readonly path?: string | undefined;
|
|
234
|
+
readonly startLine?: number | undefined;
|
|
235
|
+
readonly endLine?: number | undefined;
|
|
236
|
+
readonly title: string;
|
|
237
|
+
},
|
|
238
|
+
): void => {
|
|
239
|
+
candidates.push({
|
|
240
|
+
adjudication: StoredAdjudication.make({
|
|
241
|
+
...(target.path === undefined ? {} : { path: target.path }),
|
|
242
|
+
...(target.startLine === undefined ? {} : { startLine: target.startLine }),
|
|
243
|
+
...(target.endLine === undefined ? {} : { endLine: target.endLine }),
|
|
244
|
+
title: target.title,
|
|
245
|
+
disposition: command.disposition,
|
|
246
|
+
...(command.reason === undefined ? {} : { reason: command.reason }),
|
|
247
|
+
actor: comment.authorLogin,
|
|
248
|
+
}),
|
|
249
|
+
epochMillis: comment.createdAt === null ? -1 : DateTime.toEpochMillis(comment.createdAt),
|
|
250
|
+
sourceOrder: comment.sourceOrder,
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
const authorized = (comment: AdjudicationComment, surface: string): boolean => {
|
|
254
|
+
if (AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(comment.authorAssociation)) return true;
|
|
255
|
+
ignored.push(
|
|
256
|
+
`${surface}: unauthorized /adjudicate from @${comment.authorLogin} (${comment.authorAssociation})`,
|
|
257
|
+
);
|
|
258
|
+
return false;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (const thread of input.threads) {
|
|
262
|
+
const target = threadFindingTarget(thread);
|
|
263
|
+
for (const reply of thread.replies) {
|
|
264
|
+
const command = parseThreadAdjudication(reply.body);
|
|
265
|
+
if (command === undefined) continue;
|
|
266
|
+
const surface = `inline thread ${thread.path}`;
|
|
267
|
+
if (command === "malformed") {
|
|
268
|
+
ignored.push(`${surface}: malformed /adjudicate command from @${reply.authorLogin}`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!authorized(reply, surface)) continue;
|
|
272
|
+
if (target === undefined) {
|
|
273
|
+
ignored.push(`${surface}: thread root names no parsable finding title`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
admit(reply, command, target);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const comment of input.issueComments) {
|
|
280
|
+
const command = parseIssueAdjudication(comment.body);
|
|
281
|
+
if (command === undefined) continue;
|
|
282
|
+
const surface = "pull-request conversation";
|
|
283
|
+
if (command === "malformed") {
|
|
284
|
+
ignored.push(`${surface}: malformed /adjudicate command from @${comment.authorLogin}`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!authorized(comment, surface)) continue;
|
|
288
|
+
if (command.title === undefined) {
|
|
289
|
+
ignored.push(`${surface}: /adjudicate without a quoted target title`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
admit(comment, command, { title: command.title });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const byIdentity = new Map<string, AdjudicationCandidate>();
|
|
296
|
+
const ordered = [...candidates].sort(
|
|
297
|
+
(left, right) => left.epochMillis - right.epochMillis || left.sourceOrder - right.sourceOrder,
|
|
298
|
+
);
|
|
299
|
+
for (const candidate of ordered) {
|
|
300
|
+
const identity = adjudicationIdentity(candidate.adjudication);
|
|
301
|
+
// Delete-then-set so a later adjudication also refreshes its recency for
|
|
302
|
+
// the oldest-first drop below.
|
|
303
|
+
byIdentity.delete(identity);
|
|
304
|
+
byIdentity.set(identity, candidate);
|
|
305
|
+
}
|
|
306
|
+
const winners = [...byIdentity.values()];
|
|
307
|
+
const droppedOldest = Math.max(0, winners.length - MAX_STORED_ADJUDICATIONS);
|
|
308
|
+
return {
|
|
309
|
+
adjudications: winners.slice(droppedOldest).map((candidate) => candidate.adjudication),
|
|
310
|
+
ignored,
|
|
311
|
+
droppedOldest,
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Later-wins merge of stored prior adjudications with freshly derived ones. */
|
|
316
|
+
export const mergeAdjudications = (
|
|
317
|
+
prior: ReadonlyArray<StoredAdjudication>,
|
|
318
|
+
fresh: ReadonlyArray<StoredAdjudication>,
|
|
319
|
+
): ReadonlyArray<StoredAdjudication> => {
|
|
320
|
+
const byIdentity = new Map<string, StoredAdjudication>();
|
|
321
|
+
for (const adjudication of [...prior, ...fresh]) {
|
|
322
|
+
const identity = adjudicationIdentity(adjudication);
|
|
323
|
+
byIdentity.delete(identity);
|
|
324
|
+
byIdentity.set(identity, adjudication);
|
|
325
|
+
}
|
|
326
|
+
const merged = [...byIdentity.values()];
|
|
327
|
+
return merged.slice(Math.max(0, merged.length - MAX_STORED_ADJUDICATIONS));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Collect the standing maintainer adjudications: freshly derived through the
|
|
332
|
+
* host, merged later-wins over the prior state's stored set. The host is a
|
|
333
|
+
* visible Effect requirement; program edges that intentionally perform no
|
|
334
|
+
* reads provide {@link noReviewAdjudicationHost}. Fail-open — any listing
|
|
335
|
+
* fault keeps the complete prior set and never fails the review, because NOT
|
|
336
|
+
* suppressing a finding is the conservative direction.
|
|
337
|
+
*/
|
|
338
|
+
export const collectReviewAdjudications = Effect.fn("collectReviewAdjudications")(function* (
|
|
339
|
+
prior: ReadonlyArray<StoredAdjudication>,
|
|
340
|
+
) {
|
|
341
|
+
const host = yield* ReviewAdjudicationHost;
|
|
342
|
+
const listings = yield* Effect.all({
|
|
343
|
+
threads: host.listFindingThreads,
|
|
344
|
+
issueComments: host.listIssueComments,
|
|
345
|
+
}).pipe(
|
|
346
|
+
Effect.catch((error) =>
|
|
347
|
+
Effect.logWarning(
|
|
348
|
+
`Could not collect adjudications from '${error.operation}': ${error.reason}; retaining stored adjudications unchanged.`,
|
|
349
|
+
).pipe(Effect.as(undefined)),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
if (listings === undefined) return prior;
|
|
353
|
+
const derived = deriveAdjudications({
|
|
354
|
+
threads: listings.threads,
|
|
355
|
+
issueComments: listings.issueComments,
|
|
356
|
+
});
|
|
357
|
+
for (const note of derived.ignored) {
|
|
358
|
+
yield* Effect.logDebug(`Ignored adjudication command — ${note}`);
|
|
359
|
+
}
|
|
360
|
+
if (derived.droppedOldest > 0) {
|
|
361
|
+
yield* Effect.logWarning(
|
|
362
|
+
`Dropped ${derived.droppedOldest} oldest adjudication(s) over the ${MAX_STORED_ADJUDICATIONS}-entry bound.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return mergeAdjudications(prior, derived.adjudications);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Prompt-context rendering: deterministic bounded lines the reviewer sees.
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
const lineRange = (startLine: number, endLine: number): string =>
|
|
373
|
+
`${startLine}${endLine === startLine ? "" : `-${endLine}`}`;
|
|
374
|
+
|
|
375
|
+
/** One adjudication as a bounded reviewer-prompt context line. */
|
|
376
|
+
export const renderAdjudicationContextLine = (adjudication: StoredAdjudication): string => {
|
|
377
|
+
const location =
|
|
378
|
+
adjudication.path !== undefined &&
|
|
379
|
+
adjudication.startLine !== undefined &&
|
|
380
|
+
adjudication.endLine !== undefined
|
|
381
|
+
? `${adjudication.path}:${lineRange(adjudication.startLine, adjudication.endLine)}`
|
|
382
|
+
: "(unanchored)";
|
|
383
|
+
const reason = adjudication.reason === undefined ? "" : `: ${adjudication.reason}`;
|
|
384
|
+
return `${location} "${adjudication.title}" — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/** One prior-round finding as a bounded reviewer-prompt context line. */
|
|
388
|
+
export const renderPriorFindingContextLine = (finding: StoredReviewFinding): string =>
|
|
389
|
+
`${finding.path}:${lineRange(finding.startLine, finding.endLine)} [${finding.severity}] "${finding.title}" — ${finding.body.slice(0, 400)}`;
|
|
390
|
+
|
|
391
|
+
/** Prior-review context threaded into fan-out discovery briefs, per path. */
|
|
392
|
+
export interface PriorReviewContext {
|
|
393
|
+
/** Adjudicated identities; path-free entries apply to every unit. */
|
|
394
|
+
readonly adjudicated: ReadonlyArray<{
|
|
395
|
+
readonly path: string | undefined;
|
|
396
|
+
readonly line: string;
|
|
397
|
+
}>;
|
|
398
|
+
/** Prior-round findings whose paths are being re-reviewed. */
|
|
399
|
+
readonly priorFindings: ReadonlyArray<{ readonly path: string; readonly line: string }>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Build the fan-out prior-review context from the resolved continuity data. */
|
|
403
|
+
export const buildPriorReviewContext = (
|
|
404
|
+
adjudications: ReadonlyArray<StoredAdjudication>,
|
|
405
|
+
priorFindingsOnScope: ReadonlyArray<StoredReviewFinding>,
|
|
406
|
+
): PriorReviewContext => ({
|
|
407
|
+
adjudicated: adjudications.map((adjudication) => ({
|
|
408
|
+
path: adjudication.path,
|
|
409
|
+
line: renderAdjudicationContextLine(adjudication),
|
|
410
|
+
})),
|
|
411
|
+
priorFindings: priorFindingsOnScope.map((finding) => ({
|
|
412
|
+
path: finding.path,
|
|
413
|
+
line: renderPriorFindingContextLine(finding),
|
|
414
|
+
})),
|
|
415
|
+
});
|
package/src/internal/coverage.ts
CHANGED
|
@@ -19,37 +19,6 @@ import type { ReviewUnitPlan } from "./review-units.ts";
|
|
|
19
19
|
// results; only the flat reviewer is assessed from its Run event trace here.
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
|
-
export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
|
|
23
|
-
"@effect-agent/pr-review/FailedReviewUnit",
|
|
24
|
-
)({
|
|
25
|
-
unitId: Schema.NonEmptyString.check(Schema.isMaxLength(32)),
|
|
26
|
-
errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
|
|
27
|
-
}) {}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Compatibility diagnostic retained for callers that consumed the original
|
|
31
|
-
* `coverage` field. New UI and state decisions use ReviewInputCoverage and
|
|
32
|
-
* ReviewAssurance directly.
|
|
33
|
-
*/
|
|
34
|
-
export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
|
|
35
|
-
"@effect-agent/pr-review/ReviewCoverage",
|
|
36
|
-
)({
|
|
37
|
-
status: Schema.Literals(["complete", "incomplete"]),
|
|
38
|
-
requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
|
|
39
|
-
Schema.isMaxLength(300),
|
|
40
|
-
),
|
|
41
|
-
reviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
|
|
42
|
-
Schema.isMaxLength(300),
|
|
43
|
-
),
|
|
44
|
-
unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
|
|
45
|
-
Schema.isMaxLength(300),
|
|
46
|
-
),
|
|
47
|
-
failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
|
|
48
|
-
reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
|
|
49
|
-
Schema.isMaxLength(32),
|
|
50
|
-
),
|
|
51
|
-
}) {}
|
|
52
|
-
|
|
53
22
|
export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
|
|
54
23
|
"@effect-agent/pr-review/ReviewInputCoverage",
|
|
55
24
|
)({
|
|
@@ -159,6 +128,47 @@ export const boundedListReason = (label: string, values: Iterable<string>): stri
|
|
|
159
128
|
return rendered;
|
|
160
129
|
};
|
|
161
130
|
|
|
131
|
+
export interface CarriedScope {
|
|
132
|
+
/** Carried paths a retry can actually settle (failed passes, overflow). */
|
|
133
|
+
readonly retryablePaths: ReadonlyArray<string>;
|
|
134
|
+
/** Carried paths no retry can settle (binaries, oversized files). */
|
|
135
|
+
readonly undiffablePaths: ReadonlyArray<string>;
|
|
136
|
+
/** Whether any incompleteness beyond the undiffable files exists. */
|
|
137
|
+
readonly retryableGap: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Split carried scope into paths a retry can settle and paths it never can.
|
|
142
|
+
* Undiffable files are a property of the pull request, not a transient
|
|
143
|
+
* reviewer-side failure: gate reasons and rendered callouts must never promise
|
|
144
|
+
* they are "retried automatically" — the honest instruction is to remove them
|
|
145
|
+
* from the pull request or exclude them with ignore globs.
|
|
146
|
+
*/
|
|
147
|
+
export const splitCarriedScope = (input: {
|
|
148
|
+
readonly inputCoverage?: ReviewInputCoverage | undefined;
|
|
149
|
+
readonly assurance?: ReviewAssurance | undefined;
|
|
150
|
+
readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
|
|
151
|
+
}): CarriedScope => {
|
|
152
|
+
const undiffable = new Set(input.inputCoverage?.undiffablePaths ?? []);
|
|
153
|
+
const retryablePaths = (input.unreviewedPaths ?? []).filter((path) => !undiffable.has(path));
|
|
154
|
+
const undiffablePaths = sortedUnique(undiffable);
|
|
155
|
+
// Every non-undiffable coverage gap (range truncation, capacity overflow,
|
|
156
|
+
// truncated or missing evidence, anchor surface) contributes its own reason
|
|
157
|
+
// line, so a lone reason alongside undiffable paths means the undiffable
|
|
158
|
+
// files are the entire gap.
|
|
159
|
+
const coverageGapBeyondUndiffable =
|
|
160
|
+
input.inputCoverage?.status === "incomplete" &&
|
|
161
|
+
input.inputCoverage.reasons.length > (undiffablePaths.length > 0 ? 1 : 0);
|
|
162
|
+
return {
|
|
163
|
+
retryablePaths,
|
|
164
|
+
undiffablePaths,
|
|
165
|
+
retryableGap:
|
|
166
|
+
input.assurance?.status === "incomplete" ||
|
|
167
|
+
retryablePaths.length > 0 ||
|
|
168
|
+
coverageGapBeyondUndiffable,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
162
172
|
const anchorSurfaceAdjusted = (
|
|
163
173
|
inputCoverage: ReviewInputCoverage,
|
|
164
174
|
anchorFiles: ReadonlyArray<ChangedFile>,
|
|
@@ -345,32 +355,3 @@ export const fanOutInputCoverage = (input: {
|
|
|
345
355
|
input.totalAnchorFiles,
|
|
346
356
|
);
|
|
347
357
|
};
|
|
348
|
-
|
|
349
|
-
/** Compatibility aggregate over the two precise claims. */
|
|
350
|
-
export const compatibilityCoverage = (
|
|
351
|
-
inputCoverage: ReviewInputCoverage,
|
|
352
|
-
assurance: ReviewAssurance,
|
|
353
|
-
): ReviewCoverage => {
|
|
354
|
-
const assuranceIncomplete = assurance.status === "incomplete";
|
|
355
|
-
const failedUnits = new Map<string, FailedReviewUnit>();
|
|
356
|
-
for (const pass of assurance.failedPasses) {
|
|
357
|
-
const unitId = pass.workId.slice(0, "unit-000".length);
|
|
358
|
-
if (!failedUnits.has(unitId)) {
|
|
359
|
-
failedUnits.set(
|
|
360
|
-
unitId,
|
|
361
|
-
FailedReviewUnit.make({ unitId, errorTag: `${pass.stage}:${pass.errorTag}` }),
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return ReviewCoverage.make({
|
|
366
|
-
status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
|
|
367
|
-
requiredPaths: inputCoverage.requiredPaths,
|
|
368
|
-
reviewedPaths: inputCoverage.assignedPaths,
|
|
369
|
-
unreviewedPaths: sortedUnique([
|
|
370
|
-
...inputCoverage.partialPaths,
|
|
371
|
-
...inputCoverage.unassignedPaths,
|
|
372
|
-
]),
|
|
373
|
-
failedUnits: [...failedUnits.values()].slice(0, 8),
|
|
374
|
-
reasons: [...inputCoverage.reasons, ...(assuranceIncomplete ? assurance.reasons : [])],
|
|
375
|
-
});
|
|
376
|
-
};
|
package/src/internal/factory.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { Toolkit, type LanguageModel, type Model, type Tool } from "effect/unsta
|
|
|
11
11
|
|
|
12
12
|
import type { ChangedFile } from "./diff.ts";
|
|
13
13
|
import { makeFileReviewerDefinition } from "./fan-out.ts";
|
|
14
|
-
import { computeChangesetFingerprint } from "./fingerprint.ts";
|
|
14
|
+
import { computeChangesetFingerprint, computeProfileFingerprint } from "./fingerprint.ts";
|
|
15
15
|
import { compileIgnoreGlobs, ignoringPullRequestSourceLayer } from "./ignore.ts";
|
|
16
16
|
import {
|
|
17
17
|
clampMaxFindings,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
resolveGuidance as resolveReviewGuidance,
|
|
27
27
|
type ReviewGuidance,
|
|
28
28
|
} from "./review-agent.ts";
|
|
29
|
-
import { buildProfileMission
|
|
29
|
+
import { buildProfileMission } from "./review-state.ts";
|
|
30
30
|
import {
|
|
31
31
|
buildReviewMission,
|
|
32
32
|
executeFanOutReview,
|
|
@@ -175,8 +175,8 @@ const makeReviewSnapshot = (ignore: ReadonlyArray<string> | undefined) =>
|
|
|
175
175
|
* Build the flat reviewer: one bounded read-only agent over the whole
|
|
176
176
|
* changeset. Returns the model-agnostic definition, the explicit binding, and
|
|
177
177
|
* a `run` whose error and requirement channels stay fully inferred — the
|
|
178
|
-
* pull-request source, the publisher, extra tool handlers,
|
|
179
|
-
* Layer's requirements all remain visible to the caller.
|
|
178
|
+
* pull-request source, the publisher, `Crypto.Crypto`, extra tool handlers,
|
|
179
|
+
* and the Model Layer's requirements all remain visible to the caller.
|
|
180
180
|
*/
|
|
181
181
|
const make = <
|
|
182
182
|
Provider,
|