@effect-agent/pr-review 0.1.0-beta.13 → 0.1.0-beta.14

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.
@@ -16,6 +16,7 @@ import {
16
16
  clampMaxFindings,
17
17
  CodeReview,
18
18
  MAX_CONCERNS,
19
+ MAX_WALKTHROUGH_SUMMARY_CHARS,
19
20
  ReadFile,
20
21
  ReadFileDiff,
21
22
  readFileDiffHandler,
@@ -24,6 +25,7 @@ import {
24
25
  ReviewConcern,
25
26
  ReviewFinding,
26
27
  ReviewMission,
28
+ WalkthroughEntry,
27
29
  } from "./review-agent.ts";
28
30
  import {
29
31
  MAX_REVIEW_UNITS,
@@ -94,6 +96,10 @@ export class FileReviewReport extends Schema.Class<FileReviewReport>(
94
96
  concerns: Schema.optionalKey(
95
97
  Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
96
98
  ),
99
+ /** One-sentence per-file change summaries for the merged walkthrough. */
100
+ fileSummaries: Schema.optionalKey(
101
+ Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
102
+ ),
97
103
  }) {}
98
104
 
99
105
  /**
@@ -127,7 +133,8 @@ export const makeFileReviewerInstructions =
127
133
  "3. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
128
134
  "When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
129
135
  "Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
130
- `4. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"unitId": ${JSON.stringify(brief.unitId)}, "findings": [{"path": <string, a file in your unit>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for concerns about YOUR unit's files with no valid line anchor (a missing cleanup, a coverage gap the diff implies, sequencing the diff leaves open) — never duplicate a finding here>}.`,
136
+ `4. For every file in your unit, write one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file for a reader scanning the pull request, never a line-by-line restatement.`,
137
+ `5. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"unitId": ${JSON.stringify(brief.unitId)}, "findings": [{"path": <string, a file in your unit>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for concerns about YOUR unit's files with no valid line anchor (a missing cleanup, a coverage gap the diff implies, sequencing the diff leaves open) — never duplicate a finding here>, "fileSummaries": <array, OPTIONAL: [{"path": <string, a file in your unit>, "summary": <string, the step-4 sentence>}], one entry per file in your unit>}.`,
131
138
  `Report at most ${MAX_CHILD_FINDINGS} findings and at most ${MAX_CHILD_CONCERNS} concerns; prefer the most important ones. An empty findings array is a valid report. Never report on files outside your unit. Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.`,
132
139
  ].join("\n");
133
140
 
@@ -139,6 +146,10 @@ export const defaultFileReviewerPolicy = AgentPolicy.make({
139
146
  maxToolCalls: MAX_FILE_REVIEW_TOOL_CALLS,
140
147
  maxDuration: "6 minutes",
141
148
  toolConcurrency: 2,
149
+ // Same rationale as the flat reviewer's bound: read refusals are
150
+ // model-visible results, and one parallel batch of out-of-unit probes must
151
+ // not kill the child before it has seen a single refusal.
152
+ repeatedFailureLimit: 12,
142
153
  tokenBudget: 200_000,
143
154
  // Bound one live prompt independently from cumulative usage. The engine
144
155
  // prunes old diff/file results before paying for a summary.
@@ -179,6 +190,10 @@ export class FileReviewUnitResult extends Schema.Class<FileReviewUnitResult>(
179
190
  concerns: Schema.optionalKey(
180
191
  Schema.Array(ReviewConcern).check(Schema.isMaxLength(MAX_CHILD_CONCERNS)),
181
192
  ),
193
+ /** One-sentence per-file change summaries for the merged walkthrough. */
194
+ fileSummaries: Schema.optionalKey(
195
+ Schema.Array(WalkthroughEntry).check(Schema.isMaxLength(MAX_UNIT_FILES)),
196
+ ),
182
197
  }) {}
183
198
 
184
199
  /**
@@ -287,7 +302,8 @@ export const makeFanOutReviewInstructions =
287
302
  '3. A delegation result with "_tag" is a FAILED unit. Never retry it; instead your summary MUST name it honestly, e.g. "unit-002 unreviewed: AgentPolicyError". The plan\'s undiffablePaths and unassignedPaths must also be named as not reviewed when present.',
288
303
  `4. Merge the successful units' findings: drop duplicates sharing the same path and line range keeping the most severe, rank blocking > important > nit, and keep at most ${maxFindings} findings. Drop bloat-shaped findings during the merge — defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies; children bias toward recommending changes, and a finding must be sound, correct, and worth acting on to survive.`,
289
304
  `5. Merge the units' concerns the same way: drop duplicates keeping the most severe, and keep at most ${MAX_CONCERNS}.`,
290
- '6. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment, including every unreviewed unit or file>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string>, "startLine": <integer>, "endLine": <integer>, "severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>, "suggestion": <string, OPTIONAL>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], the merged unit concerns>}. Copy findings and concerns verbatim from the delegation results; never invent or edit anchors.',
305
+ "6. Merge the units' fileSummaries into one walkthrough: copy each entry verbatim, one entry per file, dropping duplicate paths.",
306
+ '7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment, including every unreviewed unit or file>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string>, "startLine": <integer>, "endLine": <integer>, "severity": <"blocking" | "important" | "nit">, "category": <string, OPTIONAL>, "title": <string, <= 120 chars>, "body": <string>, "suggestion": <string, OPTIONAL>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], the merged unit concerns>, "walkthrough": <array, OPTIONAL: [{"path": <string>, "summary": <string>}], the merged fileSummaries>}. Copy findings (including "category" and "suggestion" when present), concerns, and walkthrough entries verbatim from the delegation results; never invent or edit anchors.',
291
307
  'Use verdict "request-changes" only when at least one finding or concern is "blocking". An empty findings array with verdict "approve" is a valid review when every unit succeeded and found nothing.',
292
308
  ].join("\n");
293
309
  };
@@ -367,6 +383,7 @@ const makeFileReviewDelegation = (child: ReturnType<typeof makeFileReviewerDefin
367
383
  unitId: report.unitId,
368
384
  findings: report.findings,
369
385
  ...(report.concerns !== undefined ? { concerns: report.concerns } : {}),
386
+ ...(report.fileSummaries !== undefined ? { fileSummaries: report.fileSummaries } : {}),
370
387
  }),
371
388
  ),
372
389
  policy: fileReviewPolicy,
@@ -10,6 +10,8 @@ import {
10
10
  gitHubReviewPublisherLayer,
11
11
  gitHubReviewRetirementHostLayer,
12
12
  } from "./github.ts";
13
+ import type { ReviewProgressReporter } from "./progress.ts";
14
+ import { gitHubReviewProgressLayer } from "./progress.ts";
13
15
  import type { ReviewRetirementHost } from "./retirement.ts";
14
16
  import type { PullRequestSource } from "./source.ts";
15
17
 
@@ -110,7 +112,11 @@ export const resolveReviewTarget = Effect.fn("resolveReviewTarget")(function* (o
110
112
  export const gitHubReviewLayers = (
111
113
  target: ResolvedReviewTarget,
112
114
  ): Layer.Layer<
113
- PullRequestSource | ReviewPublisher | PriorReviews | ReviewRetirementHost,
115
+ | PullRequestSource
116
+ | ReviewPublisher
117
+ | PriorReviews
118
+ | ReviewRetirementHost
119
+ | ReviewProgressReporter,
114
120
  Config.ConfigError,
115
121
  HttpClient.HttpClient
116
122
  > =>
@@ -143,6 +149,7 @@ export const gitHubReviewLayers = (
143
149
  gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
144
150
  gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
145
151
  gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
152
+ gitHubReviewProgressLayer.pipe(Layer.provide(targetLayer)),
146
153
  );
147
154
  }),
148
155
  );
@@ -0,0 +1,124 @@
1
+ import type { LogLevel } from "effect";
2
+ import { Cause, Config, Effect, Layer, Logger, Predicate, References } from "effect";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Compact host logging for CI runs. The engine's telemetry logs carry full
6
+ // OTel-style annotation sets — correct for an exporter, unreadable as an
7
+ // Actions console. This logger renders each record as ONE line: known engine
8
+ // telemetry messages become short progress lines, and everything else keeps
9
+ // its message with annotations compacted (warnings and errors only). It is
10
+ // presentation only — record filtering stays with MinimumLogLevel, and
11
+ // nothing here feeds back into the run.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Everything one compact line is rendered from; pure and directly testable. */
15
+ export interface CompactLogRecord {
16
+ readonly date: Date;
17
+ readonly logLevel: LogLevel.LogLevel;
18
+ readonly message: unknown;
19
+ readonly annotations: Readonly<Record<string, unknown>>;
20
+ /** Pretty-rendered Cause, present only when the record carries one. */
21
+ readonly cause?: string | undefined;
22
+ }
23
+
24
+ const levelTag: Partial<Record<LogLevel.LogLevel, string>> = {
25
+ Trace: "trace",
26
+ Debug: "debug",
27
+ Warn: "WARN",
28
+ Error: "ERROR",
29
+ Fatal: "FATAL",
30
+ };
31
+
32
+ const asText = (value: unknown): string => {
33
+ if (Predicate.isString(value)) return value;
34
+ try {
35
+ return JSON.stringify(value) ?? String(value);
36
+ } catch {
37
+ return String(value);
38
+ }
39
+ };
40
+
41
+ const annotationText = (annotations: Readonly<Record<string, unknown>>): string =>
42
+ Object.entries(annotations)
43
+ // Dotted keys are the OTel-convention duplicates of the camelCase
44
+ // annotations; one copy per line is enough for a human console.
45
+ .filter(([key]) => !key.includes("."))
46
+ .map(([key, value]) => `${key}=${asText(value).slice(0, 120)}`)
47
+ .join(" ");
48
+
49
+ /** Known engine telemetry messages, rendered as short progress lines. */
50
+ const telemetryLine = (
51
+ message: string,
52
+ annotations: Readonly<Record<string, unknown>>,
53
+ ): string | undefined => {
54
+ const toolName = asText(annotations["toolName"] ?? "tool");
55
+ switch (message) {
56
+ case "agent tool execution completed":
57
+ return `✓ tool ${toolName}`;
58
+ case "agent tool execution failed":
59
+ return `✗ tool ${toolName} failed`;
60
+ case "agent tool handler started":
61
+ case "agent programmatic tool handler started":
62
+ return `→ tool ${toolName}`;
63
+ case "agent model call started":
64
+ return `→ model call`;
65
+ case "agent run started":
66
+ return `→ agent ${asText(annotations["agentId"] ?? "run")} started`;
67
+ default:
68
+ return undefined;
69
+ }
70
+ };
71
+
72
+ /** Render one log record as a single compact console line. */
73
+ export const formatCompactLogLine = (record: CompactLogRecord): string => {
74
+ const time = record.date.toISOString().slice(11, 19);
75
+ const messages = Array.isArray(record.message) ? record.message : [record.message];
76
+ const messageText = messages.map(asText).join(" ");
77
+ const mapped =
78
+ messages.length === 1 && Predicate.isString(messages[0])
79
+ ? telemetryLine(messages[0], record.annotations)
80
+ : undefined;
81
+ const tag = levelTag[record.logLevel];
82
+ let line: string;
83
+ if (mapped !== undefined) {
84
+ line = `[${time}] ${tag === undefined ? "" : `${tag} `}${mapped}`;
85
+ } else {
86
+ const isSevere =
87
+ record.logLevel === "Warn" || record.logLevel === "Error" || record.logLevel === "Fatal";
88
+ const annotations = isSevere ? annotationText(record.annotations) : "";
89
+ line = `[${time}] ${tag === undefined ? "" : `${tag} `}${messageText}${
90
+ annotations === "" ? "" : ` · ${annotations}`
91
+ }`;
92
+ }
93
+ return record.cause === undefined ? line : `${line}\n${record.cause}`;
94
+ };
95
+
96
+ /** The compact Logger; annotations come from the emitting fiber. */
97
+ export const compactReviewLogger: Logger.Logger<unknown, string> = Logger.make((options) =>
98
+ formatCompactLogLine({
99
+ date: options.date,
100
+ logLevel: options.logLevel,
101
+ message: options.message,
102
+ annotations: options.fiber.getRef(References.CurrentLogAnnotations),
103
+ cause: options.cause.reasons.length > 0 ? Cause.pretty(options.cause) : undefined,
104
+ }),
105
+ );
106
+
107
+ /**
108
+ * Install the compact console logger and the minimum level for one host run.
109
+ * PR_REVIEW_LOG_LEVEL widens visibility (e.g. "Debug" shows the engine's
110
+ * per-turn and per-handler telemetry); an unknown value fails loudly like
111
+ * every other configuration fault.
112
+ */
113
+ export const compactReviewLoggingLayer: Layer.Layer<never, Config.ConfigError> = Layer.unwrap(
114
+ Effect.gen(function* () {
115
+ const level = yield* Config.literals(
116
+ ["All", "Trace", "Debug", "Info", "Warn", "Error"],
117
+ "PR_REVIEW_LOG_LEVEL",
118
+ ).pipe(Config.withDefault<LogLevel.LogLevel>("Info"));
119
+ return Layer.merge(
120
+ Logger.layer([Logger.withLeveledConsole(compactReviewLogger)]),
121
+ Layer.succeed(References.MinimumLogLevel, level),
122
+ );
123
+ }),
124
+ );
@@ -0,0 +1,433 @@
1
+ import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect";
2
+ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
3
+
4
+ import {
5
+ DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN,
6
+ GitHubApiFailure,
7
+ GitHubReviewTarget,
8
+ } from "./github.ts";
9
+ import type { ReviewScopeMode } from "./review-state.ts";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // The sticky review-progress comment: one issue comment per pull request that
13
+ // says a review run is working the moment it starts, updated in place with
14
+ // the settled outcome. Progress reporting is cosmetic and FAIL-OPEN by
15
+ // design: it must never change what the review posts or how the check
16
+ // concludes, so every GitHub fault here is logged and swallowed. The review
17
+ // itself still publishes only through the validated ReviewPublisher after
18
+ // the run settles.
19
+ //
20
+ // Concurrency contract (honest, per the no-exactly-once rule): posting is
21
+ // at-least-once and writes are GENERATION-FENCED, never atomic. Each run
22
+ // embeds a claim marker (run token + start time) in the comment it writes and
23
+ // re-reads the comment immediately before every update, writing only when the
24
+ // current claim is its own or belongs to an older run — so a stale run cannot
25
+ // replace a newer run's status outside the read-then-write window. Runs adopt
26
+ // the newest existing claim comment and best-effort delete older duplicates,
27
+ // so duplicates left by unfenced overlapping runs self-heal on the next run.
28
+ // Strict single-comment behavior comes from workflow-level per-PR concurrency
29
+ // groups (as in the reference workflow), not from this adapter.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** Every progress comment starts its invisible marker with this prefix. */
33
+ export const PROGRESS_COMMENT_MARKER_PREFIX = "<!-- effect-agent-pr-review progress";
34
+
35
+ /** One run's generation fence: who wrote a progress comment, and when. */
36
+ export interface ProgressClaim {
37
+ /** Random per-run token; matching it means the comment is this run's own. */
38
+ readonly runToken: string;
39
+ /** Run start in epoch millis; newer runs may overwrite older claims. */
40
+ readonly startedMillis: number;
41
+ }
42
+
43
+ const CLAIM_PATTERN = /<!-- effect-agent-pr-review progress run=([0-9A-Za-z-]+) started=(\d+) -->/g;
44
+
45
+ /** HTML comments must not contain `--`; tokens are reduced to a safe alphabet. */
46
+ const sanitizeToken = (token: string): string =>
47
+ token.replaceAll(/[^0-9A-Za-z-]/g, "").replaceAll(/-{2,}/g, "-");
48
+
49
+ /** Render one run's claim marker (token sanitized into the safe alphabet). */
50
+ export const renderProgressClaimMarker = (claim: ProgressClaim): string =>
51
+ `${PROGRESS_COMMENT_MARKER_PREFIX} run=${sanitizeToken(claim.runToken)} started=${Math.max(0, Math.floor(claim.startedMillis))} -->`;
52
+
53
+ /** Extract the last claim marker in one comment body, if any. */
54
+ export const parseProgressClaim = (body: string): ProgressClaim | undefined => {
55
+ let last: ProgressClaim | undefined;
56
+ for (const match of body.matchAll(CLAIM_PATTERN)) {
57
+ const startedMillis = Number(match[2]);
58
+ if (match[1] !== undefined && Number.isFinite(startedMillis)) {
59
+ last = { runToken: match[1], startedMillis };
60
+ }
61
+ }
62
+ return last;
63
+ };
64
+
65
+ /** What a starting run can honestly say before any model turn has executed. */
66
+ export interface ReviewProgressBegin {
67
+ readonly headSha?: string | undefined;
68
+ readonly reviewMode?: ReviewScopeMode | undefined;
69
+ readonly reviewReason?: string | undefined;
70
+ readonly filesInScope?: number | undefined;
71
+ readonly modelLabel?: string | undefined;
72
+ readonly runUrl?: string | undefined;
73
+ }
74
+
75
+ /** How the run ended: a settled (posted) review, or a failure that posted nothing. */
76
+ export type ReviewProgressSettle =
77
+ | {
78
+ readonly outcome: "reviewed";
79
+ readonly conclusion: "success" | "blocking" | "incomplete";
80
+ readonly verdict: string;
81
+ readonly inlineComments: number;
82
+ readonly reviewUrl?: string | undefined;
83
+ readonly runUrl?: string | undefined;
84
+ readonly modelLabel?: string | undefined;
85
+ }
86
+ | {
87
+ readonly outcome: "failed";
88
+ readonly runUrl?: string | undefined;
89
+ readonly modelLabel?: string | undefined;
90
+ };
91
+
92
+ const footerLine = (options: {
93
+ readonly modelLabel?: string | undefined;
94
+ readonly runUrl?: string | undefined;
95
+ }): string => {
96
+ const parts = ["@effect-agent/pr-review"];
97
+ if (options.modelLabel !== undefined) parts.push(options.modelLabel);
98
+ if (options.runUrl !== undefined) parts.push(`[workflow run](${options.runUrl})`);
99
+ return `_${parts.join(" · ")}._`;
100
+ };
101
+
102
+ const scopeSentence = (info: ReviewProgressBegin): string => {
103
+ const subject =
104
+ info.filesInScope === undefined ? "this pull request" : `${info.filesInScope} changed file(s)`;
105
+ const at = info.headSha === undefined ? "" : ` at \`${info.headSha.slice(0, 7)}\``;
106
+ const scope =
107
+ info.reviewMode === undefined
108
+ ? ""
109
+ : ` — ${info.reviewMode === "incremental" ? "incremental" : "full-diff"} scope${
110
+ info.reviewReason === undefined ? "" : `: ${info.reviewReason.slice(0, 1_000)}`
111
+ }`;
112
+ return `Reviewing ${subject}${at}${scope}.`;
113
+ };
114
+
115
+ /** The "a run just started" body; the outcome update replaces it in place. */
116
+ export const renderProgressBeginBody = (info: ReviewProgressBegin, claim: ProgressClaim): string =>
117
+ [
118
+ "> 🔍 **Code review in progress…**",
119
+ ">",
120
+ `> ${scopeSentence(info)}`,
121
+ "",
122
+ "_This comment is updated in place by each review run._",
123
+ "",
124
+ footerLine(info),
125
+ renderProgressClaimMarker(claim),
126
+ ].join("\n");
127
+
128
+ const settleCallout = (info: ReviewProgressSettle): string => {
129
+ if (info.outcome === "failed") {
130
+ return "> ⚠️ **Code review run failed** — nothing was posted.";
131
+ }
132
+ switch (info.conclusion) {
133
+ case "success":
134
+ return `> ✅ **Code review posted** — verdict \`${info.verdict}\`, ${info.inlineComments} inline comment(s), nothing blocking.`;
135
+ case "blocking":
136
+ return `> 🛑 **Code review posted** — blocking findings; the check fails until they are addressed.`;
137
+ case "incomplete":
138
+ return `> ⚠️ **Code review posted** — required coverage is incomplete, so the check fails.`;
139
+ }
140
+ };
141
+
142
+ /** The settled-outcome body written over the in-progress comment. */
143
+ export const renderProgressSettleBody = (
144
+ info: ReviewProgressSettle,
145
+ claim: ProgressClaim,
146
+ ): string => {
147
+ const link =
148
+ info.outcome === "reviewed" && info.reviewUrl !== undefined
149
+ ? `See the [posted review](${info.reviewUrl}).`
150
+ : info.runUrl !== undefined
151
+ ? `See the [workflow run](${info.runUrl}) for details.`
152
+ : undefined;
153
+ return [
154
+ settleCallout(info),
155
+ ...(link === undefined ? [] : ["", link]),
156
+ "",
157
+ footerLine(info),
158
+ renderProgressClaimMarker(claim),
159
+ ].join("\n");
160
+ };
161
+
162
+ /**
163
+ * Maintains the sticky progress comment. Both operations are infallible by
164
+ * contract: implementations own their fault handling, because progress
165
+ * reporting may never fail or delay the review run it narrates.
166
+ */
167
+ export class ReviewProgressReporter extends Context.Service<
168
+ ReviewProgressReporter,
169
+ {
170
+ readonly begin: (info: ReviewProgressBegin) => Effect.Effect<void>;
171
+ readonly settle: (info: ReviewProgressSettle) => Effect.Effect<void>;
172
+ }
173
+ >()("@effect-agent/pr-review/ReviewProgressReporter") {}
174
+
175
+ /** Reports nothing; the substitute for hosts without a progress surface. */
176
+ export const noopReviewProgressReporterLayer: Layer.Layer<ReviewProgressReporter> = Layer.succeed(
177
+ ReviewProgressReporter,
178
+ ReviewProgressReporter.of({
179
+ begin: () => Effect.void,
180
+ settle: () => Effect.void,
181
+ }),
182
+ );
183
+
184
+ const GitHubIssueCommentWire = Schema.Struct({
185
+ id: Schema.Int,
186
+ body: Schema.optionalKey(Schema.NullOr(Schema.String)),
187
+ user: Schema.optionalKey(
188
+ Schema.NullOr(Schema.Struct({ login: Schema.String, type: Schema.String })),
189
+ ),
190
+ });
191
+ const GitHubIssueCommentsPageWire = Schema.Array(GitHubIssueCommentWire);
192
+
193
+ /** Issue comments page chronologically; the sticky-comment scan stays bounded. */
194
+ const MAX_PROGRESS_LOOKUP_PAGES = 5;
195
+
196
+ interface ProgressCandidate {
197
+ readonly id: number;
198
+ readonly body: string;
199
+ }
200
+
201
+ /** The comment carrying the newest claim wins; unparseable claims lose ties. */
202
+ const pickNewestClaim = (
203
+ candidates: ReadonlyArray<ProgressCandidate>,
204
+ ): ProgressCandidate | undefined => {
205
+ let newest: ProgressCandidate | undefined;
206
+ let newestStarted = Number.NEGATIVE_INFINITY;
207
+ for (const candidate of candidates) {
208
+ const started = parseProgressClaim(candidate.body)?.startedMillis ?? Number.NEGATIVE_INFINITY;
209
+ // >= keeps the LAST (chronologically newest) comment on ties.
210
+ if (newest === undefined || started >= newestStarted) {
211
+ newest = candidate;
212
+ newestStarted = started;
213
+ }
214
+ }
215
+ return newest;
216
+ };
217
+
218
+ /**
219
+ * GitHub-backed progress reporter over the issue-comments API. The sticky
220
+ * comment is found by its invisible marker AND the configured posting-bot
221
+ * identity — a marker pasted into someone else's comment is never edited.
222
+ * Writes are generation-fenced per the module contract above, and every
223
+ * fault (lookup, create, update, delete, bound exhaustion) degrades to a
224
+ * logged warning: a pull request without a progress comment is a cosmetic
225
+ * loss, a failed review run over a cosmetic fault would not be.
226
+ */
227
+ export const gitHubReviewProgressLayer: Layer.Layer<
228
+ ReviewProgressReporter,
229
+ never,
230
+ GitHubReviewTarget | HttpClient.HttpClient
231
+ > = Layer.effect(ReviewProgressReporter)(
232
+ Effect.gen(function* () {
233
+ const target = yield* GitHubReviewTarget;
234
+ const client = yield* HttpClient.HttpClient;
235
+ const started = yield* DateTime.now;
236
+ const claim: ProgressClaim = {
237
+ runToken: globalThis.crypto.randomUUID(),
238
+ startedMillis: DateTime.toEpochMillis(started),
239
+ };
240
+ const knownCommentId = yield* Ref.make(Option.none<number>());
241
+ const authorLogin = (
242
+ target.reviewAuthorLogin ?? DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN
243
+ ).toLowerCase();
244
+ const issuePrefix = `${target.apiUrl}/repos/${target.repository}/issues`;
245
+
246
+ /** May this run overwrite a comment currently carrying `body`? */
247
+ const canClaim = (body: string): boolean => {
248
+ const existing = parseProgressClaim(body);
249
+ if (existing === undefined) return true;
250
+ return existing.runToken === claim.runToken || claim.startedMillis >= existing.startedMillis;
251
+ };
252
+
253
+ const withHeaders = (request: HttpClientRequest.HttpClientRequest) => {
254
+ const base = request.pipe(
255
+ HttpClientRequest.setHeaders({
256
+ "X-GitHub-Api-Version": "2022-11-28",
257
+ "User-Agent": "effect-agent-pr-review",
258
+ }),
259
+ HttpClientRequest.acceptJson,
260
+ );
261
+ return Option.isSome(target.token)
262
+ ? base.pipe(HttpClientRequest.bearerToken(target.token.value))
263
+ : base;
264
+ };
265
+
266
+ const asApiFailure =
267
+ (operation: string) =>
268
+ (error: { readonly _tag: string; readonly message?: string }): GitHubApiFailure =>
269
+ GitHubApiFailure.make({
270
+ operation,
271
+ reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
272
+ });
273
+
274
+ const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
275
+ HttpClient.execute(request).pipe(
276
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
277
+ Effect.mapError(asApiFailure(operation)),
278
+ Effect.provideService(HttpClient.HttpClient, client),
279
+ );
280
+
281
+ const decodeJson = <S extends Schema.Top>(schema: S, operation: string) => {
282
+ const decode = Schema.decodeUnknownEffect(schema);
283
+ return (response: HttpClientResponse.HttpClientResponse) =>
284
+ response.json.pipe(
285
+ Effect.mapError(asApiFailure(operation)),
286
+ Effect.flatMap((payload) =>
287
+ decode(payload).pipe(Effect.mapError(asApiFailure(operation))),
288
+ ),
289
+ );
290
+ };
291
+
292
+ const findCandidates = Effect.gen(function* () {
293
+ const perPage = 100;
294
+ const found: Array<ProgressCandidate> = [];
295
+ for (let page = 1; page <= MAX_PROGRESS_LOOKUP_PAGES; page += 1) {
296
+ const response = yield* execute(
297
+ "listProgressComments",
298
+ withHeaders(
299
+ HttpClientRequest.get(`${issuePrefix}/${target.number}/comments`).pipe(
300
+ HttpClientRequest.setUrlParams({
301
+ per_page: String(perPage),
302
+ page: String(page),
303
+ }),
304
+ ),
305
+ ),
306
+ );
307
+ const wires = yield* decodeJson(
308
+ GitHubIssueCommentsPageWire,
309
+ "listProgressComments",
310
+ )(response);
311
+ for (const wire of wires) {
312
+ if (
313
+ wire.user?.login.toLowerCase() === authorLogin &&
314
+ wire.user.type === "Bot" &&
315
+ (wire.body ?? "").includes(PROGRESS_COMMENT_MARKER_PREFIX)
316
+ ) {
317
+ found.push({ id: wire.id, body: wire.body ?? "" });
318
+ }
319
+ }
320
+ if (wires.length < perPage) return found as ReadonlyArray<ProgressCandidate>;
321
+ }
322
+ return yield* GitHubApiFailure.make({
323
+ operation: "listProgressComments",
324
+ reason: `comment history exceeds the bounded ${MAX_PROGRESS_LOOKUP_PAGES * 100}-comment lookup`,
325
+ });
326
+ });
327
+
328
+ const readComment = (commentId: number) =>
329
+ execute(
330
+ "readProgressComment",
331
+ withHeaders(HttpClientRequest.get(`${issuePrefix}/comments/${commentId}`)),
332
+ ).pipe(
333
+ Effect.flatMap(decodeJson(GitHubIssueCommentWire, "readProgressComment")),
334
+ Effect.map((wire) => wire.body ?? ""),
335
+ );
336
+
337
+ const create = (body: string) =>
338
+ execute(
339
+ "createProgressComment",
340
+ withHeaders(
341
+ HttpClientRequest.post(`${issuePrefix}/${target.number}/comments`).pipe(
342
+ HttpClientRequest.bodyJsonUnsafe({ body }),
343
+ ),
344
+ ),
345
+ ).pipe(
346
+ Effect.flatMap(decodeJson(GitHubIssueCommentWire, "createProgressComment")),
347
+ Effect.map((wire) => wire.id),
348
+ );
349
+
350
+ const update = (commentId: number, body: string) =>
351
+ execute(
352
+ "updateProgressComment",
353
+ withHeaders(
354
+ HttpClientRequest.patch(`${issuePrefix}/comments/${commentId}`).pipe(
355
+ HttpClientRequest.bodyJsonUnsafe({ body }),
356
+ ),
357
+ ),
358
+ ).pipe(Effect.asVoid);
359
+
360
+ const deleteComment = (commentId: number) =>
361
+ execute(
362
+ "deleteProgressComment",
363
+ withHeaders(HttpClientRequest.delete(`${issuePrefix}/comments/${commentId}`)),
364
+ ).pipe(Effect.asVoid);
365
+
366
+ /** Re-read, fence, then write: a stale run must not replace newer status. */
367
+ const guardedUpdate = Effect.fn("ReviewProgressReporter.guardedUpdate")(function* (
368
+ commentId: number,
369
+ body: string,
370
+ ) {
371
+ const current = yield* readComment(commentId);
372
+ if (!canClaim(current)) {
373
+ return yield* Effect.logDebug(
374
+ "review progress comment is owned by a newer run; leaving it untouched",
375
+ );
376
+ }
377
+ yield* update(commentId, body);
378
+ });
379
+
380
+ const upsert = Effect.fn("ReviewProgressReporter.upsert")(function* (body: string) {
381
+ const cached = yield* Ref.get(knownCommentId);
382
+ if (Option.isSome(cached)) {
383
+ return yield* guardedUpdate(cached.value, body);
384
+ }
385
+ const candidates = yield* findCandidates;
386
+ const newest = pickNewestClaim(candidates);
387
+ if (newest === undefined) {
388
+ const created = yield* create(body);
389
+ yield* Ref.set(knownCommentId, Option.some(created));
390
+ return;
391
+ }
392
+ // Reconcile duplicates left by unfenced overlapping runs: keep the
393
+ // newest claim, best-effort delete the rest (each fault only logged).
394
+ yield* Effect.forEach(
395
+ candidates.filter((candidate) => candidate.id !== newest.id),
396
+ (duplicate) =>
397
+ deleteComment(duplicate.id).pipe(
398
+ Effect.catch((failure) =>
399
+ Effect.logWarning("duplicate review progress comment could not be deleted").pipe(
400
+ Effect.annotateLogs({ commentId: duplicate.id, reason: failure.reason }),
401
+ ),
402
+ ),
403
+ ),
404
+ { discard: true },
405
+ );
406
+ yield* Ref.set(knownCommentId, Option.some(newest.id));
407
+ if (!canClaim(newest.body)) {
408
+ return yield* Effect.logDebug(
409
+ "review progress comment is owned by a newer run; leaving it untouched",
410
+ );
411
+ }
412
+ yield* update(newest.id, body);
413
+ });
414
+
415
+ const failOpen = (phase: string) => (effect: Effect.Effect<void, GitHubApiFailure>) =>
416
+ effect.pipe(
417
+ Effect.catch((failure) =>
418
+ Effect.logWarning("review progress comment update failed").pipe(
419
+ Effect.annotateLogs({
420
+ progressPhase: phase,
421
+ operation: failure.operation,
422
+ reason: failure.reason,
423
+ }),
424
+ ),
425
+ ),
426
+ );
427
+
428
+ return ReviewProgressReporter.of({
429
+ begin: (info) => upsert(renderProgressBeginBody(info, claim)).pipe(failOpen("begin")),
430
+ settle: (info) => upsert(renderProgressSettleBody(info, claim)).pipe(failOpen("settle")),
431
+ });
432
+ }),
433
+ );