@effect-agent/pr-review 0.1.0-beta.23 → 0.1.0-beta.25
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 +83 -195
- package/dist/action.d.mts +27 -18
- package/dist/action.mjs +60 -39
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +3 -3
- package/dist/cli.mjs.map +1 -1
- package/dist/{fan-out-BJBTAYuh.d.mts → fan-out-CMEsbFLk.d.mts} +455 -177
- package/dist/{github-BbwYzNrC.mjs → github-NjgxGqwM.mjs} +2163 -1518
- package/dist/github-NjgxGqwM.mjs.map +1 -0
- package/dist/index.d.mts +30 -20
- package/dist/index.mjs +3 -3
- package/dist/{providers-NyP-4rS6.mjs → providers-CODZQCmL.mjs} +202 -80
- package/dist/providers-CODZQCmL.mjs.map +1 -0
- package/dist/testing.d.mts +3 -1
- package/dist/testing.mjs +3 -2
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +141 -78
- package/src/cli.ts +6 -1
- package/src/index.ts +1 -0
- package/src/internal/adjudication.ts +415 -0
- package/src/internal/coverage.ts +41 -60
- package/src/internal/factory.ts +4 -4
- package/src/internal/fan-out.ts +208 -14
- package/src/internal/fingerprint.ts +16 -10
- package/src/internal/fixtures.ts +6 -0
- package/src/internal/github-env.ts +9 -0
- package/src/internal/github.ts +243 -7
- package/src/internal/progress.ts +1 -1
- package/src/internal/render.ts +186 -42
- package/src/internal/retirement.ts +16 -17
- package/src/internal/review-agent.ts +39 -4
- package/src/internal/review-state.ts +315 -105
- package/src/internal/review-units.ts +10 -9
- package/src/internal/run.ts +197 -63
- package/dist/github-BbwYzNrC.mjs.map +0 -1
- package/dist/providers-NyP-4rS6.mjs.map +0 -1
|
@@ -37,18 +37,112 @@ export class StoredReviewFinding extends Schema.Class<StoredReviewFinding>(
|
|
|
37
37
|
body: StoredText,
|
|
38
38
|
}) {}
|
|
39
39
|
|
|
40
|
-
/** A compact unresolved non-anchored concern
|
|
40
|
+
/** A compact unresolved non-anchored concern with its invalidation paths. */
|
|
41
41
|
export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
|
|
42
42
|
"@effect-agent/pr-review/StoredReviewConcern",
|
|
43
43
|
)({
|
|
44
|
+
/** Absent only on legacy state written before concern path binding. */
|
|
45
|
+
evidencePaths: Schema.optionalKey(
|
|
46
|
+
Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)),
|
|
47
|
+
),
|
|
44
48
|
severity: FindingSeverity,
|
|
45
49
|
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
46
50
|
body: StoredText,
|
|
47
51
|
}) {}
|
|
48
52
|
|
|
53
|
+
/** How a maintainer settled a previously raised finding or concern. */
|
|
54
|
+
export const AdjudicationDisposition = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
|
|
55
|
+
export type AdjudicationDisposition = typeof AdjudicationDisposition.Type;
|
|
56
|
+
|
|
57
|
+
/** The adjudications bound carried by the ReviewState schema. */
|
|
58
|
+
export const MAX_STORED_ADJUDICATIONS = 20;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One maintainer adjudication of a finding or concern identity. Anchored
|
|
62
|
+
* findings carry their full location identity; unanchored concerns are
|
|
63
|
+
* identified by title alone, so the location fields stay absent.
|
|
64
|
+
*/
|
|
65
|
+
const StoredAdjudicationFields = Schema.Struct({
|
|
66
|
+
path: Schema.optionalKey(ChangedPath),
|
|
67
|
+
startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
|
|
68
|
+
endLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
|
|
69
|
+
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
70
|
+
disposition: AdjudicationDisposition,
|
|
71
|
+
reason: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(300))),
|
|
72
|
+
/** GitHub login of the maintainer whose comment adjudicated the identity. */
|
|
73
|
+
actor: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
|
|
74
|
+
}).check(
|
|
75
|
+
Schema.makeFilter(
|
|
76
|
+
(adjudication) => {
|
|
77
|
+
const locationParts = [
|
|
78
|
+
adjudication.path,
|
|
79
|
+
adjudication.startLine,
|
|
80
|
+
adjudication.endLine,
|
|
81
|
+
].filter((part) => part !== undefined).length;
|
|
82
|
+
return locationParts === 0 || locationParts === 3
|
|
83
|
+
? undefined
|
|
84
|
+
: "path, startLine, and endLine must be either all present or all absent";
|
|
85
|
+
},
|
|
86
|
+
{ title: "adjudication locations are complete or unanchored" },
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export class StoredAdjudication extends Schema.Class<StoredAdjudication>(
|
|
91
|
+
"@effect-agent/pr-review/StoredAdjudication",
|
|
92
|
+
)(StoredAdjudicationFields) {}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The one finding-identity composition shared by retirement, adjudication,
|
|
96
|
+
* and settlement. A tagged JSON tuple keeps anchored findings in a namespace
|
|
97
|
+
* disjoint from title-only concerns and remains unambiguous even when
|
|
98
|
+
* untrusted path or title text contains delimiter characters.
|
|
99
|
+
*/
|
|
100
|
+
export const findingIdentity = (finding: {
|
|
101
|
+
readonly path: string;
|
|
102
|
+
readonly startLine: number;
|
|
103
|
+
readonly endLine: number;
|
|
104
|
+
readonly title: string;
|
|
105
|
+
}): string =>
|
|
106
|
+
JSON.stringify(["finding", finding.path, finding.startLine, finding.endLine, finding.title]);
|
|
107
|
+
|
|
108
|
+
/** The disjoint title-only identity namespace for unanchored concerns. */
|
|
109
|
+
export const concernIdentity = (concern: { readonly title: string }): string =>
|
|
110
|
+
JSON.stringify(["concern", concern.title]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* An adjudication's identity: the shared finding identity when anchored, the
|
|
114
|
+
* disjoint concern identity when unanchored.
|
|
115
|
+
*/
|
|
116
|
+
export const adjudicationIdentity = (adjudication: StoredAdjudication): string =>
|
|
117
|
+
adjudication.path !== undefined &&
|
|
118
|
+
adjudication.startLine !== undefined &&
|
|
119
|
+
adjudication.endLine !== undefined
|
|
120
|
+
? findingIdentity({
|
|
121
|
+
path: adjudication.path,
|
|
122
|
+
startLine: adjudication.startLine,
|
|
123
|
+
endLine: adjudication.endLine,
|
|
124
|
+
title: adjudication.title,
|
|
125
|
+
})
|
|
126
|
+
: concernIdentity(adjudication);
|
|
127
|
+
|
|
49
128
|
/** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
|
|
50
129
|
export const MAX_STORED_UNREVIEWED_PATHS = 100;
|
|
51
130
|
|
|
131
|
+
/** Failed-pass records stored beside the leftover paths; one per unit stage. */
|
|
132
|
+
export const MAX_STORED_UNREVIEWED_PASSES = 24;
|
|
133
|
+
|
|
134
|
+
/** Stages a leftover path may need retried without a second general discovery. */
|
|
135
|
+
export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
|
|
136
|
+
export type UnreviewedStage = typeof UnreviewedStage.Type;
|
|
137
|
+
|
|
138
|
+
/** One failed fan-out pass whose paths should be retried, not rediscovered. */
|
|
139
|
+
export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
|
|
140
|
+
"@effect-agent/pr-review/StoredUnreviewedPass",
|
|
141
|
+
)({
|
|
142
|
+
stage: UnreviewedStage,
|
|
143
|
+
paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
|
|
144
|
+
}) {}
|
|
145
|
+
|
|
52
146
|
/**
|
|
53
147
|
* Versioned state embedded after EVERY completed run that can be signed. The
|
|
54
148
|
* head plus full-scope fingerprint forms an incremental baseline; an absent
|
|
@@ -56,12 +150,11 @@ export const MAX_STORED_UNREVIEWED_PATHS = 100;
|
|
|
56
150
|
* carries retryable review gaps (failed passes) forward so the next
|
|
57
151
|
* incremental run re-reviews exactly them plus the new delta — the baseline
|
|
58
152
|
* advances monotonically instead of freezing on one flaky pass and reopening
|
|
59
|
-
* the whole post-baseline scope.
|
|
60
|
-
*
|
|
61
|
-
* separately would not fit GitHub's bounded review body in the worst case.
|
|
153
|
+
* the whole post-baseline scope. Storing hundreds of path strings separately
|
|
154
|
+
* would not fit GitHub's bounded review body in the worst case.
|
|
62
155
|
*/
|
|
63
156
|
export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
|
|
64
|
-
version: Schema.Literal(
|
|
157
|
+
version: Schema.Literal(1),
|
|
65
158
|
repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
|
|
66
159
|
pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
67
160
|
baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
@@ -69,12 +162,16 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
|
|
|
69
162
|
headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
70
163
|
reviewedHeadSha: GitCommitSha,
|
|
71
164
|
profileFingerprint: Fingerprint,
|
|
72
|
-
|
|
165
|
+
settledScopeFingerprint: Fingerprint,
|
|
73
166
|
reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
|
|
74
167
|
unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
|
|
75
168
|
unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
|
|
76
169
|
/** Retryable review gaps carried into the next incremental run's scope. */
|
|
77
170
|
unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PATHS)),
|
|
171
|
+
/** Which failed pass produced those leftovers. */
|
|
172
|
+
unreviewedPasses: Schema.Array(StoredUnreviewedPass).check(
|
|
173
|
+
Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES),
|
|
174
|
+
),
|
|
78
175
|
/**
|
|
79
176
|
* True only when the producing run had complete input coverage, no
|
|
80
177
|
* unsettled pass, and nothing carried. Skip-unchanged authority: an
|
|
@@ -82,6 +179,13 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
|
|
|
82
179
|
*/
|
|
83
180
|
settled: Schema.Boolean,
|
|
84
181
|
lastReviewMode: ReviewScopeMode,
|
|
182
|
+
/**
|
|
183
|
+
* Maintainer adjudications standing against this pull request. optionalKey
|
|
184
|
+
* so state markers signed before the field existed still decode.
|
|
185
|
+
*/
|
|
186
|
+
adjudications: Schema.optionalKey(
|
|
187
|
+
Schema.Array(StoredAdjudication).check(Schema.isMaxLength(MAX_STORED_ADJUDICATIONS)),
|
|
188
|
+
),
|
|
85
189
|
}) {}
|
|
86
190
|
|
|
87
191
|
export const toStoredFinding = (finding: ReviewFinding): StoredReviewFinding =>
|
|
@@ -106,23 +210,29 @@ export const fromStoredFinding = (finding: StoredReviewFinding): ReviewFinding =
|
|
|
106
210
|
|
|
107
211
|
export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
|
|
108
212
|
StoredReviewConcern.make({
|
|
213
|
+
...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
|
|
109
214
|
severity: concern.severity,
|
|
110
215
|
title: concern.title,
|
|
111
216
|
body: concern.body.slice(0, 800),
|
|
112
217
|
});
|
|
113
218
|
|
|
114
219
|
export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
|
|
115
|
-
ReviewConcern.make({
|
|
220
|
+
ReviewConcern.make({
|
|
221
|
+
...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
|
|
222
|
+
severity: concern.severity,
|
|
223
|
+
title: concern.title,
|
|
224
|
+
body: concern.body,
|
|
225
|
+
});
|
|
116
226
|
|
|
117
|
-
const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-
|
|
227
|
+
const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
|
|
118
228
|
const STATE_MARKER_SUFFIX = " -->";
|
|
119
229
|
const STATE_MARKER_PATTERN =
|
|
120
|
-
/(?:^|\n)<!-- effect-agent-pr-review state-
|
|
121
|
-
const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-
|
|
230
|
+
/(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
|
|
231
|
+
const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\u0000";
|
|
122
232
|
export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
|
|
123
233
|
export const ReviewStateMarker = Schema.NonEmptyString.check(
|
|
124
234
|
Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
|
|
125
|
-
Schema.isPattern(/^<!-- effect-agent-pr-review state-
|
|
235
|
+
Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
|
|
126
236
|
).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
|
|
127
237
|
export type ReviewStateMarker = typeof ReviewStateMarker.Type;
|
|
128
238
|
|
|
@@ -292,19 +402,26 @@ export interface ReviewSelection {
|
|
|
292
402
|
readonly files: ReadonlyArray<ChangedFile>;
|
|
293
403
|
/** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
|
|
294
404
|
readonly affectedPaths: ReadonlyArray<string>;
|
|
405
|
+
/**
|
|
406
|
+
* Leftover paths whose contents did not change. Fan-out retries only the
|
|
407
|
+
* recorded failed stages on these paths and keeps their stored findings.
|
|
408
|
+
*/
|
|
409
|
+
readonly retryPaths: ReadonlyArray<string>;
|
|
410
|
+
readonly retryStages: ReadonlyArray<UnreviewedStage>;
|
|
295
411
|
readonly totalFiles: number;
|
|
296
412
|
readonly baselineSha: string | undefined;
|
|
297
413
|
readonly priorState: ReviewState | undefined;
|
|
298
|
-
|
|
414
|
+
/** Absent only for an explicit full review with no continuity profile. */
|
|
415
|
+
readonly profileFingerprint: string | undefined;
|
|
299
416
|
/** Action-owned authentication capability, constructed at the composition root. */
|
|
300
417
|
readonly stateAuthenticator?: ReviewStateAuthenticator["Service"] | undefined;
|
|
301
418
|
}
|
|
302
419
|
|
|
303
|
-
const
|
|
420
|
+
export const fullReviewSelection = (input: {
|
|
304
421
|
readonly reason: string;
|
|
305
422
|
readonly files: ReadonlyArray<ChangedFile>;
|
|
306
423
|
readonly totalFiles: number;
|
|
307
|
-
readonly profileFingerprint
|
|
424
|
+
readonly profileFingerprint?: string | undefined;
|
|
308
425
|
}): ReviewSelection => ({
|
|
309
426
|
mode: "full",
|
|
310
427
|
reason: input.reason,
|
|
@@ -312,12 +429,26 @@ const fullSelection = (input: {
|
|
|
312
429
|
affectedPaths: input.files.flatMap((file) =>
|
|
313
430
|
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
|
|
314
431
|
),
|
|
432
|
+
retryPaths: [],
|
|
433
|
+
retryStages: [],
|
|
315
434
|
totalFiles: input.totalFiles,
|
|
316
435
|
baselineSha: undefined,
|
|
317
436
|
priorState: undefined,
|
|
318
437
|
profileFingerprint: input.profileFingerprint,
|
|
319
438
|
});
|
|
320
439
|
|
|
440
|
+
/** Three-dot lineage from the reviewed head to the current head is usable. */
|
|
441
|
+
export const isLineageAncestor = (
|
|
442
|
+
comparison: ReviewHeadComparison,
|
|
443
|
+
priorState: ReviewState,
|
|
444
|
+
currentHeadSha: string,
|
|
445
|
+
): boolean =>
|
|
446
|
+
comparison.baseSha === priorState.reviewedHeadSha &&
|
|
447
|
+
comparison.headSha === currentHeadSha &&
|
|
448
|
+
comparison.mergeBaseSha === priorState.reviewedHeadSha &&
|
|
449
|
+
!comparison.truncated &&
|
|
450
|
+
(comparison.status === "ahead" || comparison.status === "identical");
|
|
451
|
+
|
|
321
452
|
/**
|
|
322
453
|
* Validate that persisted state belongs to this exact PR/base lineage and the
|
|
323
454
|
* same review profile. A mismatch is a full-review reason, never an error that
|
|
@@ -337,9 +468,116 @@ export const validateReviewState = (
|
|
|
337
468
|
if (state.profileFingerprint !== profileFingerprint) {
|
|
338
469
|
return "the reviewer profile or model configuration changed";
|
|
339
470
|
}
|
|
471
|
+
if (state.unresolvedConcerns.some((concern) => concern.evidencePaths === undefined)) {
|
|
472
|
+
return "stored concerns predate affected-path tracking";
|
|
473
|
+
}
|
|
340
474
|
return undefined;
|
|
341
475
|
};
|
|
342
476
|
|
|
477
|
+
const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
|
|
478
|
+
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
|
|
479
|
+
|
|
480
|
+
const incrementalFromDelta = (input: {
|
|
481
|
+
readonly current: PullRequestMetadata;
|
|
482
|
+
readonly fullFiles: ReadonlyArray<ChangedFile>;
|
|
483
|
+
readonly profileFingerprint: string;
|
|
484
|
+
readonly priorState: ReviewState;
|
|
485
|
+
readonly deltaFiles: ReadonlyArray<ChangedFile>;
|
|
486
|
+
readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
|
|
487
|
+
readonly reason: string;
|
|
488
|
+
}): ReviewSelection => {
|
|
489
|
+
const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
|
|
490
|
+
const affectedPaths = new Set([
|
|
491
|
+
...input.deltaFiles.flatMap(filePaths),
|
|
492
|
+
...(input.extraAffectedPaths ?? []),
|
|
493
|
+
]);
|
|
494
|
+
const initialAffectedCount = affectedPaths.size;
|
|
495
|
+
// Reopen every current path needed to reassess a concern touched by this
|
|
496
|
+
// delta. Repeat to a fixed point because two concerns may overlap on a path.
|
|
497
|
+
let expanded = true;
|
|
498
|
+
while (expanded) {
|
|
499
|
+
expanded = false;
|
|
500
|
+
for (const concern of input.priorState.unresolvedConcerns) {
|
|
501
|
+
const paths = concern.evidencePaths ?? [];
|
|
502
|
+
if (!paths.some((path) => affectedPaths.has(path))) continue;
|
|
503
|
+
for (const path of paths) {
|
|
504
|
+
if (!affectedPaths.has(path)) {
|
|
505
|
+
affectedPaths.add(path);
|
|
506
|
+
expanded = true;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
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
|
+
const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
|
|
521
|
+
const retryOnly = new Set<string>();
|
|
522
|
+
const retryStages = new Set<UnreviewedStage>();
|
|
523
|
+
for (const path of carriedPaths) {
|
|
524
|
+
if (affectedPaths.has(path)) continue;
|
|
525
|
+
retryOnly.add(path);
|
|
526
|
+
for (const pass of input.priorState.unreviewedPasses) {
|
|
527
|
+
if (pass.paths.includes(path)) retryStages.add(pass.stage);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (
|
|
531
|
+
retryStages.has("verification") &&
|
|
532
|
+
!retryStages.has("discovery") &&
|
|
533
|
+
!retryStages.has("specialist")
|
|
534
|
+
) {
|
|
535
|
+
retryStages.add("discovery");
|
|
536
|
+
retryStages.add("specialist");
|
|
537
|
+
}
|
|
538
|
+
if (retryOnly.size > 0 && retryStages.size === 0) {
|
|
539
|
+
for (const path of retryOnly) affectedPaths.add(path);
|
|
540
|
+
retryStages.add("discovery");
|
|
541
|
+
retryStages.add("specialist");
|
|
542
|
+
retryStages.add("verification");
|
|
543
|
+
}
|
|
544
|
+
for (const file of input.fullFiles) {
|
|
545
|
+
const needed =
|
|
546
|
+
affectedPaths.has(file.path) ||
|
|
547
|
+
(file.previousPath !== undefined && affectedPaths.has(file.previousPath)) ||
|
|
548
|
+
retryOnly.has(file.path) ||
|
|
549
|
+
(file.previousPath !== undefined && retryOnly.has(file.previousPath));
|
|
550
|
+
if (needed) selectedByPath.set(file.path, file);
|
|
551
|
+
}
|
|
552
|
+
const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
|
|
553
|
+
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
554
|
+
);
|
|
555
|
+
const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
|
|
556
|
+
const carriedReason =
|
|
557
|
+
leftoverCount > 0
|
|
558
|
+
? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
|
|
559
|
+
: carriedPaths.length > 0
|
|
560
|
+
? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
|
|
561
|
+
: "";
|
|
562
|
+
const concernPathCount = affectedPaths.size - initialAffectedCount;
|
|
563
|
+
const concernReason =
|
|
564
|
+
concernPathCount === 0
|
|
565
|
+
? ""
|
|
566
|
+
: `; reopening ${concernPathCount} related concern path(s) for context`;
|
|
567
|
+
return {
|
|
568
|
+
mode: "incremental",
|
|
569
|
+
reason: `${input.reason}${carriedReason}${concernReason}`,
|
|
570
|
+
files: selectedFiles,
|
|
571
|
+
affectedPaths: [...affectedPaths].sort(),
|
|
572
|
+
retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
|
|
573
|
+
retryStages: [...retryStages].sort(),
|
|
574
|
+
totalFiles: selectedFiles.length,
|
|
575
|
+
baselineSha: input.priorState.reviewedHeadSha,
|
|
576
|
+
priorState: input.priorState,
|
|
577
|
+
profileFingerprint: input.profileFingerprint,
|
|
578
|
+
};
|
|
579
|
+
};
|
|
580
|
+
|
|
343
581
|
/** Pure, deterministic range selection with conservative full-review fallbacks. */
|
|
344
582
|
export const selectReviewRange = (input: {
|
|
345
583
|
readonly requestedMode: ReviewMode;
|
|
@@ -349,10 +587,16 @@ export const selectReviewRange = (input: {
|
|
|
349
587
|
readonly priorState: ReviewState | undefined;
|
|
350
588
|
readonly comparison: ReviewHeadComparison | undefined;
|
|
351
589
|
readonly baseComparison?: ReviewHeadComparison | undefined;
|
|
590
|
+
/**
|
|
591
|
+
* Two-dot tree comparison used when the reviewed head is not a git ancestor
|
|
592
|
+
* (rebase, amend, force-push). Intersected with the current PR path set so
|
|
593
|
+
* main-drift outside the pull request never re-enters scope.
|
|
594
|
+
*/
|
|
595
|
+
readonly contentComparison?: ReviewHeadComparison | undefined;
|
|
352
596
|
readonly lookupFailure?: string | undefined;
|
|
353
597
|
}): ReviewSelection => {
|
|
354
598
|
const full = (reason: string) =>
|
|
355
|
-
|
|
599
|
+
fullReviewSelection({
|
|
356
600
|
reason,
|
|
357
601
|
files: input.fullFiles,
|
|
358
602
|
totalFiles: input.current.totalChangedFiles,
|
|
@@ -366,89 +610,53 @@ export const selectReviewRange = (input: {
|
|
|
366
610
|
const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
|
|
367
611
|
if (invalid !== undefined) return full(invalid);
|
|
368
612
|
const comparison = input.comparison;
|
|
369
|
-
if (comparison === undefined) return full("the incremental head comparison was unavailable");
|
|
370
613
|
if (
|
|
371
|
-
comparison
|
|
372
|
-
comparison.
|
|
373
|
-
comparison.mergeBaseSha !== input.priorState.reviewedHeadSha ||
|
|
374
|
-
(comparison.status !== "ahead" && comparison.status !== "identical")
|
|
614
|
+
comparison !== undefined &&
|
|
615
|
+
isLineageAncestor(comparison, input.priorState, input.current.headSha)
|
|
375
616
|
) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
baseComparison.
|
|
393
|
-
|
|
394
|
-
(baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
|
|
395
|
-
baseComparison.truncated
|
|
396
|
-
) {
|
|
397
|
-
return full("the pull request base changed materially or exceeded the comparison bound");
|
|
398
|
-
}
|
|
399
|
-
for (const file of baseComparison.files) {
|
|
400
|
-
affectedPaths.add(file.path);
|
|
401
|
-
if (file.previousPath !== undefined) affectedPaths.add(file.previousPath);
|
|
402
|
-
}
|
|
403
|
-
baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
|
|
404
|
-
}
|
|
405
|
-
const currentPaths = new Set(
|
|
406
|
-
input.fullFiles.flatMap((file) =>
|
|
407
|
-
file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
|
|
408
|
-
),
|
|
409
|
-
);
|
|
410
|
-
const selectedByPath = new Map<string, ChangedFile>();
|
|
411
|
-
for (const file of comparison.files) {
|
|
412
|
-
if (
|
|
413
|
-
currentPaths.has(file.path) ||
|
|
414
|
-
(file.previousPath !== undefined && currentPaths.has(file.previousPath))
|
|
415
|
-
) {
|
|
416
|
-
selectedByPath.set(file.path, file);
|
|
617
|
+
const extraAffected: Array<string> = [];
|
|
618
|
+
let baseReason = "";
|
|
619
|
+
if (input.priorState.baseSha !== input.current.baseSha) {
|
|
620
|
+
const baseComparison = input.baseComparison;
|
|
621
|
+
if (baseComparison === undefined) {
|
|
622
|
+
return full("the pull request base changed and its lineage comparison was unavailable");
|
|
623
|
+
}
|
|
624
|
+
if (
|
|
625
|
+
baseComparison.baseSha !== input.priorState.baseSha ||
|
|
626
|
+
baseComparison.headSha !== input.current.baseSha ||
|
|
627
|
+
baseComparison.mergeBaseSha !== input.priorState.baseSha ||
|
|
628
|
+
(baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
|
|
629
|
+
baseComparison.truncated
|
|
630
|
+
) {
|
|
631
|
+
return full("the pull request base changed materially or exceeded the comparison bound");
|
|
632
|
+
}
|
|
633
|
+
for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
|
|
634
|
+
baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
|
|
417
635
|
}
|
|
636
|
+
return incrementalFromDelta({
|
|
637
|
+
current: input.current,
|
|
638
|
+
fullFiles: input.fullFiles,
|
|
639
|
+
profileFingerprint: input.profileFingerprint,
|
|
640
|
+
priorState: input.priorState,
|
|
641
|
+
deltaFiles: comparison.files,
|
|
642
|
+
extraAffectedPaths: extraAffected,
|
|
643
|
+
reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
|
|
644
|
+
});
|
|
418
645
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
(rescuePaths &&
|
|
430
|
-
(affectedPaths.has(file.path) ||
|
|
431
|
-
(file.previousPath !== undefined && affectedPaths.has(file.previousPath)))) ||
|
|
432
|
-
carriedPaths.has(file.path) ||
|
|
433
|
-
(file.previousPath !== undefined && carriedPaths.has(file.previousPath));
|
|
434
|
-
if (affected) selectedByPath.set(file.path, file);
|
|
435
|
-
}
|
|
646
|
+
const contentComparison = input.contentComparison;
|
|
647
|
+
if (contentComparison !== undefined && !contentComparison.truncated) {
|
|
648
|
+
return incrementalFromDelta({
|
|
649
|
+
current: input.current,
|
|
650
|
+
fullFiles: input.fullFiles,
|
|
651
|
+
profileFingerprint: input.profileFingerprint,
|
|
652
|
+
priorState: input.priorState,
|
|
653
|
+
deltaFiles: contentComparison.files,
|
|
654
|
+
reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
|
|
655
|
+
});
|
|
436
656
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
);
|
|
440
|
-
const carriedReason =
|
|
441
|
-
carriedPaths.size === 0 ? "" : `; retrying ${carriedPaths.size} carried unreviewed path(s)`;
|
|
442
|
-
return {
|
|
443
|
-
mode: "incremental",
|
|
444
|
-
reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}${carriedReason}`,
|
|
445
|
-
files: selectedFiles,
|
|
446
|
-
affectedPaths: [...affectedPaths].sort(),
|
|
447
|
-
totalFiles: selectedFiles.length,
|
|
448
|
-
baselineSha: input.priorState.reviewedHeadSha,
|
|
449
|
-
priorState: input.priorState,
|
|
450
|
-
profileFingerprint: input.profileFingerprint,
|
|
451
|
-
};
|
|
657
|
+
if (comparison === undefined) return full("the incremental head comparison was unavailable");
|
|
658
|
+
if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
|
|
659
|
+
return full("the prior reviewed head is not an ancestor of the current head");
|
|
452
660
|
};
|
|
453
661
|
|
|
454
662
|
/** Per-run context consumed by orchestration and publication, not by the model. */
|
|
@@ -457,6 +665,20 @@ export class ReviewExecutionContext extends Context.Service<
|
|
|
457
665
|
ReviewSelection
|
|
458
666
|
>()("@effect-agent/pr-review/ReviewExecutionContext") {}
|
|
459
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Explicit direct-run adapter for callers that intentionally review the full
|
|
670
|
+
* source without authenticated incremental continuity.
|
|
671
|
+
*/
|
|
672
|
+
export const fullReviewExecutionContextLayer = (reason: string) =>
|
|
673
|
+
Layer.effect(
|
|
674
|
+
ReviewExecutionContext,
|
|
675
|
+
Effect.gen(function* () {
|
|
676
|
+
const source = yield* PullRequestSource;
|
|
677
|
+
const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
|
|
678
|
+
return fullReviewSelection({ reason, files, totalFiles: metadata.totalChangedFiles });
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
681
|
+
|
|
460
682
|
/**
|
|
461
683
|
* Decorate the full source with the selected review range. Full anchor files
|
|
462
684
|
* remain available to host-side publication validation; model tools see only
|
|
@@ -506,18 +728,6 @@ export const selectedPullRequestSourceLayer = (
|
|
|
506
728
|
}),
|
|
507
729
|
);
|
|
508
730
|
|
|
509
|
-
/** Profile fingerprints are SHA-256 over configuration-only signatures. */
|
|
510
|
-
export const computeProfileFingerprint = (signature: string): Effect.Effect<string> =>
|
|
511
|
-
Effect.promise(async () => {
|
|
512
|
-
const digest = await globalThis.crypto.subtle.digest(
|
|
513
|
-
"SHA-256",
|
|
514
|
-
new TextEncoder().encode(signature),
|
|
515
|
-
);
|
|
516
|
-
return Array.from(new Uint8Array(digest))
|
|
517
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
518
|
-
.join("");
|
|
519
|
-
});
|
|
520
|
-
|
|
521
731
|
/** Build the full-surface mission used only to resolve profile guidance. */
|
|
522
732
|
export const buildProfileMission = (
|
|
523
733
|
metadata: PullRequestMetadata,
|
|
@@ -24,9 +24,6 @@ export const MAX_REVIEW_UNITS = 8;
|
|
|
24
24
|
/** A unit never carries more files than this, regardless of their size. */
|
|
25
25
|
export const MAX_UNIT_FILES = 12;
|
|
26
26
|
|
|
27
|
-
/** Compatibility export; complete evidence chars now own unit packing. */
|
|
28
|
-
export const UNIT_CHANGED_LINE_BUDGET = 800;
|
|
29
|
-
|
|
30
27
|
/**
|
|
31
28
|
* Bound the complete model-visible evidence assigned to one child. This is a
|
|
32
29
|
* character bound rather than a token estimate because it is deterministic,
|
|
@@ -45,9 +42,6 @@ export const MAX_UNIT_EVIDENCE_SHARDS = 12;
|
|
|
45
42
|
*/
|
|
46
43
|
export const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = MAX_REVIEW_UNITS * MAX_UNIT_EVIDENCE_SHARDS;
|
|
47
44
|
|
|
48
|
-
/** @deprecated Use `MAX_PATCH_CHARS`; this is now the per-shard bound. */
|
|
49
|
-
export const MAX_FILE_EVIDENCE_CHARS = MAX_PATCH_CHARS;
|
|
50
|
-
|
|
51
45
|
/** The merged review never exceeds the `CodeReview` findings bound. */
|
|
52
46
|
export const MAX_MERGED_FINDINGS = 20;
|
|
53
47
|
|
|
@@ -468,8 +462,15 @@ export const rankAndDedupeFindings = (
|
|
|
468
462
|
};
|
|
469
463
|
|
|
470
464
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
465
|
+
* Stable identity for one concern. The paths are part of the claim: identical
|
|
466
|
+
* prose about two independent files must not collapse into one item.
|
|
467
|
+
*/
|
|
468
|
+
export const reviewConcernKey = (concern: ReviewConcern): string =>
|
|
469
|
+
`${(concern.evidencePaths ?? []).join("\u0000")}\u0001${concern.title}\u0000${concern.body}`;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The concern analogue of `rankAndDedupeFindings`: dedupe by exact scoped
|
|
473
|
+
* content keeping the most severe duplicate, rank by severity, and cap at the
|
|
473
474
|
* `CodeReview` concerns bound.
|
|
474
475
|
*/
|
|
475
476
|
export const rankAndDedupeConcerns = (
|
|
@@ -477,7 +478,7 @@ export const rankAndDedupeConcerns = (
|
|
|
477
478
|
): ReadonlyArray<ReviewConcern> => {
|
|
478
479
|
const byContent = new Map<string, ReviewConcern>();
|
|
479
480
|
for (const concern of concerns) {
|
|
480
|
-
const key =
|
|
481
|
+
const key = reviewConcernKey(concern);
|
|
481
482
|
const previous = byContent.get(key);
|
|
482
483
|
if (
|
|
483
484
|
previous === undefined ||
|