@effect-agent/pr-review 0.1.0-beta.8 → 0.1.0-beta.9
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 +10 -0
- package/dist/action.d.mts +9 -4
- package/dist/action.mjs +22 -5
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +4 -3
- package/dist/cli.mjs.map +1 -1
- package/dist/{fan-out-BBEATQwc.d.mts → fan-out-C6gq3CFg.d.mts} +87 -3
- package/dist/{github-BZNzmxao.mjs → github-Lfa_ox-u.mjs} +308 -7
- package/dist/github-Lfa_ox-u.mjs.map +1 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +3 -3
- package/dist/{providers-J6BKHyHe.mjs → providers-CaOnz7mK.mjs} +5 -4
- package/dist/{providers-J6BKHyHe.mjs.map → providers-CaOnz7mK.mjs.map} +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +5 -3
- package/dist/testing.mjs.map +1 -1
- package/package.json +2 -2
- package/src/action.ts +32 -1
- package/src/cli.ts +2 -1
- package/src/index.ts +1 -0
- package/src/internal/action-entry.ts +1 -0
- package/src/internal/fixtures.ts +5 -1
- package/src/internal/github-env.ts +20 -6
- package/src/internal/github.ts +232 -2
- package/src/internal/retirement.ts +332 -0
- package/dist/github-BZNzmxao.mjs.map +0 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { Context, DateTime, Effect, Option, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ReviewStateAuthenticator,
|
|
5
|
+
type ReviewState,
|
|
6
|
+
type StoredReviewFinding,
|
|
7
|
+
} from "./review-state.ts";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Review retirement. GitHub mutations stay behind ReviewRetirementHost; this
|
|
11
|
+
// module owns only deterministic identity matching, body rewriting, and the
|
|
12
|
+
// fail-open orchestration that turns stale reviews into quiet history.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
|
|
16
|
+
|
|
17
|
+
/** One previously posted review as observed through the retirement host. */
|
|
18
|
+
export class RetirableReview extends Schema.Class<RetirableReview>(
|
|
19
|
+
"@effect-agent/pr-review/RetirableReview",
|
|
20
|
+
)({
|
|
21
|
+
reviewId: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
22
|
+
body: Schema.String.check(Schema.isMaxLength(60_000)),
|
|
23
|
+
commitSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
|
|
24
|
+
authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
|
|
25
|
+
submittedAt: Schema.NullOr(Schema.DateTimeUtc),
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
/** One inline comment attached to a previously posted review. */
|
|
29
|
+
export class RetirableReviewComment extends Schema.Class<RetirableReviewComment>(
|
|
30
|
+
"@effect-agent/pr-review/RetirableReviewComment",
|
|
31
|
+
)({
|
|
32
|
+
nodeId: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
|
|
33
|
+
path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
|
|
34
|
+
startLine: Schema.NullOr(PositiveLine),
|
|
35
|
+
endLine: Schema.NullOr(PositiveLine),
|
|
36
|
+
body: Schema.String.check(Schema.isMaxLength(65_536)),
|
|
37
|
+
}) {}
|
|
38
|
+
|
|
39
|
+
/** A GitHub retirement read or mutation failed. */
|
|
40
|
+
export class ReviewRetirementFailure extends Schema.TaggedError<ReviewRetirementFailure>()(
|
|
41
|
+
"ReviewRetirementFailure",
|
|
42
|
+
{
|
|
43
|
+
operation: Schema.String,
|
|
44
|
+
reason: Schema.String,
|
|
45
|
+
},
|
|
46
|
+
) {
|
|
47
|
+
override get message() {
|
|
48
|
+
return `Review retirement operation '${this.operation}' failed: ${this.reason}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Host-side GitHub operations used by retirement. Domain code never reaches
|
|
54
|
+
* into REST or GraphQL directly, and deterministic tests substitute this port.
|
|
55
|
+
*/
|
|
56
|
+
export class ReviewRetirementHost extends Context.Service<
|
|
57
|
+
ReviewRetirementHost,
|
|
58
|
+
{
|
|
59
|
+
readonly listReviews: Effect.Effect<ReadonlyArray<RetirableReview>, ReviewRetirementFailure>;
|
|
60
|
+
readonly listComments: (
|
|
61
|
+
reviewId: number,
|
|
62
|
+
) => Effect.Effect<ReadonlyArray<RetirableReviewComment>, ReviewRetirementFailure>;
|
|
63
|
+
readonly updateBody: (
|
|
64
|
+
reviewId: number,
|
|
65
|
+
body: string,
|
|
66
|
+
) => Effect.Effect<void, ReviewRetirementFailure>;
|
|
67
|
+
readonly minimizeComment: (nodeId: string) => Effect.Effect<void, ReviewRetirementFailure>;
|
|
68
|
+
}
|
|
69
|
+
>()("@effect-agent/pr-review/ReviewRetirementHost") {}
|
|
70
|
+
|
|
71
|
+
/** Observable cosmetic work completed by one fail-open retirement pass. */
|
|
72
|
+
export class ReviewRetirementReport extends Schema.Class<ReviewRetirementReport>(
|
|
73
|
+
"@effect-agent/pr-review/ReviewRetirementReport",
|
|
74
|
+
)({
|
|
75
|
+
reviewsRetired: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
76
|
+
findingsResolved: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
77
|
+
commentsMinimized: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
78
|
+
failures: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
79
|
+
}) {}
|
|
80
|
+
|
|
81
|
+
export interface ReviewRetirementInput {
|
|
82
|
+
readonly currentReviewId: number;
|
|
83
|
+
readonly currentReviewUrl: string;
|
|
84
|
+
readonly currentAuthorNodeId: string;
|
|
85
|
+
readonly currentSubmittedAt: DateTime.Utc;
|
|
86
|
+
readonly currentState: ReviewState;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ReviewRetirementDecision {
|
|
90
|
+
readonly body: string;
|
|
91
|
+
readonly resolvedFindings: ReadonlyArray<StoredReviewFinding>;
|
|
92
|
+
readonly priorFindingCount: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const findingIdentity = (finding: {
|
|
96
|
+
readonly path: string;
|
|
97
|
+
readonly startLine: number;
|
|
98
|
+
readonly endLine: number;
|
|
99
|
+
readonly title: string;
|
|
100
|
+
}): string =>
|
|
101
|
+
`${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.title}`;
|
|
102
|
+
|
|
103
|
+
const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
|
|
104
|
+
const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
|
|
105
|
+
const STATE_PATTERN =
|
|
106
|
+
/<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
|
|
107
|
+
const RETIRED_ORIGINAL_PATTERN =
|
|
108
|
+
/<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
|
|
109
|
+
const MACHINE_COMMENT_PATTERN = new RegExp(
|
|
110
|
+
`${REVIEW_METADATA_PATTERN.source}|${FINGERPRINT_PATTERN.source}|${STATE_PATTERN.source}`,
|
|
111
|
+
"g",
|
|
112
|
+
);
|
|
113
|
+
const VERDICT_CALLOUT_PATTERN =
|
|
114
|
+
/^(?:> \[!(?:CAUTION|IMPORTANT)\]\n> [^\n]*(?:\n> [^\n]*)*|> (?:ℹ️|✅)[^\n]*)\n*/;
|
|
115
|
+
const INLINE_FINDING_TITLE_PATTERN = /^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit)\] ([^\n]+)\*\*$/;
|
|
116
|
+
const MAX_REVIEW_BODY_CHARS = 60_000;
|
|
117
|
+
|
|
118
|
+
/** The host-authored metadata marker is the authority gate for any edit. */
|
|
119
|
+
export const hasReviewMetadataMarker = (body: string): boolean =>
|
|
120
|
+
/<!-- effect-agent-pr-review metadata\n/.test(body);
|
|
121
|
+
|
|
122
|
+
const machineComments = (body: string): ReadonlyArray<string> =>
|
|
123
|
+
Array.from(body.matchAll(MACHINE_COMMENT_PATTERN), (match) => match[0]);
|
|
124
|
+
|
|
125
|
+
const originalVisibleBody = (body: string): string => {
|
|
126
|
+
const retired = RETIRED_ORIGINAL_PATTERN.exec(body)?.[1];
|
|
127
|
+
if (retired !== undefined) return retired;
|
|
128
|
+
return body.replace(MACHINE_COMMENT_PATTERN, "").trim().replace(VERDICT_CALLOUT_PATTERN, "");
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const findingLocation = (finding: StoredReviewFinding): string =>
|
|
132
|
+
`${finding.path}:${finding.startLine}${
|
|
133
|
+
finding.endLine === finding.startLine ? "" : `-${finding.endLine}`
|
|
134
|
+
}`;
|
|
135
|
+
|
|
136
|
+
const renderRetiredBody = (input: {
|
|
137
|
+
readonly priorBody: string;
|
|
138
|
+
readonly priorState: ReviewState;
|
|
139
|
+
readonly currentState: ReviewState;
|
|
140
|
+
readonly currentReviewUrl: string;
|
|
141
|
+
readonly resolvedFindings: ReadonlyArray<StoredReviewFinding>;
|
|
142
|
+
}): string => {
|
|
143
|
+
const shortSha = input.currentState.reviewedHeadSha.slice(0, 7);
|
|
144
|
+
const comments = machineComments(input.priorBody);
|
|
145
|
+
const original = originalVisibleBody(input.priorBody);
|
|
146
|
+
const resolved =
|
|
147
|
+
input.resolvedFindings.length === 0
|
|
148
|
+
? []
|
|
149
|
+
: [
|
|
150
|
+
"### Findings resolved by later review",
|
|
151
|
+
"",
|
|
152
|
+
...input.resolvedFindings.map(
|
|
153
|
+
(finding) =>
|
|
154
|
+
`- \`${findingLocation(finding)}\` ~~${finding.title}~~ · resolved at \`${shortSha}\``,
|
|
155
|
+
),
|
|
156
|
+
"",
|
|
157
|
+
];
|
|
158
|
+
const prefix = [
|
|
159
|
+
`> ℹ️ Superseded — ${input.resolvedFindings.length} of ${input.priorState.unresolvedFindings.length} findings resolved at \`${shortSha}\`; see [the latest review](${input.currentReviewUrl}).`,
|
|
160
|
+
"",
|
|
161
|
+
"<details>",
|
|
162
|
+
"<summary>Previous review details</summary>",
|
|
163
|
+
"",
|
|
164
|
+
...resolved,
|
|
165
|
+
"<!-- effect-agent-pr-review retired-original:start -->",
|
|
166
|
+
];
|
|
167
|
+
const suffix = [
|
|
168
|
+
"<!-- effect-agent-pr-review retired-original:end -->",
|
|
169
|
+
"",
|
|
170
|
+
"</details>",
|
|
171
|
+
...(comments.length === 0 ? [] : ["", ...comments]),
|
|
172
|
+
];
|
|
173
|
+
const render = (visible: string) => [...prefix, visible, ...suffix].join("\n");
|
|
174
|
+
if (render(original).length <= MAX_REVIEW_BODY_CHARS) return render(original);
|
|
175
|
+
const truncationNotice = "\n\n_Original review content truncated during retirement._";
|
|
176
|
+
const budget = Math.max(0, MAX_REVIEW_BODY_CHARS - render(truncationNotice).length);
|
|
177
|
+
return render(`${original.slice(0, budget)}${truncationNotice}`);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Compute one prior review's resolved subset and deterministic retired body. */
|
|
181
|
+
export const decideReviewRetirement = (input: {
|
|
182
|
+
readonly priorBody: string;
|
|
183
|
+
readonly priorState: ReviewState;
|
|
184
|
+
readonly currentState: ReviewState;
|
|
185
|
+
readonly currentReviewUrl: string;
|
|
186
|
+
}): ReviewRetirementDecision => {
|
|
187
|
+
const current = new Set(input.currentState.unresolvedFindings.map(findingIdentity));
|
|
188
|
+
const resolvedFindings = input.priorState.unresolvedFindings.filter(
|
|
189
|
+
(finding) => !current.has(findingIdentity(finding)),
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
body: renderRetiredBody({ ...input, resolvedFindings }),
|
|
193
|
+
resolvedFindings,
|
|
194
|
+
priorFindingCount: input.priorState.unresolvedFindings.length,
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const inlineCommentIdentity = (comment: RetirableReviewComment): string | undefined => {
|
|
199
|
+
if (comment.startLine === null || comment.endLine === null) return undefined;
|
|
200
|
+
const firstLine = comment.body.split("\n", 1)[0] ?? "";
|
|
201
|
+
const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine)?.[1];
|
|
202
|
+
return title === undefined
|
|
203
|
+
? undefined
|
|
204
|
+
: findingIdentity({
|
|
205
|
+
path: comment.path,
|
|
206
|
+
startLine: comment.startLine,
|
|
207
|
+
endLine: comment.endLine,
|
|
208
|
+
title,
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const failOpen = <A, E, R>(
|
|
213
|
+
effect: Effect.Effect<A, E, R>,
|
|
214
|
+
fallback: A,
|
|
215
|
+
message: string,
|
|
216
|
+
): Effect.Effect<A, never, R> =>
|
|
217
|
+
effect.pipe(
|
|
218
|
+
Effect.catch((error) =>
|
|
219
|
+
Effect.logWarning(`${message}: ${String(error)}`).pipe(Effect.as(fallback)),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const isStrictlyOlderReview = (review: RetirableReview, input: ReviewRetirementInput): boolean => {
|
|
224
|
+
if (review.submittedAt === null) return false;
|
|
225
|
+
const submittedAt = DateTime.toEpochMillis(review.submittedAt);
|
|
226
|
+
const currentSubmittedAt = DateTime.toEpochMillis(input.currentSubmittedAt);
|
|
227
|
+
return (
|
|
228
|
+
submittedAt < currentSubmittedAt ||
|
|
229
|
+
(submittedAt === currentSubmittedAt && review.reviewId < input.currentReviewId)
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Retire every marker-bearing prior review against the newest posted state.
|
|
235
|
+
* Every lookup, edit, and minimization is isolated: retirement is cosmetic
|
|
236
|
+
* and can never change the run or check outcome.
|
|
237
|
+
*/
|
|
238
|
+
export const retireStaleReviews = Effect.fn("retireStaleReviews")(function* (
|
|
239
|
+
input: ReviewRetirementInput,
|
|
240
|
+
) {
|
|
241
|
+
const host = yield* ReviewRetirementHost;
|
|
242
|
+
const authenticator = yield* ReviewStateAuthenticator;
|
|
243
|
+
if (authenticator.status !== "available") {
|
|
244
|
+
yield* Effect.logWarning(
|
|
245
|
+
"Skipping stale-review retirement because authenticated review state is unavailable.",
|
|
246
|
+
);
|
|
247
|
+
return ReviewRetirementReport.make({
|
|
248
|
+
reviewsRetired: 0,
|
|
249
|
+
findingsResolved: 0,
|
|
250
|
+
commentsMinimized: 0,
|
|
251
|
+
failures: 0,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let failures = 0;
|
|
256
|
+
let reviewsRetired = 0;
|
|
257
|
+
let findingsResolved = 0;
|
|
258
|
+
let commentsMinimized = 0;
|
|
259
|
+
const reviews = yield* failOpen(host.listReviews, undefined, "Could not list prior reviews");
|
|
260
|
+
if (reviews === undefined) {
|
|
261
|
+
return ReviewRetirementReport.make({
|
|
262
|
+
reviewsRetired,
|
|
263
|
+
findingsResolved,
|
|
264
|
+
commentsMinimized,
|
|
265
|
+
failures: 1,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const review of reviews) {
|
|
270
|
+
if (
|
|
271
|
+
review.authorNodeId !== input.currentAuthorNodeId ||
|
|
272
|
+
!isStrictlyOlderReview(review, input) ||
|
|
273
|
+
!hasReviewMetadataMarker(review.body)
|
|
274
|
+
) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const priorState = yield* failOpen(
|
|
278
|
+
authenticator.extract(review.body),
|
|
279
|
+
Option.none<ReviewState>(),
|
|
280
|
+
`Could not authenticate prior review ${review.reviewId}`,
|
|
281
|
+
);
|
|
282
|
+
if (Option.isNone(priorState)) continue;
|
|
283
|
+
|
|
284
|
+
const decision = decideReviewRetirement({
|
|
285
|
+
priorBody: review.body,
|
|
286
|
+
priorState: priorState.value,
|
|
287
|
+
currentState: input.currentState,
|
|
288
|
+
currentReviewUrl: input.currentReviewUrl,
|
|
289
|
+
});
|
|
290
|
+
const updated = yield* failOpen(
|
|
291
|
+
host.updateBody(review.reviewId, decision.body).pipe(Effect.as(true)),
|
|
292
|
+
false,
|
|
293
|
+
`Could not retire prior review ${review.reviewId}`,
|
|
294
|
+
);
|
|
295
|
+
if (updated) {
|
|
296
|
+
reviewsRetired += 1;
|
|
297
|
+
findingsResolved += decision.resolvedFindings.length;
|
|
298
|
+
} else {
|
|
299
|
+
failures += 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (decision.resolvedFindings.length === 0) continue;
|
|
303
|
+
const comments = yield* failOpen(
|
|
304
|
+
host.listComments(review.reviewId),
|
|
305
|
+
undefined,
|
|
306
|
+
`Could not list inline comments for prior review ${review.reviewId}`,
|
|
307
|
+
);
|
|
308
|
+
if (comments === undefined) {
|
|
309
|
+
failures += 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const resolved = new Set(decision.resolvedFindings.map(findingIdentity));
|
|
313
|
+
for (const comment of comments) {
|
|
314
|
+
const identity = inlineCommentIdentity(comment);
|
|
315
|
+
if (identity === undefined || !resolved.has(identity)) continue;
|
|
316
|
+
const minimized = yield* failOpen(
|
|
317
|
+
host.minimizeComment(comment.nodeId).pipe(Effect.as(true)),
|
|
318
|
+
false,
|
|
319
|
+
`Could not minimize resolved inline comment ${comment.nodeId}`,
|
|
320
|
+
);
|
|
321
|
+
if (minimized) commentsMinimized += 1;
|
|
322
|
+
else failures += 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return ReviewRetirementReport.make({
|
|
327
|
+
reviewsRetired,
|
|
328
|
+
findingsResolved,
|
|
329
|
+
commentsMinimized,
|
|
330
|
+
failures,
|
|
331
|
+
});
|
|
332
|
+
});
|