@effect-agent/pr-review 0.1.0-beta.8 → 0.1.0-beta.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +170 -158
  3. package/dist/Review.d.mts +295 -0
  4. package/dist/Review.mjs +704 -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 -716
  11. package/dist/index.mjs +3 -66
  12. package/dist/repository-BzSG74vX.mjs +101 -0
  13. package/dist/repository-BzSG74vX.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 +1058 -0
  17. package/src/ReviewRepository.ts +9 -0
  18. package/src/index.ts +2 -20
  19. package/src/internal/repository.ts +156 -0
  20. package/dist/action.d.mts +0 -185
  21. package/dist/action.mjs +0 -406
  22. package/dist/action.mjs.map +0 -1
  23. package/dist/cli.d.mts +0 -1
  24. package/dist/cli.mjs +0 -102
  25. package/dist/cli.mjs.map +0 -1
  26. package/dist/fan-out-BBEATQwc.d.mts +0 -997
  27. package/dist/github-BZNzmxao.mjs +0 -1372
  28. package/dist/github-BZNzmxao.mjs.map +0 -1
  29. package/dist/index.mjs.map +0 -1
  30. package/dist/providers-J6BKHyHe.mjs +0 -986
  31. package/dist/providers-J6BKHyHe.mjs.map +0 -1
  32. package/dist/testing.d.mts +0 -131
  33. package/dist/testing.mjs +0 -228
  34. package/dist/testing.mjs.map +0 -1
  35. package/src/action.ts +0 -666
  36. package/src/cli.ts +0 -213
  37. package/src/internal/action-entry.ts +0 -41
  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 -127
  46. package/src/internal/github-env.ts +0 -128
  47. package/src/internal/github.ts +0 -531
  48. package/src/internal/ignore.ts +0 -88
  49. package/src/internal/profiles.ts +0 -79
  50. package/src/internal/providers.ts +0 -91
  51. package/src/internal/render.ts +0 -428
  52. package/src/internal/review-agent.ts +0 -385
  53. package/src/internal/review-state.ts +0 -488
  54. package/src/internal/review-units.ts +0 -167
  55. package/src/internal/run.ts +0 -397
  56. package/src/internal/scripted.ts +0 -108
  57. package/src/internal/source.ts +0 -110
  58. package/src/testing.ts +0 -8
package/src/cli.ts DELETED
@@ -1,213 +0,0 @@
1
- import { NodeRuntime, NodeServices } from "@effect/platform-node";
2
- import { Console, Effect, Layer, Option, Schema } from "effect";
3
- import { BudgetExceeded } from "effect-agent";
4
- import { Command as CliCommand, Flag } from "effect/unstable/cli";
5
-
6
- import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./internal/effort.ts";
7
- import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
8
- import { gitHubReviewLayers, resolveReviewTarget } from "./internal/github-env.ts";
9
- import { fingerprintUnchanged } from "./internal/github.ts";
10
- import {
11
- anthropicClientLayer,
12
- DEFAULT_MODEL,
13
- DEFAULT_PROVIDER,
14
- describeReviewModel,
15
- makeAnthropicReviewModel,
16
- makeOpenAiReviewModel,
17
- openAiClientLayer,
18
- type ReviewProvider,
19
- } from "./internal/providers.ts";
20
- import { ReviewPublicationPlan } from "./internal/render.ts";
21
- import type { ReviewRunOutcome } from "./internal/run.ts";
22
-
23
- // ---------------------------------------------------------------------------
24
- // The CLI entrypoint: resolve which pull request to review (flags first, then
25
- // the GitHub Actions event environment), run one bounded ephemeral review,
26
- // and either print the validated publication plan (default: dry run) or post
27
- // it as a pull-request review (--post).
28
- // ---------------------------------------------------------------------------
29
-
30
- const repoFlag = Flag.string("repo").pipe(
31
- Flag.optional,
32
- Flag.withDescription("Repository as owner/name; defaults to GITHUB_REPOSITORY."),
33
- );
34
- const prFlag = Flag.integer("pr").pipe(
35
- Flag.optional,
36
- Flag.withDescription("Pull request number; defaults to the GITHUB_EVENT_PATH payload."),
37
- );
38
- const providerFlag = Flag.string("provider").pipe(
39
- Flag.withDefault<string>(DEFAULT_PROVIDER),
40
- Flag.withDescription('Model provider: "openai" (default) or "anthropic".'),
41
- );
42
- const modelFlag = Flag.string("model").pipe(
43
- Flag.optional,
44
- Flag.withDescription(
45
- `Model id (defaults: openai ${DEFAULT_MODEL.openai}, anthropic ${DEFAULT_MODEL.anthropic}).`,
46
- ),
47
- );
48
- const effortFlag = Flag.string("effort").pipe(
49
- Flag.optional,
50
- Flag.withDescription(
51
- 'Reasoning effort: "low", "medium", "high", "xhigh", "max", or a number in [0, 1] resolved onto the provider\'s own ladder.',
52
- ),
53
- );
54
- const postFlag = Flag.boolean("post").pipe(
55
- Flag.withDescription("Post the review to GitHub; without it the plan prints to stdout."),
56
- );
57
- const applyVerdictFlag = Flag.boolean("apply-verdict").pipe(
58
- Flag.withDescription(
59
- "Map the model verdict onto APPROVE/REQUEST_CHANGES instead of always COMMENT.",
60
- ),
61
- );
62
- const fanOutFlag = Flag.boolean("fan-out").pipe(
63
- Flag.withDescription(
64
- "Fan the review out to bounded per-unit subagent reviewers (S1 attached delegation) instead of one flat reviewer.",
65
- ),
66
- );
67
- const ignoreFlag = Flag.string("ignore").pipe(
68
- Flag.withDefault(""),
69
- Flag.withDescription(
70
- 'Comma-separated glob patterns removed from the review surface, e.g. "**/*.lock,dist/**".',
71
- ),
72
- );
73
- const maxFindingsFlag = Flag.integer("max-findings").pipe(
74
- Flag.optional,
75
- Flag.withDescription("Findings bound (1-20); the schema cap of 20 applies regardless."),
76
- );
77
- const skipUnchangedFlag = Flag.boolean("skip-unchanged").pipe(
78
- Flag.withDescription(
79
- "Skip when the changeset fingerprint matches the last posted review (explicit runs review unconditionally by default).",
80
- ),
81
- );
82
-
83
- /** An unknown --provider value; the two supported providers are spelled out. */
84
- class UnknownProvider extends Schema.TaggedError<UnknownProvider>()("UnknownProvider", {
85
- provider: Schema.String,
86
- }) {
87
- override get message() {
88
- return `Unknown provider '${this.provider}': expected "openai" or "anthropic".`;
89
- }
90
- }
91
-
92
- const decodeProvider = (raw: string): Effect.Effect<ReviewProvider, UnknownProvider> =>
93
- raw === "openai" || raw === "anthropic"
94
- ? Effect.succeed(raw)
95
- : Effect.fail(UnknownProvider.make({ provider: raw }));
96
-
97
- const command = CliCommand.make(
98
- "pr-review",
99
- {
100
- repo: repoFlag,
101
- pr: prFlag,
102
- provider: providerFlag,
103
- model: modelFlag,
104
- effort: effortFlag,
105
- post: postFlag,
106
- applyVerdict: applyVerdictFlag,
107
- fanOut: fanOutFlag,
108
- ignore: ignoreFlag,
109
- maxFindings: maxFindingsFlag,
110
- skipUnchanged: skipUnchangedFlag,
111
- },
112
- (flags) =>
113
- Effect.gen(function* () {
114
- const provider = yield* decodeProvider(flags.provider);
115
- const target = yield* resolveReviewTarget({
116
- repository: Option.getOrUndefined(flags.repo),
117
- number: Option.getOrUndefined(flags.pr),
118
- });
119
- const model = Option.getOrUndefined(flags.model);
120
- const effortRaw = Option.getOrUndefined(flags.effort);
121
- let effort: EffortPosition | undefined;
122
- if (effortRaw !== undefined) {
123
- effort = parseEffortPosition(effortRaw);
124
- if (effort === undefined) {
125
- return yield* InvalidEffortInput.make({ input: effortRaw });
126
- }
127
- }
128
- const shared = {
129
- applyVerdict: flags.applyVerdict,
130
- ignore: flags.ignore
131
- .split(",")
132
- .map((pattern) => pattern.trim())
133
- .filter((pattern) => pattern.length > 0),
134
- maxFindings: Option.getOrUndefined(flags.maxFindings),
135
- modelLabel: describeReviewModel(provider, model, effort),
136
- };
137
-
138
- yield* Console.log(
139
- `Reviewing ${target.repository}#${target.number} with ${provider}:${model ?? DEFAULT_MODEL[provider]} (${flags.post ? "posting" : "dry run"}${flags.fanOut ? ", fan-out" : ""})...`,
140
- );
141
-
142
- const githubLayers = gitHubReviewLayers(target);
143
- // Fingerprint check and run under ONE provide, sharing the cached
144
- // pull-request snapshot; None means "unchanged, skipped".
145
- const runOrSkip = <E, R, FingerprintE, FingerprintR>(reviewer: {
146
- readonly run: (runOptions?: RunReviewOptions) => Effect.Effect<ReviewRunOutcome, E, R>;
147
- readonly fingerprint: Effect.Effect<string, FingerprintE, FingerprintR>;
148
- }) =>
149
- Effect.gen(function* () {
150
- if (flags.skipUnchanged) {
151
- const current = yield* reviewer.fingerprint;
152
- if (yield* fingerprintUnchanged(current)) {
153
- return Option.none<ReviewRunOutcome>();
154
- }
155
- }
156
- return Option.some(yield* reviewer.run({ post: flags.post }));
157
- });
158
-
159
- let result: Option.Option<ReviewRunOutcome>;
160
- if (provider === "anthropic") {
161
- const boundModel = makeAnthropicReviewModel(model, effort);
162
- const reviewer = flags.fanOut
163
- ? PrReview.makeFanOut({ ...shared, model: boundModel })
164
- : PrReview.make({ ...shared, model: boundModel });
165
- result = yield* runOrSkip(reviewer).pipe(
166
- Effect.provide(Layer.merge(githubLayers, anthropicClientLayer)),
167
- );
168
- } else {
169
- const boundModel = makeOpenAiReviewModel(model, effort);
170
- const reviewer = flags.fanOut
171
- ? PrReview.makeFanOut({ ...shared, model: boundModel })
172
- : PrReview.make({ ...shared, model: boundModel });
173
- result = yield* runOrSkip(reviewer).pipe(
174
- Effect.provide(Layer.merge(githubLayers, openAiClientLayer)),
175
- );
176
- }
177
-
178
- if (Option.isNone(result)) {
179
- yield* Console.log("Skipped: changeset unchanged since the last posted review.");
180
- return;
181
- }
182
- const outcome = result.value;
183
-
184
- yield* Console.log(
185
- `Review finished in ${outcome.turns} turn(s): verdict ${outcome.review.verdict}, ` +
186
- `${outcome.plan.comments.length} inline comment(s), ${outcome.plan.demoted.length} demoted finding(s).`,
187
- );
188
- if (outcome.published !== undefined) {
189
- yield* Console.log(`Posted ${outcome.published.event} review: ${outcome.published.url}`);
190
- } else {
191
- const encodedPlan = yield* Schema.encodeEffect(ReviewPublicationPlan)(outcome.plan);
192
- yield* Console.log(JSON.stringify(encodedPlan, null, 2));
193
- }
194
- }),
195
- ).pipe(
196
- CliCommand.withDescription(
197
- "Review one GitHub pull request with an @effect-agent/pr-review reviewer and post the validated result as a pull-request review.",
198
- ),
199
- );
200
-
201
- const program = CliCommand.run(command, { version: "0.0.0" }).pipe(
202
- Effect.tapError((error) =>
203
- Console.error(
204
- Schema.is(BudgetExceeded)(error)
205
- ? `Budget exceeded: ${error.limit} observed ${error.observedValue}, limit ${error.limitValue}.`
206
- : String(error),
207
- ),
208
- ),
209
- Effect.scoped,
210
- Effect.provide(NodeServices.layer),
211
- );
212
-
213
- NodeRuntime.runMain(program, { disableErrorReporting: true });
@@ -1,41 +0,0 @@
1
- import { main } from "../action.ts";
2
-
3
- // ---------------------------------------------------------------------------
4
- // The bundled GitHub Action entrypoint (built by `scripts/build-action.ts`
5
- // into `action/dist/index.mjs`). A node-runtime action exposes its manifest
6
- // inputs only as `INPUT_<NAME>` environment variables, so this runtime
7
- // adapter — the one place the package touches `process.env` directly — maps
8
- // them onto the PR_REVIEW_* surface `resolveActionInputs` reads, plus the
9
- // provider and GitHub credentials. Explicit environment variables win over
10
- // manifest inputs.
11
- // ---------------------------------------------------------------------------
12
-
13
- const INPUT_TO_ENV: ReadonlyArray<readonly [input: string, env: string]> = [
14
- ["INPUT_PROVIDER", "PR_REVIEW_PROVIDER"],
15
- ["INPUT_MODEL", "PR_REVIEW_MODEL"],
16
- ["INPUT_EFFORT", "PR_REVIEW_EFFORT"],
17
- ["INPUT_MAX-DURATION-MINUTES", "PR_REVIEW_MAX_DURATION_MINUTES"],
18
- ["INPUT_POST", "PR_REVIEW_POST"],
19
- ["INPUT_APPLY-VERDICT", "PR_REVIEW_APPLY_VERDICT"],
20
- ["INPUT_FAN-OUT", "PR_REVIEW_FAN_OUT"],
21
- ["INPUT_GUIDANCE", "PR_REVIEW_GUIDANCE"],
22
- ["INPUT_GUIDANCE-FILE", "PR_REVIEW_GUIDANCE_FILE"],
23
- ["INPUT_IGNORE", "PR_REVIEW_IGNORE"],
24
- ["INPUT_MAX-FINDINGS", "PR_REVIEW_MAX_FINDINGS"],
25
- ["INPUT_REVIEW-MODE", "PR_REVIEW_MODE"],
26
- ["INPUT_FAIL-ON", "PR_REVIEW_FAIL_ON"],
27
- ["INPUT_SKIP-UNCHANGED", "PR_REVIEW_SKIP_UNCHANGED"],
28
- ["INPUT_STATE-SECRET", "PR_REVIEW_STATE_SECRET"],
29
- ["INPUT_OPENAI-API-KEY", "OPENAI_API_KEY"],
30
- ["INPUT_ANTHROPIC-API-KEY", "ANTHROPIC_API_KEY"],
31
- ["INPUT_GITHUB-TOKEN", "GITHUB_TOKEN"],
32
- ];
33
-
34
- for (const [input, env] of INPUT_TO_ENV) {
35
- const value = process.env[input];
36
- if (value !== undefined && value !== "" && (process.env[env] ?? "") === "") {
37
- process.env[env] = value;
38
- }
39
- }
40
-
41
- main();
@@ -1,245 +0,0 @@
1
- import { Option, Schema } from "effect";
2
- import type { RunEvent } from "effect-agent";
3
-
4
- import type { ChangedFile } from "./diff.ts";
5
- import { FileReviewDelegationFailure, FileReviewRequest, FileReviewUnitResult } from "./fan-out.ts";
6
- import { FileDiffQuery } from "./review-agent.ts";
7
- import { planReviewUnits } from "./review-units.ts";
8
-
9
- // ---------------------------------------------------------------------------
10
- // Host-owned coverage. Model summaries are untrusted prose; the check result
11
- // is based on deterministic unit planning plus the semantic Tool events that
12
- // prove which required review operations actually settled successfully.
13
- // ---------------------------------------------------------------------------
14
-
15
- export const ReviewShape = Schema.Literals(["flat", "fan-out"]);
16
- export type ReviewShape = typeof ReviewShape.Type;
17
-
18
- export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
19
- "@effect-agent/pr-review/FailedReviewUnit",
20
- )({
21
- unitId: Schema.NonEmptyString.check(Schema.isMaxLength(32)),
22
- errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
23
- }) {}
24
-
25
- export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
26
- "@effect-agent/pr-review/ReviewCoverage",
27
- )({
28
- status: Schema.Literals(["complete", "incomplete"]),
29
- requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
30
- Schema.isMaxLength(300),
31
- ),
32
- reviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
33
- Schema.isMaxLength(300),
34
- ),
35
- unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
36
- Schema.isMaxLength(300),
37
- ),
38
- failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
39
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
40
- Schema.isMaxLength(20),
41
- ),
42
- }) {}
43
-
44
- interface ToolTrace {
45
- readonly declared: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallDeclared" }>>;
46
- readonly succeeded: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallSucceeded" }>>;
47
- readonly failed: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallFailed" }>>;
48
- }
49
-
50
- const toolTrace = (events: ReadonlyArray<RunEvent>): ToolTrace => {
51
- const declared = new Map<string, Extract<RunEvent, { readonly _tag: "ToolCallDeclared" }>>();
52
- const succeeded = new Map<string, Extract<RunEvent, { readonly _tag: "ToolCallSucceeded" }>>();
53
- const failed = new Map<string, Extract<RunEvent, { readonly _tag: "ToolCallFailed" }>>();
54
- for (const event of events) {
55
- if (event._tag === "ToolCallDeclared") declared.set(event.toolCallId, event);
56
- if (event._tag === "ToolCallSucceeded") succeeded.set(event.toolCallId, event);
57
- if (event._tag === "ToolCallFailed") failed.set(event.toolCallId, event);
58
- }
59
- return { declared, succeeded, failed };
60
- };
61
-
62
- const sortedUnique = (values: Iterable<string>): ReadonlyArray<string> =>
63
- [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
64
-
65
- const boundedListReason = (label: string, values: Iterable<string>): string => {
66
- const items = sortedUnique(values);
67
- const prefix = `${label} (${items.length}): `;
68
- let rendered = prefix;
69
- for (let index = 0; index < items.length; index += 1) {
70
- const item = items[index] ?? "";
71
- const separator = index === 0 ? "" : ", ";
72
- const omitted = items.length - index - 1;
73
- const suffix = omitted === 0 ? "" : ` … (+${omitted} more)`;
74
- if (`${rendered}${separator}${item}${suffix}`.length > 1_000) {
75
- const omission = `… (+${items.length - index} more)`;
76
- return `${rendered.slice(0, 1_000 - omission.length)}${omission}`;
77
- }
78
- rendered = `${rendered}${separator}${item}`;
79
- }
80
- return rendered;
81
- };
82
-
83
- const flatCoverage = (
84
- files: ReadonlyArray<ChangedFile>,
85
- totalFiles: number,
86
- trace: ToolTrace,
87
- ): ReviewCoverage => {
88
- const requiredPaths = sortedUnique(files.map((file) => file.path));
89
- const reviewed = new Set<string>();
90
- const failedPaths = new Set<string>();
91
- for (const [toolCallId, declaration] of trace.declared) {
92
- if (declaration.toolName !== "read_file_diff") continue;
93
- const query = Schema.decodeUnknownOption(FileDiffQuery)(declaration.parameters);
94
- if (Option.isNone(query)) continue;
95
- if (trace.succeeded.has(toolCallId)) reviewed.add(query.value.path);
96
- if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
97
- }
98
- const undiffable = files.filter((file) => file.patch === undefined).map((file) => file.path);
99
- const unreviewed = requiredPaths.filter(
100
- (path) => !reviewed.has(path) || undiffable.includes(path) || failedPaths.has(path),
101
- );
102
- const reasons: Array<string> = [];
103
- if (files.length < totalFiles) {
104
- reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
105
- }
106
- if (undiffable.length > 0) {
107
- reasons.push(boundedListReason("required paths have no textual diff", undiffable));
108
- }
109
- if (failedPaths.size > 0) {
110
- reasons.push(boundedListReason("diff reads failed", failedPaths));
111
- }
112
- if (unreviewed.length > 0) {
113
- reasons.push(boundedListReason("required paths were not successfully reviewed", unreviewed));
114
- }
115
- return ReviewCoverage.make({
116
- status: reasons.length === 0 ? "complete" : "incomplete",
117
- requiredPaths,
118
- reviewedPaths: sortedUnique(reviewed),
119
- unreviewedPaths: sortedUnique(unreviewed),
120
- failedUnits: [],
121
- reasons,
122
- });
123
- };
124
-
125
- const fanOutCoverage = (
126
- files: ReadonlyArray<ChangedFile>,
127
- totalFiles: number,
128
- trace: ToolTrace,
129
- ): ReviewCoverage => {
130
- const plan = planReviewUnits(files, { totalChangedFiles: totalFiles });
131
- const declarationsByUnit = new Map<
132
- string,
133
- Array<{ readonly id: string; readonly paths: ReadonlyArray<string> }>
134
- >();
135
- for (const [toolCallId, declaration] of trace.declared) {
136
- if (declaration.toolName !== "delegate_file_review") continue;
137
- const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
138
- if (Option.isNone(request)) continue;
139
- const declarations = declarationsByUnit.get(request.value.unitId) ?? [];
140
- declarations.push({ id: toolCallId, paths: request.value.paths });
141
- declarationsByUnit.set(request.value.unitId, declarations);
142
- }
143
-
144
- const reviewed = new Set<string>();
145
- const unreviewed = new Set<string>([...plan.undiffablePaths, ...plan.unassignedPaths]);
146
- const failedUnits: Array<FailedReviewUnit> = [];
147
- const reasons: Array<string> = [];
148
- for (const unit of plan.units) {
149
- const declarations = declarationsByUnit.get(unit.unitId) ?? [];
150
- const expectedPaths = [...unit.paths];
151
- const exact = declarations.filter(
152
- (declaration) =>
153
- declaration.paths.length === expectedPaths.length &&
154
- declaration.paths.every((path, index) => path === expectedPaths[index]),
155
- );
156
- const successful = exact.filter((declaration) => {
157
- const event = trace.succeeded.get(declaration.id);
158
- if (event === undefined || trace.failed.has(declaration.id)) return false;
159
- const result = Schema.decodeUnknownOption(FileReviewUnitResult)(event.result);
160
- return Option.isSome(result) && result.value.unitId === unit.unitId;
161
- });
162
- if (declarations.length === 1 && exact.length === 1 && successful.length === 1) {
163
- for (const path of unit.paths) reviewed.add(path);
164
- continue;
165
- }
166
- for (const path of unit.paths) unreviewed.add(path);
167
- const failure = declarations
168
- .map((declaration) => trace.failed.get(declaration.id))
169
- .find((event) => event !== undefined);
170
- const returnedFailure = declarations
171
- .map((declaration) => trace.succeeded.get(declaration.id))
172
- .filter((event) => event !== undefined)
173
- .map((event) => Schema.decodeUnknownOption(FileReviewDelegationFailure)(event.result))
174
- .find(Option.isSome);
175
- failedUnits.push(
176
- FailedReviewUnit.make({
177
- unitId: unit.unitId,
178
- errorTag:
179
- failure?.errorTag ??
180
- (returnedFailure !== undefined
181
- ? returnedFailure.value._tag === "FileReviewUnitFailed"
182
- ? `${returnedFailure.value._tag}:${returnedFailure.value.childErrorTag}`
183
- : returnedFailure.value._tag
184
- : undefined) ??
185
- (declarations.length === 0
186
- ? "UnitNotAssigned"
187
- : declarations.length > 1
188
- ? "UnitAssignedMultipleTimes"
189
- : exact.length === 0
190
- ? "UnitAssignmentMismatch"
191
- : "UnitDidNotSettleSuccessfully"),
192
- }),
193
- );
194
- }
195
- if (plan.truncated) {
196
- reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
197
- }
198
- if (plan.undiffablePaths.length > 0) {
199
- reasons.push(boundedListReason("required paths have no textual diff", plan.undiffablePaths));
200
- }
201
- if (plan.unassignedPaths.length > 0) {
202
- reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
203
- }
204
- if (failedUnits.length > 0) {
205
- reasons.push(
206
- boundedListReason(
207
- "review units did not complete",
208
- failedUnits.map((unit) => `${unit.unitId} (${unit.errorTag})`),
209
- ),
210
- );
211
- }
212
- return ReviewCoverage.make({
213
- status: reasons.length === 0 ? "complete" : "incomplete",
214
- requiredPaths: sortedUnique(files.map((file) => file.path)),
215
- reviewedPaths: sortedUnique(reviewed),
216
- unreviewedPaths: sortedUnique(unreviewed),
217
- failedUnits,
218
- reasons,
219
- });
220
- };
221
-
222
- /** Assess one settled run without trusting its prose summary or verdict. */
223
- export const assessReviewCoverage = (input: {
224
- readonly shape: ReviewShape;
225
- readonly files: ReadonlyArray<ChangedFile>;
226
- readonly totalFiles: number;
227
- readonly anchorFiles: ReadonlyArray<ChangedFile>;
228
- readonly totalAnchorFiles: number;
229
- readonly events: ReadonlyArray<RunEvent>;
230
- }): ReviewCoverage => {
231
- const trace = toolTrace(input.events);
232
- const coverage =
233
- input.shape === "fan-out"
234
- ? fanOutCoverage(input.files, input.totalFiles, trace)
235
- : flatCoverage(input.files, input.totalFiles, trace);
236
- if (input.anchorFiles.length >= input.totalAnchorFiles) return coverage;
237
- return ReviewCoverage.make({
238
- ...coverage,
239
- status: "incomplete",
240
- reasons: [
241
- ...coverage.reasons,
242
- `full pull-request anchor surface exposed ${input.anchorFiles.length} of ${input.totalAnchorFiles} required files`,
243
- ],
244
- });
245
- };
@@ -1,134 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- // ---------------------------------------------------------------------------
4
- // Changed-file and unified-diff primitives shared by the tool surface, the
5
- // publication planner, and the GitHub adapter. The parser is deterministic
6
- // and bounded; it never throws on malformed hunks — unparseable patch text
7
- // simply yields no commentable lines, which fails findings closed.
8
- // ---------------------------------------------------------------------------
9
-
10
- /** A repository-relative file path as transported values carry it. */
11
- export const ChangedPath = Schema.NonEmptyString.check(Schema.isMaxLength(512));
12
-
13
- /** GitHub's changed-file status vocabulary, kept verbatim. */
14
- export const ChangedFileStatus = Schema.Literals([
15
- "added",
16
- "removed",
17
- "modified",
18
- "renamed",
19
- "copied",
20
- "changed",
21
- "unchanged",
22
- ]);
23
-
24
- /** One file changed by the pull request, with its optional textual patch. */
25
- export class ChangedFile extends Schema.Class<ChangedFile>("@effect-agent/pr-review/ChangedFile")({
26
- path: ChangedPath,
27
- status: ChangedFileStatus,
28
- additions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
29
- deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
30
- /** Present for renames/copies: the path the file previously had. */
31
- previousPath: Schema.optionalKey(ChangedPath),
32
- /** Unified-diff hunks; absent for binary or oversized files. */
33
- patch: Schema.optionalKey(Schema.String),
34
- }) {}
35
-
36
- /** One parsed line of a unified diff, with both coordinate systems. */
37
- export interface PatchLine {
38
- readonly kind: "context" | "add" | "del";
39
- readonly oldLine: number | undefined;
40
- readonly newLine: number | undefined;
41
- readonly text: string;
42
- }
43
-
44
- const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
45
-
46
- /**
47
- * Parse unified-diff hunk text into coordinate-tagged lines. Lines outside a
48
- * recognized hunk header are ignored rather than guessed at.
49
- */
50
- export const parsePatch = (patch: string): ReadonlyArray<PatchLine> => {
51
- const lines: Array<PatchLine> = [];
52
- let oldLine = 0;
53
- let newLine = 0;
54
- let inHunk = false;
55
- for (const raw of patch.split("\n")) {
56
- const header = HUNK_HEADER.exec(raw);
57
- if (header !== null) {
58
- oldLine = Number(header[1]);
59
- newLine = Number(header[2]);
60
- inHunk = true;
61
- continue;
62
- }
63
- if (!inHunk) continue;
64
- if (raw.startsWith("+")) {
65
- lines.push({ kind: "add", oldLine: undefined, newLine, text: raw.slice(1) });
66
- newLine += 1;
67
- } else if (raw.startsWith("-")) {
68
- lines.push({ kind: "del", oldLine, newLine: undefined, text: raw.slice(1) });
69
- oldLine += 1;
70
- } else if (raw.startsWith(" ") || raw === "") {
71
- lines.push({ kind: "context", oldLine, newLine, text: raw.slice(1) });
72
- oldLine += 1;
73
- newLine += 1;
74
- } else if (raw.startsWith("\\")) {
75
- // "" — metadata, not a diff line.
76
- } else {
77
- // Unrecognized content ends the current hunk conservatively.
78
- inHunk = false;
79
- }
80
- }
81
- return lines;
82
- };
83
-
84
- /**
85
- * The new-file line numbers a GitHub review comment may anchor to on the
86
- * RIGHT side: every added or context line that appears in the diff.
87
- */
88
- export const commentableLines = (patch: string): ReadonlySet<number> => {
89
- const lines = new Set<number>();
90
- for (const line of parsePatch(patch)) {
91
- if (line.newLine !== undefined) lines.add(line.newLine);
92
- }
93
- return lines;
94
- };
95
-
96
- /**
97
- * Render a patch with explicit RIGHT-side line numbers so the model can
98
- * anchor findings without arithmetic. `R<n>` marks a line that exists in the
99
- * new version of the file (`+` added, blank context); deleted lines keep a
100
- * bare `-` marker and no number.
101
- */
102
- export const annotatePatch = (patch: string): string => {
103
- const output: Array<string> = [];
104
- let oldLine = 0;
105
- let newLine = 0;
106
- let inHunk = false;
107
- for (const raw of patch.split("\n")) {
108
- const header = HUNK_HEADER.exec(raw);
109
- if (header !== null) {
110
- oldLine = Number(header[1]);
111
- newLine = Number(header[2]);
112
- inHunk = true;
113
- output.push(raw);
114
- continue;
115
- }
116
- if (!inHunk) continue;
117
- if (raw.startsWith("+")) {
118
- output.push(`R${newLine} + ${raw.slice(1)}`);
119
- newLine += 1;
120
- } else if (raw.startsWith("-")) {
121
- output.push(` - ${raw.slice(1)}`);
122
- oldLine += 1;
123
- } else if (raw.startsWith(" ") || raw === "") {
124
- output.push(`R${newLine} ${raw.slice(1)}`);
125
- oldLine += 1;
126
- newLine += 1;
127
- } else if (raw.startsWith("\\")) {
128
- output.push(` ${raw}`);
129
- } else {
130
- inHunk = false;
131
- }
132
- }
133
- return output.join("\n");
134
- };