@effect-agent/pr-review 0.1.0-beta.9 → 0.1.0-beta.90

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.
Files changed (59) hide show
  1. package/NOTICE +26 -0
  2. package/README.md +170 -168
  3. package/dist/Review.d.mts +294 -0
  4. package/dist/Review.mjs +702 -0
  5. package/dist/Review.mjs.map +1 -0
  6. package/dist/ReviewRepository-Wd_4qCaO.d.mts +71 -0
  7. package/dist/ReviewRepository.d.mts +2 -0
  8. package/dist/ReviewRepository.mjs +15 -0
  9. package/dist/ReviewRepository.mjs.map +1 -0
  10. package/dist/index.d.mts +3 -717
  11. package/dist/index.mjs +3 -66
  12. package/dist/repository-D7NN3225.mjs +101 -0
  13. package/dist/repository-D7NN3225.mjs.map +1 -0
  14. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  15. package/package.json +1 -54
  16. package/src/Review.ts +1052 -0
  17. package/src/ReviewRepository.ts +9 -0
  18. package/src/index.ts +2 -21
  19. package/src/internal/repository.ts +156 -0
  20. package/dist/action.d.mts +0 -190
  21. package/dist/action.mjs +0 -423
  22. package/dist/action.mjs.map +0 -1
  23. package/dist/cli.d.mts +0 -1
  24. package/dist/cli.mjs +0 -103
  25. package/dist/cli.mjs.map +0 -1
  26. package/dist/fan-out-C6gq3CFg.d.mts +0 -1081
  27. package/dist/github-Lfa_ox-u.mjs +0 -1673
  28. package/dist/github-Lfa_ox-u.mjs.map +0 -1
  29. package/dist/index.mjs.map +0 -1
  30. package/dist/providers-CaOnz7mK.mjs +0 -987
  31. package/dist/providers-CaOnz7mK.mjs.map +0 -1
  32. package/dist/testing.d.mts +0 -131
  33. package/dist/testing.mjs +0 -230
  34. package/dist/testing.mjs.map +0 -1
  35. package/src/action.ts +0 -697
  36. package/src/cli.ts +0 -214
  37. package/src/internal/action-entry.ts +0 -42
  38. package/src/internal/coverage.ts +0 -245
  39. package/src/internal/diff.ts +0 -134
  40. package/src/internal/effort.ts +0 -86
  41. package/src/internal/factory.ts +0 -374
  42. package/src/internal/fan-out-scripted.ts +0 -164
  43. package/src/internal/fan-out.ts +0 -450
  44. package/src/internal/fingerprint.ts +0 -74
  45. package/src/internal/fixtures.ts +0 -131
  46. package/src/internal/github-env.ts +0 -142
  47. package/src/internal/github.ts +0 -761
  48. package/src/internal/ignore.ts +0 -88
  49. package/src/internal/profiles.ts +0 -79
  50. package/src/internal/providers.ts +0 -91
  51. package/src/internal/render.ts +0 -428
  52. package/src/internal/retirement.ts +0 -332
  53. package/src/internal/review-agent.ts +0 -385
  54. package/src/internal/review-state.ts +0 -488
  55. package/src/internal/review-units.ts +0 -167
  56. package/src/internal/run.ts +0 -397
  57. package/src/internal/scripted.ts +0 -108
  58. package/src/internal/source.ts +0 -110
  59. package/src/testing.ts +0 -8
@@ -1,397 +0,0 @@
1
- import { Effect, Option, Schema } from "effect";
2
- import {
3
- makeUsageBudget,
4
- toRunBudgetHook,
5
- UsageBudgetLimits,
6
- UsageTotals,
7
- AgentRuntime,
8
- type RuntimeBinding,
9
- } from "effect-agent";
10
- import { type Tool } from "effect/unstable/ai";
11
-
12
- import { assessReviewCoverage, ReviewCoverage, type ReviewShape } from "./coverage.ts";
13
- import type { ChangedFile } from "./diff.ts";
14
- import { computeChangesetFingerprint } from "./fingerprint.ts";
15
- import { PublishedReview, ReviewPublisher } from "./github.ts";
16
- import { planPublication, ReviewPublicationPlan } from "./render.ts";
17
- import {
18
- clampMaxFindings,
19
- CodeReview,
20
- ReviewConcern,
21
- ReviewFinding,
22
- ReviewMission,
23
- } from "./review-agent.ts";
24
- import {
25
- fromStoredConcern,
26
- fromStoredFinding,
27
- ReviewExecutionContext,
28
- ReviewState,
29
- toStoredConcern,
30
- toStoredFinding,
31
- } from "./review-state.ts";
32
- import { rankAndDedupeFindings } from "./review-units.ts";
33
- import { PullRequestSource, type PullRequestMetadata } from "./source.ts";
34
-
35
- // ---------------------------------------------------------------------------
36
- // One review run, end to end: read the pull request, run the bounded agent,
37
- // validate the review against the real diff, then (optionally) publish.
38
- // Publication happens strictly AFTER the agent loop so no model turn can
39
- // observe or influence the mutation, and a failed run publishes nothing.
40
- // ---------------------------------------------------------------------------
41
-
42
- /**
43
- * Run-level usage bounds on top of the definition's AgentPolicy. Real diffs
44
- * are token-heavy, so the input budget is research-sized with cost as the
45
- * safety net.
46
- */
47
- export const reviewBudgetLimits = UsageBudgetLimits.make({
48
- maxInputTokens: 400_000,
49
- maxOutputTokens: 16_000,
50
- maxToolCalls: 24,
51
- maxCostMicrousd: 2_000_000,
52
- maxDurationMillis: 480_000,
53
- });
54
-
55
- /**
56
- * Run-level bounds for the fan-out coordinator. This budget observes only
57
- * the COORDINATOR'S own usage — delegated children are bounded separately by
58
- * the delegation's `SubagentPolicy` reservation and the child definition's
59
- * own `AgentPolicy`, never silently by the parent's budget. The duration
60
- * ceiling is wider because delegation Tool Calls hold the parent turn open
61
- * while bounded children run.
62
- */
63
- export const fanOutReviewBudgetLimits = UsageBudgetLimits.make({
64
- maxInputTokens: 400_000,
65
- maxOutputTokens: 16_000,
66
- maxToolCalls: 24,
67
- maxCostMicrousd: 2_000_000,
68
- maxDurationMillis: 900_000,
69
- });
70
-
71
- /** Everything one review run produced, publication receipt included. */
72
- export class ReviewRunOutcome extends Schema.Class<ReviewRunOutcome>(
73
- "@effect-agent/pr-review/ReviewRunOutcome",
74
- )({
75
- review: CodeReview,
76
- /** All currently unresolved findings, including unchanged carried scope. */
77
- activeFindings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(20)),
78
- /** All currently unresolved concerns, including concerns carried to final audit. */
79
- activeConcerns: Schema.Array(ReviewConcern).check(Schema.isMaxLength(10)),
80
- /** Host-owned structural coverage used by the Actions check conclusion. */
81
- coverage: ReviewCoverage,
82
- plan: ReviewPublicationPlan,
83
- published: Schema.optionalKey(PublishedReview),
84
- turns: Schema.Int.check(Schema.isGreaterThan(0)),
85
- /**
86
- * The run budget's observed usage. For the fan-out reviewer this observes
87
- * the COORDINATOR only — delegated children are bounded and accounted
88
- * separately by their reservations.
89
- */
90
- usage: Schema.optionalKey(UsageTotals),
91
- /**
92
- * What `usage` observed: the whole run, or a fan-out coordinator only.
93
- * Absent when the caller declared no scope — consumers must not present
94
- * unscoped usage as whole-run totals.
95
- */
96
- usageScope: Schema.optionalKey(Schema.Literals(["run", "coordinator"])),
97
- reviewMode: Schema.optionalKey(Schema.Literals(["incremental", "full"])),
98
- reviewReason: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(1_000))),
99
- state: Schema.optionalKey(ReviewState),
100
- }) {}
101
-
102
- export interface ExecuteReviewOptions {
103
- /** Post the review to GitHub; `false` stops after planning (dry run). */
104
- readonly post: boolean;
105
- /** Map the model's verdict onto APPROVE/REQUEST_CHANGES instead of COMMENT. */
106
- readonly applyVerdict: boolean;
107
- /** Run-level usage bounds; defaults to `reviewBudgetLimits`. */
108
- readonly limits?: UsageBudgetLimits | undefined;
109
- /**
110
- * Host-side findings bound (fail-closed backstop for the instruction-level
111
- * bound): a review carrying more findings is ranked by severity, deduped by
112
- * anchor, and trimmed — never published oversized. Clamped to the schema cap.
113
- */
114
- readonly maxFindings?: number | undefined;
115
- /**
116
- * Prompt signature for changeset fingerprinting. When present, the
117
- * changeset fingerprint is computed and embedded invisibly in the review
118
- * body so later runs can skip an unchanged changeset.
119
- */
120
- readonly signature?: ((mission: ReviewMission) => string) | undefined;
121
- /** Provider binding descriptor rendered into the review footer. */
122
- readonly modelLabel?: string | undefined;
123
- /** Workflow-run URL rendered into the review footer. */
124
- readonly runUrl?: string | undefined;
125
- /**
126
- * What the run budget observes: the whole run, or a fan-out coordinator
127
- * only. Without a declared scope the footer omits usage entirely — this
128
- * generic path cannot know what a caller's binding shape observes, and an
129
- * unlabeled number would read as whole-run totals.
130
- */
131
- readonly usageScope?: "run" | "coordinator" | undefined;
132
- /** Host-owned coverage shape; defaults to the flat reviewer. */
133
- readonly reviewShape?: ReviewShape | undefined;
134
- }
135
-
136
- /** Build the mission one review run frames from the source's snapshot. */
137
- export const buildReviewMission = (
138
- metadata: PullRequestMetadata,
139
- files: ReadonlyArray<ChangedFile>,
140
- ): ReviewMission =>
141
- ReviewMission.make({
142
- repository: metadata.repository,
143
- number: metadata.number,
144
- title: metadata.title,
145
- body: metadata.body,
146
- baseRef: metadata.baseRef,
147
- headRef: metadata.headRef,
148
- changedFileCount: files.length,
149
- });
150
-
151
- /** Enforce the configured findings bound on an already-validated review. */
152
- export const enforceFindingsBound = (review: CodeReview, maxFindings: number): CodeReview =>
153
- review.findings.length <= maxFindings
154
- ? review
155
- : CodeReview.make({
156
- summary: review.summary,
157
- verdict: review.verdict,
158
- findings: rankAndDedupeFindings(review.findings).slice(0, maxFindings),
159
- ...(review.concerns !== undefined ? { concerns: review.concerns } : {}),
160
- });
161
-
162
- const findingKey = (finding: ReviewFinding): string =>
163
- `${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.severity}\u0000${finding.title}`;
164
-
165
- const severityRank: Record<ReviewConcern["severity"], number> = {
166
- blocking: 0,
167
- important: 1,
168
- nit: 2,
169
- };
170
-
171
- const rankAndDedupeConcerns = (
172
- concerns: ReadonlyArray<ReviewConcern>,
173
- ): ReadonlyArray<ReviewConcern> => {
174
- const byContent = new Map<string, ReviewConcern>();
175
- for (const concern of concerns) {
176
- const key = `${concern.title}\u0000${concern.body}`;
177
- const previous = byContent.get(key);
178
- if (
179
- previous === undefined ||
180
- severityRank[concern.severity] < severityRank[previous.severity]
181
- ) {
182
- byContent.set(key, concern);
183
- }
184
- }
185
- return [...byContent.values()]
186
- .sort((left, right) => severityRank[left.severity] - severityRank[right.severity])
187
- .slice(0, 10);
188
- };
189
-
190
- /**
191
- * Execute one review with any explicit Agent Binding whose contract is
192
- * `ReviewMission -> CodeReview` — the flat reviewer or the fan-out
193
- * coordinator; the toolkit stays generic because publication only depends on
194
- * the shared output contract. The binding stays a parameter (D-027): tests
195
- * pass scripted models, hosts pass live provider bindings, and the model
196
- * Layer's requirements stay visible in this Effect's `R`.
197
- */
198
- export const executeReview = <
199
- Instructions,
200
- Tools extends Record<string, Tool.Any>,
201
- Provider,
202
- ModelProvides,
203
- ModelRequires,
204
- >(
205
- binding: RuntimeBinding<
206
- typeof ReviewMission,
207
- typeof CodeReview,
208
- Instructions,
209
- Tools,
210
- Provider,
211
- ModelProvides,
212
- ModelRequires
213
- >,
214
- options: ExecuteReviewOptions,
215
- ) =>
216
- Effect.gen(function* () {
217
- const source = yield* PullRequestSource;
218
- const metadata = yield* source.metadata;
219
- const files = yield* source.changedFiles;
220
- const anchorFiles = yield* source.anchorFiles;
221
- const executionContext = Option.getOrUndefined(
222
- yield* Effect.serviceOption(ReviewExecutionContext),
223
- );
224
- const mission = buildReviewMission(metadata, files);
225
- const fullMission = buildReviewMission(metadata, anchorFiles);
226
- const fingerprint =
227
- options.signature === undefined
228
- ? undefined
229
- : yield* computeChangesetFingerprint(anchorFiles, options.signature(fullMission));
230
-
231
- const budget = yield* makeUsageBudget(options.limits ?? reviewBudgetLimits);
232
- const detached = yield* AgentRuntime.start(binding, mission, {
233
- budget: toRunBudgetHook(budget),
234
- estimateCostMicrousd: () => Effect.succeed(500),
235
- });
236
- const result = yield* detached.await;
237
- const events = yield* detached.events;
238
-
239
- // The engine validated the terminal JSON against the output schema; this
240
- // decode recovers the typed value on this side of the generic boundary.
241
- const decoded = yield* Schema.decodeUnknownEffect(CodeReview)(result.output);
242
- const review = enforceFindingsBound(decoded, clampMaxFindings(options.maxFindings));
243
- const usage = yield* budget.snapshot;
244
- const affectedPaths = new Set(
245
- executionContext?.affectedPaths ??
246
- files.flatMap((file) =>
247
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
248
- ),
249
- );
250
- const priorState =
251
- executionContext?.mode === "incremental" ? executionContext.priorState : undefined;
252
- const carriedCandidates =
253
- priorState?.unresolvedFindings
254
- .filter((finding) => !affectedPaths.has(finding.path))
255
- .map(fromStoredFinding) ?? [];
256
- const activeFindings = rankAndDedupeFindings([...carriedCandidates, ...review.findings]).slice(
257
- 0,
258
- clampMaxFindings(options.maxFindings),
259
- );
260
- const activeFindingKeys = new Set(activeFindings.map(findingKey));
261
- const currentFindingKeys = new Set(review.findings.map(findingKey));
262
- const carriedFindings = carriedCandidates.filter(
263
- (finding) =>
264
- activeFindingKeys.has(findingKey(finding)) && !currentFindingKeys.has(findingKey(finding)),
265
- );
266
- // Non-anchored concerns cannot be mapped safely to one affected path, so
267
- // incremental runs carry them conservatively until the explicit final audit.
268
- const carriedConcernCandidates = priorState?.unresolvedConcerns.map(fromStoredConcern) ?? [];
269
- const activeConcerns = rankAndDedupeConcerns([
270
- ...carriedConcernCandidates,
271
- ...(review.concerns ?? []),
272
- ]);
273
- const currentConcernKeys = new Set(
274
- (review.concerns ?? []).map((concern) => `${concern.title}\u0000${concern.body}`),
275
- );
276
- const activeConcernKeys = new Set(
277
- activeConcerns.map((concern) => `${concern.title}\u0000${concern.body}`),
278
- );
279
- const carriedConcerns = carriedConcernCandidates.filter((concern) => {
280
- const key = `${concern.title}\u0000${concern.body}`;
281
- return activeConcernKeys.has(key) && !currentConcernKeys.has(key);
282
- });
283
- const reviewTotalFiles = executionContext?.totalFiles ?? metadata.totalChangedFiles;
284
- const coverage = assessReviewCoverage({
285
- shape: options.reviewShape ?? "flat",
286
- files,
287
- totalFiles: reviewTotalFiles,
288
- anchorFiles,
289
- totalAnchorFiles: metadata.totalChangedFiles,
290
- events,
291
- });
292
- const stateCandidate =
293
- executionContext !== undefined &&
294
- coverage.status === "complete" &&
295
- fingerprint !== undefined &&
296
- metadata.baseSha !== undefined &&
297
- executionContext.stateAuthenticator?.status === "available"
298
- ? ReviewState.make({
299
- version: 1,
300
- repository: metadata.repository,
301
- pullRequestNumber: metadata.number,
302
- baseRef: metadata.baseRef,
303
- baseSha: metadata.baseSha,
304
- headRef: metadata.headRef,
305
- reviewedHeadSha: metadata.headSha,
306
- profileFingerprint: executionContext.profileFingerprint,
307
- acceptedScopeFingerprint: fingerprint,
308
- reviewedPathCount: anchorFiles.length,
309
- unresolvedFindings: activeFindings.map(toStoredFinding),
310
- unresolvedConcerns: activeConcerns.map(toStoredConcern),
311
- lastReviewMode: executionContext.mode,
312
- })
313
- : undefined;
314
- const continuity =
315
- stateCandidate === undefined || executionContext?.stateAuthenticator === undefined
316
- ? {
317
- state: undefined,
318
- marker: undefined,
319
- notice:
320
- executionContext?.stateAuthenticator?.status === "unavailable" &&
321
- coverage.status === "complete"
322
- ? (executionContext.stateAuthenticator.unavailableReason ??
323
- "authenticated continuity state is unavailable")
324
- : undefined,
325
- }
326
- : yield* executionContext.stateAuthenticator.render(stateCandidate).pipe(
327
- Effect.match({
328
- onFailure: (error) => ({
329
- state: undefined,
330
- marker: undefined,
331
- notice:
332
- error._tag === "ReviewStateMarkerTooLarge"
333
- ? `authenticated continuity state exceeded its ${error.maximumChars}-character bound`
334
- : `authenticated continuity state could not be signed: ${error.reason}`,
335
- }),
336
- onSuccess: (marker) => ({ state: stateCandidate, marker, notice: undefined }),
337
- }),
338
- );
339
- const plan = planPublication(review, anchorFiles, {
340
- applyVerdict: options.applyVerdict,
341
- headSha: metadata.headSha,
342
- totalChangedFiles: metadata.totalChangedFiles,
343
- baseRef: metadata.baseRef,
344
- headRef: metadata.headRef,
345
- modelLabel: options.modelLabel,
346
- runUrl: options.runUrl,
347
- usage,
348
- usageScope: options.usageScope,
349
- fingerprint: coverage.status === "complete" ? fingerprint : undefined,
350
- coverage,
351
- carriedFindings,
352
- carriedConcerns,
353
- reviewMode: executionContext?.mode,
354
- reviewReason: executionContext?.reason,
355
- baselineSha: executionContext?.baselineSha,
356
- reviewFilesVisible: files.length,
357
- reviewTotalFiles,
358
- stateMarker: continuity.marker,
359
- stateNotice: continuity.notice,
360
- });
361
-
362
- const scope =
363
- options.usageScope === undefined ? {} : ({ usageScope: options.usageScope } as const);
364
- if (!options.post) {
365
- return ReviewRunOutcome.make({
366
- review,
367
- activeFindings,
368
- activeConcerns,
369
- coverage,
370
- plan,
371
- turns: result.turns,
372
- usage,
373
- ...scope,
374
- ...(executionContext === undefined
375
- ? {}
376
- : { reviewMode: executionContext.mode, reviewReason: executionContext.reason }),
377
- ...(continuity.state === undefined ? {} : { state: continuity.state }),
378
- });
379
- }
380
- const publisher = yield* ReviewPublisher;
381
- const published = yield* publisher.publish(plan);
382
- return ReviewRunOutcome.make({
383
- review,
384
- activeFindings,
385
- activeConcerns,
386
- coverage,
387
- plan,
388
- published,
389
- turns: result.turns,
390
- usage,
391
- ...scope,
392
- ...(executionContext === undefined
393
- ? {}
394
- : { reviewMode: executionContext.mode, reviewReason: executionContext.reason }),
395
- ...(continuity.state === undefined ? {} : { state: continuity.state }),
396
- });
397
- });
@@ -1,108 +0,0 @@
1
- import { Effect, Layer, Ref, Schema, Stream } from "effect";
2
- import { LanguageModel, Model, type Response } from "effect/unstable/ai";
3
-
4
- import { CodeReview } from "./review-agent.ts";
5
-
6
- // ---------------------------------------------------------------------------
7
- // Deterministic offline model for the flat reviewer: a prompt-aware scripted
8
- // model that walks the real tool surface — list, diff, read — then returns
9
- // the scripted review as its terminal JSON. Decisions key on committed
10
- // history in the prompt, never on call order, so replays stay honest.
11
- // ---------------------------------------------------------------------------
12
-
13
- export const OFFLINE_LIST_CALL_ID = "list-1";
14
- export const OFFLINE_DIFF_CALL_ID = "diff-1";
15
- export const OFFLINE_READ_CALL_ID = "read-1";
16
-
17
- /** Usage attached to EVERY scripted model turn; tests pin exact aggregates. */
18
- export const SCRIPTED_TURN_USAGE = { inputTokens: 64, outputTokens: 48 } as const;
19
-
20
- const scriptedUsage = {
21
- inputTokens: { total: SCRIPTED_TURN_USAGE.inputTokens },
22
- outputTokens: { total: SCRIPTED_TURN_USAGE.outputTokens },
23
- };
24
-
25
- export const scriptedToolTurn = (
26
- ...calls: ReadonlyArray<Response.StreamPartEncoded>
27
- ): ReadonlyArray<Response.StreamPartEncoded> => [
28
- ...calls,
29
- { type: "finish", reason: "tool-calls", usage: scriptedUsage },
30
- ];
31
-
32
- export const scriptedFinalParts = (text: string): ReadonlyArray<Response.StreamPartEncoded> => [
33
- { type: "text-start", id: "code-review" },
34
- { type: "text-delta", id: "code-review", delta: text },
35
- { type: "text-end", id: "code-review" },
36
- { type: "finish", reason: "stop", usage: scriptedUsage },
37
- ];
38
-
39
- /** A prompt-keyed scripted LanguageModel with call and prompt observability. */
40
- export const makePromptKeyedModel = (
41
- name: string,
42
- decide: (promptJson: string) => ReadonlyArray<Response.StreamPartEncoded>,
43
- ) =>
44
- Effect.gen(function* () {
45
- const calls = yield* Ref.make(0);
46
- const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
47
- const model = Model.make(
48
- "scripted",
49
- name,
50
- Layer.effect(
51
- LanguageModel.LanguageModel,
52
- LanguageModel.make({
53
- generateText: () => Effect.succeed([]),
54
- streamText: (request) =>
55
- Stream.unwrap(
56
- Effect.gen(function* () {
57
- yield* Ref.update(calls, (value) => value + 1);
58
- const promptJson = JSON.stringify(request.prompt);
59
- yield* Ref.update(prompts, (previous) => [...previous, promptJson]);
60
- return Stream.fromIterable(decide(promptJson));
61
- }),
62
- ),
63
- }),
64
- ),
65
- );
66
- return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };
67
- });
68
-
69
- /**
70
- * Build the offline scripted reviewer model. Turn 1 lists the changeset,
71
- * Turn 2 reads one file diff, Turn 3 reads head context, Turn 4 returns the
72
- * scripted review JSON.
73
- */
74
- export const makeOfflineReviewerModel = (script: {
75
- readonly diffPath: string;
76
- readonly readPath: string;
77
- readonly review: CodeReview;
78
- }) =>
79
- makePromptKeyedModel("pr-review-offline", (promptJson) => {
80
- if (promptJson.includes(OFFLINE_READ_CALL_ID)) {
81
- return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));
82
- }
83
- if (promptJson.includes(OFFLINE_DIFF_CALL_ID)) {
84
- return scriptedToolTurn({
85
- type: "tool-call",
86
- id: OFFLINE_READ_CALL_ID,
87
- name: "read_file",
88
- params: { path: script.readPath },
89
- providerExecuted: false,
90
- });
91
- }
92
- if (promptJson.includes(OFFLINE_LIST_CALL_ID)) {
93
- return scriptedToolTurn({
94
- type: "tool-call",
95
- id: OFFLINE_DIFF_CALL_ID,
96
- name: "read_file_diff",
97
- params: { path: script.diffPath },
98
- providerExecuted: false,
99
- });
100
- }
101
- return scriptedToolTurn({
102
- type: "tool-call",
103
- id: OFFLINE_LIST_CALL_ID,
104
- name: "list_changed_files",
105
- params: { scope: "all" },
106
- providerExecuted: false,
107
- });
108
- });
@@ -1,110 +0,0 @@
1
- import { Context, Effect, Schema } from "effect";
2
-
3
- import { ChangedFile } from "./diff.ts";
4
-
5
- // ---------------------------------------------------------------------------
6
- // The pull-request source port: everything the review tools may observe about
7
- // one pull request. The live adapter speaks the GitHub REST API; the fixture
8
- // adapter (testing entry) serves an in-memory pull request so ordinary gates
9
- // and dry runs need no network or credential.
10
- // ---------------------------------------------------------------------------
11
-
12
- /** Reading a file head version larger than this is refused, never truncated silently. */
13
- export const MAX_FILE_CHARS = 200_000;
14
-
15
- /** The changeset surface is bounded; larger pull requests fail typed. */
16
- export const MAX_CHANGED_FILES = 300;
17
-
18
- /** Pull-request identity and framing shown to the agent as its mission. */
19
- export class PullRequestMetadata extends Schema.Class<PullRequestMetadata>(
20
- "@effect-agent/pr-review/PullRequestMetadata",
21
- )({
22
- /** `owner/name`, exactly as GitHub renders it. */
23
- repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
24
- number: Schema.Int.check(Schema.isGreaterThan(0)),
25
- title: Schema.String.check(Schema.isMaxLength(400)),
26
- /** Author-provided description; empty when the author left none. */
27
- body: Schema.String.check(Schema.isMaxLength(20_000)),
28
- baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
29
- /** Exact base commit used to validate persisted incremental-review lineage. */
30
- baseSha: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(64))),
31
- headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
32
- headSha: Schema.NonEmptyString.check(Schema.isMaxLength(64)),
33
- /** GitHub's own changed-file total; may exceed what `changedFiles` returns. */
34
- totalChangedFiles: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
35
- }) {}
36
-
37
- /** The upstream source failed: API error, network fault, or malformed payload. */
38
- export class PullRequestSourceFailure extends Schema.TaggedError<PullRequestSourceFailure>()(
39
- "PullRequestSourceFailure",
40
- {
41
- operation: Schema.String,
42
- reason: Schema.String,
43
- },
44
- ) {
45
- override get message() {
46
- return `Pull-request source operation '${this.operation}' failed: ${this.reason}`;
47
- }
48
- }
49
-
50
- /** A model-supplied path or range was invalid; always fail-closed (SEC-007). */
51
- export class ReviewInputViolation extends Schema.TaggedError<ReviewInputViolation>()(
52
- "ReviewInputViolation",
53
- {
54
- input: Schema.String,
55
- reason: Schema.String,
56
- },
57
- ) {
58
- override get message() {
59
- return `Rejected review input '${this.input}': ${this.reason}`;
60
- }
61
- }
62
-
63
- const BACKSLASH = String.fromCharCode(92);
64
-
65
- /**
66
- * Normalize and validate one model-supplied repository-relative path.
67
- * Absolute paths, drive letters, backslashes, empty segments, `.` and `..`
68
- * segments are all violations — never silently fixed. The changeset list is
69
- * the real allowlist; this check is defense in depth for URL construction.
70
- */
71
- export const normalizeRepoRelativePath = (
72
- path: string,
73
- ): Effect.Effect<string, ReviewInputViolation> => {
74
- const fail = (reason: string) => Effect.fail(ReviewInputViolation.make({ input: path, reason }));
75
- if (path.length === 0 || path.length > 512) {
76
- return fail("Path length is out of bounds.");
77
- }
78
- if (path.includes(BACKSLASH)) {
79
- return fail("Path contains a forbidden backslash.");
80
- }
81
- if (path.startsWith("/") || /^[A-Za-z]:/.test(path)) {
82
- return fail("Path must be repository-relative, not absolute.");
83
- }
84
- const segments = path.split("/");
85
- for (const segment of segments) {
86
- if (segment === "" || segment === "." || segment === "..") {
87
- return fail("Path segments must not be empty, '.', or '..'.");
88
- }
89
- }
90
- return Effect.succeed(segments.join("/"));
91
- };
92
-
93
- /** Read-only view of one pull request; the only repository access tools get. */
94
- export class PullRequestSource extends Context.Service<
95
- PullRequestSource,
96
- {
97
- readonly metadata: Effect.Effect<PullRequestMetadata, PullRequestSourceFailure>;
98
- /** Files exposed to the model for this run (full PR or selected delta). */
99
- readonly changedFiles: Effect.Effect<ReadonlyArray<ChangedFile>, PullRequestSourceFailure>;
100
- /** Full current PR diff used only for host-side anchor/state validation. */
101
- readonly anchorFiles: Effect.Effect<ReadonlyArray<ChangedFile>, PullRequestSourceFailure>;
102
- /**
103
- * The head-version content of one CHANGED file. Paths outside the
104
- * changeset are violations: the reviewer reads the change, not the tree.
105
- */
106
- readonly readFile: (
107
- path: string,
108
- ) => Effect.Effect<string, PullRequestSourceFailure | ReviewInputViolation>;
109
- }
110
- >()("@effect-agent/pr-review/PullRequestSource") {}
package/src/testing.ts DELETED
@@ -1,8 +0,0 @@
1
- // Deterministic test helpers: in-memory adapters for both ports and
2
- // prompt-keyed scripted models that walk the real tool surfaces. Everything
3
- // here runs with no network and no credentials, so consumers can test their
4
- // adaptations — guidance, ignore globs, extra tools, custom ports — on every
5
- // ordinary gate.
6
- export * from "./internal/fan-out-scripted.ts";
7
- export * from "./internal/fixtures.ts";
8
- export * from "./internal/scripted.ts";