@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/internal/ignore.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { Effect, Layer } from "effect";
|
|
2
|
-
|
|
3
|
-
import { PullRequestMetadata, PullRequestSource, ReviewInputViolation } from "./source.ts";
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Configured ignore globs, applied at the source port. Ignored files are
|
|
7
|
-
// removed from the reviewer's entire observation surface — the changeset
|
|
8
|
-
// list, diffs, and head reads — so the model never spends budget on them and
|
|
9
|
-
// can never anchor a finding to them. Filtering fails closed: reading an
|
|
10
|
-
// ignored path is a ReviewInputViolation, exactly like a path outside the
|
|
11
|
-
// changeset.
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
const REGEX_SPECIALS = /[.+^${}()|[\]\\]/g;
|
|
15
|
-
|
|
16
|
-
// Placeholders for the directory-crossing wildcard while single-segment
|
|
17
|
-
// wildcards are rewritten; NUL/SOH cannot appear in a valid repository path.
|
|
18
|
-
const CROSSING_SLASH = "\u0000";
|
|
19
|
-
const CROSSING = "\u0001";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The supported glob vocabulary is deliberately minimal: `**` crosses
|
|
23
|
-
* directory separators, `*` and `?` stay within one path segment, everything
|
|
24
|
-
* else is literal. Every string compiles — there is no invalid pattern.
|
|
25
|
-
*/
|
|
26
|
-
const globToRegExpSource = (pattern: string): string =>
|
|
27
|
-
pattern
|
|
28
|
-
.replace(REGEX_SPECIALS, String.raw`\$&`)
|
|
29
|
-
.replaceAll("**/", CROSSING_SLASH)
|
|
30
|
-
.replaceAll("**", CROSSING)
|
|
31
|
-
.replaceAll("*", "[^/]*")
|
|
32
|
-
.replaceAll("?", "[^/]")
|
|
33
|
-
.replaceAll(CROSSING_SLASH, "(?:.*/)?")
|
|
34
|
-
.replaceAll(CROSSING, ".*");
|
|
35
|
-
|
|
36
|
-
/** Compile ignore globs into one predicate over repository-relative paths. */
|
|
37
|
-
export const compileIgnoreGlobs = (
|
|
38
|
-
patterns: ReadonlyArray<string>,
|
|
39
|
-
): ((path: string) => boolean) => {
|
|
40
|
-
if (patterns.length === 0) return () => false;
|
|
41
|
-
const expressions = patterns.map((pattern) => new RegExp(`^(?:${globToRegExpSource(pattern)})$`));
|
|
42
|
-
return (path) => expressions.some((expression) => expression.test(path));
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Decorate the ambient PullRequestSource with configured ignore globs. The
|
|
47
|
-
* resulting Layer requires the undecorated source, so callers provide their
|
|
48
|
-
* real adapter beneath it. Metadata's changed-file total is reduced by the
|
|
49
|
-
* ignored count: from the reviewer's perspective the ignored files do not
|
|
50
|
-
* exist, and truncation reporting stays about the reviewer's own bound.
|
|
51
|
-
*/
|
|
52
|
-
export const ignoringPullRequestSourceLayer = (
|
|
53
|
-
patterns: ReadonlyArray<string>,
|
|
54
|
-
): Layer.Layer<PullRequestSource, never, PullRequestSource> =>
|
|
55
|
-
Layer.effect(PullRequestSource)(
|
|
56
|
-
Effect.gen(function* () {
|
|
57
|
-
const source = yield* PullRequestSource;
|
|
58
|
-
const ignored = compileIgnoreGlobs(patterns);
|
|
59
|
-
const changedFiles = source.changedFiles.pipe(
|
|
60
|
-
Effect.map((files) => files.filter((file) => !ignored(file.path))),
|
|
61
|
-
);
|
|
62
|
-
const anchorFiles = source.anchorFiles.pipe(
|
|
63
|
-
Effect.map((files) => files.filter((file) => !ignored(file.path))),
|
|
64
|
-
);
|
|
65
|
-
const metadata = Effect.gen(function* () {
|
|
66
|
-
const [meta, files] = yield* Effect.all([source.metadata, source.anchorFiles]);
|
|
67
|
-
const ignoredCount = files.filter((file) => ignored(file.path)).length;
|
|
68
|
-
return PullRequestMetadata.make({
|
|
69
|
-
...meta,
|
|
70
|
-
totalChangedFiles: Math.max(0, meta.totalChangedFiles - ignoredCount),
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
return PullRequestSource.of({
|
|
74
|
-
metadata,
|
|
75
|
-
changedFiles,
|
|
76
|
-
anchorFiles,
|
|
77
|
-
readFile: (path) =>
|
|
78
|
-
ignored(path)
|
|
79
|
-
? Effect.fail(
|
|
80
|
-
ReviewInputViolation.make({
|
|
81
|
-
input: path,
|
|
82
|
-
reason: "Path is excluded from this review by configuration.",
|
|
83
|
-
}),
|
|
84
|
-
)
|
|
85
|
-
: source.readFile(path),
|
|
86
|
-
});
|
|
87
|
-
}),
|
|
88
|
-
);
|
package/src/internal/profiles.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Committed capability claims, schema-first: what this package's reviewers
|
|
5
|
-
// promise and — just as deliberately — what they never claim. Deployment
|
|
6
|
-
// class E (ephemeral): one bounded AgentRuntime.run per invocation, no
|
|
7
|
-
// durability, no exactly-once external effects (DUR-003).
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
/** The flat reviewer's committed capability claim. */
|
|
11
|
-
export class PullRequestReviewerProfile extends Schema.Class<PullRequestReviewerProfile>(
|
|
12
|
-
"@effect-agent/pr-review/PullRequestReviewerProfile",
|
|
13
|
-
)({
|
|
14
|
-
/** Ephemeral runtime: one bounded AgentRuntime.run per invocation. */
|
|
15
|
-
deploymentClass: Schema.Literal("E"),
|
|
16
|
-
/** Every model-callable tool is a read of the pull request; none mutate. */
|
|
17
|
-
readOnlyToolSurface: Schema.Literal(true),
|
|
18
|
-
/** The review is posted by the host AFTER the run settles, never by a tool. */
|
|
19
|
-
publicationOutsideAgentLoop: Schema.Literal(true),
|
|
20
|
-
/** Finding anchors are validated against the parsed diff before posting. */
|
|
21
|
-
anchorsValidatedBeforePublication: Schema.Literal(true),
|
|
22
|
-
/** The live profile is env-gated out of every ordinary test gate. */
|
|
23
|
-
liveProfileOptIn: Schema.Literal(true),
|
|
24
|
-
/** Never claimed at any phase (DUR-003). */
|
|
25
|
-
exactlyOnceExternalEffects: Schema.Literal(false),
|
|
26
|
-
}) {}
|
|
27
|
-
|
|
28
|
-
export const pullRequestReviewerProfile = PullRequestReviewerProfile.make({
|
|
29
|
-
deploymentClass: "E",
|
|
30
|
-
readOnlyToolSurface: true,
|
|
31
|
-
publicationOutsideAgentLoop: true,
|
|
32
|
-
anchorsValidatedBeforePublication: true,
|
|
33
|
-
liveProfileOptIn: true,
|
|
34
|
-
exactlyOnceExternalEffects: false,
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
/** The fan-out reviewer's committed capability claim, schema-first. */
|
|
38
|
-
export class FanOutReviewerProfile extends Schema.Class<FanOutReviewerProfile>(
|
|
39
|
-
"@effect-agent/pr-review/FanOutReviewerProfile",
|
|
40
|
-
)({
|
|
41
|
-
/** Ephemeral runtime: one bounded AgentRuntime.run per invocation. */
|
|
42
|
-
deploymentClass: Schema.Literal("E"),
|
|
43
|
-
/** Every model-callable tool — parent and child — is a read; none mutate. */
|
|
44
|
-
readOnlyToolSurface: Schema.Literal(true),
|
|
45
|
-
/** The review is posted by the host AFTER the run settles, never by a tool. */
|
|
46
|
-
publicationOutsideAgentLoop: Schema.Literal(true),
|
|
47
|
-
/** Child findings are untrusted; anchors are validated against the parsed diff. */
|
|
48
|
-
anchorsValidatedBeforePublication: Schema.Literal(true),
|
|
49
|
-
/** S1 attached ephemeral delegation at depth 1; nested delegation is rejected. */
|
|
50
|
-
attachedEphemeralDelegation: Schema.Literal(true),
|
|
51
|
-
/** A failed unit surfaces to the coordinator as a typed failed result, never retried. */
|
|
52
|
-
failedUnitsReportedNotRetried: Schema.Literal(true),
|
|
53
|
-
/** The live profile is env-gated out of every ordinary test gate. */
|
|
54
|
-
liveProfileOptIn: Schema.Literal(true),
|
|
55
|
-
/** Never claimed at any phase (DUR-003). */
|
|
56
|
-
exactlyOnceExternalEffects: Schema.Literal(false),
|
|
57
|
-
}) {}
|
|
58
|
-
|
|
59
|
-
export const fanOutReviewerProfile = FanOutReviewerProfile.make({
|
|
60
|
-
deploymentClass: "E",
|
|
61
|
-
readOnlyToolSurface: true,
|
|
62
|
-
publicationOutsideAgentLoop: true,
|
|
63
|
-
anchorsValidatedBeforePublication: true,
|
|
64
|
-
attachedEphemeralDelegation: true,
|
|
65
|
-
failedUnitsReportedNotRetried: true,
|
|
66
|
-
liveProfileOptIn: true,
|
|
67
|
-
exactlyOnceExternalEffects: false,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
export const LIVE_GATE_ENV = "EFFECT_AGENT_LIVE";
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* `EFFECT_AGENT_LIVE=1` plus the named credential is the only enabling
|
|
74
|
-
* combination for live (network, billed) profiles.
|
|
75
|
-
*/
|
|
76
|
-
export const liveProfileEnabled = (
|
|
77
|
-
env: Record<string, string | undefined>,
|
|
78
|
-
credentialEnv: string,
|
|
79
|
-
): boolean => env[LIVE_GATE_ENV] === "1" && (env[credentialEnv] ?? "") !== "";
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
|
|
2
|
-
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
|
|
3
|
-
import { Config, Layer } from "effect";
|
|
4
|
-
import { FetchHttpClient } from "effect/unstable/http";
|
|
5
|
-
|
|
6
|
-
import { resolveEffortRung, type EffortAliasName, type EffortPosition } from "./effort.ts";
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Built-in provider bindings for the two host entrypoints (CLI and Action).
|
|
10
|
-
// The library itself stays provider-agnostic — the configuration factory
|
|
11
|
-
// takes any Effect AI Model — and these helpers exist so the batteries-
|
|
12
|
-
// included paths need one flag and one credential, nothing more. Client
|
|
13
|
-
// Layers carry their redacted credentials from configuration; the
|
|
14
|
-
// application supplies them at the edge (D-027).
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
|
-
export type ReviewProvider = "openai" | "anthropic";
|
|
18
|
-
|
|
19
|
-
export const DEFAULT_PROVIDER: ReviewProvider = "openai";
|
|
20
|
-
|
|
21
|
-
export const DEFAULT_MODEL: Record<ReviewProvider, string> = {
|
|
22
|
-
openai: "gpt-5.6-sol",
|
|
23
|
-
anthropic: "claude-sonnet-5",
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export const PROVIDER_CREDENTIAL_ENV: Record<ReviewProvider, string> = {
|
|
27
|
-
openai: "OPENAI_API_KEY",
|
|
28
|
-
anthropic: "ANTHROPIC_API_KEY",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Each provider's offered reasoning-effort ladder, cheapest first. The rungs
|
|
33
|
-
* that turn reasoning off (`none`, `minimal`) are deliberately not offered —
|
|
34
|
-
* no review run wants them. An `EffortPosition` resolves into the running
|
|
35
|
-
* provider's own ladder, so the same stored position survives a provider or
|
|
36
|
-
* model change.
|
|
37
|
-
*/
|
|
38
|
-
export const PROVIDER_EFFORT_RUNGS = {
|
|
39
|
-
openai: ["low", "medium", "high", "xhigh"],
|
|
40
|
-
anthropic: ["low", "medium", "high"],
|
|
41
|
-
} as const satisfies Record<
|
|
42
|
-
ReviewProvider,
|
|
43
|
-
readonly [EffortAliasName, ...ReadonlyArray<EffortAliasName>]
|
|
44
|
-
>;
|
|
45
|
-
|
|
46
|
-
/** One OpenAI review model binding with the package's structured-output settings. */
|
|
47
|
-
export const makeOpenAiReviewModel = (model?: string, effort?: EffortPosition) =>
|
|
48
|
-
OpenAiLanguageModel.model(model ?? DEFAULT_MODEL.openai, {
|
|
49
|
-
max_output_tokens: 8_000,
|
|
50
|
-
store: false,
|
|
51
|
-
strictJsonSchema: true,
|
|
52
|
-
...(effort === undefined
|
|
53
|
-
? {}
|
|
54
|
-
: { reasoning: { effort: resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS.openai) } }),
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/** One Anthropic review model binding with the package's output settings. */
|
|
58
|
-
export const makeAnthropicReviewModel = (model?: string, effort?: EffortPosition) =>
|
|
59
|
-
AnthropicLanguageModel.model(model ?? DEFAULT_MODEL.anthropic, {
|
|
60
|
-
max_tokens: 8_000,
|
|
61
|
-
...(effort === undefined
|
|
62
|
-
? {}
|
|
63
|
-
: { output_config: { effort: resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS.anthropic) } }),
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The human-readable descriptor of one provider binding, e.g.
|
|
68
|
-
* `openai/gpt-5.6-sol (effort high)`. Rendered into the review footer and
|
|
69
|
-
* included in the changeset-fingerprint signature, so a provider, model, or
|
|
70
|
-
* effort change re-reviews instead of skipping.
|
|
71
|
-
*/
|
|
72
|
-
export const describeReviewModel = (
|
|
73
|
-
provider: ReviewProvider,
|
|
74
|
-
model?: string,
|
|
75
|
-
effort?: EffortPosition,
|
|
76
|
-
): string => {
|
|
77
|
-
const base = `${provider}/${model ?? DEFAULT_MODEL[provider]}`;
|
|
78
|
-
return effort === undefined
|
|
79
|
-
? base
|
|
80
|
-
: `${base} (effort ${resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS[provider])})`;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/** The OpenAI client Layer, credential from `OPENAI_API_KEY`. */
|
|
84
|
-
export const openAiClientLayer = OpenAiClient.layerConfig({
|
|
85
|
-
apiKey: Config.redacted(PROVIDER_CREDENTIAL_ENV.openai),
|
|
86
|
-
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
87
|
-
|
|
88
|
-
/** The Anthropic client Layer, credential from `ANTHROPIC_API_KEY`. */
|
|
89
|
-
export const anthropicClientLayer = AnthropicClient.layerConfig({
|
|
90
|
-
apiKey: Config.redacted(PROVIDER_CREDENTIAL_ENV.anthropic),
|
|
91
|
-
}).pipe(Layer.provide(FetchHttpClient.layer));
|
package/src/internal/render.ts
DELETED
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
|
|
3
|
-
import type { ReviewCoverage } from "./coverage.ts";
|
|
4
|
-
import { commentableLines, type ChangedFile } from "./diff.ts";
|
|
5
|
-
import { renderFingerprintMarker } from "./fingerprint.ts";
|
|
6
|
-
import { ReviewFinding, type CodeReview, type ReviewConcern } from "./review-agent.ts";
|
|
7
|
-
import type { ReviewScopeMode, ReviewStateMarker } from "./review-state.ts";
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Publication planning: pure, deterministic, and fail-closed. Model output is
|
|
11
|
-
// untrusted input, so every finding anchor is validated against the parsed
|
|
12
|
-
// diff before it may become an inline comment; findings that fail validation
|
|
13
|
-
// are demoted into the review body instead of being dropped or trusted. This
|
|
14
|
-
// module is deliberately not configurable — customization widens what goes
|
|
15
|
-
// into a review, never what leaves it unvalidated.
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
export const ReviewEvent = Schema.Literals(["COMMENT", "APPROVE", "REQUEST_CHANGES"]);
|
|
19
|
-
export type ReviewEvent = typeof ReviewEvent.Type;
|
|
20
|
-
|
|
21
|
-
/** One inline comment exactly as the GitHub review API accepts it. */
|
|
22
|
-
export class ReviewCommentDraft extends Schema.Class<ReviewCommentDraft>(
|
|
23
|
-
"@effect-agent/pr-review/ReviewCommentDraft",
|
|
24
|
-
)({
|
|
25
|
-
path: Schema.NonEmptyString,
|
|
26
|
-
/** The last (or only) commented line, RIGHT side of the diff. */
|
|
27
|
-
line: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
28
|
-
/** Present only for multi-line comments; strictly less than `line`. */
|
|
29
|
-
startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
|
|
30
|
-
body: Schema.NonEmptyString,
|
|
31
|
-
}) {}
|
|
32
|
-
|
|
33
|
-
/** The complete, validated review ready for one GitHub reviews API call. */
|
|
34
|
-
export class ReviewPublicationPlan extends Schema.Class<ReviewPublicationPlan>(
|
|
35
|
-
"@effect-agent/pr-review/ReviewPublicationPlan",
|
|
36
|
-
)({
|
|
37
|
-
event: ReviewEvent,
|
|
38
|
-
body: Schema.String.check(Schema.isMaxLength(60_000)),
|
|
39
|
-
comments: Schema.Array(ReviewCommentDraft),
|
|
40
|
-
/** Findings whose anchors failed diff validation; folded into `body`. */
|
|
41
|
-
demoted: Schema.Array(ReviewFinding),
|
|
42
|
-
/** The head commit the diffs were fetched at; pins the posted review. */
|
|
43
|
-
commitSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
|
|
44
|
-
}) {}
|
|
45
|
-
|
|
46
|
-
const severityEmoji: Record<ReviewFinding["severity"], string> = {
|
|
47
|
-
blocking: "🛑",
|
|
48
|
-
important: "⚠️",
|
|
49
|
-
nit: "💅",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const severityRank: Record<ReviewFinding["severity"], number> = {
|
|
53
|
-
blocking: 0,
|
|
54
|
-
important: 1,
|
|
55
|
-
nit: 2,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const severityLabel: Record<ReviewFinding["severity"], string> = {
|
|
59
|
-
blocking: `${severityEmoji.blocking} blocking`,
|
|
60
|
-
important: `${severityEmoji.important} important`,
|
|
61
|
-
nit: `${severityEmoji.nit} nit`,
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
/** A fence long enough that the suggestion content can never close it early. */
|
|
65
|
-
const suggestionFence = (suggestion: string): string => {
|
|
66
|
-
let fence = "```";
|
|
67
|
-
while (suggestion.includes(fence)) fence = `${fence}\``;
|
|
68
|
-
return fence;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const renderCommentBody = (finding: ReviewFinding): string => {
|
|
72
|
-
const parts = [`**[${severityLabel[finding.severity]}] ${finding.title}**`, "", finding.body];
|
|
73
|
-
if (finding.suggestion !== undefined) {
|
|
74
|
-
const fence = suggestionFence(finding.suggestion);
|
|
75
|
-
parts.push("", `${fence}suggestion`, finding.suggestion, fence);
|
|
76
|
-
}
|
|
77
|
-
return parts.join("\n");
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const renderDemoted = (finding: ReviewFinding, reason: string): string => {
|
|
81
|
-
const location = `\`${finding.path}:${finding.startLine}${
|
|
82
|
-
finding.endLine !== finding.startLine ? `-${finding.endLine}` : ""
|
|
83
|
-
}\``;
|
|
84
|
-
return `- ${location} **[${severityLabel[finding.severity]}] ${finding.title}** — ${finding.body} _(demoted: ${reason})_`;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const countNoun = (count: number, noun: string): string =>
|
|
88
|
-
`${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
89
|
-
|
|
90
|
-
/** The validated finding + concern severities, tallied for callout and event. */
|
|
91
|
-
const severityCounts = (
|
|
92
|
-
review: CodeReview,
|
|
93
|
-
carriedFindings: ReadonlyArray<ReviewFinding> = [],
|
|
94
|
-
carriedConcerns: ReadonlyArray<ReviewConcern> = [],
|
|
95
|
-
) => {
|
|
96
|
-
const severities = [
|
|
97
|
-
...review.findings.map((finding) => finding.severity),
|
|
98
|
-
...(review.concerns ?? []).map((concern) => concern.severity),
|
|
99
|
-
...carriedFindings.map((finding) => finding.severity),
|
|
100
|
-
...carriedConcerns.map((concern) => concern.severity),
|
|
101
|
-
];
|
|
102
|
-
return {
|
|
103
|
-
blocking: severities.filter((severity) => severity === "blocking").length,
|
|
104
|
-
important: severities.filter((severity) => severity === "important").length,
|
|
105
|
-
total: severities.length,
|
|
106
|
-
};
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* The opening callout: the review's overall tier, derived HOST-SIDE from the
|
|
111
|
-
* validated severities (never from model prose), described by what GitHub
|
|
112
|
-
* renders it as. `[!CAUTION]` is a red banner, `[!IMPORTANT]` a purple one;
|
|
113
|
-
* the blockquote tiers read as informational.
|
|
114
|
-
*/
|
|
115
|
-
const renderVerdictCallout = (
|
|
116
|
-
review: CodeReview,
|
|
117
|
-
options: {
|
|
118
|
-
readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
|
|
119
|
-
readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
|
|
120
|
-
readonly coverage?: ReviewCoverage | undefined;
|
|
121
|
-
},
|
|
122
|
-
): string => {
|
|
123
|
-
const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
|
|
124
|
-
if (options.coverage?.status === "incomplete") {
|
|
125
|
-
const suffix =
|
|
126
|
-
counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
|
|
127
|
-
return `> [!CAUTION]\n> Review coverage is incomplete — the check must not pass.${suffix}`;
|
|
128
|
-
}
|
|
129
|
-
if (counts.blocking > 0) {
|
|
130
|
-
return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
|
|
131
|
-
}
|
|
132
|
-
if (counts.important > 0) {
|
|
133
|
-
return `> [!IMPORTANT]\n> ${countNoun(counts.important, "important finding")} to address before merging.`;
|
|
134
|
-
}
|
|
135
|
-
if (counts.total > 0) {
|
|
136
|
-
return "> ℹ️ Minor suggestions only — mergeable as-is.";
|
|
137
|
-
}
|
|
138
|
-
return review.verdict === "approve"
|
|
139
|
-
? "> ✅ No issues found."
|
|
140
|
-
: "> ℹ️ No findings — see the summary.";
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const renderConcern = (concern: ReviewConcern): string =>
|
|
144
|
-
[`### ${severityEmoji[concern.severity]} ${concern.title}`, "", concern.body].join("\n");
|
|
145
|
-
|
|
146
|
-
const renderCarriedFinding = (finding: ReviewFinding): string =>
|
|
147
|
-
`- \`${finding.path}:${finding.startLine}${finding.endLine === finding.startLine ? "" : `-${finding.endLine}`}\` **[${severityLabel[finding.severity]}] ${finding.title}** — ${finding.body}`;
|
|
148
|
-
|
|
149
|
-
/** HTML comments must not contain `--`; interpolated values are sanitized. */
|
|
150
|
-
const commentSafe = (value: string): string => value.replaceAll("--", "- -");
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* The invisible staleness note addressed to whoever reads the review later —
|
|
154
|
-
* a human or a downstream agent: which commit the findings were written
|
|
155
|
-
* against, and that line callouts age the moment new commits land.
|
|
156
|
-
*/
|
|
157
|
-
const renderReviewMetadata = (options: {
|
|
158
|
-
readonly headSha: string;
|
|
159
|
-
readonly baseRef?: string | undefined;
|
|
160
|
-
readonly headRef?: string | undefined;
|
|
161
|
-
readonly filesVisible: number;
|
|
162
|
-
readonly totalChangedFiles: number;
|
|
163
|
-
readonly reviewMode?: ReviewScopeMode | undefined;
|
|
164
|
-
readonly baselineSha?: string | undefined;
|
|
165
|
-
}): string =>
|
|
166
|
-
[
|
|
167
|
-
"<!-- effect-agent-pr-review metadata",
|
|
168
|
-
`reviewed-head: ${commentSafe(options.headSha)}`,
|
|
169
|
-
...(options.baseRef !== undefined && options.headRef !== undefined
|
|
170
|
-
? [`base-ref: ${commentSafe(options.baseRef)}`, `head-ref: ${commentSafe(options.headRef)}`]
|
|
171
|
-
: []),
|
|
172
|
-
// The observation surface, not a coverage claim: the host cannot know
|
|
173
|
-
// which visible files the model actually examined, and the summary is
|
|
174
|
-
// where unreviewed units are named.
|
|
175
|
-
`files-visible: ${options.filesVisible} of ${options.totalChangedFiles}`,
|
|
176
|
-
...(options.reviewMode === undefined ? [] : [`review-mode: ${options.reviewMode}`]),
|
|
177
|
-
...(options.baselineSha === undefined
|
|
178
|
-
? []
|
|
179
|
-
: [`incremental-baseline: ${commentSafe(options.baselineSha)}`]),
|
|
180
|
-
"Findings were written against the head commit above; if commits have landed",
|
|
181
|
-
"since, treat file and line callouts as potentially stale and re-diff first.",
|
|
182
|
-
"-->",
|
|
183
|
-
].join("\n");
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Why one finding cannot become an inline comment, or undefined when it can.
|
|
187
|
-
* Exported so tests can pin each rule individually.
|
|
188
|
-
*/
|
|
189
|
-
export const anchorViolation = (
|
|
190
|
-
finding: ReviewFinding,
|
|
191
|
-
files: ReadonlyArray<ChangedFile>,
|
|
192
|
-
): string | undefined => {
|
|
193
|
-
const file = files.find((candidate) => candidate.path === finding.path);
|
|
194
|
-
if (file === undefined) return "path is not part of the changeset";
|
|
195
|
-
if (file.patch === undefined) return "file has no textual diff";
|
|
196
|
-
if (finding.endLine < finding.startLine) return "endLine precedes startLine";
|
|
197
|
-
if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
|
|
198
|
-
const anchors = commentableLines(file.patch);
|
|
199
|
-
for (let line = finding.startLine; line <= finding.endLine; line += 1) {
|
|
200
|
-
if (!anchors.has(line)) return `line ${line} is not part of the diff`;
|
|
201
|
-
}
|
|
202
|
-
return undefined;
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Turn one validated review into the exact GitHub publication payload.
|
|
207
|
-
* `applyVerdict: false` (the safe default) always posts a COMMENT review;
|
|
208
|
-
* `true` maps the model's verdict onto APPROVE / REQUEST_CHANGES.
|
|
209
|
-
*/
|
|
210
|
-
export const planPublication = (
|
|
211
|
-
review: CodeReview,
|
|
212
|
-
files: ReadonlyArray<ChangedFile>,
|
|
213
|
-
options: {
|
|
214
|
-
readonly applyVerdict: boolean;
|
|
215
|
-
/** Head commit the changeset was fetched at (pins the posted review). */
|
|
216
|
-
readonly headSha: string;
|
|
217
|
-
/** GitHub's changed-file total, for honest truncation reporting. */
|
|
218
|
-
readonly totalChangedFiles: number;
|
|
219
|
-
/** Base/head refs for the staleness metadata comment. */
|
|
220
|
-
readonly baseRef?: string | undefined;
|
|
221
|
-
readonly headRef?: string | undefined;
|
|
222
|
-
/** Provider binding descriptor rendered into the footer. */
|
|
223
|
-
readonly modelLabel?: string | undefined;
|
|
224
|
-
/** Workflow-run URL rendered into the footer. */
|
|
225
|
-
readonly runUrl?: string | undefined;
|
|
226
|
-
/** Observed run usage rendered into the footer. */
|
|
227
|
-
readonly usage?: { readonly inputTokens: number; readonly outputTokens: number } | undefined;
|
|
228
|
-
/** What the usage observed: the whole run, or the coordinator only. */
|
|
229
|
-
readonly usageScope?: "run" | "coordinator" | undefined;
|
|
230
|
-
/**
|
|
231
|
-
* Changeset fingerprint embedded invisibly in the review body so a later
|
|
232
|
-
* run can skip re-reviewing an unchanged changeset.
|
|
233
|
-
*/
|
|
234
|
-
readonly fingerprint?: string | undefined;
|
|
235
|
-
/** Host-owned coverage; incomplete coverage is rendered and fails the check. */
|
|
236
|
-
readonly coverage?: ReviewCoverage | undefined;
|
|
237
|
-
/** Unchanged unresolved items carried from the prior successfully reviewed head. */
|
|
238
|
-
readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
|
|
239
|
-
readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
|
|
240
|
-
/** Selected review scope, made visible whenever orchestration chose it. */
|
|
241
|
-
readonly reviewMode?: ReviewScopeMode | undefined;
|
|
242
|
-
readonly reviewReason?: string | undefined;
|
|
243
|
-
readonly baselineSha?: string | undefined;
|
|
244
|
-
readonly reviewFilesVisible?: number | undefined;
|
|
245
|
-
readonly reviewTotalFiles?: number | undefined;
|
|
246
|
-
/** Authenticated continuity state is emitted only after complete host-owned coverage. */
|
|
247
|
-
readonly stateMarker?: ReviewStateMarker | undefined;
|
|
248
|
-
/** Visible reason continuity state was omitted; the next run will review fully. */
|
|
249
|
-
readonly stateNotice?: string | undefined;
|
|
250
|
-
},
|
|
251
|
-
): ReviewPublicationPlan => {
|
|
252
|
-
const comments: Array<ReviewCommentDraft> = [];
|
|
253
|
-
const demoted: Array<{ readonly finding: ReviewFinding; readonly reason: string }> = [];
|
|
254
|
-
for (const finding of review.findings) {
|
|
255
|
-
const violation = anchorViolation(finding, files);
|
|
256
|
-
if (violation === undefined) {
|
|
257
|
-
comments.push(
|
|
258
|
-
ReviewCommentDraft.make({
|
|
259
|
-
path: finding.path,
|
|
260
|
-
line: finding.endLine,
|
|
261
|
-
...(finding.endLine > finding.startLine ? { startLine: finding.startLine } : {}),
|
|
262
|
-
body: renderCommentBody(finding),
|
|
263
|
-
}),
|
|
264
|
-
);
|
|
265
|
-
} else {
|
|
266
|
-
demoted.push({ finding, reason: violation });
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Rendered most-severe first so the size cap below sheds the least severe.
|
|
271
|
-
const sortedConcerns = [...(review.concerns ?? [])].sort(
|
|
272
|
-
(a, b) => severityRank[a.severity] - severityRank[b.severity],
|
|
273
|
-
);
|
|
274
|
-
const sortedDemoted = [...demoted].sort(
|
|
275
|
-
(a, b) => severityRank[a.finding.severity] - severityRank[b.finding.severity],
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
const footerParts = ["Automated review by @effect-agent/pr-review"];
|
|
279
|
-
if (options.modelLabel !== undefined) footerParts.push(options.modelLabel);
|
|
280
|
-
// Usage renders only under an EXPLICIT scope: this planner cannot know
|
|
281
|
-
// whether a budget snapshot observed the whole run or only a fan-out
|
|
282
|
-
// coordinator, and omitting the number is honest where mislabeling is not.
|
|
283
|
-
if (options.usage !== undefined && options.usageScope !== undefined) {
|
|
284
|
-
const scope = options.usageScope === "coordinator" ? " (coordinator)" : "";
|
|
285
|
-
footerParts.push(
|
|
286
|
-
`${options.usage.inputTokens} in / ${options.usage.outputTokens} out tokens${scope}`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
if (options.runUrl !== undefined) footerParts.push(`[run](${options.runUrl})`);
|
|
290
|
-
footerParts.push(`reviewed at ${options.headSha.slice(0, 7)}`);
|
|
291
|
-
const footer = `_${footerParts.join(" · ")}._`;
|
|
292
|
-
|
|
293
|
-
const renderHead = (concernsKept: number, demotedKept: number, omitted: number): string => {
|
|
294
|
-
const carriedFindings = options.carriedFindings ?? [];
|
|
295
|
-
const carriedConcerns = options.carriedConcerns ?? [];
|
|
296
|
-
const parts = [
|
|
297
|
-
renderVerdictCallout(review, {
|
|
298
|
-
carriedFindings,
|
|
299
|
-
carriedConcerns,
|
|
300
|
-
coverage: options.coverage,
|
|
301
|
-
}),
|
|
302
|
-
];
|
|
303
|
-
if (options.reviewMode !== undefined && options.reviewReason !== undefined) {
|
|
304
|
-
parts.push(
|
|
305
|
-
"",
|
|
306
|
-
options.reviewMode === "incremental"
|
|
307
|
-
? `**Incremental scope:** reviewed ${options.reviewFilesVisible ?? files.length} file(s) ${options.reviewReason}. Unchanged accepted scope was preserved and not reopened.`
|
|
308
|
-
: `**Full-diff scope:** ${options.reviewReason}.`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
if (options.stateNotice !== undefined) {
|
|
312
|
-
parts.push(
|
|
313
|
-
"",
|
|
314
|
-
`⚠️ Continuity state was not stored (${options.stateNotice.slice(0, 1_000)}); the next run will safely review the full diff.`,
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
parts.push("", review.summary);
|
|
318
|
-
if (options.coverage?.status === "incomplete") {
|
|
319
|
-
parts.push(
|
|
320
|
-
"",
|
|
321
|
-
"### 🛑 Incomplete coverage",
|
|
322
|
-
"",
|
|
323
|
-
...options.coverage.reasons.map((reason) => `- ${reason}`),
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
if (carriedFindings.length > 0) {
|
|
327
|
-
parts.push(
|
|
328
|
-
"",
|
|
329
|
-
"### Unresolved findings carried from unchanged scope",
|
|
330
|
-
"",
|
|
331
|
-
...carriedFindings.map(renderCarriedFinding),
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
if (carriedConcerns.length > 0) {
|
|
335
|
-
parts.push("", "### Unresolved concerns carried to the final audit");
|
|
336
|
-
for (const concern of carriedConcerns) parts.push("", renderConcern(concern));
|
|
337
|
-
}
|
|
338
|
-
for (const concern of sortedConcerns.slice(0, concernsKept)) {
|
|
339
|
-
parts.push("", renderConcern(concern));
|
|
340
|
-
}
|
|
341
|
-
if (files.length < options.totalChangedFiles) {
|
|
342
|
-
parts.push(
|
|
343
|
-
"",
|
|
344
|
-
`⚠️ Reviewed ${files.length} of ${options.totalChangedFiles} changed files — the changeset exceeded the reviewer's file bound.`,
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
if (demotedKept > 0) {
|
|
348
|
-
parts.push(
|
|
349
|
-
"",
|
|
350
|
-
"### Findings without a valid diff anchor",
|
|
351
|
-
...sortedDemoted
|
|
352
|
-
.slice(0, demotedKept)
|
|
353
|
-
.map(({ finding, reason }) => renderDemoted(finding, reason)),
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
if (omitted > 0) {
|
|
357
|
-
parts.push(
|
|
358
|
-
"",
|
|
359
|
-
`⚠️ ${countNoun(omitted, "review item")} omitted — the body exceeded GitHub's review size cap.`,
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
parts.push("", footer);
|
|
363
|
-
return parts.join("\n");
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
// The model's verdict may not contradict the reported severities (model
|
|
367
|
-
// output is untrusted input): any blocking item forces REQUEST_CHANGES, a
|
|
368
|
-
// review with no blocking item can never REQUEST_CHANGES, and an approval
|
|
369
|
-
// is honored only when nothing blocking or important was reported — the
|
|
370
|
-
// event always agrees with the callout tier. Demoted findings and concerns
|
|
371
|
-
// count like anchored findings: anchor validation validates LOCATIONS, not
|
|
372
|
-
// truth, so severity is equally model-claimed for all three, and counting
|
|
373
|
-
// them only ever moves the event toward the closed direction.
|
|
374
|
-
const counts = severityCounts(
|
|
375
|
-
review,
|
|
376
|
-
options.carriedFindings ?? [],
|
|
377
|
-
options.carriedConcerns ?? [],
|
|
378
|
-
);
|
|
379
|
-
const event: ReviewEvent = !options.applyVerdict
|
|
380
|
-
? "COMMENT"
|
|
381
|
-
: options.coverage?.status === "incomplete" || counts.blocking > 0
|
|
382
|
-
? "REQUEST_CHANGES"
|
|
383
|
-
: review.verdict === "approve" && counts.important === 0
|
|
384
|
-
? "APPROVE"
|
|
385
|
-
: "COMMENT";
|
|
386
|
-
|
|
387
|
-
// The invisible tail (metadata + fingerprint marker) must survive the body
|
|
388
|
-
// cap, so the cap reserves exactly the room it needs.
|
|
389
|
-
const tail = [
|
|
390
|
-
renderReviewMetadata({
|
|
391
|
-
headSha: options.headSha,
|
|
392
|
-
baseRef: options.baseRef,
|
|
393
|
-
headRef: options.headRef,
|
|
394
|
-
filesVisible: options.reviewFilesVisible ?? files.length,
|
|
395
|
-
totalChangedFiles: options.reviewTotalFiles ?? options.totalChangedFiles,
|
|
396
|
-
reviewMode: options.reviewMode,
|
|
397
|
-
baselineSha: options.baselineSha,
|
|
398
|
-
}),
|
|
399
|
-
...(options.fingerprint === undefined ? [] : [renderFingerprintMarker(options.fingerprint)]),
|
|
400
|
-
...(options.stateMarker === undefined ? [] : [options.stateMarker]),
|
|
401
|
-
].join("\n");
|
|
402
|
-
const headBudget = 60_000 - tail.length - 1;
|
|
403
|
-
|
|
404
|
-
// Shed whole trailing items — demoted bullets first (they already failed
|
|
405
|
-
// validation), then concerns — instead of slicing markdown mid-block. Every
|
|
406
|
-
// omission is announced, and `plan.demoted` keeps the full data regardless.
|
|
407
|
-
let concernsKept = sortedConcerns.length;
|
|
408
|
-
let demotedKept = sortedDemoted.length;
|
|
409
|
-
let omitted = 0;
|
|
410
|
-
let head = renderHead(concernsKept, demotedKept, omitted);
|
|
411
|
-
while (head.length > headBudget && (demotedKept > 0 || concernsKept > 0)) {
|
|
412
|
-
if (demotedKept > 0) demotedKept -= 1;
|
|
413
|
-
else concernsKept -= 1;
|
|
414
|
-
omitted += 1;
|
|
415
|
-
head = renderHead(concernsKept, demotedKept, omitted);
|
|
416
|
-
}
|
|
417
|
-
// Last resort for a pathological summary; unreachable while the CodeReview
|
|
418
|
-
// schema caps the summary well below the budget.
|
|
419
|
-
const body = `${head.slice(0, headBudget)}\n${tail}`;
|
|
420
|
-
|
|
421
|
-
return ReviewPublicationPlan.make({
|
|
422
|
-
event,
|
|
423
|
-
body,
|
|
424
|
-
comments,
|
|
425
|
-
demoted: demoted.map(({ finding }) => finding),
|
|
426
|
-
commitSha: options.headSha,
|
|
427
|
-
});
|
|
428
|
-
};
|