@effect-agent/pr-review 0.1.0-beta.11 → 0.1.0-beta.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +181 -172
  3. package/dist/Review.d.mts +294 -0
  4. package/dist/Review.mjs +738 -0
  5. package/dist/Review.mjs.map +1 -0
  6. package/dist/ReviewRepository-Wd_4qCaO.d.mts +71 -0
  7. package/dist/ReviewRepository.d.mts +2 -0
  8. package/dist/ReviewRepository.mjs +15 -0
  9. package/dist/ReviewRepository.mjs.map +1 -0
  10. package/dist/index.d.mts +3 -718
  11. package/dist/index.mjs +3 -66
  12. package/dist/repository-D7NN3225.mjs +101 -0
  13. package/dist/repository-D7NN3225.mjs.map +1 -0
  14. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  15. package/package.json +1 -54
  16. package/src/Review.ts +1111 -0
  17. package/src/ReviewRepository.ts +9 -0
  18. package/src/index.ts +2 -21
  19. package/src/internal/repository.ts +156 -0
  20. package/dist/action.d.mts +0 -190
  21. package/dist/action.mjs +0 -423
  22. package/dist/action.mjs.map +0 -1
  23. package/dist/cli.d.mts +0 -1
  24. package/dist/cli.mjs +0 -103
  25. package/dist/cli.mjs.map +0 -1
  26. package/dist/fan-out-TrA9EUCr.d.mts +0 -1085
  27. package/dist/github-5TCFrxfX.mjs +0 -1676
  28. package/dist/github-5TCFrxfX.mjs.map +0 -1
  29. package/dist/index.mjs.map +0 -1
  30. package/dist/providers-DobNWMUn.mjs +0 -990
  31. package/dist/providers-DobNWMUn.mjs.map +0 -1
  32. package/dist/testing.d.mts +0 -131
  33. package/dist/testing.mjs +0 -230
  34. package/dist/testing.mjs.map +0 -1
  35. package/src/action.ts +0 -697
  36. package/src/cli.ts +0 -214
  37. package/src/internal/action-entry.ts +0 -43
  38. package/src/internal/coverage.ts +0 -245
  39. package/src/internal/diff.ts +0 -134
  40. package/src/internal/effort.ts +0 -86
  41. package/src/internal/factory.ts +0 -374
  42. package/src/internal/fan-out-scripted.ts +0 -164
  43. package/src/internal/fan-out.ts +0 -450
  44. package/src/internal/fingerprint.ts +0 -74
  45. package/src/internal/fixtures.ts +0 -131
  46. package/src/internal/github-env.ts +0 -148
  47. package/src/internal/github.ts +0 -774
  48. package/src/internal/ignore.ts +0 -88
  49. package/src/internal/profiles.ts +0 -79
  50. package/src/internal/providers.ts +0 -94
  51. package/src/internal/render.ts +0 -428
  52. package/src/internal/retirement.ts +0 -332
  53. package/src/internal/review-agent.ts +0 -385
  54. package/src/internal/review-state.ts +0 -488
  55. package/src/internal/review-units.ts +0 -167
  56. package/src/internal/run.ts +0 -397
  57. package/src/internal/scripted.ts +0 -108
  58. package/src/internal/source.ts +0 -110
  59. package/src/testing.ts +0 -8
@@ -1,428 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- import type { ReviewCoverage } from "./coverage.ts";
4
- import { commentableLines, type ChangedFile } from "./diff.ts";
5
- import { renderFingerprintMarker } from "./fingerprint.ts";
6
- import { ReviewFinding, type CodeReview, type ReviewConcern } from "./review-agent.ts";
7
- import type { ReviewScopeMode, ReviewStateMarker } from "./review-state.ts";
8
-
9
- // ---------------------------------------------------------------------------
10
- // Publication planning: pure, deterministic, and fail-closed. Model output is
11
- // untrusted input, so every finding anchor is validated against the parsed
12
- // diff before it may become an inline comment; findings that fail validation
13
- // are demoted into the review body instead of being dropped or trusted. This
14
- // module is deliberately not configurable — customization widens what goes
15
- // into a review, never what leaves it unvalidated.
16
- // ---------------------------------------------------------------------------
17
-
18
- export const ReviewEvent = Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]);
19
- export type ReviewEvent = typeof ReviewEvent.Type;
20
-
21
- /** One inline comment exactly as the GitHub review API accepts it. */
22
- export class ReviewCommentDraft extends Schema.Class<ReviewCommentDraft>(
23
- "@effect-agent/pr-review/ReviewCommentDraft",
24
- )({
25
- path: Schema.NonEmptyString,
26
- /** The last (or only) commented line, RIGHT side of the diff. */
27
- line: Schema.Int.check(Schema.isGreaterThan(0)),
28
- /** Present only for multi-line comments; strictly less than `line`. */
29
- startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
30
- body: Schema.NonEmptyString,
31
- }) {}
32
-
33
- /** The complete, validated review ready for one GitHub reviews API call. */
34
- export class ReviewPublicationPlan extends Schema.Class<ReviewPublicationPlan>(
35
- "@effect-agent/pr-review/ReviewPublicationPlan",
36
- )({
37
- event: ReviewEvent,
38
- body: Schema.String.check(Schema.isMaxLength(60_000)),
39
- comments: Schema.Array(ReviewCommentDraft),
40
- /** Findings whose anchors failed diff validation; folded into `body`. */
41
- demoted: Schema.Array(ReviewFinding),
42
- /** The head commit the diffs were fetched at; pins the posted review. */
43
- commitSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
44
- }) {}
45
-
46
- const severityEmoji: Record<ReviewFinding["severity"], string> = {
47
- blocking: "🛑",
48
- important: "⚠️",
49
- nit: "💅",
50
- };
51
-
52
- const severityRank: Record<ReviewFinding["severity"], number> = {
53
- blocking: 0,
54
- important: 1,
55
- nit: 2,
56
- };
57
-
58
- const severityLabel: Record<ReviewFinding["severity"], string> = {
59
- blocking: `${severityEmoji.blocking} blocking`,
60
- important: `${severityEmoji.important} important`,
61
- nit: `${severityEmoji.nit} nit`,
62
- };
63
-
64
- /** A fence long enough that the suggestion content can never close it early. */
65
- const suggestionFence = (suggestion: string): string => {
66
- let fence = "```";
67
- while (suggestion.includes(fence)) fence = `${fence}\``;
68
- return fence;
69
- };
70
-
71
- const renderCommentBody = (finding: ReviewFinding): string => {
72
- const parts = [`**[${severityLabel[finding.severity]}] ${finding.title}**`, "", finding.body];
73
- if (finding.suggestion !== undefined) {
74
- const fence = suggestionFence(finding.suggestion);
75
- parts.push("", `${fence}suggestion`, finding.suggestion, fence);
76
- }
77
- return parts.join("\n");
78
- };
79
-
80
- const renderDemoted = (finding: ReviewFinding, reason: string): string => {
81
- const location = `\`${finding.path}:${finding.startLine}${
82
- finding.endLine !== finding.startLine ? `-${finding.endLine}` : ""
83
- }\``;
84
- return `- ${location} **[${severityLabel[finding.severity]}] ${finding.title}** — ${finding.body} _(demoted: ${reason})_`;
85
- };
86
-
87
- const countNoun = (count: number, noun: string): string =>
88
- `${count} ${noun}${count === 1 ? "" : "s"}`;
89
-
90
- /** The validated finding + concern severities, tallied for callout and event. */
91
- const severityCounts = (
92
- review: CodeReview,
93
- carriedFindings: ReadonlyArray<ReviewFinding> = [],
94
- carriedConcerns: ReadonlyArray<ReviewConcern> = [],
95
- ) => {
96
- const severities = [
97
- ...review.findings.map((finding) => finding.severity),
98
- ...(review.concerns ?? []).map((concern) => concern.severity),
99
- ...carriedFindings.map((finding) => finding.severity),
100
- ...carriedConcerns.map((concern) => concern.severity),
101
- ];
102
- return {
103
- blocking: severities.filter((severity) => severity === "blocking").length,
104
- important: severities.filter((severity) => severity === "important").length,
105
- total: severities.length,
106
- };
107
- };
108
-
109
- /**
110
- * The opening callout: the review's overall tier, derived HOST-SIDE from the
111
- * validated severities (never from model prose), described by what GitHub
112
- * renders it as. `[!CAUTION]` is a red banner, `[!IMPORTANT]` a purple one;
113
- * the blockquote tiers read as informational.
114
- */
115
- const renderVerdictCallout = (
116
- review: CodeReview,
117
- options: {
118
- readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
119
- readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
120
- readonly coverage?: ReviewCoverage | undefined;
121
- },
122
- ): string => {
123
- const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
124
- if (options.coverage?.status === "incomplete") {
125
- const suffix =
126
- counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
127
- return `> [!CAUTION]\n> Review coverage is incomplete — the check must not pass.${suffix}`;
128
- }
129
- if (counts.blocking > 0) {
130
- return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
131
- }
132
- if (counts.important > 0) {
133
- return `> [!IMPORTANT]\n> ${countNoun(counts.important, "important finding")} to address before merging.`;
134
- }
135
- if (counts.total > 0) {
136
- return "> ℹ️ Minor suggestions only — mergeable as-is.";
137
- }
138
- return review.verdict === "approve"
139
- ? "> ✅ No issues found."
140
- : "> ℹ️ No findings — see the summary.";
141
- };
142
-
143
- const renderConcern = (concern: ReviewConcern): string =>
144
- [`### ${severityEmoji[concern.severity]} ${concern.title}`, "", concern.body].join("\n");
145
-
146
- const renderCarriedFinding = (finding: ReviewFinding): string =>
147
- `- \`${finding.path}:${finding.startLine}${finding.endLine === finding.startLine ? "" : `-${finding.endLine}`}\` **[${severityLabel[finding.severity]}] ${finding.title}** — ${finding.body}`;
148
-
149
- /** HTML comments must not contain `--`; interpolated values are sanitized. */
150
- const commentSafe = (value: string): string => value.replaceAll("--", "- -");
151
-
152
- /**
153
- * The invisible staleness note addressed to whoever reads the review later —
154
- * a human or a downstream agent: which commit the findings were written
155
- * against, and that line callouts age the moment new commits land.
156
- */
157
- const renderReviewMetadata = (options: {
158
- readonly headSha: string;
159
- readonly baseRef?: string | undefined;
160
- readonly headRef?: string | undefined;
161
- readonly filesVisible: number;
162
- readonly totalChangedFiles: number;
163
- readonly reviewMode?: ReviewScopeMode | undefined;
164
- readonly baselineSha?: string | undefined;
165
- }): string =>
166
- [
167
- "<!-- effect-agent-pr-review metadata",
168
- `reviewed-head: ${commentSafe(options.headSha)}`,
169
- ...(options.baseRef !== undefined && options.headRef !== undefined
170
- ? [`base-ref: ${commentSafe(options.baseRef)}`, `head-ref: ${commentSafe(options.headRef)}`]
171
- : []),
172
- // The observation surface, not a coverage claim: the host cannot know
173
- // which visible files the model actually examined, and the summary is
174
- // where unreviewed units are named.
175
- `files-visible: ${options.filesVisible} of ${options.totalChangedFiles}`,
176
- ...(options.reviewMode === undefined ? [] : [`review-mode: ${options.reviewMode}`]),
177
- ...(options.baselineSha === undefined
178
- ? []
179
- : [`incremental-baseline: ${commentSafe(options.baselineSha)}`]),
180
- "Findings were written against the head commit above; if commits have landed",
181
- "since, treat file and line callouts as potentially stale and re-diff first.",
182
- "-->",
183
- ].join("\n");
184
-
185
- /**
186
- * Why one finding cannot become an inline comment, or undefined when it can.
187
- * Exported so tests can pin each rule individually.
188
- */
189
- export const anchorViolation = (
190
- finding: ReviewFinding,
191
- files: ReadonlyArray<ChangedFile>,
192
- ): string | undefined => {
193
- const file = files.find((candidate) => candidate.path === finding.path);
194
- if (file === undefined) return "path is not part of the changeset";
195
- if (file.patch === undefined) return "file has no textual diff";
196
- if (finding.endLine < finding.startLine) return "endLine precedes startLine";
197
- if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
198
- const anchors = commentableLines(file.patch);
199
- for (let line = finding.startLine; line <= finding.endLine; line += 1) {
200
- if (!anchors.has(line)) return `line ${line} is not part of the diff`;
201
- }
202
- return undefined;
203
- };
204
-
205
- /**
206
- * Turn one validated review into the exact GitHub publication payload.
207
- * `applyVerdict: false` (the safe default) always posts a COMMENT review;
208
- * `true` maps the model's verdict onto APPROVE / REQUEST_CHANGES.
209
- */
210
- export const planPublication = (
211
- review: CodeReview,
212
- files: ReadonlyArray<ChangedFile>,
213
- options: {
214
- readonly applyVerdict: boolean;
215
- /** Head commit the changeset was fetched at (pins the posted review). */
216
- readonly headSha: string;
217
- /** GitHub's changed-file total, for honest truncation reporting. */
218
- readonly totalChangedFiles: number;
219
- /** Base/head refs for the staleness metadata comment. */
220
- readonly baseRef?: string | undefined;
221
- readonly headRef?: string | undefined;
222
- /** Provider binding descriptor rendered into the footer. */
223
- readonly modelLabel?: string | undefined;
224
- /** Workflow-run URL rendered into the footer. */
225
- readonly runUrl?: string | undefined;
226
- /** Observed run usage rendered into the footer. */
227
- readonly usage?: { readonly inputTokens: number; readonly outputTokens: number } | undefined;
228
- /** What the usage observed: the whole run, or the coordinator only. */
229
- readonly usageScope?: "run" | "coordinator" | undefined;
230
- /**
231
- * Changeset fingerprint embedded invisibly in the review body so a later
232
- * run can skip re-reviewing an unchanged changeset.
233
- */
234
- readonly fingerprint?: string | undefined;
235
- /** Host-owned coverage; incomplete coverage is rendered and fails the check. */
236
- readonly coverage?: ReviewCoverage | undefined;
237
- /** Unchanged unresolved items carried from the prior successfully reviewed head. */
238
- readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
239
- readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
240
- /** Selected review scope, made visible whenever orchestration chose it. */
241
- readonly reviewMode?: ReviewScopeMode | undefined;
242
- readonly reviewReason?: string | undefined;
243
- readonly baselineSha?: string | undefined;
244
- readonly reviewFilesVisible?: number | undefined;
245
- readonly reviewTotalFiles?: number | undefined;
246
- /** Authenticated continuity state is emitted only after complete host-owned coverage. */
247
- readonly stateMarker?: ReviewStateMarker | undefined;
248
- /** Visible reason continuity state was omitted; the next run will review fully. */
249
- readonly stateNotice?: string | undefined;
250
- },
251
- ): ReviewPublicationPlan => {
252
- const comments: Array<ReviewCommentDraft> = [];
253
- const demoted: Array<{ readonly finding: ReviewFinding; readonly reason: string }> = [];
254
- for (const finding of review.findings) {
255
- const violation = anchorViolation(finding, files);
256
- if (violation === undefined) {
257
- comments.push(
258
- ReviewCommentDraft.make({
259
- path: finding.path,
260
- line: finding.endLine,
261
- ...(finding.endLine > finding.startLine ? { startLine: finding.startLine } : {}),
262
- body: renderCommentBody(finding),
263
- }),
264
- );
265
- } else {
266
- demoted.push({ finding, reason: violation });
267
- }
268
- }
269
-
270
- // Rendered most-severe first so the size cap below sheds the least severe.
271
- const sortedConcerns = [...(review.concerns ?? [])].sort(
272
- (a, b) => severityRank[a.severity] - severityRank[b.severity],
273
- );
274
- const sortedDemoted = [...demoted].sort(
275
- (a, b) => severityRank[a.finding.severity] - severityRank[b.finding.severity],
276
- );
277
-
278
- const footerParts = ["Automated review by @effect-agent/pr-review"];
279
- if (options.modelLabel !== undefined) footerParts.push(options.modelLabel);
280
- // Usage renders only under an EXPLICIT scope: this planner cannot know
281
- // whether a budget snapshot observed the whole run or only a fan-out
282
- // coordinator, and omitting the number is honest where mislabeling is not.
283
- if (options.usage !== undefined && options.usageScope !== undefined) {
284
- const scope = options.usageScope === "coordinator" ? " (coordinator)" : "";
285
- footerParts.push(
286
- `${options.usage.inputTokens} in / ${options.usage.outputTokens} out tokens${scope}`,
287
- );
288
- }
289
- if (options.runUrl !== undefined) footerParts.push(`[run](${options.runUrl})`);
290
- footerParts.push(`reviewed at ${options.headSha.slice(0, 7)}`);
291
- const footer = `_${footerParts.join(" · ")}._`;
292
-
293
- const renderHead = (concernsKept: number, demotedKept: number, omitted: number): string => {
294
- const carriedFindings = options.carriedFindings ?? [];
295
- const carriedConcerns = options.carriedConcerns ?? [];
296
- const parts = [
297
- renderVerdictCallout(review, {
298
- carriedFindings,
299
- carriedConcerns,
300
- coverage: options.coverage,
301
- }),
302
- ];
303
- if (options.reviewMode !== undefined && options.reviewReason !== undefined) {
304
- parts.push(
305
- "",
306
- options.reviewMode === "incremental"
307
- ? `**Incremental scope:** reviewed ${options.reviewFilesVisible ?? files.length} file(s) ${options.reviewReason}. Unchanged accepted scope was preserved and not reopened.`
308
- : `**Full-diff scope:** ${options.reviewReason}.`,
309
- );
310
- }
311
- if (options.stateNotice !== undefined) {
312
- parts.push(
313
- "",
314
- `⚠️ Continuity state was not stored (${options.stateNotice.slice(0, 1_000)}); the next run will safely review the full diff.`,
315
- );
316
- }
317
- parts.push("", review.summary);
318
- if (options.coverage?.status === "incomplete") {
319
- parts.push(
320
- "",
321
- "### 🛑 Incomplete coverage",
322
- "",
323
- ...options.coverage.reasons.map((reason) => `- ${reason}`),
324
- );
325
- }
326
- if (carriedFindings.length > 0) {
327
- parts.push(
328
- "",
329
- "### Unresolved findings carried from unchanged scope",
330
- "",
331
- ...carriedFindings.map(renderCarriedFinding),
332
- );
333
- }
334
- if (carriedConcerns.length > 0) {
335
- parts.push("", "### Unresolved concerns carried to the final audit");
336
- for (const concern of carriedConcerns) parts.push("", renderConcern(concern));
337
- }
338
- for (const concern of sortedConcerns.slice(0, concernsKept)) {
339
- parts.push("", renderConcern(concern));
340
- }
341
- if (files.length < options.totalChangedFiles) {
342
- parts.push(
343
- "",
344
- `⚠️ Reviewed ${files.length} of ${options.totalChangedFiles} changed files — the changeset exceeded the reviewer's file bound.`,
345
- );
346
- }
347
- if (demotedKept > 0) {
348
- parts.push(
349
- "",
350
- "### Findings without a valid diff anchor",
351
- ...sortedDemoted
352
- .slice(0, demotedKept)
353
- .map(({ finding, reason }) => renderDemoted(finding, reason)),
354
- );
355
- }
356
- if (omitted > 0) {
357
- parts.push(
358
- "",
359
- `⚠️ ${countNoun(omitted, "review item")} omitted — the body exceeded GitHub's review size cap.`,
360
- );
361
- }
362
- parts.push("", footer);
363
- return parts.join("\n");
364
- };
365
-
366
- // The model's verdict may not contradict the reported severities (model
367
- // output is untrusted input): any blocking item forces REQUEST_CHANGES, a
368
- // review with no blocking item can never REQUEST_CHANGES, and an approval
369
- // is honored only when nothing blocking or important was reported — the
370
- // event always agrees with the callout tier. Demoted findings and concerns
371
- // count like anchored findings: anchor validation validates LOCATIONS, not
372
- // truth, so severity is equally model-claimed for all three, and counting
373
- // them only ever moves the event toward the closed direction.
374
- const counts = severityCounts(
375
- review,
376
- options.carriedFindings ?? [],
377
- options.carriedConcerns ?? [],
378
- );
379
- const event: ReviewEvent = !options.applyVerdict
380
- ? "COMMENT"
381
- : options.coverage?.status === "incomplete" || counts.blocking > 0
382
- ? "REQUEST_CHANGES"
383
- : review.verdict === "approve" && counts.important === 0
384
- ? "APPROVE"
385
- : "COMMENT";
386
-
387
- // The invisible tail (metadata + fingerprint marker) must survive the body
388
- // cap, so the cap reserves exactly the room it needs.
389
- const tail = [
390
- renderReviewMetadata({
391
- headSha: options.headSha,
392
- baseRef: options.baseRef,
393
- headRef: options.headRef,
394
- filesVisible: options.reviewFilesVisible ?? files.length,
395
- totalChangedFiles: options.reviewTotalFiles ?? options.totalChangedFiles,
396
- reviewMode: options.reviewMode,
397
- baselineSha: options.baselineSha,
398
- }),
399
- ...(options.fingerprint === undefined ? [] : [renderFingerprintMarker(options.fingerprint)]),
400
- ...(options.stateMarker === undefined ? [] : [options.stateMarker]),
401
- ].join("\n");
402
- const headBudget = 60_000 - tail.length - 1;
403
-
404
- // Shed whole trailing items — demoted bullets first (they already failed
405
- // validation), then concerns — instead of slicing markdown mid-block. Every
406
- // omission is announced, and `plan.demoted` keeps the full data regardless.
407
- let concernsKept = sortedConcerns.length;
408
- let demotedKept = sortedDemoted.length;
409
- let omitted = 0;
410
- let head = renderHead(concernsKept, demotedKept, omitted);
411
- while (head.length > headBudget && (demotedKept > 0 || concernsKept > 0)) {
412
- if (demotedKept > 0) demotedKept -= 1;
413
- else concernsKept -= 1;
414
- omitted += 1;
415
- head = renderHead(concernsKept, demotedKept, omitted);
416
- }
417
- // Last resort for a pathological summary; unreachable while the CodeReview
418
- // schema caps the summary well below the budget.
419
- const body = `${head.slice(0, headBudget)}\n${tail}`;
420
-
421
- return ReviewPublicationPlan.make({
422
- event,
423
- body,
424
- comments,
425
- demoted: demoted.map(({ finding }) => finding),
426
- commitSha: options.headSha,
427
- });
428
- };