@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.
- package/NOTICE +26 -0
- package/README.md +170 -158
- package/dist/Review.d.mts +295 -0
- package/dist/Review.mjs +704 -0
- package/dist/Review.mjs.map +1 -0
- package/dist/ReviewRepository-Wd_4qCaO.d.mts +71 -0
- package/dist/ReviewRepository.d.mts +2 -0
- package/dist/ReviewRepository.mjs +15 -0
- package/dist/ReviewRepository.mjs.map +1 -0
- package/dist/index.d.mts +3 -716
- package/dist/index.mjs +3 -66
- package/dist/repository-BzSG74vX.mjs +101 -0
- package/dist/repository-BzSG74vX.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -54
- package/src/Review.ts +1058 -0
- package/src/ReviewRepository.ts +9 -0
- package/src/index.ts +2 -20
- package/src/internal/repository.ts +156 -0
- package/dist/action.d.mts +0 -185
- package/dist/action.mjs +0 -406
- package/dist/action.mjs.map +0 -1
- package/dist/cli.d.mts +0 -1
- package/dist/cli.mjs +0 -102
- package/dist/cli.mjs.map +0 -1
- package/dist/fan-out-BBEATQwc.d.mts +0 -997
- package/dist/github-BZNzmxao.mjs +0 -1372
- package/dist/github-BZNzmxao.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/providers-J6BKHyHe.mjs +0 -986
- package/dist/providers-J6BKHyHe.mjs.map +0 -1
- package/dist/testing.d.mts +0 -131
- package/dist/testing.mjs +0 -228
- package/dist/testing.mjs.map +0 -1
- package/src/action.ts +0 -666
- package/src/cli.ts +0 -213
- package/src/internal/action-entry.ts +0 -41
- package/src/internal/coverage.ts +0 -245
- package/src/internal/diff.ts +0 -134
- package/src/internal/effort.ts +0 -86
- package/src/internal/factory.ts +0 -374
- package/src/internal/fan-out-scripted.ts +0 -164
- package/src/internal/fan-out.ts +0 -450
- package/src/internal/fingerprint.ts +0 -74
- package/src/internal/fixtures.ts +0 -127
- package/src/internal/github-env.ts +0 -128
- package/src/internal/github.ts +0 -531
- package/src/internal/ignore.ts +0 -88
- package/src/internal/profiles.ts +0 -79
- package/src/internal/providers.ts +0 -91
- package/src/internal/render.ts +0 -428
- package/src/internal/review-agent.ts +0 -385
- package/src/internal/review-state.ts +0 -488
- package/src/internal/review-units.ts +0 -167
- package/src/internal/run.ts +0 -397
- package/src/internal/scripted.ts +0 -108
- package/src/internal/source.ts +0 -110
- 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();
|
package/src/internal/coverage.ts
DELETED
|
@@ -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
|
-
};
|
package/src/internal/diff.ts
DELETED
|
@@ -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
|
-
};
|