@effect-agent/pr-review 0.1.0-beta.26 → 0.1.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -3
- package/dist/action.d.mts +11 -8
- package/dist/action.mjs +24 -9
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +7 -4
- package/dist/cli.mjs.map +1 -1
- package/dist/{fan-out-CMEsbFLk.d.mts → fan-out-C3yG1cx3.d.mts} +45 -20
- package/dist/{github-NjgxGqwM.mjs → github-CCuLgyqb.mjs} +254 -81
- package/dist/github-CCuLgyqb.mjs.map +1 -0
- package/dist/index.d.mts +164 -13
- package/dist/index.mjs +3 -3
- package/dist/{providers-CODZQCmL.mjs → providers-Br9FRn7j.mjs} +35 -12
- package/dist/providers-Br9FRn7j.mjs.map +1 -0
- package/dist/testing.d.mts +3 -3
- package/dist/testing.mjs +1 -1
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +48 -11
- package/src/cli.ts +14 -2
- package/src/internal/action-entry.ts +1 -0
- package/src/internal/factory.ts +2 -1
- package/src/internal/fan-out.ts +150 -66
- package/src/internal/fixtures.ts +3 -3
- package/src/internal/github.ts +169 -22
- package/src/internal/providers.ts +47 -8
- package/src/internal/review-state.ts +85 -47
- package/src/internal/run.ts +8 -3
- package/dist/github-NjgxGqwM.mjs.map +0 -1
- package/dist/providers-CODZQCmL.mjs.map +0 -1
package/src/internal/github.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
ReviewAdjudicationFailure,
|
|
12
12
|
ReviewAdjudicationHost,
|
|
13
13
|
} from "./adjudication.ts";
|
|
14
|
-
import { ChangedFile } from "./diff.ts";
|
|
14
|
+
import { ChangedFile, ChangedPath } from "./diff.ts";
|
|
15
15
|
import { extractFingerprint } from "./fingerprint.ts";
|
|
16
16
|
import type { ReviewPublicationPlan } from "./render.ts";
|
|
17
17
|
import {
|
|
@@ -21,8 +21,11 @@ import {
|
|
|
21
21
|
ReviewRetirementHost,
|
|
22
22
|
} from "./retirement.ts";
|
|
23
23
|
import {
|
|
24
|
+
GitCommitSha,
|
|
25
|
+
MAX_TREE_COMPARISON_PATHS,
|
|
24
26
|
ReviewHeadComparison,
|
|
25
27
|
ReviewStateAuthenticator,
|
|
28
|
+
ReviewTreeComparison,
|
|
26
29
|
type ReviewState,
|
|
27
30
|
} from "./review-state.ts";
|
|
28
31
|
import {
|
|
@@ -884,14 +887,15 @@ export class PriorReviews extends Context.Service<
|
|
|
884
887
|
headSha: string,
|
|
885
888
|
) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
|
|
886
889
|
/**
|
|
887
|
-
*
|
|
888
|
-
* not a git ancestor
|
|
889
|
-
*
|
|
890
|
+
* Compare complete commit tree snapshots for a bounded path allowlist.
|
|
891
|
+
* Used when the reviewed head is not a git ancestor after a rebase,
|
|
892
|
+
* amend, or force-push.
|
|
890
893
|
*/
|
|
891
894
|
readonly compareTrees: (
|
|
892
895
|
baseSha: string,
|
|
893
896
|
headSha: string,
|
|
894
|
-
|
|
897
|
+
paths: ReadonlyArray<string>,
|
|
898
|
+
) => Effect.Effect<ReviewTreeComparison, PriorReviewLookupFailure>;
|
|
895
899
|
}
|
|
896
900
|
>()("@effect-agent/pr-review/PriorReviews") {}
|
|
897
901
|
|
|
@@ -916,6 +920,45 @@ const GitHubCompareWire = Schema.Struct({
|
|
|
916
920
|
files: GitHubFilesPageWire,
|
|
917
921
|
});
|
|
918
922
|
|
|
923
|
+
const GitHubGitCommitWire = Schema.Struct({
|
|
924
|
+
sha: GitCommitSha,
|
|
925
|
+
tree: Schema.Struct({ sha: GitCommitSha }),
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const GitHubTreeEntryFields = {
|
|
929
|
+
path: Schema.String.check(Schema.isMaxLength(4_096)),
|
|
930
|
+
sha: GitCommitSha,
|
|
931
|
+
} as const;
|
|
932
|
+
|
|
933
|
+
const GitHubTreeEntryWire = Schema.Union([
|
|
934
|
+
Schema.Struct({
|
|
935
|
+
...GitHubTreeEntryFields,
|
|
936
|
+
mode: Schema.Literals(["100644", "100755", "120000"]),
|
|
937
|
+
type: Schema.Literal("blob"),
|
|
938
|
+
}),
|
|
939
|
+
Schema.Struct({
|
|
940
|
+
...GitHubTreeEntryFields,
|
|
941
|
+
mode: Schema.Literal("040000"),
|
|
942
|
+
type: Schema.Literal("tree"),
|
|
943
|
+
}),
|
|
944
|
+
Schema.Struct({
|
|
945
|
+
...GitHubTreeEntryFields,
|
|
946
|
+
mode: Schema.Literal("160000"),
|
|
947
|
+
type: Schema.Literal("commit"),
|
|
948
|
+
}),
|
|
949
|
+
]);
|
|
950
|
+
|
|
951
|
+
const MAX_RECURSIVE_TREE_ENTRIES = 100_000;
|
|
952
|
+
const GitHubTreeWire = Schema.Struct({
|
|
953
|
+
sha: GitCommitSha,
|
|
954
|
+
tree: Schema.Array(GitHubTreeEntryWire).check(Schema.isMaxLength(MAX_RECURSIVE_TREE_ENTRIES)),
|
|
955
|
+
truncated: Schema.Boolean,
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
const TreeComparisonPaths = Schema.Array(ChangedPath).check(
|
|
959
|
+
Schema.isMaxLength(MAX_TREE_COMPARISON_PATHS),
|
|
960
|
+
);
|
|
961
|
+
|
|
919
962
|
/** Reviews are paged chronologically; scanning stays bounded. */
|
|
920
963
|
const MAX_PRIOR_REVIEW_PAGES = 5;
|
|
921
964
|
|
|
@@ -935,6 +978,24 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
|
|
|
935
978
|
PriorReviewLookupFailure.make({
|
|
936
979
|
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
937
980
|
});
|
|
981
|
+
const asTreeLookupFailure =
|
|
982
|
+
(operation: string) => (error: { readonly _tag: string; readonly message?: string }) =>
|
|
983
|
+
PriorReviewLookupFailure.make({
|
|
984
|
+
reason: `${operation}: ${error._tag}: ${error.message ?? "request failed"}`.slice(
|
|
985
|
+
0,
|
|
986
|
+
2_048,
|
|
987
|
+
),
|
|
988
|
+
});
|
|
989
|
+
const decodeLookupJson = <S extends Schema.Top>(schema: S, operation: string) => {
|
|
990
|
+
const decode = Schema.decodeUnknownEffect(schema);
|
|
991
|
+
return (response: HttpClientResponse.HttpClientResponse) =>
|
|
992
|
+
response.json.pipe(
|
|
993
|
+
Effect.mapError(asTreeLookupFailure(operation)),
|
|
994
|
+
Effect.flatMap((body) =>
|
|
995
|
+
decode(body).pipe(Effect.mapError(asTreeLookupFailure(operation))),
|
|
996
|
+
),
|
|
997
|
+
);
|
|
998
|
+
};
|
|
938
999
|
const readMarkers = (authenticator: Option.Option<ReviewStateAuthenticator["Service"]>) =>
|
|
939
1000
|
Effect.gen(function* () {
|
|
940
1001
|
const perPage = 100;
|
|
@@ -995,12 +1056,12 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
|
|
|
995
1056
|
}
|
|
996
1057
|
return { latestFingerprint: latest, latestState };
|
|
997
1058
|
}).pipe(Effect.provideService(HttpClient.HttpClient, client));
|
|
998
|
-
const compareCommits = (baseSha: string, headSha: string
|
|
1059
|
+
const compareCommits = (baseSha: string, headSha: string) =>
|
|
999
1060
|
Effect.gen(function* () {
|
|
1000
1061
|
const response = yield* HttpClient.execute(
|
|
1001
1062
|
withCommonHeaders(
|
|
1002
1063
|
HttpClientRequest.get(
|
|
1003
|
-
`${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}
|
|
1064
|
+
`${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
|
|
1004
1065
|
).pipe(HttpClientRequest.acceptJson),
|
|
1005
1066
|
target.token,
|
|
1006
1067
|
),
|
|
@@ -1023,21 +1084,107 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
|
|
|
1023
1084
|
truncated: files.length >= MAX_CHANGED_FILES,
|
|
1024
1085
|
});
|
|
1025
1086
|
}).pipe(Effect.provideService(HttpClient.HttpClient, client));
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1087
|
+
const readTreeSnapshot = Effect.fn("PriorReviews.readTreeSnapshot")(function* (
|
|
1088
|
+
commitSha: string,
|
|
1089
|
+
) {
|
|
1090
|
+
const commitResponse = yield* client
|
|
1091
|
+
.execute(
|
|
1092
|
+
withCommonHeaders(
|
|
1093
|
+
HttpClientRequest.get(
|
|
1094
|
+
`${target.apiUrl}/repos/${target.repository}/git/commits/${encodeURIComponent(commitSha)}`,
|
|
1095
|
+
).pipe(HttpClientRequest.acceptJson),
|
|
1096
|
+
target.token,
|
|
1097
|
+
),
|
|
1098
|
+
)
|
|
1099
|
+
.pipe(
|
|
1100
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
1101
|
+
Effect.mapError(asTreeLookupFailure("get Git commit")),
|
|
1102
|
+
);
|
|
1103
|
+
const commit = yield* decodeLookupJson(
|
|
1104
|
+
GitHubGitCommitWire,
|
|
1105
|
+
"decode Git commit",
|
|
1106
|
+
)(commitResponse);
|
|
1107
|
+
if (commit.sha !== commitSha) {
|
|
1108
|
+
return yield* PriorReviewLookupFailure.make({
|
|
1109
|
+
reason: `GitHub returned commit ${commit.sha} for requested snapshot ${commitSha}`,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
const treeResponse = yield* client
|
|
1113
|
+
.execute(
|
|
1114
|
+
withCommonHeaders(
|
|
1115
|
+
HttpClientRequest.get(
|
|
1116
|
+
`${target.apiUrl}/repos/${target.repository}/git/trees/${encodeURIComponent(commit.tree.sha)}`,
|
|
1117
|
+
).pipe(
|
|
1118
|
+
HttpClientRequest.acceptJson,
|
|
1119
|
+
HttpClientRequest.setUrlParams({ recursive: "1" }),
|
|
1120
|
+
),
|
|
1121
|
+
target.token,
|
|
1122
|
+
),
|
|
1123
|
+
)
|
|
1124
|
+
.pipe(
|
|
1125
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
1126
|
+
Effect.mapError(asTreeLookupFailure("get recursive Git tree")),
|
|
1127
|
+
);
|
|
1128
|
+
const tree = yield* decodeLookupJson(
|
|
1129
|
+
GitHubTreeWire,
|
|
1130
|
+
"decode recursive Git tree",
|
|
1131
|
+
)(treeResponse);
|
|
1132
|
+
if (tree.sha !== commit.tree.sha) {
|
|
1133
|
+
return yield* PriorReviewLookupFailure.make({
|
|
1134
|
+
reason: `GitHub returned tree ${tree.sha} for requested tree ${commit.tree.sha}`,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
const entries = new Map<string, typeof GitHubTreeEntryWire.Type>();
|
|
1138
|
+
for (const entry of tree.tree) {
|
|
1139
|
+
if (entries.has(entry.path)) {
|
|
1140
|
+
return yield* PriorReviewLookupFailure.make({
|
|
1141
|
+
reason: `GitHub returned duplicate path '${entry.path}' in tree ${tree.sha}`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
entries.set(entry.path, entry);
|
|
1145
|
+
}
|
|
1146
|
+
return { entries, truncated: tree.truncated } as const;
|
|
1147
|
+
});
|
|
1148
|
+
const compareTrees = Effect.fn("PriorReviews.compareTrees")(function* (
|
|
1149
|
+
baseSha: string,
|
|
1150
|
+
headSha: string,
|
|
1151
|
+
paths: ReadonlyArray<string>,
|
|
1152
|
+
) {
|
|
1153
|
+
const decodeSha = Schema.decodeUnknownEffect(GitCommitSha);
|
|
1154
|
+
const [validatedBaseSha, validatedHeadSha, validatedPaths] = yield* Effect.all([
|
|
1155
|
+
decodeSha(baseSha),
|
|
1156
|
+
decodeSha(headSha),
|
|
1157
|
+
Schema.decodeUnknownEffect(TreeComparisonPaths)(paths),
|
|
1158
|
+
]).pipe(Effect.mapError(asTreeLookupFailure("validate tree comparison request")));
|
|
1159
|
+
const uniquePaths = [...new Set(validatedPaths)].sort();
|
|
1160
|
+
const { base, head } = yield* Effect.all(
|
|
1161
|
+
{
|
|
1162
|
+
base: readTreeSnapshot(validatedBaseSha),
|
|
1163
|
+
head: readTreeSnapshot(validatedHeadSha),
|
|
1164
|
+
},
|
|
1165
|
+
{ concurrency: 2 },
|
|
1040
1166
|
);
|
|
1167
|
+
if (base.truncated || head.truncated) {
|
|
1168
|
+
return ReviewTreeComparison.make({
|
|
1169
|
+
baseSha: validatedBaseSha,
|
|
1170
|
+
headSha: validatedHeadSha,
|
|
1171
|
+
changedPaths: [],
|
|
1172
|
+
truncated: true,
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
const changedPaths = uniquePaths.filter((path) => {
|
|
1176
|
+
const before = base.entries.get(path);
|
|
1177
|
+
const after = head.entries.get(path);
|
|
1178
|
+
if (before === undefined || after === undefined) return before !== after;
|
|
1179
|
+
return before.sha !== after.sha || before.mode !== after.mode || before.type !== after.type;
|
|
1180
|
+
});
|
|
1181
|
+
return ReviewTreeComparison.make({
|
|
1182
|
+
baseSha: validatedBaseSha,
|
|
1183
|
+
headSha: validatedHeadSha,
|
|
1184
|
+
changedPaths,
|
|
1185
|
+
truncated: false,
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
1041
1188
|
return PriorReviews.of({
|
|
1042
1189
|
latestFingerprint: readMarkers(Option.none()).pipe(
|
|
1043
1190
|
Effect.map((markers) => markers.latestFingerprint),
|
|
@@ -1048,7 +1195,7 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
|
|
|
1048
1195
|
Effect.map((markers) => markers.latestState),
|
|
1049
1196
|
);
|
|
1050
1197
|
}),
|
|
1051
|
-
compareHeads:
|
|
1198
|
+
compareHeads: compareCommits,
|
|
1052
1199
|
compareTrees,
|
|
1053
1200
|
});
|
|
1054
1201
|
}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
|
|
2
2
|
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
|
|
3
|
-
import { Config, Layer } from "effect";
|
|
3
|
+
import { Config, Effect, Layer, Schema } from "effect";
|
|
4
4
|
import { FetchHttpClient } from "effect/unstable/http";
|
|
5
5
|
|
|
6
6
|
import { resolveEffortRung, type EffortAliasName, type EffortPosition } from "./effort.ts";
|
|
@@ -14,7 +14,36 @@ import { resolveEffortRung, type EffortAliasName, type EffortPosition } from "./
|
|
|
14
14
|
// application supplies them at the edge (D-027).
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
|
-
export
|
|
17
|
+
export const ReviewProvider = Schema.Literals(["openai", "anthropic"]);
|
|
18
|
+
export type ReviewProvider = typeof ReviewProvider.Type;
|
|
19
|
+
|
|
20
|
+
/** OpenAI Responses service tiers supported by the packaged reviewer. */
|
|
21
|
+
export const OpenAiServiceTier = Schema.Literal("fast");
|
|
22
|
+
export type OpenAiServiceTier = typeof OpenAiServiceTier.Type;
|
|
23
|
+
|
|
24
|
+
/** A provider-specific OpenAI tier was configured for another provider. */
|
|
25
|
+
export class UnsupportedServiceTierProvider extends Schema.TaggedError<UnsupportedServiceTierProvider>()(
|
|
26
|
+
"UnsupportedServiceTierProvider",
|
|
27
|
+
{
|
|
28
|
+
provider: ReviewProvider,
|
|
29
|
+
serviceTier: OpenAiServiceTier,
|
|
30
|
+
},
|
|
31
|
+
) {
|
|
32
|
+
override get message() {
|
|
33
|
+
return `Service tier '${this.serviceTier}' requires provider 'openai'; received '${this.provider}'.`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reject an OpenAI-only service tier before constructing another provider's model. */
|
|
38
|
+
export const validateReviewServiceTier = Effect.fn("validateReviewServiceTier")(function* (
|
|
39
|
+
provider: ReviewProvider,
|
|
40
|
+
serviceTier: OpenAiServiceTier | undefined,
|
|
41
|
+
): Effect.fn.Return<OpenAiServiceTier | undefined, UnsupportedServiceTierProvider> {
|
|
42
|
+
if (serviceTier !== undefined && provider !== "openai") {
|
|
43
|
+
return yield* UnsupportedServiceTierProvider.make({ provider, serviceTier });
|
|
44
|
+
}
|
|
45
|
+
return serviceTier;
|
|
46
|
+
});
|
|
18
47
|
|
|
19
48
|
export const DEFAULT_PROVIDER: ReviewProvider = "openai";
|
|
20
49
|
|
|
@@ -44,7 +73,11 @@ export const PROVIDER_EFFORT_RUNGS = {
|
|
|
44
73
|
>;
|
|
45
74
|
|
|
46
75
|
/** One OpenAI review model binding with the package's structured-output settings. */
|
|
47
|
-
export const makeOpenAiReviewModel = (
|
|
76
|
+
export const makeOpenAiReviewModel = (
|
|
77
|
+
model?: string,
|
|
78
|
+
effort?: EffortPosition,
|
|
79
|
+
serviceTier?: OpenAiServiceTier,
|
|
80
|
+
) =>
|
|
48
81
|
OpenAiLanguageModel.model(model ?? DEFAULT_MODEL.openai, {
|
|
49
82
|
// OpenAI counts hidden reasoning tokens and visible answer tokens against
|
|
50
83
|
// this same ceiling. High-effort reviews can exhaust an 8k allowance
|
|
@@ -52,6 +85,7 @@ export const makeOpenAiReviewModel = (model?: string, effort?: EffortPosition) =
|
|
|
52
85
|
max_output_tokens: 32_000,
|
|
53
86
|
store: false,
|
|
54
87
|
strictJsonSchema: true,
|
|
88
|
+
...(serviceTier === undefined ? {} : { service_tier: serviceTier }),
|
|
55
89
|
...(effort === undefined
|
|
56
90
|
? {}
|
|
57
91
|
: { reasoning: { effort: resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS.openai) } }),
|
|
@@ -68,19 +102,24 @@ export const makeAnthropicReviewModel = (model?: string, effort?: EffortPosition
|
|
|
68
102
|
|
|
69
103
|
/**
|
|
70
104
|
* The human-readable descriptor of one provider binding, e.g.
|
|
71
|
-
* `openai/gpt-5.6-sol (effort high)`. Rendered into the review footer and
|
|
105
|
+
* `openai/gpt-5.6-sol (effort high, service tier fast)`. Rendered into the review footer and
|
|
72
106
|
* included in the changeset-fingerprint signature, so a provider, model, or
|
|
73
|
-
*
|
|
107
|
+
* request-profile change re-reviews instead of skipping.
|
|
74
108
|
*/
|
|
75
109
|
export const describeReviewModel = (
|
|
76
110
|
provider: ReviewProvider,
|
|
77
111
|
model?: string,
|
|
78
112
|
effort?: EffortPosition,
|
|
113
|
+
serviceTier?: OpenAiServiceTier,
|
|
79
114
|
): string => {
|
|
80
115
|
const base = `${provider}/${model ?? DEFAULT_MODEL[provider]}`;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
const details = [
|
|
117
|
+
...(effort === undefined
|
|
118
|
+
? []
|
|
119
|
+
: [`effort ${resolveEffortRung(effort, PROVIDER_EFFORT_RUNGS[provider])}`]),
|
|
120
|
+
...(serviceTier === undefined ? [] : [`service tier ${serviceTier}`]),
|
|
121
|
+
];
|
|
122
|
+
return details.length === 0 ? base : `${base} (${details.join(", ")})`;
|
|
84
123
|
};
|
|
85
124
|
|
|
86
125
|
/** The OpenAI client Layer, credential from `OPENAI_API_KEY`. */
|
|
@@ -131,11 +131,11 @@ export const MAX_STORED_UNREVIEWED_PATHS = 100;
|
|
|
131
131
|
/** Failed-pass records stored beside the leftover paths; one per unit stage. */
|
|
132
132
|
export const MAX_STORED_UNREVIEWED_PASSES = 24;
|
|
133
133
|
|
|
134
|
-
/** Stages a leftover path may need retried
|
|
134
|
+
/** Stages a leftover path may need retried on the next incremental run. */
|
|
135
135
|
export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
|
|
136
136
|
export type UnreviewedStage = typeof UnreviewedStage.Type;
|
|
137
137
|
|
|
138
|
-
/** One failed fan-out pass whose
|
|
138
|
+
/** One failed fan-out pass whose stage remains attached to its exact paths. */
|
|
139
139
|
export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
|
|
140
140
|
"@effect-agent/pr-review/StoredUnreviewedPass",
|
|
141
141
|
)({
|
|
@@ -395,6 +395,23 @@ export class ReviewHeadComparison extends Schema.Class<ReviewHeadComparison>(
|
|
|
395
395
|
truncated: Schema.Boolean,
|
|
396
396
|
}) {}
|
|
397
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Current and previous paths for 300 PR files plus bounded stored continuity
|
|
400
|
+
* paths. The live adapter refuses a larger snapshot-comparison request.
|
|
401
|
+
*/
|
|
402
|
+
export const MAX_TREE_COMPARISON_PATHS = 750;
|
|
403
|
+
|
|
404
|
+
/** A direct comparison of two complete commit tree snapshots. */
|
|
405
|
+
export class ReviewTreeComparison extends Schema.Class<ReviewTreeComparison>(
|
|
406
|
+
"@effect-agent/pr-review/ReviewTreeComparison",
|
|
407
|
+
)({
|
|
408
|
+
baseSha: GitCommitSha,
|
|
409
|
+
headSha: GitCommitSha,
|
|
410
|
+
changedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_TREE_COMPARISON_PATHS)),
|
|
411
|
+
/** True when GitHub returned either recursive tree incompletely. */
|
|
412
|
+
truncated: Schema.Boolean,
|
|
413
|
+
}) {}
|
|
414
|
+
|
|
398
415
|
/** Internal review selection applied as a decorator over the full PR source. */
|
|
399
416
|
export interface ReviewSelection {
|
|
400
417
|
readonly mode: ReviewScopeMode;
|
|
@@ -403,9 +420,12 @@ export interface ReviewSelection {
|
|
|
403
420
|
/** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
|
|
404
421
|
readonly affectedPaths: ReadonlyArray<string>;
|
|
405
422
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
423
|
+
* Failed stages attached to the unchanged paths that own them. Verification
|
|
424
|
+
* retries reopen discovery for only their paths because candidates are not
|
|
425
|
+
* persisted in review state.
|
|
408
426
|
*/
|
|
427
|
+
readonly retryPasses?: ReadonlyArray<StoredUnreviewedPass>;
|
|
428
|
+
/** Flattened summaries retained for diagnostics and compatibility. */
|
|
409
429
|
readonly retryPaths: ReadonlyArray<string>;
|
|
410
430
|
readonly retryStages: ReadonlyArray<UnreviewedStage>;
|
|
411
431
|
readonly totalFiles: number;
|
|
@@ -429,6 +449,7 @@ export const fullReviewSelection = (input: {
|
|
|
429
449
|
affectedPaths: input.files.flatMap((file) =>
|
|
430
450
|
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
|
|
431
451
|
),
|
|
452
|
+
retryPasses: [],
|
|
432
453
|
retryPaths: [],
|
|
433
454
|
retryStages: [],
|
|
434
455
|
totalFiles: input.totalFiles,
|
|
@@ -478,19 +499,15 @@ const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
|
|
|
478
499
|
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
|
|
479
500
|
|
|
480
501
|
const incrementalFromDelta = (input: {
|
|
481
|
-
readonly current: PullRequestMetadata;
|
|
482
502
|
readonly fullFiles: ReadonlyArray<ChangedFile>;
|
|
483
503
|
readonly profileFingerprint: string;
|
|
484
504
|
readonly priorState: ReviewState;
|
|
485
|
-
readonly
|
|
505
|
+
readonly deltaPaths: ReadonlyArray<string>;
|
|
486
506
|
readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
|
|
487
507
|
readonly reason: string;
|
|
488
508
|
}): ReviewSelection => {
|
|
489
509
|
const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
|
|
490
|
-
const affectedPaths = new Set([
|
|
491
|
-
...input.deltaFiles.flatMap(filePaths),
|
|
492
|
-
...(input.extraAffectedPaths ?? []),
|
|
493
|
-
]);
|
|
510
|
+
const affectedPaths = new Set([...input.deltaPaths, ...(input.extraAffectedPaths ?? [])]);
|
|
494
511
|
const initialAffectedCount = affectedPaths.size;
|
|
495
512
|
// Reopen every current path needed to reassess a concern touched by this
|
|
496
513
|
// delta. Repeat to a fixed point because two concerns may overlap on a path.
|
|
@@ -509,38 +526,45 @@ const incrementalFromDelta = (input: {
|
|
|
509
526
|
}
|
|
510
527
|
}
|
|
511
528
|
const selectedByPath = new Map<string, ChangedFile>();
|
|
512
|
-
for (const file of input.deltaFiles) {
|
|
513
|
-
if (
|
|
514
|
-
currentPaths.has(file.path) ||
|
|
515
|
-
(file.previousPath !== undefined && currentPaths.has(file.previousPath))
|
|
516
|
-
) {
|
|
517
|
-
selectedByPath.set(file.path, file);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
529
|
const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
|
|
521
530
|
const retryOnly = new Set<string>();
|
|
522
|
-
const retryStages = new Set<UnreviewedStage>();
|
|
523
531
|
for (const path of carriedPaths) {
|
|
524
532
|
if (affectedPaths.has(path)) continue;
|
|
525
533
|
retryOnly.add(path);
|
|
526
|
-
for (const pass of input.priorState.unreviewedPasses) {
|
|
527
|
-
if (pass.paths.includes(path)) retryStages.add(pass.stage);
|
|
528
|
-
}
|
|
529
534
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
535
|
+
|
|
536
|
+
const retryPathsByStage = new Map<UnreviewedStage, Set<string>>();
|
|
537
|
+
const representedRetryPaths = new Set<string>();
|
|
538
|
+
for (const pass of input.priorState.unreviewedPasses) {
|
|
539
|
+
for (const path of pass.paths) {
|
|
540
|
+
if (!retryOnly.has(path)) continue;
|
|
541
|
+
const paths = retryPathsByStage.get(pass.stage) ?? new Set<string>();
|
|
542
|
+
paths.add(path);
|
|
543
|
+
retryPathsByStage.set(pass.stage, paths);
|
|
544
|
+
representedRetryPaths.add(path);
|
|
545
|
+
}
|
|
537
546
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
547
|
+
// Some continuity gaps (capacity overflow, partial evidence, legacy state)
|
|
548
|
+
// have no failed-pass record. They conservatively re-enter fresh discovery
|
|
549
|
+
// instead of inheriting another path's unrelated failed stage.
|
|
550
|
+
for (const path of retryOnly) {
|
|
551
|
+
if (representedRetryPaths.has(path)) continue;
|
|
552
|
+
retryOnly.delete(path);
|
|
553
|
+
affectedPaths.add(path);
|
|
543
554
|
}
|
|
555
|
+
const retryPasses = (["discovery", "specialist", "verification"] as const).flatMap((stage) => {
|
|
556
|
+
const paths = [...(retryPathsByStage.get(stage) ?? [])]
|
|
557
|
+
.filter((path) => retryOnly.has(path))
|
|
558
|
+
.sort();
|
|
559
|
+
return Array.from({ length: Math.ceil(paths.length / 12) }, (_, index) =>
|
|
560
|
+
StoredUnreviewedPass.make({
|
|
561
|
+
stage,
|
|
562
|
+
paths: paths.slice(index * 12, (index + 1) * 12),
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
const retryPaths = [...retryOnly].sort();
|
|
567
|
+
const retryStages = [...new Set(retryPasses.map((pass) => pass.stage))];
|
|
544
568
|
for (const file of input.fullFiles) {
|
|
545
569
|
const needed =
|
|
546
570
|
affectedPaths.has(file.path) ||
|
|
@@ -552,10 +576,10 @@ const incrementalFromDelta = (input: {
|
|
|
552
576
|
const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
|
|
553
577
|
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
554
578
|
);
|
|
555
|
-
const leftoverCount =
|
|
579
|
+
const leftoverCount = retryPaths.length;
|
|
556
580
|
const carriedReason =
|
|
557
581
|
leftoverCount > 0
|
|
558
|
-
? `; retrying ${leftoverCount} unchanged leftover path(s)
|
|
582
|
+
? `; retrying ${leftoverCount} unchanged leftover path(s) by recorded failed stage`
|
|
559
583
|
: carriedPaths.length > 0
|
|
560
584
|
? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
|
|
561
585
|
: "";
|
|
@@ -569,8 +593,9 @@ const incrementalFromDelta = (input: {
|
|
|
569
593
|
reason: `${input.reason}${carriedReason}${concernReason}`,
|
|
570
594
|
files: selectedFiles,
|
|
571
595
|
affectedPaths: [...affectedPaths].sort(),
|
|
572
|
-
|
|
573
|
-
|
|
596
|
+
retryPasses,
|
|
597
|
+
retryPaths,
|
|
598
|
+
retryStages,
|
|
574
599
|
totalFiles: selectedFiles.length,
|
|
575
600
|
baselineSha: input.priorState.reviewedHeadSha,
|
|
576
601
|
priorState: input.priorState,
|
|
@@ -588,11 +613,12 @@ export const selectReviewRange = (input: {
|
|
|
588
613
|
readonly comparison: ReviewHeadComparison | undefined;
|
|
589
614
|
readonly baseComparison?: ReviewHeadComparison | undefined;
|
|
590
615
|
/**
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* main-drift outside the pull request never re-enters scope.
|
|
616
|
+
* Direct commit-tree snapshot comparison used when the reviewed head is not
|
|
617
|
+
* a git ancestor. Selection hydrates these paths from the current PR files.
|
|
594
618
|
*/
|
|
595
|
-
readonly contentComparison?:
|
|
619
|
+
readonly contentComparison?: ReviewTreeComparison | undefined;
|
|
620
|
+
/** Why the direct snapshot comparison could not produce complete evidence. */
|
|
621
|
+
readonly contentComparisonFailure?: string | undefined;
|
|
596
622
|
readonly lookupFailure?: string | undefined;
|
|
597
623
|
}): ReviewSelection => {
|
|
598
624
|
const full = (reason: string) =>
|
|
@@ -634,26 +660,38 @@ export const selectReviewRange = (input: {
|
|
|
634
660
|
baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
|
|
635
661
|
}
|
|
636
662
|
return incrementalFromDelta({
|
|
637
|
-
current: input.current,
|
|
638
663
|
fullFiles: input.fullFiles,
|
|
639
664
|
profileFingerprint: input.profileFingerprint,
|
|
640
665
|
priorState: input.priorState,
|
|
641
|
-
|
|
666
|
+
deltaPaths: comparison.files.flatMap(filePaths),
|
|
642
667
|
extraAffectedPaths: extraAffected,
|
|
643
668
|
reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
|
|
644
669
|
});
|
|
645
670
|
}
|
|
646
671
|
const contentComparison = input.contentComparison;
|
|
647
|
-
if (contentComparison !== undefined
|
|
672
|
+
if (contentComparison !== undefined) {
|
|
673
|
+
if (
|
|
674
|
+
contentComparison.baseSha !== input.priorState.reviewedHeadSha ||
|
|
675
|
+
contentComparison.headSha !== input.current.headSha
|
|
676
|
+
) {
|
|
677
|
+
return full("the rewritten-head tree snapshot comparison did not match the requested heads");
|
|
678
|
+
}
|
|
679
|
+
if (contentComparison.truncated) {
|
|
680
|
+
return full("the rewritten-head tree snapshot comparison was truncated");
|
|
681
|
+
}
|
|
648
682
|
return incrementalFromDelta({
|
|
649
|
-
current: input.current,
|
|
650
683
|
fullFiles: input.fullFiles,
|
|
651
684
|
profileFingerprint: input.profileFingerprint,
|
|
652
685
|
priorState: input.priorState,
|
|
653
|
-
|
|
686
|
+
deltaPaths: contentComparison.changedPaths,
|
|
654
687
|
reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
|
|
655
688
|
});
|
|
656
689
|
}
|
|
690
|
+
if (input.contentComparisonFailure !== undefined) {
|
|
691
|
+
return full(
|
|
692
|
+
`the rewritten-head tree snapshot comparison failed: ${input.contentComparisonFailure.slice(0, 2_048)}`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
657
695
|
if (comparison === undefined) return full("the incremental head comparison was unavailable");
|
|
658
696
|
if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
|
|
659
697
|
return full("the prior reviewed head is not an ancestor of the current head");
|
package/src/internal/run.ts
CHANGED
|
@@ -553,17 +553,22 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
|
|
|
553
553
|
const budget = yield* makeUsageBudget(options.limits ?? fanOutReviewBudgetLimits);
|
|
554
554
|
const totalFiles = executionContext.totalFiles;
|
|
555
555
|
const continuity = yield* resolveReviewContinuityContext();
|
|
556
|
+
const retryPasses =
|
|
557
|
+
executionContext.retryPasses ??
|
|
558
|
+
executionContext.retryStages.map((stage) => ({
|
|
559
|
+
stage,
|
|
560
|
+
paths: executionContext.retryPaths,
|
|
561
|
+
}));
|
|
556
562
|
const pipeline = yield* runFanOutReview(binding, {
|
|
557
563
|
files,
|
|
558
564
|
anchorFiles,
|
|
559
565
|
totalChangedFiles: totalFiles,
|
|
560
566
|
maxFindings: options.maxFindings,
|
|
561
567
|
budget: toRunBudgetHook(budget),
|
|
562
|
-
...(
|
|
568
|
+
...(retryPasses.length > 0
|
|
563
569
|
? {
|
|
564
570
|
retry: {
|
|
565
|
-
|
|
566
|
-
stages: executionContext.retryStages,
|
|
571
|
+
passes: retryPasses,
|
|
567
572
|
},
|
|
568
573
|
}
|
|
569
574
|
: {}),
|