@effect-agent/pr-review 0.1.0-beta.24 → 0.1.0-beta.26
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 -202
- package/dist/action.d.mts +25 -16
- package/dist/action.mjs +51 -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-Bi1v0VaU.d.mts → fan-out-CMEsbFLk.d.mts} +432 -206
- package/dist/{github-C6jrBLA2.mjs → github-NjgxGqwM.mjs} +2141 -1658
- package/dist/github-NjgxGqwM.mjs.map +1 -0
- package/dist/index.d.mts +20 -12
- package/dist/index.mjs +3 -3
- package/dist/{providers-2Jao2ZAX.mjs → providers-CODZQCmL.mjs} +192 -79
- package/dist/providers-CODZQCmL.mjs.map +1 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/package.json +2 -2
- package/src/action.ts +123 -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/fan-out.ts +56 -4
- package/src/internal/github-env.ts +9 -0
- package/src/internal/github.ts +207 -0
- 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 +161 -42
- package/src/internal/review-units.ts +10 -9
- package/src/internal/run.ts +178 -64
- package/dist/github-C6jrBLA2.mjs.map +0 -1
- package/dist/providers-2Jao2ZAX.mjs.map +0 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Config, Effect, FileSystem, Layer, Option, Schema } from "effect";
|
|
2
2
|
import type { HttpClient } from "effect/unstable/http";
|
|
3
3
|
|
|
4
|
+
import type { ReviewAdjudicationHost } from "./adjudication.ts";
|
|
4
5
|
import type { PriorReviews, ReviewPublisher } from "./github.ts";
|
|
5
6
|
import {
|
|
6
7
|
DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN,
|
|
7
8
|
GitHubReviewTarget,
|
|
8
9
|
gitHubPriorReviewsLayer,
|
|
9
10
|
gitHubPullRequestSourceLayer,
|
|
11
|
+
gitHubReviewAdjudicationHostLayer,
|
|
10
12
|
gitHubReviewPublisherLayer,
|
|
11
13
|
gitHubReviewRetirementHostLayer,
|
|
12
14
|
} from "./github.ts";
|
|
@@ -116,6 +118,7 @@ export const gitHubReviewLayers = (
|
|
|
116
118
|
| ReviewPublisher
|
|
117
119
|
| PriorReviews
|
|
118
120
|
| ReviewRetirementHost
|
|
121
|
+
| ReviewAdjudicationHost
|
|
119
122
|
| ReviewProgressReporter,
|
|
120
123
|
Config.ConfigError,
|
|
121
124
|
HttpClient.HttpClient
|
|
@@ -144,11 +147,17 @@ export const gitHubReviewLayers = (
|
|
|
144
147
|
token,
|
|
145
148
|
reviewAuthorLogin,
|
|
146
149
|
});
|
|
150
|
+
const adjudicationHostLayer = gitHubReviewAdjudicationHostLayer.pipe(
|
|
151
|
+
Layer.provide(targetLayer),
|
|
152
|
+
);
|
|
147
153
|
return Layer.mergeAll(
|
|
148
154
|
gitHubPullRequestSourceLayer.pipe(Layer.provide(targetLayer)),
|
|
149
155
|
gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
|
|
150
156
|
gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
|
|
151
157
|
gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
|
|
158
|
+
// Adjudication is an unconditional run dependency, so the public
|
|
159
|
+
// GitHub bundle owns its target-bound live host explicitly.
|
|
160
|
+
adjudicationHostLayer,
|
|
152
161
|
gitHubReviewProgressLayer.pipe(Layer.provide(targetLayer)),
|
|
153
162
|
);
|
|
154
163
|
}),
|
package/src/internal/github.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { Redacted } from "effect";
|
|
|
2
2
|
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
AdjudicableThread,
|
|
7
|
+
AdjudicationComment,
|
|
8
|
+
AUTHORIZED_ADJUDICATION_ASSOCIATIONS,
|
|
9
|
+
MAX_THREAD_ADJUDICATION_COMMANDS,
|
|
10
|
+
parseThreadAdjudication,
|
|
11
|
+
ReviewAdjudicationFailure,
|
|
12
|
+
ReviewAdjudicationHost,
|
|
13
|
+
} from "./adjudication.ts";
|
|
5
14
|
import { ChangedFile } from "./diff.ts";
|
|
6
15
|
import { extractFingerprint } from "./fingerprint.ts";
|
|
7
16
|
import type { ReviewPublicationPlan } from "./render.ts";
|
|
@@ -642,6 +651,204 @@ export const gitHubReviewRetirementHostLayer: Layer.Layer<
|
|
|
642
651
|
}),
|
|
643
652
|
);
|
|
644
653
|
|
|
654
|
+
// --- Live ReviewAdjudicationHost ----------------------------------------------
|
|
655
|
+
|
|
656
|
+
const MAX_ADJUDICATION_PAGES = 5;
|
|
657
|
+
|
|
658
|
+
const GitHubThreadCommentWire = Schema.Struct({
|
|
659
|
+
id: Schema.Int,
|
|
660
|
+
in_reply_to_id: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
661
|
+
path: Schema.String,
|
|
662
|
+
body: Schema.String,
|
|
663
|
+
author_association: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
664
|
+
user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
|
|
665
|
+
created_at: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
666
|
+
line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
667
|
+
original_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
668
|
+
start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
669
|
+
original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
670
|
+
});
|
|
671
|
+
const GitHubThreadCommentsPageWire = Schema.Array(GitHubThreadCommentWire);
|
|
672
|
+
|
|
673
|
+
const GitHubIssueCommentWire = Schema.Struct({
|
|
674
|
+
body: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
675
|
+
author_association: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
676
|
+
user: Schema.NullOr(Schema.Struct({ login: Schema.String })),
|
|
677
|
+
created_at: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
678
|
+
});
|
|
679
|
+
const GitHubIssueCommentsPageWire = Schema.Array(GitHubIssueCommentWire);
|
|
680
|
+
|
|
681
|
+
const toAdjudicationComment = (
|
|
682
|
+
wire: {
|
|
683
|
+
readonly body?: string | null | undefined;
|
|
684
|
+
readonly author_association?: string | null | undefined;
|
|
685
|
+
readonly user: { readonly login: string } | null;
|
|
686
|
+
readonly created_at?: string | null | undefined;
|
|
687
|
+
},
|
|
688
|
+
sourceOrder: number,
|
|
689
|
+
): AdjudicationComment | undefined => {
|
|
690
|
+
// A comment without an attributable author cannot authorize anything —
|
|
691
|
+
// skip it fail-closed rather than inventing an actor.
|
|
692
|
+
const login = wire.user?.login;
|
|
693
|
+
if (login === undefined || login.length === 0) return undefined;
|
|
694
|
+
return AdjudicationComment.make({
|
|
695
|
+
body: (wire.body ?? "").slice(0, 65_536),
|
|
696
|
+
authorAssociation: (wire.author_association ?? "NONE").slice(0, 40),
|
|
697
|
+
authorLogin: login.slice(0, 100),
|
|
698
|
+
createdAt: parseGitHubSubmittedAt(wire.created_at ?? null),
|
|
699
|
+
sourceOrder,
|
|
700
|
+
});
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* GitHub-backed host reads for maintainer adjudication, installed by
|
|
705
|
+
* `gitHubReviewLayers` in `github-env.ts` at the public composition root: this action's own
|
|
706
|
+
* inline finding threads (roots authored by the configured review author)
|
|
707
|
+
* with their replies, and the pull request's top-level conversation comments.
|
|
708
|
+
* Both listings are creation-ordered.
|
|
709
|
+
*/
|
|
710
|
+
export const gitHubReviewAdjudicationHostLayer: Layer.Layer<
|
|
711
|
+
ReviewAdjudicationHost,
|
|
712
|
+
never,
|
|
713
|
+
GitHubReviewTarget | HttpClient.HttpClient
|
|
714
|
+
> = Layer.effect(ReviewAdjudicationHost)(
|
|
715
|
+
Effect.gen(function* () {
|
|
716
|
+
const target = yield* GitHubReviewTarget;
|
|
717
|
+
const client = yield* HttpClient.HttpClient;
|
|
718
|
+
const reviewAuthorLogin = (
|
|
719
|
+
target.reviewAuthorLogin ?? DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN
|
|
720
|
+
).toLowerCase();
|
|
721
|
+
const asAdjudicationFailure =
|
|
722
|
+
(operation: string) =>
|
|
723
|
+
(error: { readonly _tag: string; readonly message?: string }): ReviewAdjudicationFailure =>
|
|
724
|
+
ReviewAdjudicationFailure.make({
|
|
725
|
+
operation,
|
|
726
|
+
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
727
|
+
});
|
|
728
|
+
const decodeAdjudication = <S extends Schema.Top>(schema: S, operation: string) => {
|
|
729
|
+
const decode = Schema.decodeUnknownEffect(schema);
|
|
730
|
+
return (response: HttpClientResponse.HttpClientResponse) =>
|
|
731
|
+
response.json.pipe(
|
|
732
|
+
Effect.mapError(asAdjudicationFailure(operation)),
|
|
733
|
+
Effect.flatMap((body) =>
|
|
734
|
+
decode(body).pipe(Effect.mapError(asAdjudicationFailure(operation))),
|
|
735
|
+
),
|
|
736
|
+
);
|
|
737
|
+
};
|
|
738
|
+
const listPaged = <A>(input: {
|
|
739
|
+
readonly operation: string;
|
|
740
|
+
readonly url: string;
|
|
741
|
+
readonly decode: (
|
|
742
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
743
|
+
) => Effect.Effect<ReadonlyArray<A>, ReviewAdjudicationFailure>;
|
|
744
|
+
}) =>
|
|
745
|
+
Effect.gen(function* () {
|
|
746
|
+
const values: Array<A> = [];
|
|
747
|
+
const perPage = 100;
|
|
748
|
+
for (let page = 1; page <= MAX_ADJUDICATION_PAGES; page += 1) {
|
|
749
|
+
const response = yield* client
|
|
750
|
+
.execute(
|
|
751
|
+
withCommonHeaders(
|
|
752
|
+
HttpClientRequest.get(input.url).pipe(
|
|
753
|
+
HttpClientRequest.acceptJson,
|
|
754
|
+
HttpClientRequest.setUrlParams({
|
|
755
|
+
per_page: String(perPage),
|
|
756
|
+
page: String(page),
|
|
757
|
+
sort: "created",
|
|
758
|
+
direction: "asc",
|
|
759
|
+
}),
|
|
760
|
+
),
|
|
761
|
+
target.token,
|
|
762
|
+
),
|
|
763
|
+
)
|
|
764
|
+
.pipe(
|
|
765
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
766
|
+
Effect.mapError(asAdjudicationFailure(input.operation)),
|
|
767
|
+
);
|
|
768
|
+
const pageValues = yield* input.decode(response);
|
|
769
|
+
values.push(...pageValues);
|
|
770
|
+
if (pageValues.length < perPage) return values;
|
|
771
|
+
}
|
|
772
|
+
return yield* ReviewAdjudicationFailure.make({
|
|
773
|
+
operation: input.operation,
|
|
774
|
+
reason: `history exceeds the bounded ${MAX_ADJUDICATION_PAGES * 100}-item lookup`,
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const listFindingThreads = Effect.gen(function* () {
|
|
779
|
+
const wires = yield* listPaged({
|
|
780
|
+
operation: "listReviewCommentsForAdjudication",
|
|
781
|
+
url: `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}/comments`,
|
|
782
|
+
decode: decodeAdjudication(
|
|
783
|
+
GitHubThreadCommentsPageWire,
|
|
784
|
+
"listReviewCommentsForAdjudication",
|
|
785
|
+
),
|
|
786
|
+
});
|
|
787
|
+
const positiveLine = (value: number | null | undefined): number | null =>
|
|
788
|
+
value !== undefined && value !== null && value > 0 ? value : null;
|
|
789
|
+
const threads = new Map<
|
|
790
|
+
number,
|
|
791
|
+
{ readonly root: (typeof wires)[number]; readonly replies: Array<AdjudicationComment> }
|
|
792
|
+
>();
|
|
793
|
+
for (const wire of wires) {
|
|
794
|
+
if (wire.in_reply_to_id !== undefined && wire.in_reply_to_id !== null) continue;
|
|
795
|
+
// Only this action's own finding threads can be adjudicated inline.
|
|
796
|
+
if (wire.user?.login.toLowerCase() !== reviewAuthorLogin) continue;
|
|
797
|
+
threads.set(wire.id, { root: wire, replies: [] });
|
|
798
|
+
}
|
|
799
|
+
for (const [sourceOrder, wire] of wires.entries()) {
|
|
800
|
+
if (wire.in_reply_to_id === undefined || wire.in_reply_to_id === null) continue;
|
|
801
|
+
const thread = threads.get(wire.in_reply_to_id);
|
|
802
|
+
if (thread === undefined) continue;
|
|
803
|
+
const reply = toAdjudicationComment(wire, sourceOrder);
|
|
804
|
+
if (reply === undefined) continue;
|
|
805
|
+
const command = parseThreadAdjudication(reply.body);
|
|
806
|
+
if (command === undefined) continue;
|
|
807
|
+
if (!AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(reply.authorAssociation)) {
|
|
808
|
+
yield* Effect.logDebug(
|
|
809
|
+
`Ignored inline adjudication command from @${reply.authorLogin} (${reply.authorAssociation}).`,
|
|
810
|
+
);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (thread.replies.length >= MAX_THREAD_ADJUDICATION_COMMANDS) {
|
|
814
|
+
return yield* ReviewAdjudicationFailure.make({
|
|
815
|
+
operation: "listReviewCommentsForAdjudication",
|
|
816
|
+
reason: `inline thread ${wire.in_reply_to_id} exceeds the bounded ${MAX_THREAD_ADJUDICATION_COMMANDS}-command adjudication lookup`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
thread.replies.push(reply);
|
|
820
|
+
}
|
|
821
|
+
return [...threads.values()]
|
|
822
|
+
.filter((thread) => thread.root.path.length > 0 && thread.root.path.length <= 500)
|
|
823
|
+
.map(({ root, replies }) => {
|
|
824
|
+
const endLine = positiveLine(root.line ?? root.original_line);
|
|
825
|
+
const startLine = positiveLine(root.start_line ?? root.original_start_line) ?? endLine;
|
|
826
|
+
return AdjudicableThread.make({
|
|
827
|
+
path: root.path,
|
|
828
|
+
startLine,
|
|
829
|
+
endLine,
|
|
830
|
+
rootBody: root.body.slice(0, 65_536),
|
|
831
|
+
replies,
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
const listIssueComments = Effect.gen(function* () {
|
|
837
|
+
const wires = yield* listPaged({
|
|
838
|
+
operation: "listIssueCommentsForAdjudication",
|
|
839
|
+
url: `${target.apiUrl}/repos/${target.repository}/issues/${target.number}/comments`,
|
|
840
|
+
decode: decodeAdjudication(GitHubIssueCommentsPageWire, "listIssueCommentsForAdjudication"),
|
|
841
|
+
});
|
|
842
|
+
return wires.flatMap((wire, sourceOrder) => {
|
|
843
|
+
const comment = toAdjudicationComment(wire, sourceOrder);
|
|
844
|
+
return comment === undefined ? [] : [comment];
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
return ReviewAdjudicationHost.of({ listFindingThreads, listIssueComments });
|
|
849
|
+
}),
|
|
850
|
+
);
|
|
851
|
+
|
|
645
852
|
// --- Prior reviews (fingerprint deduplication) ---------------------------------
|
|
646
853
|
|
|
647
854
|
/** Reading the pull request's previously posted reviews failed. */
|
package/src/internal/progress.ts
CHANGED
|
@@ -133,7 +133,7 @@ const settleCallout = (info: ReviewProgressSettle): string => {
|
|
|
133
133
|
case "success":
|
|
134
134
|
return `> ✅ **Code review posted** — verdict \`${info.verdict}\`, ${info.inlineComments} inline comment(s), nothing blocking.`;
|
|
135
135
|
case "blocking":
|
|
136
|
-
return `> 🛑 **Code review posted
|
|
136
|
+
return `> 🛑 **Code review posted:** blocking review items; the check fails until they are addressed.`;
|
|
137
137
|
case "incomplete":
|
|
138
138
|
return `> ⚠️ **Code review posted** — input coverage or configured review assurance is incomplete, so the check fails.`;
|
|
139
139
|
}
|
package/src/internal/render.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Schema } from "effect";
|
|
|
2
2
|
|
|
3
3
|
import { anchorViolation } from "./anchors.ts";
|
|
4
4
|
export { anchorViolation } from "./anchors.ts";
|
|
5
|
+
import { splitCarriedScope } from "./coverage.ts";
|
|
5
6
|
import type { ReviewAssurance, ReviewInputCoverage } from "./coverage.ts";
|
|
6
7
|
import type { ChangedFile } from "./diff.ts";
|
|
7
8
|
import { renderFingerprintMarker } from "./fingerprint.ts";
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
type ReviewConcern,
|
|
12
13
|
type WalkthroughEntry,
|
|
13
14
|
} from "./review-agent.ts";
|
|
14
|
-
import type { ReviewScopeMode, ReviewStateMarker } from "./review-state.ts";
|
|
15
|
+
import type { ReviewScopeMode, ReviewStateMarker, StoredAdjudication } from "./review-state.ts";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Publication planning: pure, deterministic, and fail-closed. Model output is
|
|
@@ -185,25 +186,86 @@ const renderDemoted = (finding: ReviewFinding, reason: string): string => {
|
|
|
185
186
|
const countNoun = (count: number, noun: string): string =>
|
|
186
187
|
`${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
187
188
|
|
|
188
|
-
|
|
189
|
+
interface SeverityTally {
|
|
190
|
+
readonly blocking: number;
|
|
191
|
+
readonly important: number;
|
|
192
|
+
readonly nit: number;
|
|
193
|
+
readonly total: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface ReviewItemCounts {
|
|
197
|
+
readonly findings: SeverityTally;
|
|
198
|
+
readonly concerns: SeverityTally;
|
|
199
|
+
readonly carriedFindings: SeverityTally;
|
|
200
|
+
readonly carriedConcerns: SeverityTally;
|
|
201
|
+
readonly total: SeverityTally;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tallySeverities = (
|
|
205
|
+
items: ReadonlyArray<{ readonly severity: ReviewFinding["severity"] }>,
|
|
206
|
+
): SeverityTally => {
|
|
207
|
+
const blocking = items.filter((item) => item.severity === "blocking").length;
|
|
208
|
+
const important = items.filter((item) => item.severity === "important").length;
|
|
209
|
+
const nit = items.length - blocking - important;
|
|
210
|
+
return { blocking, important, nit, total: items.length };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** The validated finding + concern severities, kept separate by provenance. */
|
|
189
214
|
const severityCounts = (
|
|
190
215
|
review: CodeReview,
|
|
191
216
|
carriedFindings: ReadonlyArray<ReviewFinding> = [],
|
|
192
217
|
carriedConcerns: ReadonlyArray<ReviewConcern> = [],
|
|
193
|
-
) => {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
...carriedConcerns.map((concern) => concern.severity),
|
|
199
|
-
];
|
|
218
|
+
): ReviewItemCounts => {
|
|
219
|
+
const findings = tallySeverities(review.findings);
|
|
220
|
+
const concerns = tallySeverities(review.concerns ?? []);
|
|
221
|
+
const priorFindings = tallySeverities(carriedFindings);
|
|
222
|
+
const priorConcerns = tallySeverities(carriedConcerns);
|
|
200
223
|
return {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
224
|
+
findings,
|
|
225
|
+
concerns,
|
|
226
|
+
carriedFindings: priorFindings,
|
|
227
|
+
carriedConcerns: priorConcerns,
|
|
228
|
+
total: {
|
|
229
|
+
blocking:
|
|
230
|
+
findings.blocking + concerns.blocking + priorFindings.blocking + priorConcerns.blocking,
|
|
231
|
+
important:
|
|
232
|
+
findings.important + concerns.important + priorFindings.important + priorConcerns.important,
|
|
233
|
+
nit: findings.nit + concerns.nit + priorFindings.nit + priorConcerns.nit,
|
|
234
|
+
total: findings.total + concerns.total + priorFindings.total + priorConcerns.total,
|
|
235
|
+
},
|
|
204
236
|
};
|
|
205
237
|
};
|
|
206
238
|
|
|
239
|
+
const joinItemCounts = (items: ReadonlyArray<string>): string =>
|
|
240
|
+
items.length <= 1
|
|
241
|
+
? (items[0] ?? "none")
|
|
242
|
+
: items.length === 2
|
|
243
|
+
? `${items[0]} and ${items[1]}`
|
|
244
|
+
: `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
245
|
+
|
|
246
|
+
const severityItemParts = (
|
|
247
|
+
counts: ReviewItemCounts,
|
|
248
|
+
severity: keyof Pick<SeverityTally, "blocking" | "important" | "nit">,
|
|
249
|
+
): ReadonlyArray<string> => [
|
|
250
|
+
...(counts.findings[severity] === 0
|
|
251
|
+
? []
|
|
252
|
+
: [countNoun(counts.findings[severity], `${severity} finding`)]),
|
|
253
|
+
...(counts.concerns[severity] === 0
|
|
254
|
+
? []
|
|
255
|
+
: [countNoun(counts.concerns[severity], `${severity} concern`)]),
|
|
256
|
+
...(counts.carriedFindings[severity] === 0
|
|
257
|
+
? []
|
|
258
|
+
: [countNoun(counts.carriedFindings[severity], `carried ${severity} finding`)]),
|
|
259
|
+
...(counts.carriedConcerns[severity] === 0
|
|
260
|
+
? []
|
|
261
|
+
: [countNoun(counts.carriedConcerns[severity], `carried ${severity} concern`)]),
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const renderSeverityItems = (
|
|
265
|
+
counts: ReviewItemCounts,
|
|
266
|
+
severity: keyof Pick<SeverityTally, "blocking" | "important" | "nit">,
|
|
267
|
+
): string => joinItemCounts(severityItemParts(counts, severity));
|
|
268
|
+
|
|
207
269
|
/**
|
|
208
270
|
* The opening callout: the review's overall tier, derived HOST-SIDE from the
|
|
209
271
|
* validated severities (never from model prose), described by what GitHub
|
|
@@ -223,37 +285,73 @@ const renderVerdictCallout = (
|
|
|
223
285
|
const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
|
|
224
286
|
// Code findings outrank machinery gaps: a blocking finding is the
|
|
225
287
|
// actionable signal, and unsettled reviewer-side work is carried forward.
|
|
226
|
-
if (counts.blocking > 0) {
|
|
227
|
-
return `> [!CAUTION]\n> ${
|
|
288
|
+
if (counts.total.blocking > 0) {
|
|
289
|
+
return `> [!CAUTION]\n> ${renderSeverityItems(counts, "blocking")}. Do not merge before addressing ${counts.total.blocking === 1 ? "it" : "them"}.`;
|
|
228
290
|
}
|
|
229
|
-
const carried = options.unreviewedPaths?.length ?? 0;
|
|
230
291
|
if (
|
|
231
292
|
options.inputCoverage?.status === "incomplete" ||
|
|
232
293
|
options.assurance?.status === "incomplete"
|
|
233
294
|
) {
|
|
295
|
+
const scope = splitCarriedScope(options);
|
|
296
|
+
const undiffableCount = scope.undiffablePaths.length;
|
|
297
|
+
const undiffableNote =
|
|
298
|
+
undiffableCount > 0
|
|
299
|
+
? ` ${countNoun(undiffableCount, "unreviewable file")} (binary or oversized) ${undiffableCount === 1 ? "has" : "have"} no diff a retry could settle — remove ${undiffableCount === 1 ? "it" : "them"} from the pull request or exclude ${undiffableCount === 1 ? "it" : "them"} with ignore globs.`
|
|
300
|
+
: "";
|
|
301
|
+
// Undiffable-only gaps are NOT reviewer-side and DO ask for a change (to
|
|
302
|
+
// the changeset or the ignore configuration), so they get their own
|
|
303
|
+
// banner instead of the carried-forward retry promise.
|
|
304
|
+
if (!scope.retryableGap) {
|
|
305
|
+
return `> [!WARNING]\n>${undiffableNote} The check reports "incomplete" while ${undiffableCount === 1 ? "it remains" : "they remain"} part of the pull request.`;
|
|
306
|
+
}
|
|
307
|
+
const retryableCount = scope.retryablePaths.length;
|
|
234
308
|
const carriedNote =
|
|
235
|
-
|
|
236
|
-
? ` ${countNoun(
|
|
309
|
+
retryableCount > 0
|
|
310
|
+
? ` ${countNoun(retryableCount, "affected path")} ${retryableCount === 1 ? "is" : "are"} carried forward and retried automatically on the next run.`
|
|
237
311
|
: "";
|
|
238
|
-
return `> [!WARNING]\n> Review infrastructure did not settle
|
|
312
|
+
return `> [!WARNING]\n> Review infrastructure did not settle. This is a reviewer-side gap, NOT a request to change code.${carriedNote}${undiffableNote} The check reports "incomplete" until a run settles.`;
|
|
239
313
|
}
|
|
240
|
-
if (counts.important > 0) {
|
|
241
|
-
return `> [!IMPORTANT]\n> ${
|
|
314
|
+
if (counts.total.important > 0) {
|
|
315
|
+
return `> [!IMPORTANT]\n> ${renderSeverityItems(counts, "important")} to address before merging.`;
|
|
242
316
|
}
|
|
243
|
-
if (counts.total > 0) {
|
|
244
|
-
return
|
|
317
|
+
if (counts.total.total > 0) {
|
|
318
|
+
return `> ℹ️ ${renderSeverityItems(counts, "nit")}; mergeable as-is.`;
|
|
245
319
|
}
|
|
246
320
|
return review.verdict === "approve"
|
|
247
321
|
? "> ✅ No issues found."
|
|
248
|
-
: "> ℹ️ No
|
|
322
|
+
: "> ℹ️ No review items. See the summary.";
|
|
249
323
|
};
|
|
250
324
|
|
|
251
325
|
const renderConcern = (concern: ReviewConcern): string =>
|
|
252
|
-
[
|
|
326
|
+
[
|
|
327
|
+
`### ${severityEmoji[concern.severity]} ${concern.title}`,
|
|
328
|
+
...(concern.evidencePaths === undefined
|
|
329
|
+
? []
|
|
330
|
+
: ["", `_Affected paths: ${concern.evidencePaths.map((path) => `\`${path}\``).join(", ")}_`]),
|
|
331
|
+
"",
|
|
332
|
+
concern.body,
|
|
333
|
+
].join("\n");
|
|
253
334
|
|
|
254
335
|
const renderCarriedFinding = (finding: ReviewFinding): string =>
|
|
255
336
|
`- \`${finding.path}:${finding.startLine}${finding.endLine === finding.startLine ? "" : `-${finding.endLine}`}\` **[${findingLabel(finding)}] ${finding.title}** — ${finding.body}`;
|
|
256
337
|
|
|
338
|
+
/**
|
|
339
|
+
* One adjudicated identity: its location (absent for unanchored concerns),
|
|
340
|
+
* title, maintainer disposition, actor, and the optional reason. Rendered in
|
|
341
|
+
* the collapsed adjudicated section so the audit distinguishes fixed,
|
|
342
|
+
* adjudicated, and still-open items.
|
|
343
|
+
*/
|
|
344
|
+
const renderAdjudicated = (adjudication: StoredAdjudication): string => {
|
|
345
|
+
const location =
|
|
346
|
+
adjudication.path !== undefined &&
|
|
347
|
+
adjudication.startLine !== undefined &&
|
|
348
|
+
adjudication.endLine !== undefined
|
|
349
|
+
? `\`${adjudication.path}:${adjudication.startLine}${adjudication.endLine === adjudication.startLine ? "" : `-${adjudication.endLine}`}\` `
|
|
350
|
+
: "";
|
|
351
|
+
const reason = adjudication.reason === undefined ? "" : `: ${adjudication.reason}`;
|
|
352
|
+
return `- ${location}${adjudication.title} — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
|
|
353
|
+
};
|
|
354
|
+
|
|
257
355
|
/**
|
|
258
356
|
* Validate the model's walkthrough against the real changeset: entries whose
|
|
259
357
|
* path is not a changed file are dropped (the walkthrough analogue of anchor
|
|
@@ -311,7 +409,7 @@ export const estimateReviewEffort = (
|
|
|
311
409
|
const renderReviewStats = (
|
|
312
410
|
files: ReadonlyArray<ChangedFile>,
|
|
313
411
|
totalChangedFiles: number,
|
|
314
|
-
counts:
|
|
412
|
+
counts: ReviewItemCounts,
|
|
315
413
|
): string => {
|
|
316
414
|
const additions = files.reduce((total, file) => total + file.additions, 0);
|
|
317
415
|
const deletions = files.reduce((total, file) => total + file.deletions, 0);
|
|
@@ -319,17 +417,16 @@ const renderReviewStats = (
|
|
|
319
417
|
files.length < totalChangedFiles
|
|
320
418
|
? `${files.length} of ${totalChangedFiles} files`
|
|
321
419
|
: countNoun(files.length, "file");
|
|
322
|
-
const nits = counts.total - counts.blocking - counts.important;
|
|
323
420
|
const tally =
|
|
324
|
-
counts.total === 0
|
|
421
|
+
counts.total.total === 0
|
|
325
422
|
? "none"
|
|
326
|
-
:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
423
|
+
: joinItemCounts(
|
|
424
|
+
(["blocking", "important", "nit"] as const).flatMap((severity) =>
|
|
425
|
+
severityItemParts(counts, severity),
|
|
426
|
+
),
|
|
427
|
+
);
|
|
331
428
|
const effort = estimateReviewEffort(files);
|
|
332
|
-
return `**Changeset:** ${fileCount} (+${additions} / −${deletions}) · **
|
|
429
|
+
return `**Changeset:** ${fileCount} (+${additions} / −${deletions}) · **Review items:** ${tally} · **Review effort:** ${effort.score}/5 (${effort.label})`;
|
|
333
430
|
};
|
|
334
431
|
|
|
335
432
|
/** HTML comments must not contain `--`; interpolated values are sanitized. */
|
|
@@ -409,6 +506,12 @@ export const planPublication = (
|
|
|
409
506
|
/** Unchanged unresolved items carried from the prior reviewed baseline. */
|
|
410
507
|
readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
|
|
411
508
|
readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
|
|
509
|
+
/**
|
|
510
|
+
* Standing maintainer adjudications. The caller excludes their identities
|
|
511
|
+
* from the review, the carried items, and every severity count; this
|
|
512
|
+
* planner only renders them as the collapsed adjudicated section.
|
|
513
|
+
*/
|
|
514
|
+
readonly adjudications?: ReadonlyArray<StoredAdjudication> | undefined;
|
|
412
515
|
/** Selected review scope, made visible whenever orchestration chose it. */
|
|
413
516
|
readonly reviewMode?: ReviewScopeMode | undefined;
|
|
414
517
|
readonly reviewReason?: string | undefined;
|
|
@@ -475,6 +578,7 @@ export const planPublication = (
|
|
|
475
578
|
omitted: number,
|
|
476
579
|
walkthroughKept: boolean,
|
|
477
580
|
promptsKept: boolean,
|
|
581
|
+
adjudicationsKept: boolean,
|
|
478
582
|
): string => {
|
|
479
583
|
const carriedFindings = options.carriedFindings ?? [];
|
|
480
584
|
const carriedConcerns = options.carriedConcerns ?? [];
|
|
@@ -544,12 +648,33 @@ export const planPublication = (
|
|
|
544
648
|
);
|
|
545
649
|
}
|
|
546
650
|
if (carriedConcerns.length > 0) {
|
|
547
|
-
parts.push(
|
|
651
|
+
parts.push(
|
|
652
|
+
"",
|
|
653
|
+
`### Unresolved concerns from unchanged paths (${carriedConcerns.length})`,
|
|
654
|
+
"",
|
|
655
|
+
"These concerns were reported in an earlier review and were not reverified in this incremental pass. They remain active because none of their affected paths changed.",
|
|
656
|
+
);
|
|
548
657
|
for (const concern of carriedConcerns) parts.push("", renderConcern(concern));
|
|
549
658
|
}
|
|
550
659
|
for (const concern of sortedConcerns.slice(0, concernsKept)) {
|
|
551
660
|
parts.push("", renderConcern(concern));
|
|
552
661
|
}
|
|
662
|
+
const adjudications = options.adjudications ?? [];
|
|
663
|
+
if (adjudications.length > 0) {
|
|
664
|
+
parts.push(
|
|
665
|
+
"",
|
|
666
|
+
adjudicationsKept
|
|
667
|
+
? [
|
|
668
|
+
"<details>",
|
|
669
|
+
`<summary>Adjudicated (${adjudications.length})</summary>`,
|
|
670
|
+
"",
|
|
671
|
+
...adjudications.map(renderAdjudicated),
|
|
672
|
+
"",
|
|
673
|
+
"</details>",
|
|
674
|
+
].join("\n")
|
|
675
|
+
: "⚠️ Adjudicated section omitted — the body exceeded GitHub's review size cap.",
|
|
676
|
+
);
|
|
677
|
+
}
|
|
553
678
|
if (files.length < options.totalChangedFiles) {
|
|
554
679
|
parts.push(
|
|
555
680
|
"",
|
|
@@ -607,9 +732,9 @@ export const planPublication = (
|
|
|
607
732
|
options.inputCoverage?.status === "incomplete" || options.assurance?.status === "incomplete";
|
|
608
733
|
const event: ReviewEvent = !options.applyVerdict
|
|
609
734
|
? "COMMENT"
|
|
610
|
-
: counts.blocking > 0
|
|
735
|
+
: counts.total.blocking > 0
|
|
611
736
|
? "REQUEST_CHANGES"
|
|
612
|
-
: review.verdict === "approve" && counts.important === 0 && !unclean
|
|
737
|
+
: review.verdict === "approve" && counts.total.important === 0 && !unclean
|
|
613
738
|
? "APPROVE"
|
|
614
739
|
: "COMMENT";
|
|
615
740
|
|
|
@@ -631,20 +756,30 @@ export const planPublication = (
|
|
|
631
756
|
const headBudget = 60_000 - tail.length - 1;
|
|
632
757
|
|
|
633
758
|
// Shed whole trailing items — the derivative consolidated prompt first,
|
|
634
|
-
// then the informational walkthrough, then
|
|
635
|
-
// failed validation), then concerns — instead
|
|
636
|
-
// mid-block. Every omission is announced, and
|
|
637
|
-
// data regardless.
|
|
759
|
+
// then the informational walkthrough, then the adjudicated record, then
|
|
760
|
+
// demoted bullets (they already failed validation), then concerns — instead
|
|
761
|
+
// of slicing markdown mid-block. Every omission is announced, and
|
|
762
|
+
// `plan.demoted` keeps the full data regardless.
|
|
763
|
+
const adjudicationCount = options.adjudications?.length ?? 0;
|
|
638
764
|
let concernsKept = sortedConcerns.length;
|
|
639
765
|
let demotedKept = sortedDemoted.length;
|
|
640
766
|
let omitted = 0;
|
|
641
767
|
let walkthroughKept = true;
|
|
642
768
|
let promptsKept = true;
|
|
643
|
-
let
|
|
769
|
+
let adjudicationsKept = true;
|
|
770
|
+
let head = renderHead(
|
|
771
|
+
concernsKept,
|
|
772
|
+
demotedKept,
|
|
773
|
+
omitted,
|
|
774
|
+
walkthroughKept,
|
|
775
|
+
promptsKept,
|
|
776
|
+
adjudicationsKept,
|
|
777
|
+
);
|
|
644
778
|
while (
|
|
645
779
|
head.length > headBudget &&
|
|
646
780
|
((promptsKept && consolidatedPromptWanted) ||
|
|
647
781
|
(walkthroughKept && walkthrough.length > 0) ||
|
|
782
|
+
(adjudicationsKept && adjudicationCount > 0) ||
|
|
648
783
|
demotedKept > 0 ||
|
|
649
784
|
concernsKept > 0)
|
|
650
785
|
) {
|
|
@@ -652,6 +787,8 @@ export const planPublication = (
|
|
|
652
787
|
promptsKept = false;
|
|
653
788
|
} else if (walkthroughKept && walkthrough.length > 0) {
|
|
654
789
|
walkthroughKept = false;
|
|
790
|
+
} else if (adjudicationsKept && adjudicationCount > 0) {
|
|
791
|
+
adjudicationsKept = false;
|
|
655
792
|
} else if (demotedKept > 0) {
|
|
656
793
|
demotedKept -= 1;
|
|
657
794
|
omitted += 1;
|
|
@@ -659,7 +796,14 @@ export const planPublication = (
|
|
|
659
796
|
concernsKept -= 1;
|
|
660
797
|
omitted += 1;
|
|
661
798
|
}
|
|
662
|
-
head = renderHead(
|
|
799
|
+
head = renderHead(
|
|
800
|
+
concernsKept,
|
|
801
|
+
demotedKept,
|
|
802
|
+
omitted,
|
|
803
|
+
walkthroughKept,
|
|
804
|
+
promptsKept,
|
|
805
|
+
adjudicationsKept,
|
|
806
|
+
);
|
|
663
807
|
}
|
|
664
808
|
// Last resort for a pathological summary; unreachable while the CodeReview
|
|
665
809
|
// schema caps the summary well below the budget.
|