@effect-agent/pr-review 0.1.0-beta.12 → 0.1.0-beta.14
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 +53 -14
- package/dist/action.d.mts +13 -3
- package/dist/action.mjs +35 -5
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +2 -2
- package/dist/{fan-out-TrA9EUCr.d.mts → fan-out-DBHPcJwC.d.mts} +133 -35
- package/dist/{github-5TCFrxfX.mjs → github-mmanX6hk.mjs} +204 -40
- package/dist/github-mmanX6hk.mjs.map +1 -0
- package/dist/index.d.mts +90 -4
- package/dist/index.mjs +4 -3
- package/dist/index.mjs.map +1 -1
- package/dist/logging-Q4j0oub-.mjs +75 -0
- package/dist/logging-Q4j0oub-.mjs.map +1 -0
- package/dist/{providers-DobNWMUn.mjs → providers-DD2GdrXQ.mjs} +387 -26
- package/dist/providers-DD2GdrXQ.mjs.map +1 -0
- package/dist/testing.d.mts +2 -1
- package/dist/testing.mjs +23 -15
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +60 -2
- package/src/index.ts +2 -0
- package/src/internal/action-entry.ts +2 -0
- package/src/internal/coverage.ts +42 -4
- package/src/internal/diff.ts +59 -0
- package/src/internal/fan-out.ts +23 -3
- package/src/internal/fingerprint.ts +1 -1
- package/src/internal/fixtures.ts +15 -4
- package/src/internal/github-env.ts +8 -1
- package/src/internal/github.ts +73 -12
- package/src/internal/logging.ts +124 -0
- package/src/internal/progress.ts +433 -0
- package/src/internal/render.ts +251 -19
- package/src/internal/retirement.ts +5 -1
- package/src/internal/review-agent.ts +84 -10
- package/src/internal/review-state.ts +21 -1
- package/src/internal/review-units.ts +17 -11
- package/src/internal/run.ts +33 -2
- package/dist/github-5TCFrxfX.mjs.map +0 -1
- package/dist/providers-DobNWMUn.mjs.map +0 -1
package/src/action.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./
|
|
|
7
7
|
import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
|
|
8
8
|
import { readGitHubEvent, resolveReviewTarget, gitHubReviewLayers } from "./internal/github-env.ts";
|
|
9
9
|
import { PriorReviews } from "./internal/github.ts";
|
|
10
|
+
import { compactReviewLoggingLayer } from "./internal/logging.ts";
|
|
11
|
+
import { ReviewProgressReporter } from "./internal/progress.ts";
|
|
10
12
|
import {
|
|
11
13
|
anthropicClientLayer,
|
|
12
14
|
DEFAULT_PROVIDER,
|
|
@@ -92,6 +94,7 @@ export interface ResolvedActionInputs {
|
|
|
92
94
|
readonly failOn: FailOnPolicy;
|
|
93
95
|
readonly skipUnchanged: boolean;
|
|
94
96
|
readonly retireStaleReviews: boolean;
|
|
97
|
+
readonly progressComment: boolean;
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/** Read the PR_REVIEW_* input surface (all optional, all defaulted). */
|
|
@@ -135,6 +138,9 @@ export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* ()
|
|
|
135
138
|
const retireStaleReviews = yield* Config.boolean("PR_REVIEW_RETIRE_STALE_REVIEWS").pipe(
|
|
136
139
|
Config.withDefault(true),
|
|
137
140
|
);
|
|
141
|
+
const progressComment = yield* Config.boolean("PR_REVIEW_PROGRESS_COMMENT").pipe(
|
|
142
|
+
Config.withDefault(true),
|
|
143
|
+
);
|
|
138
144
|
return {
|
|
139
145
|
provider,
|
|
140
146
|
model: Option.getOrUndefined(model),
|
|
@@ -154,6 +160,7 @@ export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* ()
|
|
|
154
160
|
failOn,
|
|
155
161
|
skipUnchanged,
|
|
156
162
|
retireStaleReviews,
|
|
163
|
+
progressComment,
|
|
157
164
|
} satisfies ResolvedActionInputs;
|
|
158
165
|
});
|
|
159
166
|
|
|
@@ -408,6 +415,13 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
|
|
|
408
415
|
readonly priorReviews?: PriorReviews["Service"] | undefined;
|
|
409
416
|
/** Retire marker-bearing prior reviews after a successful post (default true). */
|
|
410
417
|
readonly retireStaleReviews?: boolean | undefined;
|
|
418
|
+
/**
|
|
419
|
+
* Maintain one sticky "review in progress" issue comment, updated in
|
|
420
|
+
* place when the run settles. Default false here for custom-harness
|
|
421
|
+
* compatibility; the packaged action enables it by default. Dry runs
|
|
422
|
+
* (`post: false`) never post progress.
|
|
423
|
+
*/
|
|
424
|
+
readonly progressComment?: boolean | undefined;
|
|
411
425
|
} = {},
|
|
412
426
|
) =>
|
|
413
427
|
Effect.gen(function* () {
|
|
@@ -561,7 +575,37 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
|
|
|
561
575
|
`Reviewing ${target.repository}#${target.number} (${options.post === false ? "dry run" : "posting"})...`,
|
|
562
576
|
);
|
|
563
577
|
const runUrl = yield* resolveRunUrl();
|
|
564
|
-
|
|
578
|
+
// Progress is a cosmetic, fail-open narration surface: it says a run is
|
|
579
|
+
// working the moment execution starts and is overwritten in place when
|
|
580
|
+
// the run settles. It never gates or delays the review itself.
|
|
581
|
+
const progress =
|
|
582
|
+
options.progressComment === true && options.post !== false
|
|
583
|
+
? Option.some(yield* ReviewProgressReporter)
|
|
584
|
+
: Option.none<ReviewProgressReporter["Service"]>();
|
|
585
|
+
if (Option.isSome(progress)) {
|
|
586
|
+
const source = yield* PullRequestSource;
|
|
587
|
+
const headMetadata = yield* source.metadata.pipe(Effect.orElseSucceed(() => undefined));
|
|
588
|
+
yield* progress.value.begin({
|
|
589
|
+
headSha: headMetadata?.headSha,
|
|
590
|
+
reviewMode: selection?.mode,
|
|
591
|
+
reviewReason: selection?.reason,
|
|
592
|
+
filesInScope: selection?.files.length,
|
|
593
|
+
modelLabel: options.modelLabel,
|
|
594
|
+
runUrl,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
const runReview = reviewer.run({ post: options.post ?? true, runUrl });
|
|
598
|
+
const reviewEffect = Option.isSome(progress)
|
|
599
|
+
? runReview.pipe(
|
|
600
|
+
Effect.tapCause(() =>
|
|
601
|
+
progress.value.settle({
|
|
602
|
+
outcome: "failed",
|
|
603
|
+
runUrl,
|
|
604
|
+
modelLabel: options.modelLabel,
|
|
605
|
+
}),
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
: runReview;
|
|
565
609
|
const outcome = yield* selection === undefined
|
|
566
610
|
? reviewEffect
|
|
567
611
|
: reviewEffect.pipe(
|
|
@@ -597,6 +641,17 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
|
|
|
597
641
|
}
|
|
598
642
|
}
|
|
599
643
|
const check = concludeReviewOutcome(outcome);
|
|
644
|
+
if (Option.isSome(progress)) {
|
|
645
|
+
yield* progress.value.settle({
|
|
646
|
+
outcome: "reviewed",
|
|
647
|
+
conclusion: check.conclusion,
|
|
648
|
+
verdict: outcome.review.verdict,
|
|
649
|
+
inlineComments: outcome.plan.comments.length,
|
|
650
|
+
reviewUrl: outcome.published?.url,
|
|
651
|
+
runUrl,
|
|
652
|
+
modelLabel: options.modelLabel,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
600
655
|
yield* writeActionOutputs(outcomeOutputs(outcome, check.conclusion));
|
|
601
656
|
yield* writeStepSummary(outcomeSummary(outcome, options.modelLabel, check.conclusion));
|
|
602
657
|
if (check.conclusion !== "success") {
|
|
@@ -651,6 +706,7 @@ export const reviewActionProgram = Effect.gen(function* () {
|
|
|
651
706
|
skipUnchanged: inputs.skipUnchanged,
|
|
652
707
|
reviewMode: inputs.reviewMode,
|
|
653
708
|
retireStaleReviews: inputs.retireStaleReviews,
|
|
709
|
+
progressComment: inputs.progressComment,
|
|
654
710
|
modelLabel,
|
|
655
711
|
};
|
|
656
712
|
if (inputs.provider === "anthropic") {
|
|
@@ -683,7 +739,9 @@ export const main = (): void =>
|
|
|
683
739
|
),
|
|
684
740
|
),
|
|
685
741
|
Effect.scoped,
|
|
686
|
-
Effect.provide(
|
|
742
|
+
Effect.provide(
|
|
743
|
+
Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer, compactReviewLoggingLayer),
|
|
744
|
+
),
|
|
687
745
|
),
|
|
688
746
|
{ disableErrorReporting: true },
|
|
689
747
|
);
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,9 @@ export * from "./internal/fingerprint.ts";
|
|
|
10
10
|
export * from "./internal/github.ts";
|
|
11
11
|
export * from "./internal/github-env.ts";
|
|
12
12
|
export * from "./internal/ignore.ts";
|
|
13
|
+
export * from "./internal/logging.ts";
|
|
13
14
|
export * from "./internal/profiles.ts";
|
|
15
|
+
export * from "./internal/progress.ts";
|
|
14
16
|
export * from "./internal/providers.ts";
|
|
15
17
|
export * from "./internal/render.ts";
|
|
16
18
|
export * from "./internal/retirement.ts";
|
|
@@ -26,6 +26,8 @@ const INPUT_TO_ENV: ReadonlyArray<readonly [input: string, env: string]> = [
|
|
|
26
26
|
["INPUT_FAIL-ON", "PR_REVIEW_FAIL_ON"],
|
|
27
27
|
["INPUT_SKIP-UNCHANGED", "PR_REVIEW_SKIP_UNCHANGED"],
|
|
28
28
|
["INPUT_RETIRE-STALE-REVIEWS", "PR_REVIEW_RETIRE_STALE_REVIEWS"],
|
|
29
|
+
["INPUT_PROGRESS-COMMENT", "PR_REVIEW_PROGRESS_COMMENT"],
|
|
30
|
+
["INPUT_LOG-LEVEL", "PR_REVIEW_LOG_LEVEL"],
|
|
29
31
|
["INPUT_STATE-SECRET", "PR_REVIEW_STATE_SECRET"],
|
|
30
32
|
["INPUT_REVIEW-AUTHOR", "PR_REVIEW_AUTHOR_LOGIN"],
|
|
31
33
|
["INPUT_OPENAI-API-KEY", "OPENAI_API_KEY"],
|
package/src/internal/coverage.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { Option, Schema } from "effect";
|
|
|
2
2
|
import type { RunEvent } from "effect-agent";
|
|
3
3
|
|
|
4
4
|
import type { ChangedFile } from "./diff.ts";
|
|
5
|
+
import { isReviewableFile } from "./diff.ts";
|
|
5
6
|
import { FileReviewDelegationFailure, FileReviewRequest, FileReviewUnitResult } from "./fan-out.ts";
|
|
6
|
-
import { FileDiffQuery } from "./review-agent.ts";
|
|
7
|
+
import { FileDiffQuery, type WalkthroughEntry } from "./review-agent.ts";
|
|
7
8
|
import { planReviewUnits } from "./review-units.ts";
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
@@ -95,7 +96,7 @@ const flatCoverage = (
|
|
|
95
96
|
if (trace.succeeded.has(toolCallId)) reviewed.add(query.value.path);
|
|
96
97
|
if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
|
|
97
98
|
}
|
|
98
|
-
const undiffable = files.filter((file) => file
|
|
99
|
+
const undiffable = files.filter((file) => !isReviewableFile(file)).map((file) => file.path);
|
|
99
100
|
const unreviewed = requiredPaths.filter(
|
|
100
101
|
(path) => !reviewed.has(path) || undiffable.includes(path) || failedPaths.has(path),
|
|
101
102
|
);
|
|
@@ -104,7 +105,9 @@ const flatCoverage = (
|
|
|
104
105
|
reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
|
|
105
106
|
}
|
|
106
107
|
if (undiffable.length > 0) {
|
|
107
|
-
reasons.push(
|
|
108
|
+
reasons.push(
|
|
109
|
+
boundedListReason("required paths have no reviewable diff or bounded text", undiffable),
|
|
110
|
+
);
|
|
108
111
|
}
|
|
109
112
|
if (failedPaths.size > 0) {
|
|
110
113
|
reasons.push(boundedListReason("diff reads failed", failedPaths));
|
|
@@ -196,7 +199,12 @@ const fanOutCoverage = (
|
|
|
196
199
|
reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
|
|
197
200
|
}
|
|
198
201
|
if (plan.undiffablePaths.length > 0) {
|
|
199
|
-
reasons.push(
|
|
202
|
+
reasons.push(
|
|
203
|
+
boundedListReason(
|
|
204
|
+
"required paths have no reviewable diff or bounded text",
|
|
205
|
+
plan.undiffablePaths,
|
|
206
|
+
),
|
|
207
|
+
);
|
|
200
208
|
}
|
|
201
209
|
if (plan.unassignedPaths.length > 0) {
|
|
202
210
|
reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
|
|
@@ -219,6 +227,36 @@ const fanOutCoverage = (
|
|
|
219
227
|
});
|
|
220
228
|
};
|
|
221
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Host-verified per-file summaries from the fan-out run's Tool events: for
|
|
232
|
+
* every successfully settled delegation, the child-reported `fileSummaries`
|
|
233
|
+
* whose paths belong to that invocation's requested unit. This is the
|
|
234
|
+
* declassification check `projectResult` cannot perform itself (it never sees
|
|
235
|
+
* the request): a child assigned file A cannot smuggle a summary for changed
|
|
236
|
+
* file B into the merged walkthrough, and a coordinator cannot invent or edit
|
|
237
|
+
* entries — only exact child-reported, in-unit summaries survive.
|
|
238
|
+
*/
|
|
239
|
+
export const collectUnitFileSummaries = (
|
|
240
|
+
events: ReadonlyArray<RunEvent>,
|
|
241
|
+
): ReadonlyArray<WalkthroughEntry> => {
|
|
242
|
+
const trace = toolTrace(events);
|
|
243
|
+
const entries: Array<WalkthroughEntry> = [];
|
|
244
|
+
for (const [toolCallId, declaration] of trace.declared) {
|
|
245
|
+
if (declaration.toolName !== "delegate_file_review") continue;
|
|
246
|
+
const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
|
|
247
|
+
if (Option.isNone(request)) continue;
|
|
248
|
+
const success = trace.succeeded.get(toolCallId);
|
|
249
|
+
if (success === undefined || trace.failed.has(toolCallId)) continue;
|
|
250
|
+
const result = Schema.decodeUnknownOption(FileReviewUnitResult)(success.result);
|
|
251
|
+
if (Option.isNone(result) || result.value.unitId !== request.value.unitId) continue;
|
|
252
|
+
const assigned = new Set(request.value.paths);
|
|
253
|
+
for (const entry of result.value.fileSummaries ?? []) {
|
|
254
|
+
if (assigned.has(entry.path)) entries.push(entry);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return entries;
|
|
258
|
+
};
|
|
259
|
+
|
|
222
260
|
/** Assess one settled run without trusting its prose summary or verdict. */
|
|
223
261
|
export const assessReviewCoverage = (input: {
|
|
224
262
|
readonly shape: ReviewShape;
|
package/src/internal/diff.ts
CHANGED
|
@@ -31,8 +31,67 @@ export class ChangedFile extends Schema.Class<ChangedFile>("@effect-agent/pr-rev
|
|
|
31
31
|
previousPath: Schema.optionalKey(ChangedPath),
|
|
32
32
|
/** Unified-diff hunks; absent for binary or oversized files. */
|
|
33
33
|
patch: Schema.optionalKey(Schema.String),
|
|
34
|
+
/**
|
|
35
|
+
* Bounded UTF-8 content used only when the provider omitted `patch`.
|
|
36
|
+
* Modified files require both sides; additions require head content and
|
|
37
|
+
* deletions require base content. These values are review evidence, never
|
|
38
|
+
* GitHub inline-comment anchors.
|
|
39
|
+
*/
|
|
40
|
+
reviewBaseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200_000))),
|
|
41
|
+
reviewHeadContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200_000))),
|
|
34
42
|
}) {}
|
|
35
43
|
|
|
44
|
+
/** Complete rendered fallback evidence must fit one ordinary model context. */
|
|
45
|
+
export const MAX_REVIEW_CONTENT_CHARS = 220_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render complete patchless evidence, or refuse it when a required side is
|
|
49
|
+
* absent or B/H annotation would exceed the model-facing bound. Callers use
|
|
50
|
+
* this same value for planning and tool output so truncated fallback evidence
|
|
51
|
+
* can never count as complete coverage.
|
|
52
|
+
*/
|
|
53
|
+
export const renderReviewContent = (file: ChangedFile): string | undefined => {
|
|
54
|
+
if (file.patch !== undefined) return undefined;
|
|
55
|
+
const includeBase = file.status !== "added";
|
|
56
|
+
const includeHead = file.status !== "removed";
|
|
57
|
+
const sections: Array<string> = [
|
|
58
|
+
"[GitHub omitted the unified diff. B/H lines below are bounded full-file review content, not valid inline-comment anchors. Report defects from this evidence as non-anchored concerns.]",
|
|
59
|
+
];
|
|
60
|
+
let renderedLength = sections[0]?.length ?? 0;
|
|
61
|
+
const append = (part: string): boolean => {
|
|
62
|
+
const nextLength = renderedLength + 1 + part.length;
|
|
63
|
+
if (nextLength > MAX_REVIEW_CONTENT_CHARS) return false;
|
|
64
|
+
sections.push(part);
|
|
65
|
+
renderedLength = nextLength;
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
const appendSide = (side: "B" | "H", header: string, content: string): boolean => {
|
|
69
|
+
if (!append(header)) return false;
|
|
70
|
+
const lines = content.split("\n");
|
|
71
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
72
|
+
if (!append(`${side}${index + 1} ${lines[index] ?? ""}`)) return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
if (includeBase) {
|
|
77
|
+
if (file.reviewBaseContent === undefined) return undefined;
|
|
78
|
+
if (!appendSide("B", "[BASE VERSION]", file.reviewBaseContent)) return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (includeHead) {
|
|
81
|
+
if (file.reviewHeadContent === undefined) return undefined;
|
|
82
|
+
if (!appendSide("H", "[HEAD VERSION]", file.reviewHeadContent)) return undefined;
|
|
83
|
+
}
|
|
84
|
+
return sections.join("\n");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Whether complete patchless evidence fits the model-facing review bound. */
|
|
88
|
+
export const hasReviewableContent = (file: ChangedFile): boolean =>
|
|
89
|
+
renderReviewContent(file) !== undefined;
|
|
90
|
+
|
|
91
|
+
/** Whether the reviewer has either a real patch or bounded textual fallback evidence. */
|
|
92
|
+
export const isReviewableFile = (file: ChangedFile): boolean =>
|
|
93
|
+
file.patch !== undefined || hasReviewableContent(file);
|
|
94
|
+
|
|
36
95
|
/** One parsed line of a unified diff, with both coordinate systems. */
|
|
37
96
|
export interface PatchLine {
|
|
38
97
|
readonly kind: "context" | "add" | "del";
|
package/src/internal/fan-out.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SubagentPolicy,
|
|
7
7
|
SubagentRuntime,
|
|
8
8
|
ToolExecutionClass,
|
|
9
|
+
ToolResultBounds,
|
|
9
10
|
type RuntimeBinding,
|
|
10
11
|
} from "effect-agent";
|
|
11
12
|
import { Tool, Toolkit } from "effect/unstable/ai";
|
|
@@ -15,13 +16,16 @@ import {
|
|
|
15
16
|
clampMaxFindings,
|
|
16
17
|
CodeReview,
|
|
17
18
|
MAX_CONCERNS,
|
|
19
|
+
MAX_WALKTHROUGH_SUMMARY_CHARS,
|
|
18
20
|
ReadFile,
|
|
19
21
|
ReadFileDiff,
|
|
20
22
|
readFileDiffHandler,
|
|
21
23
|
readFileHandler,
|
|
24
|
+
REVIEW_TOOL_RESULT_MAX_BYTES,
|
|
22
25
|
ReviewConcern,
|
|
23
26
|
ReviewFinding,
|
|
24
27
|
ReviewMission,
|
|
28
|
+
WalkthroughEntry,
|
|
25
29
|
} from "./review-agent.ts";
|
|
26
30
|
import {
|
|
27
31
|
MAX_REVIEW_UNITS,
|
|
@@ -92,6 +96,10 @@ export class FileReviewReport extends Schema.Class<FileReviewReport>(
|
|
|
92
96
|
concerns: Schema.optionalKey(
|
|
93
97
|
Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
|
|
94
98
|
),
|
|
99
|
+
/** One-sentence per-file change summaries for the merged walkthrough. */
|
|
100
|
+
fileSummaries: Schema.optionalKey(
|
|
101
|
+
Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
|
|
102
|
+
),
|
|
95
103
|
}) {}
|
|
96
104
|
|
|
97
105
|
/**
|
|
@@ -120,12 +128,13 @@ export const makeFileReviewerInstructions =
|
|
|
120
128
|
`You are a code reviewer for one unit of a pull request: unit ${brief.unitId}, covering exactly these changed files: ${brief.paths.join(", ")}. Focus: ${brief.focus}.`,
|
|
121
129
|
...staticGuidanceLines(options.guidance),
|
|
122
130
|
"Work in this order:",
|
|
123
|
-
"1. Call read_file_diff for every file in your unit.
|
|
131
|
+
"1. Call read_file_diff for every file in your unit. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
|
|
124
132
|
"2. Call read_file when you need surrounding context the diff does not show. ONLY files in the changeset are readable: a request for any other path (an import, a neighbor, a config) returns a failed result — do not retry it; reason from the diff instead and note the gap in your report when it matters.",
|
|
125
133
|
"3. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
|
|
126
134
|
"When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
|
|
127
135
|
"Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
|
|
128
|
-
`4.
|
|
136
|
+
`4. For every file in your unit, write one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file — for a reader scanning the pull request, never a line-by-line restatement.`,
|
|
137
|
+
`5. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"unitId": ${JSON.stringify(brief.unitId)}, "findings": [{"path": <string, a file in your unit>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for concerns about YOUR unit's files with no valid line anchor (a missing cleanup, a coverage gap the diff implies, sequencing the diff leaves open) — never duplicate a finding here>, "fileSummaries": <array, OPTIONAL: [{"path": <string, a file in your unit>, "summary": <string, the step-4 sentence>}], one entry per file in your unit>}.`,
|
|
129
138
|
`Report at most ${MAX_CHILD_FINDINGS} findings and at most ${MAX_CHILD_CONCERNS} concerns; prefer the most important ones. An empty findings array is a valid report. Never report on files outside your unit. Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.`,
|
|
130
139
|
].join("\n");
|
|
131
140
|
|
|
@@ -137,10 +146,15 @@ export const defaultFileReviewerPolicy = AgentPolicy.make({
|
|
|
137
146
|
maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
|
|
138
147
|
maxDuration: "6 minutes",
|
|
139
148
|
toolConcurrency: 2,
|
|
149
|
+
// Same rationale as the flat reviewer's bound: read refusals are
|
|
150
|
+
// model-visible results, and one parallel batch of out-of-unit probes must
|
|
151
|
+
// not kill the child before it has seen a single refusal.
|
|
152
|
+
repeatedFailureLimit: 12,
|
|
140
153
|
tokenBudget: 200_000,
|
|
141
154
|
// Bound one live prompt independently from cumulative usage. The engine
|
|
142
155
|
// prunes old diff/file results before paying for a summary.
|
|
143
156
|
contextTokenLimit: 150_000,
|
|
157
|
+
toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
|
|
144
158
|
// Typed exhaustion, deliberately NOT the final-answer soft landing: a
|
|
145
159
|
// review is a coverage claim, and a child whose reads were rejected could
|
|
146
160
|
// still emit schema-valid findings — laundering budget exhaustion into
|
|
@@ -176,6 +190,10 @@ export class FileReviewUnitResult extends Schema.Class<FileReviewUnitResult>(
|
|
|
176
190
|
concerns: Schema.optionalKey(
|
|
177
191
|
Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
|
|
178
192
|
),
|
|
193
|
+
/** One-sentence per-file change summaries for the merged walkthrough. */
|
|
194
|
+
fileSummaries: Schema.optionalKey(
|
|
195
|
+
Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
|
|
196
|
+
),
|
|
179
197
|
}) {}
|
|
180
198
|
|
|
181
199
|
/**
|
|
@@ -284,7 +302,8 @@ export const makeFanOutReviewInstructions =
|
|
|
284
302
|
'3. A delegation result with "_tag" is a FAILED unit. Never retry it; instead your summary MUST name it honestly, e.g. "unit-002 unreviewed: AgentPolicyError". The plan\'s undiffablePaths and unassignedPaths must also be named as not reviewed when present.',
|
|
285
303
|
`4. Merge the successful units' findings: drop duplicates sharing the same path and line range keeping the most severe, rank blocking > important > nit, and keep at most ${maxFindings} findings. Drop bloat-shaped findings during the merge — defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies; children bias toward recommending changes, and a finding must be sound, correct, and worth acting on to survive.`,
|
|
286
304
|
`5. Merge the units' concerns the same way: drop duplicates keeping the most severe, and keep at most ${MAX_CONCERNS}.`,
|
|
287
|
-
|
|
305
|
+
"6. Merge the units' fileSummaries into one walkthrough: copy each entry verbatim, one entry per file, dropping duplicate paths.",
|
|
306
|
+
'7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment, including every unreviewed unit or file>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string>, "startLine": <integer>, "endLine": <integer>, "severity": <"blocking" | "important" | "nit">, "category": <string, OPTIONAL>, "title": <string, <= 120 chars>, "body": <string>, "suggestion": <string, OPTIONAL>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], the merged unit concerns>, "walkthrough": <array, OPTIONAL: [{"path": <string>, "summary": <string>}], the merged fileSummaries>}. Copy findings (including "category" and "suggestion" when present), concerns, and walkthrough entries verbatim from the delegation results; never invent or edit anchors.',
|
|
288
307
|
'Use verdict "request-changes" only when at least one finding or concern is "blocking". An empty findings array with verdict "approve" is a valid review when every unit succeeded and found nothing.',
|
|
289
308
|
].join("\n");
|
|
290
309
|
};
|
|
@@ -364,6 +383,7 @@ const makeFileReviewDelegation = (child: ReturnType<typeof makeFileReviewerDefin
|
|
|
364
383
|
unitId: report.unitId,
|
|
365
384
|
findings: report.findings,
|
|
366
385
|
...(report.concerns !== undefined ? { concerns: report.concerns } : {}),
|
|
386
|
+
...(report.fileSummaries !== undefined ? { fileSummaries: report.fileSummaries } : {}),
|
|
367
387
|
}),
|
|
368
388
|
),
|
|
369
389
|
policy: fileReviewPolicy,
|
|
@@ -57,7 +57,7 @@ const canonicalChangeset = (files: ReadonlyArray<ChangedFile>): string =>
|
|
|
57
57
|
files
|
|
58
58
|
.map(
|
|
59
59
|
(file) =>
|
|
60
|
-
`${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch ?? ""}`,
|
|
60
|
+
`${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch ?? ""}${FIELD}${file.reviewBaseContent ?? ""}${FIELD}${file.reviewHeadContent ?? ""}`,
|
|
61
61
|
)
|
|
62
62
|
.sort()
|
|
63
63
|
.join(RECORD);
|
package/src/internal/fixtures.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
/** One fixture file: its changeset entry plus optional head content. */
|
|
29
29
|
export class FixtureFile extends Schema.Class<FixtureFile>("@effect-agent/pr-review/FixtureFile")({
|
|
30
30
|
file: ChangedFile,
|
|
31
|
+
baseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
|
|
31
32
|
headContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
|
|
32
33
|
}) {}
|
|
33
34
|
|
|
@@ -57,12 +58,21 @@ const requireChanged = (
|
|
|
57
58
|
/** Deterministic `PullRequestSource` over one fixture pull request. */
|
|
58
59
|
export const fixturePullRequestSourceLayer = (
|
|
59
60
|
fixture: FixturePullRequest,
|
|
60
|
-
): Layer.Layer<PullRequestSource> =>
|
|
61
|
-
|
|
61
|
+
): Layer.Layer<PullRequestSource> => {
|
|
62
|
+
const files = fixture.files.map((entry) =>
|
|
63
|
+
entry.file.patch !== undefined
|
|
64
|
+
? entry.file
|
|
65
|
+
: ChangedFile.make({
|
|
66
|
+
...entry.file,
|
|
67
|
+
...(entry.baseContent === undefined ? {} : { reviewBaseContent: entry.baseContent }),
|
|
68
|
+
...(entry.headContent === undefined ? {} : { reviewHeadContent: entry.headContent }),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
return Layer.succeed(PullRequestSource)(
|
|
62
72
|
PullRequestSource.of({
|
|
63
73
|
metadata: Effect.succeed(fixture.metadata),
|
|
64
|
-
changedFiles: Effect.succeed(
|
|
65
|
-
anchorFiles: Effect.succeed(
|
|
74
|
+
changedFiles: Effect.succeed(files),
|
|
75
|
+
anchorFiles: Effect.succeed(files),
|
|
66
76
|
readFile: (path) =>
|
|
67
77
|
Effect.gen(function* () {
|
|
68
78
|
const relative = yield* normalizeRepoRelativePath(path);
|
|
@@ -77,6 +87,7 @@ export const fixturePullRequestSourceLayer = (
|
|
|
77
87
|
}),
|
|
78
88
|
}),
|
|
79
89
|
);
|
|
90
|
+
};
|
|
80
91
|
|
|
81
92
|
/** In-memory publisher: records every plan and mints a deterministic receipt. */
|
|
82
93
|
export const collectingReviewPublisherLayer = (
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
gitHubReviewPublisherLayer,
|
|
11
11
|
gitHubReviewRetirementHostLayer,
|
|
12
12
|
} from "./github.ts";
|
|
13
|
+
import type { ReviewProgressReporter } from "./progress.ts";
|
|
14
|
+
import { gitHubReviewProgressLayer } from "./progress.ts";
|
|
13
15
|
import type { ReviewRetirementHost } from "./retirement.ts";
|
|
14
16
|
import type { PullRequestSource } from "./source.ts";
|
|
15
17
|
|
|
@@ -110,7 +112,11 @@ export const resolveReviewTarget = Effect.fn("resolveReviewTarget")(function* (o
|
|
|
110
112
|
export const gitHubReviewLayers = (
|
|
111
113
|
target: ResolvedReviewTarget,
|
|
112
114
|
): Layer.Layer<
|
|
113
|
-
|
|
115
|
+
| PullRequestSource
|
|
116
|
+
| ReviewPublisher
|
|
117
|
+
| PriorReviews
|
|
118
|
+
| ReviewRetirementHost
|
|
119
|
+
| ReviewProgressReporter,
|
|
114
120
|
Config.ConfigError,
|
|
115
121
|
HttpClient.HttpClient
|
|
116
122
|
> =>
|
|
@@ -143,6 +149,7 @@ export const gitHubReviewLayers = (
|
|
|
143
149
|
gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
|
|
144
150
|
gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
|
|
145
151
|
gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
|
|
152
|
+
gitHubReviewProgressLayer.pipe(Layer.provide(targetLayer)),
|
|
146
153
|
);
|
|
147
154
|
}),
|
|
148
155
|
);
|
package/src/internal/github.ts
CHANGED
|
@@ -313,21 +313,13 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
|
|
|
313
313
|
const metadata = yield* Effect.cached(
|
|
314
314
|
fetchMetadata.pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
315
315
|
);
|
|
316
|
-
const
|
|
316
|
+
const rawFiles = yield* Effect.cached(
|
|
317
317
|
fetchFiles.pipe(Effect.provideService(HttpClient.HttpClient, client)),
|
|
318
318
|
);
|
|
319
319
|
|
|
320
|
-
const
|
|
320
|
+
const readRepositoryFile = (path: string, ref: string) =>
|
|
321
321
|
Effect.gen(function* () {
|
|
322
322
|
const relative = yield* normalizeRepoRelativePath(path);
|
|
323
|
-
const files = yield* changedFiles;
|
|
324
|
-
if (!files.some((file) => file.path === relative)) {
|
|
325
|
-
return yield* ReviewInputViolation.make({
|
|
326
|
-
input: relative,
|
|
327
|
-
reason: "Path is not part of this pull request's changeset.",
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
const head = yield* metadata;
|
|
331
323
|
const encodedPath = relative.split("/").map(encodeURIComponent).join("/");
|
|
332
324
|
const response = yield* executeOk(
|
|
333
325
|
"readFile",
|
|
@@ -336,12 +328,32 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
|
|
|
336
328
|
`${target.apiUrl}/repos/${target.repository}/contents/${encodedPath}`,
|
|
337
329
|
).pipe(
|
|
338
330
|
HttpClientRequest.accept("application/vnd.github.raw+json"),
|
|
339
|
-
HttpClientRequest.setUrlParams({ ref
|
|
331
|
+
HttpClientRequest.setUrlParams({ ref }),
|
|
340
332
|
),
|
|
341
333
|
target.token,
|
|
342
334
|
),
|
|
343
335
|
).pipe(Effect.provideService(HttpClient.HttpClient, client));
|
|
344
|
-
const
|
|
336
|
+
const buffer = yield* response.arrayBuffer.pipe(Effect.mapError(failWith("readFile")));
|
|
337
|
+
if (buffer.byteLength > MAX_FILE_CHARS) {
|
|
338
|
+
return yield* ReviewInputViolation.make({
|
|
339
|
+
input: relative,
|
|
340
|
+
reason: `File is larger than the ${MAX_FILE_CHARS}-byte read bound.`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const text = yield* Effect.try({
|
|
344
|
+
try: () => new TextDecoder("utf-8", { fatal: true }).decode(buffer),
|
|
345
|
+
catch: () =>
|
|
346
|
+
ReviewInputViolation.make({
|
|
347
|
+
input: relative,
|
|
348
|
+
reason: "File is not valid UTF-8 text.",
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
if (text.includes("\u0000")) {
|
|
352
|
+
return yield* ReviewInputViolation.make({
|
|
353
|
+
input: relative,
|
|
354
|
+
reason: "File contains binary NUL bytes.",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
345
357
|
if (text.length > MAX_FILE_CHARS) {
|
|
346
358
|
return yield* ReviewInputViolation.make({
|
|
347
359
|
input: relative,
|
|
@@ -351,6 +363,55 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
|
|
|
351
363
|
return text;
|
|
352
364
|
});
|
|
353
365
|
|
|
366
|
+
const changedFiles = yield* Effect.cached(
|
|
367
|
+
Effect.gen(function* () {
|
|
368
|
+
const [files, pullRequest] = yield* Effect.all([rawFiles, metadata]);
|
|
369
|
+
return yield* Effect.forEach(
|
|
370
|
+
files,
|
|
371
|
+
(file) => {
|
|
372
|
+
if (file.patch !== undefined) return Effect.succeed(file);
|
|
373
|
+
const basePath = file.previousPath ?? file.path;
|
|
374
|
+
const base =
|
|
375
|
+
file.status === "added"
|
|
376
|
+
? Effect.succeed(Option.none<string>())
|
|
377
|
+
: readRepositoryFile(basePath, pullRequest.baseSha ?? pullRequest.baseRef).pipe(
|
|
378
|
+
Effect.option,
|
|
379
|
+
);
|
|
380
|
+
const head =
|
|
381
|
+
file.status === "removed"
|
|
382
|
+
? Effect.succeed(Option.none<string>())
|
|
383
|
+
: readRepositoryFile(file.path, pullRequest.headSha).pipe(Effect.option);
|
|
384
|
+
return Effect.all({ base, head }).pipe(
|
|
385
|
+
Effect.map(({ base, head }) =>
|
|
386
|
+
ChangedFile.make({
|
|
387
|
+
...file,
|
|
388
|
+
...(Option.isSome(base) ? { reviewBaseContent: base.value } : {}),
|
|
389
|
+
...(Option.isSome(head) ? { reviewHeadContent: head.value } : {}),
|
|
390
|
+
}),
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
{ concurrency: 4 },
|
|
395
|
+
);
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const readFile = (path: string) =>
|
|
400
|
+
Effect.gen(function* () {
|
|
401
|
+
const relative = yield* normalizeRepoRelativePath(path);
|
|
402
|
+
const files = yield* changedFiles;
|
|
403
|
+
const file = files.find((candidate) => candidate.path === relative);
|
|
404
|
+
if (file === undefined) {
|
|
405
|
+
return yield* ReviewInputViolation.make({
|
|
406
|
+
input: relative,
|
|
407
|
+
reason: "Path is not part of this pull request's changeset.",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (file.reviewHeadContent !== undefined) return file.reviewHeadContent;
|
|
411
|
+
const head = yield* metadata;
|
|
412
|
+
return yield* readRepositoryFile(relative, head.headSha);
|
|
413
|
+
});
|
|
414
|
+
|
|
354
415
|
return PullRequestSource.of({ metadata, changedFiles, anchorFiles: changedFiles, readFile });
|
|
355
416
|
}),
|
|
356
417
|
);
|