@effect-agent/pr-review 0.1.0-beta.28 → 0.1.0-beta.29
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 +9 -204
- package/dist/index.d.mts +87 -914
- package/dist/index.mjs +163 -71
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -18
- package/src/index.ts +1 -25
- package/src/review.ts +212 -0
- package/dist/action.d.mts +0 -215
- package/dist/action.mjs +0 -505
- package/dist/action.mjs.map +0 -1
- package/dist/cli.d.mts +0 -1
- package/dist/cli.mjs +0 -106
- package/dist/cli.mjs.map +0 -1
- package/dist/fan-out-C3yG1cx3.d.mts +0 -1526
- package/dist/github-CCuLgyqb.mjs +0 -3437
- package/dist/github-CCuLgyqb.mjs.map +0 -1
- package/dist/logging-Q4j0oub-.mjs +0 -75
- package/dist/logging-Q4j0oub-.mjs.map +0 -1
- package/dist/providers-Br9FRn7j.mjs +0 -1349
- package/dist/providers-Br9FRn7j.mjs.map +0 -1
- package/dist/testing.d.mts +0 -86
- package/dist/testing.mjs +0 -184
- package/dist/testing.mjs.map +0 -1
- package/src/action.ts +0 -906
- package/src/cli.ts +0 -235
- package/src/internal/action-entry.ts +0 -45
- package/src/internal/adjudication.ts +0 -415
- package/src/internal/anchors.ts +0 -20
- package/src/internal/coverage.ts +0 -357
- package/src/internal/diff.ts +0 -193
- package/src/internal/effort.ts +0 -86
- package/src/internal/factory.ts +0 -357
- package/src/internal/fan-out-scripted.ts +0 -77
- package/src/internal/fan-out.ts +0 -1148
- package/src/internal/fingerprint.ts +0 -89
- package/src/internal/fixtures.ts +0 -148
- package/src/internal/github-env.ts +0 -164
- package/src/internal/github.ts +0 -1218
- package/src/internal/ignore.ts +0 -88
- package/src/internal/logging.ts +0 -124
- package/src/internal/profiles.ts +0 -91
- package/src/internal/progress.ts +0 -433
- package/src/internal/providers.ts +0 -133
- package/src/internal/render.ts +0 -819
- package/src/internal/retirement.ts +0 -337
- package/src/internal/review-agent.ts +0 -543
- package/src/internal/review-state.ts +0 -782
- package/src/internal/review-units.ts +0 -493
- package/src/internal/run.ts +0 -611
- package/src/internal/scripted.ts +0 -108
- package/src/internal/source.ts +0 -110
- package/src/testing.ts +0 -8
package/src/internal/progress.ts
DELETED
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect";
|
|
2
|
-
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN,
|
|
6
|
-
GitHubApiFailure,
|
|
7
|
-
GitHubReviewTarget,
|
|
8
|
-
} from "./github.ts";
|
|
9
|
-
import type { ReviewScopeMode } from "./review-state.ts";
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// The sticky review-progress comment: one issue comment per pull request that
|
|
13
|
-
// says a review run is working the moment it starts, updated in place with
|
|
14
|
-
// the settled outcome. Progress reporting is cosmetic and FAIL-OPEN by
|
|
15
|
-
// design: it must never change what the review posts or how the check
|
|
16
|
-
// concludes, so every GitHub fault here is logged and swallowed. The review
|
|
17
|
-
// itself still publishes only through the validated ReviewPublisher after
|
|
18
|
-
// the run settles.
|
|
19
|
-
//
|
|
20
|
-
// Concurrency contract (honest, per the no-exactly-once rule): posting is
|
|
21
|
-
// at-least-once and writes are GENERATION-FENCED, never atomic. Each run
|
|
22
|
-
// embeds a claim marker (run token + start time) in the comment it writes and
|
|
23
|
-
// re-reads the comment immediately before every update, writing only when the
|
|
24
|
-
// current claim is its own or belongs to an older run — so a stale run cannot
|
|
25
|
-
// replace a newer run's status outside the read-then-write window. Runs adopt
|
|
26
|
-
// the newest existing claim comment and best-effort delete older duplicates,
|
|
27
|
-
// so duplicates left by unfenced overlapping runs self-heal on the next run.
|
|
28
|
-
// Strict single-comment behavior comes from workflow-level per-PR concurrency
|
|
29
|
-
// groups (as in the reference workflow), not from this adapter.
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
/** Every progress comment starts its invisible marker with this prefix. */
|
|
33
|
-
export const PROGRESS_COMMENT_MARKER_PREFIX = "<!-- effect-agent-pr-review progress";
|
|
34
|
-
|
|
35
|
-
/** One run's generation fence: who wrote a progress comment, and when. */
|
|
36
|
-
export interface ProgressClaim {
|
|
37
|
-
/** Random per-run token; matching it means the comment is this run's own. */
|
|
38
|
-
readonly runToken: string;
|
|
39
|
-
/** Run start in epoch millis; newer runs may overwrite older claims. */
|
|
40
|
-
readonly startedMillis: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const CLAIM_PATTERN = /<!-- effect-agent-pr-review progress run=([0-9A-Za-z-]+) started=(\d+) -->/g;
|
|
44
|
-
|
|
45
|
-
/** HTML comments must not contain `--`; tokens are reduced to a safe alphabet. */
|
|
46
|
-
const sanitizeToken = (token: string): string =>
|
|
47
|
-
token.replaceAll(/[^0-9A-Za-z-]/g, "").replaceAll(/-{2,}/g, "-");
|
|
48
|
-
|
|
49
|
-
/** Render one run's claim marker (token sanitized into the safe alphabet). */
|
|
50
|
-
export const renderProgressClaimMarker = (claim: ProgressClaim): string =>
|
|
51
|
-
`${PROGRESS_COMMENT_MARKER_PREFIX} run=${sanitizeToken(claim.runToken)} started=${Math.max(0, Math.floor(claim.startedMillis))} -->`;
|
|
52
|
-
|
|
53
|
-
/** Extract the last claim marker in one comment body, if any. */
|
|
54
|
-
export const parseProgressClaim = (body: string): ProgressClaim | undefined => {
|
|
55
|
-
let last: ProgressClaim | undefined;
|
|
56
|
-
for (const match of body.matchAll(CLAIM_PATTERN)) {
|
|
57
|
-
const startedMillis = Number(match[2]);
|
|
58
|
-
if (match[1] !== undefined && Number.isFinite(startedMillis)) {
|
|
59
|
-
last = { runToken: match[1], startedMillis };
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return last;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
/** What a starting run can honestly say before any model turn has executed. */
|
|
66
|
-
export interface ReviewProgressBegin {
|
|
67
|
-
readonly headSha?: string | undefined;
|
|
68
|
-
readonly reviewMode?: ReviewScopeMode | undefined;
|
|
69
|
-
readonly reviewReason?: string | undefined;
|
|
70
|
-
readonly filesInScope?: number | undefined;
|
|
71
|
-
readonly modelLabel?: string | undefined;
|
|
72
|
-
readonly runUrl?: string | undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** How the run ended: a settled (posted) review, or a failure that posted nothing. */
|
|
76
|
-
export type ReviewProgressSettle =
|
|
77
|
-
| {
|
|
78
|
-
readonly outcome: "reviewed";
|
|
79
|
-
readonly conclusion: "success" | "blocking" | "incomplete";
|
|
80
|
-
readonly verdict: string;
|
|
81
|
-
readonly inlineComments: number;
|
|
82
|
-
readonly reviewUrl?: string | undefined;
|
|
83
|
-
readonly runUrl?: string | undefined;
|
|
84
|
-
readonly modelLabel?: string | undefined;
|
|
85
|
-
}
|
|
86
|
-
| {
|
|
87
|
-
readonly outcome: "failed";
|
|
88
|
-
readonly runUrl?: string | undefined;
|
|
89
|
-
readonly modelLabel?: string | undefined;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const footerLine = (options: {
|
|
93
|
-
readonly modelLabel?: string | undefined;
|
|
94
|
-
readonly runUrl?: string | undefined;
|
|
95
|
-
}): string => {
|
|
96
|
-
const parts = ["@effect-agent/pr-review"];
|
|
97
|
-
if (options.modelLabel !== undefined) parts.push(options.modelLabel);
|
|
98
|
-
if (options.runUrl !== undefined) parts.push(`[workflow run](${options.runUrl})`);
|
|
99
|
-
return `_${parts.join(" · ")}._`;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const scopeSentence = (info: ReviewProgressBegin): string => {
|
|
103
|
-
const subject =
|
|
104
|
-
info.filesInScope === undefined ? "this pull request" : `${info.filesInScope} changed file(s)`;
|
|
105
|
-
const at = info.headSha === undefined ? "" : ` at \`${info.headSha.slice(0, 7)}\``;
|
|
106
|
-
const scope =
|
|
107
|
-
info.reviewMode === undefined
|
|
108
|
-
? ""
|
|
109
|
-
: ` — ${info.reviewMode === "incremental" ? "incremental" : "full-diff"} scope${
|
|
110
|
-
info.reviewReason === undefined ? "" : `: ${info.reviewReason.slice(0, 1_000)}`
|
|
111
|
-
}`;
|
|
112
|
-
return `Reviewing ${subject}${at}${scope}.`;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
/** The "a run just started" body; the outcome update replaces it in place. */
|
|
116
|
-
export const renderProgressBeginBody = (info: ReviewProgressBegin, claim: ProgressClaim): string =>
|
|
117
|
-
[
|
|
118
|
-
"> 🔍 **Code review in progress…**",
|
|
119
|
-
">",
|
|
120
|
-
`> ${scopeSentence(info)}`,
|
|
121
|
-
"",
|
|
122
|
-
"_This comment is updated in place by each review run._",
|
|
123
|
-
"",
|
|
124
|
-
footerLine(info),
|
|
125
|
-
renderProgressClaimMarker(claim),
|
|
126
|
-
].join("\n");
|
|
127
|
-
|
|
128
|
-
const settleCallout = (info: ReviewProgressSettle): string => {
|
|
129
|
-
if (info.outcome === "failed") {
|
|
130
|
-
return "> ⚠️ **Code review run failed** — nothing was posted.";
|
|
131
|
-
}
|
|
132
|
-
switch (info.conclusion) {
|
|
133
|
-
case "success":
|
|
134
|
-
return `> ✅ **Code review posted** — verdict \`${info.verdict}\`, ${info.inlineComments} inline comment(s), nothing blocking.`;
|
|
135
|
-
case "blocking":
|
|
136
|
-
return `> 🛑 **Code review posted:** blocking review items; the check fails until they are addressed.`;
|
|
137
|
-
case "incomplete":
|
|
138
|
-
return `> ⚠️ **Code review posted** — input coverage or configured review assurance is incomplete, so the check fails.`;
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
/** The settled-outcome body written over the in-progress comment. */
|
|
143
|
-
export const renderProgressSettleBody = (
|
|
144
|
-
info: ReviewProgressSettle,
|
|
145
|
-
claim: ProgressClaim,
|
|
146
|
-
): string => {
|
|
147
|
-
const link =
|
|
148
|
-
info.outcome === "reviewed" && info.reviewUrl !== undefined
|
|
149
|
-
? `See the [posted review](${info.reviewUrl}).`
|
|
150
|
-
: info.runUrl !== undefined
|
|
151
|
-
? `See the [workflow run](${info.runUrl}) for details.`
|
|
152
|
-
: undefined;
|
|
153
|
-
return [
|
|
154
|
-
settleCallout(info),
|
|
155
|
-
...(link === undefined ? [] : ["", link]),
|
|
156
|
-
"",
|
|
157
|
-
footerLine(info),
|
|
158
|
-
renderProgressClaimMarker(claim),
|
|
159
|
-
].join("\n");
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Maintains the sticky progress comment. Both operations are infallible by
|
|
164
|
-
* contract: implementations own their fault handling, because progress
|
|
165
|
-
* reporting may never fail or delay the review run it narrates.
|
|
166
|
-
*/
|
|
167
|
-
export class ReviewProgressReporter extends Context.Service<
|
|
168
|
-
ReviewProgressReporter,
|
|
169
|
-
{
|
|
170
|
-
readonly begin: (info: ReviewProgressBegin) => Effect.Effect<void>;
|
|
171
|
-
readonly settle: (info: ReviewProgressSettle) => Effect.Effect<void>;
|
|
172
|
-
}
|
|
173
|
-
>()("@effect-agent/pr-review/ReviewProgressReporter") {}
|
|
174
|
-
|
|
175
|
-
/** Reports nothing; the substitute for hosts without a progress surface. */
|
|
176
|
-
export const noopReviewProgressReporterLayer: Layer.Layer<ReviewProgressReporter> = Layer.succeed(
|
|
177
|
-
ReviewProgressReporter,
|
|
178
|
-
ReviewProgressReporter.of({
|
|
179
|
-
begin: () => Effect.void,
|
|
180
|
-
settle: () => Effect.void,
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const GitHubIssueCommentWire = Schema.Struct({
|
|
185
|
-
id: Schema.Int,
|
|
186
|
-
body: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
187
|
-
user: Schema.optionalKey(
|
|
188
|
-
Schema.NullOr(Schema.Struct({ login: Schema.String, type: Schema.String })),
|
|
189
|
-
),
|
|
190
|
-
});
|
|
191
|
-
const GitHubIssueCommentsPageWire = Schema.Array(GitHubIssueCommentWire);
|
|
192
|
-
|
|
193
|
-
/** Issue comments page chronologically; the sticky-comment scan stays bounded. */
|
|
194
|
-
const MAX_PROGRESS_LOOKUP_PAGES = 5;
|
|
195
|
-
|
|
196
|
-
interface ProgressCandidate {
|
|
197
|
-
readonly id: number;
|
|
198
|
-
readonly body: string;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** The comment carrying the newest claim wins; unparseable claims lose ties. */
|
|
202
|
-
const pickNewestClaim = (
|
|
203
|
-
candidates: ReadonlyArray<ProgressCandidate>,
|
|
204
|
-
): ProgressCandidate | undefined => {
|
|
205
|
-
let newest: ProgressCandidate | undefined;
|
|
206
|
-
let newestStarted = Number.NEGATIVE_INFINITY;
|
|
207
|
-
for (const candidate of candidates) {
|
|
208
|
-
const started = parseProgressClaim(candidate.body)?.startedMillis ?? Number.NEGATIVE_INFINITY;
|
|
209
|
-
// >= keeps the LAST (chronologically newest) comment on ties.
|
|
210
|
-
if (newest === undefined || started >= newestStarted) {
|
|
211
|
-
newest = candidate;
|
|
212
|
-
newestStarted = started;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return newest;
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* GitHub-backed progress reporter over the issue-comments API. The sticky
|
|
220
|
-
* comment is found by its invisible marker AND the configured posting-bot
|
|
221
|
-
* identity — a marker pasted into someone else's comment is never edited.
|
|
222
|
-
* Writes are generation-fenced per the module contract above, and every
|
|
223
|
-
* fault (lookup, create, update, delete, bound exhaustion) degrades to a
|
|
224
|
-
* logged warning: a pull request without a progress comment is a cosmetic
|
|
225
|
-
* loss, a failed review run over a cosmetic fault would not be.
|
|
226
|
-
*/
|
|
227
|
-
export const gitHubReviewProgressLayer: Layer.Layer<
|
|
228
|
-
ReviewProgressReporter,
|
|
229
|
-
never,
|
|
230
|
-
GitHubReviewTarget | HttpClient.HttpClient
|
|
231
|
-
> = Layer.effect(ReviewProgressReporter)(
|
|
232
|
-
Effect.gen(function* () {
|
|
233
|
-
const target = yield* GitHubReviewTarget;
|
|
234
|
-
const client = yield* HttpClient.HttpClient;
|
|
235
|
-
const started = yield* DateTime.now;
|
|
236
|
-
const claim: ProgressClaim = {
|
|
237
|
-
runToken: globalThis.crypto.randomUUID(),
|
|
238
|
-
startedMillis: DateTime.toEpochMillis(started),
|
|
239
|
-
};
|
|
240
|
-
const knownCommentId = yield* Ref.make(Option.none<number>());
|
|
241
|
-
const authorLogin = (
|
|
242
|
-
target.reviewAuthorLogin ?? DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN
|
|
243
|
-
).toLowerCase();
|
|
244
|
-
const issuePrefix = `${target.apiUrl}/repos/${target.repository}/issues`;
|
|
245
|
-
|
|
246
|
-
/** May this run overwrite a comment currently carrying `body`? */
|
|
247
|
-
const canClaim = (body: string): boolean => {
|
|
248
|
-
const existing = parseProgressClaim(body);
|
|
249
|
-
if (existing === undefined) return true;
|
|
250
|
-
return existing.runToken === claim.runToken || claim.startedMillis >= existing.startedMillis;
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const withHeaders = (request: HttpClientRequest.HttpClientRequest) => {
|
|
254
|
-
const base = request.pipe(
|
|
255
|
-
HttpClientRequest.setHeaders({
|
|
256
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
257
|
-
"User-Agent": "effect-agent-pr-review",
|
|
258
|
-
}),
|
|
259
|
-
HttpClientRequest.acceptJson,
|
|
260
|
-
);
|
|
261
|
-
return Option.isSome(target.token)
|
|
262
|
-
? base.pipe(HttpClientRequest.bearerToken(target.token.value))
|
|
263
|
-
: base;
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
const asApiFailure =
|
|
267
|
-
(operation: string) =>
|
|
268
|
-
(error: { readonly _tag: string; readonly message?: string }): GitHubApiFailure =>
|
|
269
|
-
GitHubApiFailure.make({
|
|
270
|
-
operation,
|
|
271
|
-
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
275
|
-
HttpClient.execute(request).pipe(
|
|
276
|
-
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
277
|
-
Effect.mapError(asApiFailure(operation)),
|
|
278
|
-
Effect.provideService(HttpClient.HttpClient, client),
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
const decodeJson = <S extends Schema.Top>(schema: S, operation: string) => {
|
|
282
|
-
const decode = Schema.decodeUnknownEffect(schema);
|
|
283
|
-
return (response: HttpClientResponse.HttpClientResponse) =>
|
|
284
|
-
response.json.pipe(
|
|
285
|
-
Effect.mapError(asApiFailure(operation)),
|
|
286
|
-
Effect.flatMap((payload) =>
|
|
287
|
-
decode(payload).pipe(Effect.mapError(asApiFailure(operation))),
|
|
288
|
-
),
|
|
289
|
-
);
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const findCandidates = Effect.gen(function* () {
|
|
293
|
-
const perPage = 100;
|
|
294
|
-
const found: Array<ProgressCandidate> = [];
|
|
295
|
-
for (let page = 1; page <= MAX_PROGRESS_LOOKUP_PAGES; page += 1) {
|
|
296
|
-
const response = yield* execute(
|
|
297
|
-
"listProgressComments",
|
|
298
|
-
withHeaders(
|
|
299
|
-
HttpClientRequest.get(`${issuePrefix}/${target.number}/comments`).pipe(
|
|
300
|
-
HttpClientRequest.setUrlParams({
|
|
301
|
-
per_page: String(perPage),
|
|
302
|
-
page: String(page),
|
|
303
|
-
}),
|
|
304
|
-
),
|
|
305
|
-
),
|
|
306
|
-
);
|
|
307
|
-
const wires = yield* decodeJson(
|
|
308
|
-
GitHubIssueCommentsPageWire,
|
|
309
|
-
"listProgressComments",
|
|
310
|
-
)(response);
|
|
311
|
-
for (const wire of wires) {
|
|
312
|
-
if (
|
|
313
|
-
wire.user?.login.toLowerCase() === authorLogin &&
|
|
314
|
-
wire.user.type === "Bot" &&
|
|
315
|
-
(wire.body ?? "").includes(PROGRESS_COMMENT_MARKER_PREFIX)
|
|
316
|
-
) {
|
|
317
|
-
found.push({ id: wire.id, body: wire.body ?? "" });
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
if (wires.length < perPage) return found as ReadonlyArray<ProgressCandidate>;
|
|
321
|
-
}
|
|
322
|
-
return yield* GitHubApiFailure.make({
|
|
323
|
-
operation: "listProgressComments",
|
|
324
|
-
reason: `comment history exceeds the bounded ${MAX_PROGRESS_LOOKUP_PAGES * 100}-comment lookup`,
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
const readComment = (commentId: number) =>
|
|
329
|
-
execute(
|
|
330
|
-
"readProgressComment",
|
|
331
|
-
withHeaders(HttpClientRequest.get(`${issuePrefix}/comments/${commentId}`)),
|
|
332
|
-
).pipe(
|
|
333
|
-
Effect.flatMap(decodeJson(GitHubIssueCommentWire, "readProgressComment")),
|
|
334
|
-
Effect.map((wire) => wire.body ?? ""),
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
const create = (body: string) =>
|
|
338
|
-
execute(
|
|
339
|
-
"createProgressComment",
|
|
340
|
-
withHeaders(
|
|
341
|
-
HttpClientRequest.post(`${issuePrefix}/${target.number}/comments`).pipe(
|
|
342
|
-
HttpClientRequest.bodyJsonUnsafe({ body }),
|
|
343
|
-
),
|
|
344
|
-
),
|
|
345
|
-
).pipe(
|
|
346
|
-
Effect.flatMap(decodeJson(GitHubIssueCommentWire, "createProgressComment")),
|
|
347
|
-
Effect.map((wire) => wire.id),
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
const update = (commentId: number, body: string) =>
|
|
351
|
-
execute(
|
|
352
|
-
"updateProgressComment",
|
|
353
|
-
withHeaders(
|
|
354
|
-
HttpClientRequest.patch(`${issuePrefix}/comments/${commentId}`).pipe(
|
|
355
|
-
HttpClientRequest.bodyJsonUnsafe({ body }),
|
|
356
|
-
),
|
|
357
|
-
),
|
|
358
|
-
).pipe(Effect.asVoid);
|
|
359
|
-
|
|
360
|
-
const deleteComment = (commentId: number) =>
|
|
361
|
-
execute(
|
|
362
|
-
"deleteProgressComment",
|
|
363
|
-
withHeaders(HttpClientRequest.delete(`${issuePrefix}/comments/${commentId}`)),
|
|
364
|
-
).pipe(Effect.asVoid);
|
|
365
|
-
|
|
366
|
-
/** Re-read, fence, then write: a stale run must not replace newer status. */
|
|
367
|
-
const guardedUpdate = Effect.fn("ReviewProgressReporter.guardedUpdate")(function* (
|
|
368
|
-
commentId: number,
|
|
369
|
-
body: string,
|
|
370
|
-
) {
|
|
371
|
-
const current = yield* readComment(commentId);
|
|
372
|
-
if (!canClaim(current)) {
|
|
373
|
-
return yield* Effect.logDebug(
|
|
374
|
-
"review progress comment is owned by a newer run; leaving it untouched",
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
yield* update(commentId, body);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const upsert = Effect.fn("ReviewProgressReporter.upsert")(function* (body: string) {
|
|
381
|
-
const cached = yield* Ref.get(knownCommentId);
|
|
382
|
-
if (Option.isSome(cached)) {
|
|
383
|
-
return yield* guardedUpdate(cached.value, body);
|
|
384
|
-
}
|
|
385
|
-
const candidates = yield* findCandidates;
|
|
386
|
-
const newest = pickNewestClaim(candidates);
|
|
387
|
-
if (newest === undefined) {
|
|
388
|
-
const created = yield* create(body);
|
|
389
|
-
yield* Ref.set(knownCommentId, Option.some(created));
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
// Reconcile duplicates left by unfenced overlapping runs: keep the
|
|
393
|
-
// newest claim, best-effort delete the rest (each fault only logged).
|
|
394
|
-
yield* Effect.forEach(
|
|
395
|
-
candidates.filter((candidate) => candidate.id !== newest.id),
|
|
396
|
-
(duplicate) =>
|
|
397
|
-
deleteComment(duplicate.id).pipe(
|
|
398
|
-
Effect.catch((failure) =>
|
|
399
|
-
Effect.logWarning("duplicate review progress comment could not be deleted").pipe(
|
|
400
|
-
Effect.annotateLogs({ commentId: duplicate.id, reason: failure.reason }),
|
|
401
|
-
),
|
|
402
|
-
),
|
|
403
|
-
),
|
|
404
|
-
{ discard: true },
|
|
405
|
-
);
|
|
406
|
-
yield* Ref.set(knownCommentId, Option.some(newest.id));
|
|
407
|
-
if (!canClaim(newest.body)) {
|
|
408
|
-
return yield* Effect.logDebug(
|
|
409
|
-
"review progress comment is owned by a newer run; leaving it untouched",
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
yield* update(newest.id, body);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
const failOpen = (phase: string) => (effect: Effect.Effect<void, GitHubApiFailure>) =>
|
|
416
|
-
effect.pipe(
|
|
417
|
-
Effect.catch((failure) =>
|
|
418
|
-
Effect.logWarning("review progress comment update failed").pipe(
|
|
419
|
-
Effect.annotateLogs({
|
|
420
|
-
progressPhase: phase,
|
|
421
|
-
operation: failure.operation,
|
|
422
|
-
reason: failure.reason,
|
|
423
|
-
}),
|
|
424
|
-
),
|
|
425
|
-
),
|
|
426
|
-
);
|
|
427
|
-
|
|
428
|
-
return ReviewProgressReporter.of({
|
|
429
|
-
begin: (info) => upsert(renderProgressBeginBody(info, claim)).pipe(failOpen("begin")),
|
|
430
|
-
settle: (info) => upsert(renderProgressSettleBody(info, claim)).pipe(failOpen("settle")),
|
|
431
|
-
});
|
|
432
|
-
}),
|
|
433
|
-
);
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
|
|
2
|
-
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
|
|
3
|
-
import { Config, Effect, Layer, Schema } from "effect";
|
|
4
|
-
import { FetchHttpClient } from "effect/unstable/http";
|
|
5
|
-
|
|
6
|
-
import { resolveEffortRung, type EffortAliasName, type EffortPosition } from "./effort.ts";
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Built-in provider bindings for the two host entrypoints (CLI and Action).
|
|
10
|
-
// The library itself stays provider-agnostic — the configuration factory
|
|
11
|
-
// takes any Effect AI Model — and these helpers exist so the batteries-
|
|
12
|
-
// included paths need one flag and one credential, nothing more. Client
|
|
13
|
-
// Layers carry their redacted credentials from configuration; the
|
|
14
|
-
// application supplies them at the edge (D-027).
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
export const ReviewProvider = Schema.Literals(["openai", "anthropic"]);
|
|
18
|
-
export type ReviewProvider = typeof ReviewProvider.Type;
|
|
19
|
-
|
|
20
|
-
/** OpenAI Responses service tiers supported by the packaged reviewer. */
|
|
21
|
-
export const OpenAiServiceTier = Schema.Literal("fast");
|
|
22
|
-
export type OpenAiServiceTier = typeof OpenAiServiceTier.Type;
|
|
23
|
-
|
|
24
|
-
/** A provider-specific OpenAI tier was configured for another provider. */
|
|
25
|
-
export class UnsupportedServiceTierProvider extends Schema.TaggedError<UnsupportedServiceTierProvider>()(
|
|
26
|
-
"UnsupportedServiceTierProvider",
|
|
27
|
-
{
|
|
28
|
-
provider: ReviewProvider,
|
|
29
|
-
serviceTier: OpenAiServiceTier,
|
|
30
|
-
},
|
|
31
|
-
) {
|
|
32
|
-
override get message() {
|
|
33
|
-
return `Service tier '${this.serviceTier}' requires provider 'openai'; received '${this.provider}'.`;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Reject an OpenAI-only service tier before constructing another provider's model. */
|
|
38
|
-
export const validateReviewServiceTier = Effect.fn("validateReviewServiceTier")(function* (
|
|
39
|
-
provider: ReviewProvider,
|
|
40
|
-
serviceTier: OpenAiServiceTier | undefined,
|
|
41
|
-
): Effect.fn.Return<OpenAiServiceTier | undefined, UnsupportedServiceTierProvider> {
|
|
42
|
-
if (serviceTier !== undefined && provider !== "openai") {
|
|
43
|
-
return yield* UnsupportedServiceTierProvider.make({ provider, serviceTier });
|
|
44
|
-
}
|
|
45
|
-
return serviceTier;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
export const DEFAULT_PROVIDER: ReviewProvider = "openai";
|
|
49
|
-
|
|
50
|
-
export const DEFAULT_MODEL: Record<ReviewProvider, string> = {
|
|
51
|
-
openai: "gpt-5.6-sol",
|
|
52
|
-
anthropic: "claude-sonnet-5",
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const PROVIDER_CREDENTIAL_ENV: Record<ReviewProvider, string> = {
|
|
56
|
-
openai: "OPENAI_API_KEY",
|
|
57
|
-
anthropic: "ANTHROPIC_API_KEY",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Each provider's offered reasoning-effort ladder, cheapest first. The rungs
|
|
62
|
-
* that turn reasoning off (`none`, `minimal`) are deliberately not offered —
|
|
63
|
-
* no review run wants them. An `EffortPosition` resolves into the running
|
|
64
|
-
* provider's own ladder, so the same stored position survives a provider or
|
|
65
|
-
* model change.
|
|
66
|
-
*/
|
|
67
|
-
export const PROVIDER_EFFORT_RUNGS = {
|
|
68
|
-
openai: ["low", "medium", "high", "xhigh"],
|
|
69
|
-
anthropic: ["low", "medium", "high"],
|
|
70
|
-
} as const satisfies Record<
|
|
71
|
-
ReviewProvider,
|
|
72
|
-
readonly [EffortAliasName, ...ReadonlyArray<EffortAliasName>]
|
|
73
|
-
>;
|
|
74
|
-
|
|
75
|
-
/** One OpenAI review model binding with the package's structured-output settings. */
|
|
76
|
-
export const makeOpenAiReviewModel = (
|
|
77
|
-
model?: string,
|
|
78
|
-
effort?: EffortPosition,
|
|
79
|
-
serviceTier?: OpenAiServiceTier,
|
|
80
|
-
) =>
|
|
81
|
-
OpenAiLanguageModel.model(model ?? DEFAULT_MODEL.openai, {
|
|
82
|
-
// OpenAI counts hidden reasoning tokens and visible answer tokens against
|
|
83
|
-
// this same ceiling. High-effort reviews can exhaust an 8k allowance
|
|
84
|
-
// after reading every file but before emitting their structured report.
|
|
85
|
-
max_output_tokens: 32_000,
|
|
86
|
-
store: false,
|
|
87
|
-
strictJsonSchema: true,
|
|
88
|
-
...(serviceTier === undefined ? {} : { service_tier: serviceTier }),
|
|
89
|
-
...(effort === undefined
|
|
90
|
-
? {}
|
|
91
|
-
: { reasoning: { effort: resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS.openai) } }),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
/** One Anthropic review model binding with the package's output settings. */
|
|
95
|
-
export const makeAnthropicReviewModel = (model?: string, effort?: EffortPosition) =>
|
|
96
|
-
AnthropicLanguageModel.model(model ?? DEFAULT_MODEL.anthropic, {
|
|
97
|
-
max_tokens: 8_000,
|
|
98
|
-
...(effort === undefined
|
|
99
|
-
? {}
|
|
100
|
-
: { output_config: { effort: resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS.anthropic) } }),
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The human-readable descriptor of one provider binding, e.g.
|
|
105
|
-
* `openai/gpt-5.6-sol (effort high, service tier fast)`. Rendered into the review footer and
|
|
106
|
-
* included in the changeset-fingerprint signature, so a provider, model, or
|
|
107
|
-
* request-profile change re-reviews instead of skipping.
|
|
108
|
-
*/
|
|
109
|
-
export const describeReviewModel = (
|
|
110
|
-
provider: ReviewProvider,
|
|
111
|
-
model?: string,
|
|
112
|
-
effort?: EffortPosition,
|
|
113
|
-
serviceTier?: OpenAiServiceTier,
|
|
114
|
-
): string => {
|
|
115
|
-
const base = `${provider}/${model ?? DEFAULT_MODEL[provider]}`;
|
|
116
|
-
const details = [
|
|
117
|
-
...(effort === undefined
|
|
118
|
-
? []
|
|
119
|
-
: [`effort ${resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS[provider])}`]),
|
|
120
|
-
...(serviceTier === undefined ? [] : [`service tier ${serviceTier}`]),
|
|
121
|
-
];
|
|
122
|
-
return details.length === 0 ? base : `${base} (${details.join(", ")})`;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
/** The OpenAI client Layer, credential from `OPENAI_API_KEY`. */
|
|
126
|
-
export const openAiClientLayer = OpenAiClient.layerConfig({
|
|
127
|
-
apiKey: Config.redacted(PROVIDER_CREDENTIAL_ENV.openai),
|
|
128
|
-
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
129
|
-
|
|
130
|
-
/** The Anthropic client Layer, credential from `ANTHROPIC_API_KEY`. */
|
|
131
|
-
export const anthropicClientLayer = AnthropicClient.layerConfig({
|
|
132
|
-
apiKey: Config.redacted(PROVIDER_CREDENTIAL_ENV.anthropic),
|
|
133
|
-
}).pipe(Layer.provide(FetchHttpClient.layer));
|