@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
package/dist/testing.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing.mjs","names":[],"sources":["../src/internal/scripted.ts","../src/internal/fan-out-scripted.ts","../src/internal/fixtures.ts"],"sourcesContent":["import { Effect, Layer, Ref, Schema, Stream } from \"effect\";\nimport { LanguageModel, Model, type Response } from \"effect/unstable/ai\";\n\nimport { CodeReview } from \"./review-agent.ts\";\n\n// ---------------------------------------------------------------------------\n// Deterministic offline model for the flat reviewer: a prompt-aware scripted\n// model that walks the real tool surface — list, diff, read — then returns\n// the scripted review as its terminal JSON. Decisions key on committed\n// history in the prompt, never on call order, so replays stay honest.\n// ---------------------------------------------------------------------------\n\nexport const OFFLINE_LIST_CALL_ID = \"list-1\";\nexport const OFFLINE_DIFF_CALL_ID = \"diff-1\";\nexport const OFFLINE_READ_CALL_ID = \"read-1\";\n\n/** Usage attached to EVERY scripted model turn; tests pin exact aggregates. */\nexport const SCRIPTED_TURN_USAGE = { inputTokens: 64, outputTokens: 48 } as const;\n\nconst scriptedUsage = {\n inputTokens: { total: SCRIPTED_TURN_USAGE.inputTokens },\n outputTokens: { total: SCRIPTED_TURN_USAGE.outputTokens },\n};\n\nexport const scriptedToolTurn = (\n ...calls: ReadonlyArray<Response.StreamPartEncoded>\n): ReadonlyArray<Response.StreamPartEncoded> => [\n ...calls,\n { type: \"finish\", reason: \"tool-calls\", usage: scriptedUsage },\n];\n\nexport const scriptedFinalParts = (text: string): ReadonlyArray<Response.StreamPartEncoded> => [\n { type: \"text-start\", id: \"code-review\" },\n { type: \"text-delta\", id: \"code-review\", delta: text },\n { type: \"text-end\", id: \"code-review\" },\n { type: \"finish\", reason: \"stop\", usage: scriptedUsage },\n];\n\n/** A prompt-keyed scripted LanguageModel with call and prompt observability. */\nexport const makePromptKeyedModel = (\n name: string,\n decide: (promptJson: string) => ReadonlyArray<Response.StreamPartEncoded>,\n) =>\n Effect.gen(function* () {\n const calls = yield* Ref.make(0);\n const prompts = yield* Ref.make<ReadonlyArray<string>>([]);\n const model = Model.make(\n \"scripted\",\n name,\n Layer.effect(\n LanguageModel.LanguageModel,\n LanguageModel.make({\n generateText: () => Effect.succeed([]),\n streamText: (request) =>\n Stream.unwrap(\n Effect.gen(function* () {\n yield* Ref.update(calls, (value) => value + 1);\n const promptJson = JSON.stringify(request.prompt);\n yield* Ref.update(prompts, (previous) => [...previous, promptJson]);\n return Stream.fromIterable(decide(promptJson));\n }),\n ),\n }),\n ),\n );\n return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };\n });\n\n/**\n * Build the offline scripted reviewer model. Turn 1 lists the changeset,\n * Turn 2 reads one file diff, Turn 3 reads head context, Turn 4 returns the\n * scripted review JSON.\n */\nexport const makeOfflineReviewerModel = (script: {\n readonly diffPath: string;\n readonly readPath: string;\n readonly review: CodeReview;\n}) =>\n makePromptKeyedModel(\"pr-review-offline\", (promptJson) => {\n if (promptJson.includes(OFFLINE_READ_CALL_ID)) {\n return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));\n }\n if (promptJson.includes(OFFLINE_DIFF_CALL_ID)) {\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_READ_CALL_ID,\n name: \"read_file\",\n params: { path: script.readPath },\n providerExecuted: false,\n });\n }\n if (promptJson.includes(OFFLINE_LIST_CALL_ID)) {\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_DIFF_CALL_ID,\n name: \"read_file_diff\",\n params: { path: script.diffPath },\n providerExecuted: false,\n });\n }\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_LIST_CALL_ID,\n name: \"list_changed_files\",\n params: { scope: \"all\" },\n providerExecuted: false,\n });\n });\n","import { Effect, Layer, Ref, Schema, Stream } from \"effect\";\nimport { LanguageModel, Model, type Response } from \"effect/unstable/ai\";\n\nimport { FileReviewReport } from \"./fan-out.ts\";\nimport { CodeReview } from \"./review-agent.ts\";\nimport { makePromptKeyedModel, scriptedFinalParts, scriptedToolTurn } from \"./scripted.ts\";\n\n// ---------------------------------------------------------------------------\n// Deterministic offline models for the fan-out reviewer: prompt-keyed\n// scripted models for BOTH the coordinator and the file-reviewer children.\n// Both key every decision on committed history in the prompt (tool-call ids\n// and briefed unit ids), never on call order, so concurrent children and\n// replays stay honest.\n// ---------------------------------------------------------------------------\n\nexport const OFFLINE_UNITS_CALL_ID = \"units-1\";\n\n/** The delegation Tool Call id the scripted coordinator uses for one unit. */\nexport const offlineUnitCallId = (unitId: string): string => `delegate-${unitId}`;\n\n/** The diff Tool Call id the scripted child uses for one unit. */\nexport const offlineChildDiffCallId = (unitId: string): string => `fanout-diff-${unitId}`;\n\n/** One scripted delegation the offline coordinator declares. */\nexport interface OfflineUnitCall {\n readonly unitId: string;\n readonly paths: ReadonlyArray<string>;\n}\n\n/**\n * Build the offline scripted coordinator model. Turn 1 lists the review\n * units, Turn 2 declares one delegation Tool Call per scripted unit in one\n * batch, Turn 3 returns the scripted merged review JSON. Decisions key on\n * tool-call ids already committed to the prompt.\n */\nexport const makeOfflineFanOutCoordinatorModel = (script: {\n readonly unitCalls: ReadonlyArray<OfflineUnitCall>;\n readonly review: CodeReview;\n}) => {\n const firstUnitCallId = offlineUnitCallId(script.unitCalls[0]?.unitId ?? \"unit-none\");\n return makePromptKeyedModel(\"pr-fanout-coordinator-offline\", (promptJson) => {\n if (promptJson.includes(firstUnitCallId)) {\n return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));\n }\n if (promptJson.includes(OFFLINE_UNITS_CALL_ID)) {\n return scriptedToolTurn(\n ...script.unitCalls.map(\n (unit): Response.StreamPartEncoded => ({\n type: \"tool-call\",\n id: offlineUnitCallId(unit.unitId),\n name: \"delegate_file_review\",\n params: { unitId: unit.unitId, paths: unit.paths },\n providerExecuted: false,\n }),\n ),\n );\n }\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_UNITS_CALL_ID,\n name: \"list_review_units\",\n params: { scope: \"all\" },\n providerExecuted: false,\n });\n });\n};\n\n/** How one scripted child behaves for its briefed unit. */\nexport type OfflineUnitOutcome =\n /** Read one diff, then return the scripted report. */\n | { readonly _tag: \"findings\"; readonly report: FileReviewReport }\n /** Read one diff, then return non-JSON — the child fails typed (AgentOutputError). */\n | { readonly _tag: \"malformed-output\" }\n /**\n * Declare more Tool Calls than the child's AgentPolicy allows in one turn —\n * none executes and the child fails typed (AgentPolicyError \"tool-calls\",\n * the reviewer's deliberate `onExhaustion: \"fail\"` pin).\n */\n | { readonly _tag: \"budget-runaway\"; readonly declaredCalls: number };\n\nexport interface OfflineUnitScript {\n readonly unitId: string;\n /** The one file the scripted child reads the diff of. */\n readonly diffPath: string;\n readonly outcome: OfflineUnitOutcome;\n}\n\n/**\n * Build the offline scripted file-reviewer model shared by every delegated\n * child. Each child Run builds the Model Layer inside its own scope; the\n * script entry is selected by the briefed unitId present in the child's OWN\n * prompt, and the turn is selected by whether that unit's diff Tool Call id\n * is already committed there — content-keyed on both axes, so concurrent\n * children never interfere. First-turn child prompts are recorded for\n * context-isolation assertions.\n */\nexport const makeOfflineFileReviewerModel = (scripts: ReadonlyArray<OfflineUnitScript>) =>\n Effect.gen(function* () {\n const calls = yield* Ref.make(0);\n const prompts = yield* Ref.make<ReadonlyArray<string>>([]);\n const decide = (promptJson: string): ReadonlyArray<Response.StreamPartEncoded> | undefined => {\n const script = scripts.find((candidate) => promptJson.includes(candidate.unitId));\n if (script === undefined) return undefined;\n switch (script.outcome._tag) {\n case \"budget-runaway\": {\n return scriptedToolTurn(\n ...Array.from(\n { length: script.outcome.declaredCalls },\n (_, index): Response.StreamPartEncoded => ({\n type: \"tool-call\",\n id: `runaway-${script.unitId}-${index + 1}`,\n name: \"read_file_diff\",\n params: { path: script.diffPath },\n providerExecuted: false,\n }),\n ),\n );\n }\n case \"malformed-output\":\n case \"findings\": {\n if (promptJson.includes(offlineChildDiffCallId(script.unitId))) {\n return scriptedFinalParts(\n script.outcome._tag === \"findings\"\n ? JSON.stringify(Schema.encodeSync(FileReviewReport)(script.outcome.report))\n : \"this is not the JSON you are looking for\",\n );\n }\n return scriptedToolTurn({\n type: \"tool-call\",\n id: offlineChildDiffCallId(script.unitId),\n name: \"read_file_diff\",\n params: { path: script.diffPath },\n providerExecuted: false,\n });\n }\n }\n };\n const model = Model.make(\n \"scripted\",\n \"pr-fanout-file-reviewer-offline\",\n Layer.effect(\n LanguageModel.LanguageModel,\n LanguageModel.make({\n generateText: () => Effect.succeed([]),\n streamText: (request) =>\n Stream.unwrap(\n Effect.gen(function* () {\n yield* Ref.update(calls, (value) => value + 1);\n const promptJson = JSON.stringify(request.prompt);\n yield* Ref.update(prompts, (previous) => [...previous, promptJson]);\n const parts = decide(promptJson);\n if (parts === undefined) {\n return yield* Effect.die(\n new Error(\"The child prompt names no scripted review unit\"),\n );\n }\n return Stream.fromIterable(parts);\n }),\n ),\n }),\n ),\n );\n return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };\n });\n","import { Effect, Layer, Option, Ref, Schema } from \"effect\";\n\nimport { ChangedFile } from \"./diff.ts\";\nimport {\n PriorReviewLookupFailure,\n PriorReviews,\n PublishedReview,\n ReviewPublisher,\n} from \"./github.ts\";\nimport type { ReviewPublicationPlan } from \"./render.ts\";\nimport type { ReviewHeadComparison, ReviewState } from \"./review-state.ts\";\nimport {\n MAX_CHANGED_FILES,\n MAX_FILE_CHARS,\n normalizeRepoRelativePath,\n PullRequestMetadata,\n PullRequestSource,\n ReviewInputViolation,\n} from \"./source.ts\";\n\n// ---------------------------------------------------------------------------\n// Deterministic in-memory adapters for both ports: a fixture pull request\n// serving the PullRequestSource, and a collecting ReviewPublisher recording\n// every plan. Tests, dry runs, and live smokes run against these with no\n// network and no credentials.\n// ---------------------------------------------------------------------------\n\n/** One fixture file: its changeset entry plus optional head content. */\nexport class FixtureFile extends Schema.Class<FixtureFile>(\"@effect-agent/pr-review/FixtureFile\")({\n file: ChangedFile,\n headContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),\n}) {}\n\n/** A complete in-memory pull request for tests, dry runs, and live smokes. */\nexport class FixturePullRequest extends Schema.Class<FixturePullRequest>(\n \"@effect-agent/pr-review/FixturePullRequest\",\n)({\n metadata: PullRequestMetadata,\n files: Schema.Array(FixtureFile).check(Schema.isMaxLength(MAX_CHANGED_FILES)),\n}) {}\n\nconst requireChanged = (\n fixture: FixturePullRequest,\n path: string,\n): Effect.Effect<FixtureFile, ReviewInputViolation> => {\n const entry = fixture.files.find((candidate) => candidate.file.path === path);\n return entry === undefined\n ? Effect.fail(\n ReviewInputViolation.make({\n input: path,\n reason: \"Path is not part of this pull request's changeset.\",\n }),\n )\n : Effect.succeed(entry);\n};\n\n/** Deterministic `PullRequestSource` over one fixture pull request. */\nexport const fixturePullRequestSourceLayer = (\n fixture: FixturePullRequest,\n): Layer.Layer<PullRequestSource> =>\n Layer.succeed(PullRequestSource)(\n PullRequestSource.of({\n metadata: Effect.succeed(fixture.metadata),\n changedFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),\n anchorFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),\n readFile: (path) =>\n Effect.gen(function* () {\n const relative = yield* normalizeRepoRelativePath(path);\n const entry = yield* requireChanged(fixture, relative);\n if (entry.headContent === undefined) {\n return yield* ReviewInputViolation.make({\n input: relative,\n reason: \"No head content is available for this file.\",\n });\n }\n return entry.headContent;\n }),\n }),\n );\n\n/** In-memory publisher: records every plan and mints a deterministic receipt. */\nexport const collectingReviewPublisherLayer = (\n published: Ref.Ref<ReadonlyArray<ReviewPublicationPlan>>,\n): Layer.Layer<ReviewPublisher> =>\n Layer.succeed(ReviewPublisher)(\n ReviewPublisher.of({\n publish: (plan) =>\n Ref.update(published, (plans) => [...plans, plan]).pipe(\n Effect.flatMap(() => Ref.get(published)),\n Effect.map((plans) =>\n PublishedReview.make({\n reviewId: plans.length,\n url: `memory://review/${plans.length}`,\n event: plan.event,\n inlineComments: plan.comments.length,\n }),\n ),\n ),\n }),\n );\n\n/** Static `PriorReviews` service for tests: fixed history and comparisons. */\nexport const staticPriorReviews = (\n fingerprint: Option.Option<string>,\n options: {\n readonly state?: Option.Option<ReviewState> | undefined;\n readonly comparison?: ReviewHeadComparison | undefined;\n } = {},\n): PriorReviews[\"Service\"] =>\n PriorReviews.of({\n latestFingerprint: Effect.succeed(fingerprint),\n latestState: Effect.succeed(options.state ?? Option.none()),\n compareHeads: () =>\n options.comparison === undefined\n ? Effect.fail(PriorReviewLookupFailure.make({ reason: \"no fixture comparison\" }))\n : Effect.succeed(options.comparison),\n });\n\n/** Layer form for consumers whose Effect explicitly requires `PriorReviews`. */\nexport const staticPriorReviewsLayer = (\n fingerprint: Option.Option<string>,\n options: {\n readonly state?: Option.Option<ReviewState> | undefined;\n readonly comparison?: ReviewHeadComparison | undefined;\n } = {},\n): Layer.Layer<PriorReviews> =>\n Layer.succeed(PriorReviews)(staticPriorReviews(fingerprint, options));\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"testing.mjs","names":[],"sources":["../src/internal/scripted.ts","../src/internal/fan-out-scripted.ts","../src/internal/fixtures.ts"],"sourcesContent":["import { Effect, Layer, Ref, Schema, Stream } from \"effect\";\nimport { LanguageModel, Model, type Response } from \"effect/unstable/ai\";\n\nimport { CodeReview } from \"./review-agent.ts\";\n\n// ---------------------------------------------------------------------------\n// Deterministic offline model for the flat reviewer: a prompt-aware scripted\n// model that walks the real tool surface — list, diff, read — then returns\n// the scripted review as its terminal JSON. Decisions key on committed\n// history in the prompt, never on call order, so replays stay honest.\n// ---------------------------------------------------------------------------\n\nexport const OFFLINE_LIST_CALL_ID = \"list-1\";\nexport const OFFLINE_DIFF_CALL_ID = \"diff-1\";\nexport const OFFLINE_READ_CALL_ID = \"read-1\";\n\n/** Usage attached to EVERY scripted model turn; tests pin exact aggregates. */\nexport const SCRIPTED_TURN_USAGE = { inputTokens: 64, outputTokens: 48 } as const;\n\nconst scriptedUsage = {\n inputTokens: { total: SCRIPTED_TURN_USAGE.inputTokens },\n outputTokens: { total: SCRIPTED_TURN_USAGE.outputTokens },\n};\n\nexport const scriptedToolTurn = (\n ...calls: ReadonlyArray<Response.StreamPartEncoded>\n): ReadonlyArray<Response.StreamPartEncoded> => [\n ...calls,\n { type: \"finish\", reason: \"tool-calls\", usage: scriptedUsage },\n];\n\nexport const scriptedFinalParts = (text: string): ReadonlyArray<Response.StreamPartEncoded> => [\n { type: \"text-start\", id: \"code-review\" },\n { type: \"text-delta\", id: \"code-review\", delta: text },\n { type: \"text-end\", id: \"code-review\" },\n { type: \"finish\", reason: \"stop\", usage: scriptedUsage },\n];\n\n/** A prompt-keyed scripted LanguageModel with call and prompt observability. */\nexport const makePromptKeyedModel = (\n name: string,\n decide: (promptJson: string) => ReadonlyArray<Response.StreamPartEncoded>,\n) =>\n Effect.gen(function* () {\n const calls = yield* Ref.make(0);\n const prompts = yield* Ref.make<ReadonlyArray<string>>([]);\n const model = Model.make(\n \"scripted\",\n name,\n Layer.effect(\n LanguageModel.LanguageModel,\n LanguageModel.make({\n generateText: () => Effect.succeed([]),\n streamText: (request) =>\n Stream.unwrap(\n Effect.gen(function* () {\n yield* Ref.update(calls, (value) => value + 1);\n const promptJson = JSON.stringify(request.prompt);\n yield* Ref.update(prompts, (previous) => [...previous, promptJson]);\n return Stream.fromIterable(decide(promptJson));\n }),\n ),\n }),\n ),\n );\n return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };\n });\n\n/**\n * Build the offline scripted reviewer model. Turn 1 lists the changeset,\n * Turn 2 reads one file diff, Turn 3 reads head context, Turn 4 returns the\n * scripted review JSON.\n */\nexport const makeOfflineReviewerModel = (script: {\n readonly diffPath: string;\n readonly readPath: string;\n readonly review: CodeReview;\n}) =>\n makePromptKeyedModel(\"pr-review-offline\", (promptJson) => {\n if (promptJson.includes(OFFLINE_READ_CALL_ID)) {\n return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));\n }\n if (promptJson.includes(OFFLINE_DIFF_CALL_ID)) {\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_READ_CALL_ID,\n name: \"read_file\",\n params: { path: script.readPath },\n providerExecuted: false,\n });\n }\n if (promptJson.includes(OFFLINE_LIST_CALL_ID)) {\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_DIFF_CALL_ID,\n name: \"read_file_diff\",\n params: { path: script.diffPath },\n providerExecuted: false,\n });\n }\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_LIST_CALL_ID,\n name: \"list_changed_files\",\n params: { scope: \"all\" },\n providerExecuted: false,\n });\n });\n","import { Effect, Layer, Ref, Schema, Stream } from \"effect\";\nimport { LanguageModel, Model, type Response } from \"effect/unstable/ai\";\n\nimport { FileReviewReport } from \"./fan-out.ts\";\nimport { CodeReview } from \"./review-agent.ts\";\nimport { makePromptKeyedModel, scriptedFinalParts, scriptedToolTurn } from \"./scripted.ts\";\n\n// ---------------------------------------------------------------------------\n// Deterministic offline models for the fan-out reviewer: prompt-keyed\n// scripted models for BOTH the coordinator and the file-reviewer children.\n// Both key every decision on committed history in the prompt (tool-call ids\n// and briefed unit ids), never on call order, so concurrent children and\n// replays stay honest.\n// ---------------------------------------------------------------------------\n\nexport const OFFLINE_UNITS_CALL_ID = \"units-1\";\n\n/** The delegation Tool Call id the scripted coordinator uses for one unit. */\nexport const offlineUnitCallId = (unitId: string): string => `delegate-${unitId}`;\n\n/** The diff Tool Call id the scripted child uses for one unit. */\nexport const offlineChildDiffCallId = (unitId: string): string => `fanout-diff-${unitId}`;\n\n/** One scripted delegation the offline coordinator declares. */\nexport interface OfflineUnitCall {\n readonly unitId: string;\n readonly paths: ReadonlyArray<string>;\n}\n\n/**\n * Build the offline scripted coordinator model. Turn 1 lists the review\n * units, Turn 2 declares one delegation Tool Call per scripted unit in one\n * batch, Turn 3 returns the scripted merged review JSON. Decisions key on\n * tool-call ids already committed to the prompt.\n */\nexport const makeOfflineFanOutCoordinatorModel = (script: {\n readonly unitCalls: ReadonlyArray<OfflineUnitCall>;\n readonly review: CodeReview;\n}) => {\n const firstUnitCallId = offlineUnitCallId(script.unitCalls[0]?.unitId ?? \"unit-none\");\n return makePromptKeyedModel(\"pr-fanout-coordinator-offline\", (promptJson) => {\n if (promptJson.includes(firstUnitCallId)) {\n return scriptedFinalParts(JSON.stringify(Schema.encodeSync(CodeReview)(script.review)));\n }\n if (promptJson.includes(OFFLINE_UNITS_CALL_ID)) {\n return scriptedToolTurn(\n ...script.unitCalls.map(\n (unit): Response.StreamPartEncoded => ({\n type: \"tool-call\",\n id: offlineUnitCallId(unit.unitId),\n name: \"delegate_file_review\",\n params: { unitId: unit.unitId, paths: unit.paths },\n providerExecuted: false,\n }),\n ),\n );\n }\n return scriptedToolTurn({\n type: \"tool-call\",\n id: OFFLINE_UNITS_CALL_ID,\n name: \"list_review_units\",\n params: { scope: \"all\" },\n providerExecuted: false,\n });\n });\n};\n\n/** How one scripted child behaves for its briefed unit. */\nexport type OfflineUnitOutcome =\n /** Read one diff, then return the scripted report. */\n | { readonly _tag: \"findings\"; readonly report: FileReviewReport }\n /** Read one diff, then return non-JSON — the child fails typed (AgentOutputError). */\n | { readonly _tag: \"malformed-output\" }\n /**\n * Declare more Tool Calls than the child's AgentPolicy allows in one turn —\n * none executes and the child fails typed (AgentPolicyError \"tool-calls\",\n * the reviewer's deliberate `onExhaustion: \"fail\"` pin).\n */\n | { readonly _tag: \"budget-runaway\"; readonly declaredCalls: number };\n\nexport interface OfflineUnitScript {\n readonly unitId: string;\n /** The one file the scripted child reads the diff of. */\n readonly diffPath: string;\n readonly outcome: OfflineUnitOutcome;\n}\n\n/**\n * Build the offline scripted file-reviewer model shared by every delegated\n * child. Each child Run builds the Model Layer inside its own scope; the\n * script entry is selected by the briefed unitId present in the child's OWN\n * prompt, and the turn is selected by whether that unit's diff Tool Call id\n * is already committed there — content-keyed on both axes, so concurrent\n * children never interfere. First-turn child prompts are recorded for\n * context-isolation assertions.\n */\nexport const makeOfflineFileReviewerModel = (scripts: ReadonlyArray<OfflineUnitScript>) =>\n Effect.gen(function* () {\n const calls = yield* Ref.make(0);\n const prompts = yield* Ref.make<ReadonlyArray<string>>([]);\n const decide = (promptJson: string): ReadonlyArray<Response.StreamPartEncoded> | undefined => {\n const script = scripts.find((candidate) => promptJson.includes(candidate.unitId));\n if (script === undefined) return undefined;\n switch (script.outcome._tag) {\n case \"budget-runaway\": {\n return scriptedToolTurn(\n ...Array.from(\n { length: script.outcome.declaredCalls },\n (_, index): Response.StreamPartEncoded => ({\n type: \"tool-call\",\n id: `runaway-${script.unitId}-${index + 1}`,\n name: \"read_file_diff\",\n params: { path: script.diffPath },\n providerExecuted: false,\n }),\n ),\n );\n }\n case \"malformed-output\":\n case \"findings\": {\n if (promptJson.includes(offlineChildDiffCallId(script.unitId))) {\n return scriptedFinalParts(\n script.outcome._tag === \"findings\"\n ? JSON.stringify(Schema.encodeSync(FileReviewReport)(script.outcome.report))\n : \"this is not the JSON you are looking for\",\n );\n }\n return scriptedToolTurn({\n type: \"tool-call\",\n id: offlineChildDiffCallId(script.unitId),\n name: \"read_file_diff\",\n params: { path: script.diffPath },\n providerExecuted: false,\n });\n }\n }\n };\n const model = Model.make(\n \"scripted\",\n \"pr-fanout-file-reviewer-offline\",\n Layer.effect(\n LanguageModel.LanguageModel,\n LanguageModel.make({\n generateText: () => Effect.succeed([]),\n streamText: (request) =>\n Stream.unwrap(\n Effect.gen(function* () {\n yield* Ref.update(calls, (value) => value + 1);\n const promptJson = JSON.stringify(request.prompt);\n yield* Ref.update(prompts, (previous) => [...previous, promptJson]);\n const parts = decide(promptJson);\n if (parts === undefined) {\n return yield* Effect.die(\n new Error(\"The child prompt names no scripted review unit\"),\n );\n }\n return Stream.fromIterable(parts);\n }),\n ),\n }),\n ),\n );\n return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };\n });\n","import { DateTime, Effect, Layer, Option, Ref, Schema } from \"effect\";\n\nimport { ChangedFile } from \"./diff.ts\";\nimport {\n PriorReviewLookupFailure,\n PriorReviews,\n PublishedReview,\n ReviewPublisher,\n} from \"./github.ts\";\nimport type { ReviewPublicationPlan } from \"./render.ts\";\nimport type { ReviewHeadComparison, ReviewState } from \"./review-state.ts\";\nimport {\n MAX_CHANGED_FILES,\n MAX_FILE_CHARS,\n normalizeRepoRelativePath,\n PullRequestMetadata,\n PullRequestSource,\n ReviewInputViolation,\n} from \"./source.ts\";\n\n// ---------------------------------------------------------------------------\n// Deterministic in-memory adapters for both ports: a fixture pull request\n// serving the PullRequestSource, and a collecting ReviewPublisher recording\n// every plan. Tests, dry runs, and live smokes run against these with no\n// network and no credentials.\n// ---------------------------------------------------------------------------\n\n/** One fixture file: its changeset entry plus optional head content. */\nexport class FixtureFile extends Schema.Class<FixtureFile>(\"@effect-agent/pr-review/FixtureFile\")({\n file: ChangedFile,\n headContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),\n}) {}\n\n/** A complete in-memory pull request for tests, dry runs, and live smokes. */\nexport class FixturePullRequest extends Schema.Class<FixturePullRequest>(\n \"@effect-agent/pr-review/FixturePullRequest\",\n)({\n metadata: PullRequestMetadata,\n files: Schema.Array(FixtureFile).check(Schema.isMaxLength(MAX_CHANGED_FILES)),\n}) {}\n\nconst requireChanged = (\n fixture: FixturePullRequest,\n path: string,\n): Effect.Effect<FixtureFile, ReviewInputViolation> => {\n const entry = fixture.files.find((candidate) => candidate.file.path === path);\n return entry === undefined\n ? Effect.fail(\n ReviewInputViolation.make({\n input: path,\n reason: \"Path is not part of this pull request's changeset.\",\n }),\n )\n : Effect.succeed(entry);\n};\n\n/** Deterministic `PullRequestSource` over one fixture pull request. */\nexport const fixturePullRequestSourceLayer = (\n fixture: FixturePullRequest,\n): Layer.Layer<PullRequestSource> =>\n Layer.succeed(PullRequestSource)(\n PullRequestSource.of({\n metadata: Effect.succeed(fixture.metadata),\n changedFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),\n anchorFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),\n readFile: (path) =>\n Effect.gen(function* () {\n const relative = yield* normalizeRepoRelativePath(path);\n const entry = yield* requireChanged(fixture, relative);\n if (entry.headContent === undefined) {\n return yield* ReviewInputViolation.make({\n input: relative,\n reason: \"No head content is available for this file.\",\n });\n }\n return entry.headContent;\n }),\n }),\n );\n\n/** In-memory publisher: records every plan and mints a deterministic receipt. */\nexport const collectingReviewPublisherLayer = (\n published: Ref.Ref<ReadonlyArray<ReviewPublicationPlan>>,\n): Layer.Layer<ReviewPublisher> =>\n Layer.succeed(ReviewPublisher)(\n ReviewPublisher.of({\n publish: (plan) =>\n Ref.update(published, (plans) => [...plans, plan]).pipe(\n Effect.flatMap(() => Ref.get(published)),\n Effect.map((plans) =>\n PublishedReview.make({\n reviewId: plans.length,\n url: `memory://review/${plans.length}`,\n event: plan.event,\n inlineComments: plan.comments.length,\n authorNodeId: \"BOT_memory-reviewer\",\n submittedAt: DateTime.makeUnsafe(\n `2026-01-01T00:00:${String(plans.length).padStart(2, \"0\")}Z`,\n ),\n }),\n ),\n ),\n }),\n );\n\n/** Static `PriorReviews` service for tests: fixed history and comparisons. */\nexport const staticPriorReviews = (\n fingerprint: Option.Option<string>,\n options: {\n readonly state?: Option.Option<ReviewState> | undefined;\n readonly comparison?: ReviewHeadComparison | undefined;\n } = {},\n): PriorReviews[\"Service\"] =>\n PriorReviews.of({\n latestFingerprint: Effect.succeed(fingerprint),\n latestState: Effect.succeed(options.state ?? Option.none()),\n compareHeads: () =>\n options.comparison === undefined\n ? Effect.fail(PriorReviewLookupFailure.make({ reason: \"no fixture comparison\" }))\n : Effect.succeed(options.comparison),\n });\n\n/** Layer form for consumers whose Effect explicitly requires `PriorReviews`. */\nexport const staticPriorReviewsLayer = (\n fingerprint: Option.Option<string>,\n options: {\n readonly state?: Option.Option<ReviewState> | undefined;\n readonly comparison?: ReviewHeadComparison | undefined;\n } = {},\n): Layer.Layer<PriorReviews> =>\n Layer.succeed(PriorReviews)(staticPriorReviews(fingerprint, options));\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-agent/pr-review",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.9",
|
|
4
4
|
"exports": {
|
|
5
5
|
".": {
|
|
6
6
|
"types": "./dist/index.d.mts",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@effect/ai-openai": "4.0.0-beta.107",
|
|
25
25
|
"@effect/platform-node": "4.0.0-beta.107",
|
|
26
26
|
"effect": "4.0.0-beta.107",
|
|
27
|
-
"effect-agent": "0.1.0-beta.
|
|
27
|
+
"effect-agent": "0.1.0-beta.9"
|
|
28
28
|
},
|
|
29
29
|
"description": "Bounded, fail-closed GitHub pull-request reviewer built on the effect-agent public surface: schema-first review contracts, read-only tools, GitHub adapters, a configuration factory, and CLI + GitHub Actions entrypoints.",
|
|
30
30
|
"license": "MIT",
|
package/src/action.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Layer, Option, Redacted, Schema } from "effect";
|
|
3
3
|
import { BudgetExceeded, UsageBudgetLimits } from "effect-agent";
|
|
4
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
4
5
|
|
|
5
6
|
import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./internal/effort.ts";
|
|
6
7
|
import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
openAiClientLayer,
|
|
16
17
|
type ReviewProvider,
|
|
17
18
|
} from "./internal/providers.ts";
|
|
19
|
+
import { retireStaleReviews } from "./internal/retirement.ts";
|
|
18
20
|
import {
|
|
19
21
|
ReviewExecutionContext,
|
|
20
22
|
ReviewHeadComparison,
|
|
@@ -89,6 +91,7 @@ export interface ResolvedActionInputs {
|
|
|
89
91
|
/** Deprecated compatibility input; conclusions are always conservative. */
|
|
90
92
|
readonly failOn: FailOnPolicy;
|
|
91
93
|
readonly skipUnchanged: boolean;
|
|
94
|
+
readonly retireStaleReviews: boolean;
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/** Read the PR_REVIEW_* input surface (all optional, all defaulted). */
|
|
@@ -129,6 +132,9 @@ export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* ()
|
|
|
129
132
|
const skipUnchanged = yield* Config.boolean("PR_REVIEW_SKIP_UNCHANGED").pipe(
|
|
130
133
|
Config.withDefault(true),
|
|
131
134
|
);
|
|
135
|
+
const retireStaleReviews = yield* Config.boolean("PR_REVIEW_RETIRE_STALE_REVIEWS").pipe(
|
|
136
|
+
Config.withDefault(true),
|
|
137
|
+
);
|
|
132
138
|
return {
|
|
133
139
|
provider,
|
|
134
140
|
model: Option.getOrUndefined(model),
|
|
@@ -147,6 +153,7 @@ export const resolveActionInputs = Effect.fn("resolveActionInputs")(function* ()
|
|
|
147
153
|
reviewMode,
|
|
148
154
|
failOn,
|
|
149
155
|
skipUnchanged,
|
|
156
|
+
retireStaleReviews,
|
|
150
157
|
} satisfies ResolvedActionInputs;
|
|
151
158
|
});
|
|
152
159
|
|
|
@@ -399,6 +406,8 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
|
|
|
399
406
|
readonly modelLabel?: string | undefined;
|
|
400
407
|
/** Explicit test/custom-host history override; GitHub owns the default adapter. */
|
|
401
408
|
readonly priorReviews?: PriorReviews["Service"] | undefined;
|
|
409
|
+
/** Retire marker-bearing prior reviews after a successful post (default true). */
|
|
410
|
+
readonly retireStaleReviews?: boolean | undefined;
|
|
402
411
|
} = {},
|
|
403
412
|
) =>
|
|
404
413
|
Effect.gen(function* () {
|
|
@@ -565,6 +574,27 @@ export const runReviewAction = <E, R, FingerprintE = never, FingerprintR = never
|
|
|
565
574
|
);
|
|
566
575
|
if (outcome.published !== undefined) {
|
|
567
576
|
yield* Console.log(`Posted ${outcome.published.event} review: ${outcome.published.url}`);
|
|
577
|
+
if (options.retireStaleReviews !== false && outcome.state !== undefined) {
|
|
578
|
+
if (outcome.published.authorNodeId === null || outcome.published.submittedAt === null) {
|
|
579
|
+
yield* Console.warn(
|
|
580
|
+
"Skipping stale-review retirement because GitHub did not return the posted review's actor and submission time.",
|
|
581
|
+
);
|
|
582
|
+
} else {
|
|
583
|
+
const report = yield* retireStaleReviews({
|
|
584
|
+
currentReviewId: outcome.published.reviewId,
|
|
585
|
+
currentReviewUrl: outcome.published.url,
|
|
586
|
+
currentAuthorNodeId: outcome.published.authorNodeId,
|
|
587
|
+
currentSubmittedAt: outcome.published.submittedAt,
|
|
588
|
+
currentState: outcome.state,
|
|
589
|
+
});
|
|
590
|
+
yield* Console.log(
|
|
591
|
+
`Review retirement: ${report.reviewsRetired} prior review(s), ` +
|
|
592
|
+
`${report.findingsResolved} resolved finding(s), ` +
|
|
593
|
+
`${report.commentsMinimized} minimized inline comment(s), ` +
|
|
594
|
+
`${report.failures} failure(s).`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
568
598
|
}
|
|
569
599
|
const check = concludeReviewOutcome(outcome);
|
|
570
600
|
yield* writeActionOutputs(outcomeOutputs(outcome, check.conclusion));
|
|
@@ -620,6 +650,7 @@ export const reviewActionProgram = Effect.gen(function* () {
|
|
|
620
650
|
failOn: inputs.failOn,
|
|
621
651
|
skipUnchanged: inputs.skipUnchanged,
|
|
622
652
|
reviewMode: inputs.reviewMode,
|
|
653
|
+
retireStaleReviews: inputs.retireStaleReviews,
|
|
623
654
|
modelLabel,
|
|
624
655
|
};
|
|
625
656
|
if (inputs.provider === "anthropic") {
|
|
@@ -652,7 +683,7 @@ export const main = (): void =>
|
|
|
652
683
|
),
|
|
653
684
|
),
|
|
654
685
|
Effect.scoped,
|
|
655
|
-
Effect.provide(NodeServices.layer),
|
|
686
|
+
Effect.provide(Layer.merge(NodeServices.layer, FetchHttpClient.layer)),
|
|
656
687
|
),
|
|
657
688
|
{ disableErrorReporting: true },
|
|
658
689
|
);
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
|
2
2
|
import { Console, Effect, Layer, Option, Schema } from "effect";
|
|
3
3
|
import { BudgetExceeded } from "effect-agent";
|
|
4
4
|
import { Command as CliCommand, Flag } from "effect/unstable/cli";
|
|
5
|
+
import { FetchHttpClient } from "effect/unstable/http";
|
|
5
6
|
|
|
6
7
|
import { InvalidEffortInput, parseEffortPosition, type EffortPosition } from "./internal/effort.ts";
|
|
7
8
|
import { PrReview, type RunReviewOptions } from "./internal/factory.ts";
|
|
@@ -207,7 +208,7 @@ const program = CliCommand.run(command, { version: "0.0.0" }).pipe(
|
|
|
207
208
|
),
|
|
208
209
|
),
|
|
209
210
|
Effect.scoped,
|
|
210
|
-
Effect.provide(NodeServices.layer),
|
|
211
|
+
Effect.provide(Layer.merge(NodeServices.layer, FetchHttpClient.layer)),
|
|
211
212
|
);
|
|
212
213
|
|
|
213
214
|
NodeRuntime.runMain(program, { disableErrorReporting: true });
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./internal/ignore.ts";
|
|
|
13
13
|
export * from "./internal/profiles.ts";
|
|
14
14
|
export * from "./internal/providers.ts";
|
|
15
15
|
export * from "./internal/render.ts";
|
|
16
|
+
export * from "./internal/retirement.ts";
|
|
16
17
|
export * from "./internal/review-agent.ts";
|
|
17
18
|
export * from "./internal/review-state.ts";
|
|
18
19
|
export * from "./internal/review-units.ts";
|
|
@@ -25,6 +25,7 @@ const INPUT_TO_ENV: ReadonlyArray<readonly [input: string, env: string]> = [
|
|
|
25
25
|
["INPUT_REVIEW-MODE", "PR_REVIEW_MODE"],
|
|
26
26
|
["INPUT_FAIL-ON", "PR_REVIEW_FAIL_ON"],
|
|
27
27
|
["INPUT_SKIP-UNCHANGED", "PR_REVIEW_SKIP_UNCHANGED"],
|
|
28
|
+
["INPUT_RETIRE-STALE-REVIEWS", "PR_REVIEW_RETIRE_STALE_REVIEWS"],
|
|
28
29
|
["INPUT_STATE-SECRET", "PR_REVIEW_STATE_SECRET"],
|
|
29
30
|
["INPUT_OPENAI-API-KEY", "OPENAI_API_KEY"],
|
|
30
31
|
["INPUT_ANTHROPIC-API-KEY", "ANTHROPIC_API_KEY"],
|
package/src/internal/fixtures.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Layer, Option, Ref, Schema } from "effect";
|
|
1
|
+
import { DateTime, Effect, Layer, Option, Ref, Schema } from "effect";
|
|
2
2
|
|
|
3
3
|
import { ChangedFile } from "./diff.ts";
|
|
4
4
|
import {
|
|
@@ -93,6 +93,10 @@ export const collectingReviewPublisherLayer = (
|
|
|
93
93
|
url: `memory://review/${plans.length}`,
|
|
94
94
|
event: plan.event,
|
|
95
95
|
inlineComments: plan.comments.length,
|
|
96
|
+
authorNodeId: "BOT_memory-reviewer",
|
|
97
|
+
submittedAt: DateTime.makeUnsafe(
|
|
98
|
+
`2026-01-01T00:00:${String(plans.length).padStart(2, "0")}Z`,
|
|
99
|
+
),
|
|
96
100
|
}),
|
|
97
101
|
),
|
|
98
102
|
),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Config, Effect, FileSystem, Layer, Option, Schema } from "effect";
|
|
2
|
-
import {
|
|
2
|
+
import type { HttpClient } from "effect/unstable/http";
|
|
3
3
|
|
|
4
4
|
import type { PriorReviews, ReviewPublisher } from "./github.ts";
|
|
5
5
|
import {
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
gitHubPriorReviewsLayer,
|
|
8
8
|
gitHubPullRequestSourceLayer,
|
|
9
9
|
gitHubReviewPublisherLayer,
|
|
10
|
+
gitHubReviewRetirementHostLayer,
|
|
10
11
|
} from "./github.ts";
|
|
12
|
+
import type { ReviewRetirementHost } from "./retirement.ts";
|
|
11
13
|
import type { PullRequestSource } from "./source.ts";
|
|
12
14
|
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
@@ -105,24 +107,36 @@ export const resolveReviewTarget = Effect.fn("resolveReviewTarget")(function* (o
|
|
|
105
107
|
*/
|
|
106
108
|
export const gitHubReviewLayers = (
|
|
107
109
|
target: ResolvedReviewTarget,
|
|
108
|
-
): Layer.Layer<
|
|
110
|
+
): Layer.Layer<
|
|
111
|
+
PullRequestSource | ReviewPublisher | PriorReviews | ReviewRetirementHost,
|
|
112
|
+
Config.ConfigError,
|
|
113
|
+
HttpClient.HttpClient
|
|
114
|
+
> =>
|
|
109
115
|
Layer.unwrap(
|
|
110
116
|
Effect.gen(function* () {
|
|
111
117
|
const apiUrl = yield* Config.string("GITHUB_API_URL").pipe(
|
|
112
118
|
Config.withDefault("https://api.github.com"),
|
|
113
119
|
);
|
|
120
|
+
const graphqlUrl = yield* Config.string("GITHUB_GRAPHQL_URL").pipe(
|
|
121
|
+
Config.withDefault(
|
|
122
|
+
apiUrl === "https://api.github.com"
|
|
123
|
+
? "https://api.github.com/graphql"
|
|
124
|
+
: apiUrl.replace(/\/api\/v3$/, "/api/graphql"),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
114
127
|
const token = yield* Config.option(Config.redacted("GITHUB_TOKEN"));
|
|
115
128
|
const targetLayer = GitHubReviewTarget.layer({
|
|
116
129
|
apiUrl,
|
|
130
|
+
graphqlUrl,
|
|
117
131
|
repository: target.repository,
|
|
118
132
|
number: target.number,
|
|
119
133
|
token,
|
|
120
134
|
});
|
|
121
|
-
const deps = Layer.merge(targetLayer, FetchHttpClient.layer);
|
|
122
135
|
return Layer.mergeAll(
|
|
123
|
-
gitHubPullRequestSourceLayer.pipe(Layer.provide(
|
|
124
|
-
gitHubReviewPublisherLayer.pipe(Layer.provide(
|
|
125
|
-
gitHubPriorReviewsLayer.pipe(Layer.provide(
|
|
136
|
+
gitHubPullRequestSourceLayer.pipe(Layer.provide(targetLayer)),
|
|
137
|
+
gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
|
|
138
|
+
gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
|
|
139
|
+
gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
|
|
126
140
|
);
|
|
127
141
|
}),
|
|
128
142
|
);
|
package/src/internal/github.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { Redacted } from "effect";
|
|
2
|
-
import { Context, Effect, Layer, Option, Schema } from "effect";
|
|
2
|
+
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
3
3
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
|
|
4
4
|
|
|
5
5
|
import { ChangedFile } from "./diff.ts";
|
|
6
6
|
import { extractFingerprint } from "./fingerprint.ts";
|
|
7
7
|
import type { ReviewPublicationPlan } from "./render.ts";
|
|
8
|
+
import {
|
|
9
|
+
RetirableReview,
|
|
10
|
+
RetirableReviewComment,
|
|
11
|
+
ReviewRetirementFailure,
|
|
12
|
+
ReviewRetirementHost,
|
|
13
|
+
} from "./retirement.ts";
|
|
8
14
|
import {
|
|
9
15
|
ReviewHeadComparison,
|
|
10
16
|
ReviewStateAuthenticator,
|
|
@@ -27,12 +33,19 @@ import {
|
|
|
27
33
|
// GitHubApiFailure instead of an untyped defect.
|
|
28
34
|
// ---------------------------------------------------------------------------
|
|
29
35
|
|
|
36
|
+
const defaultGraphqlUrl = (apiUrl: string): string =>
|
|
37
|
+
apiUrl === "https://api.github.com"
|
|
38
|
+
? "https://api.github.com/graphql"
|
|
39
|
+
: apiUrl.replace(/\/api\/v3$/, "/api/graphql");
|
|
40
|
+
|
|
30
41
|
/** Which pull request to review and how to reach the API. */
|
|
31
42
|
export class GitHubReviewTarget extends Context.Service<
|
|
32
43
|
GitHubReviewTarget,
|
|
33
44
|
{
|
|
34
45
|
/** API root, e.g. `https://api.github.com` (no trailing slash). */
|
|
35
46
|
readonly apiUrl: string;
|
|
47
|
+
/** GraphQL root, e.g. `https://api.github.com/graphql`. */
|
|
48
|
+
readonly graphqlUrl: string;
|
|
36
49
|
/** `owner/name`. */
|
|
37
50
|
readonly repository: string;
|
|
38
51
|
readonly number: number;
|
|
@@ -42,11 +55,18 @@ export class GitHubReviewTarget extends Context.Service<
|
|
|
42
55
|
>()("@effect-agent/pr-review/GitHubReviewTarget") {
|
|
43
56
|
static layer(config: {
|
|
44
57
|
readonly apiUrl: string;
|
|
58
|
+
readonly graphqlUrl?: string | undefined;
|
|
45
59
|
readonly repository: string;
|
|
46
60
|
readonly number: number;
|
|
47
61
|
readonly token: Option.Option<Redacted.Redacted<string>>;
|
|
48
62
|
}): Layer.Layer<GitHubReviewTarget> {
|
|
49
|
-
return Layer.succeed(
|
|
63
|
+
return Layer.succeed(
|
|
64
|
+
this,
|
|
65
|
+
GitHubReviewTarget.of({
|
|
66
|
+
...config,
|
|
67
|
+
graphqlUrl: config.graphqlUrl ?? defaultGraphqlUrl(config.apiUrl),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
50
70
|
}
|
|
51
71
|
}
|
|
52
72
|
|
|
@@ -82,11 +102,54 @@ const GitHubFileWire = Schema.Struct({
|
|
|
82
102
|
|
|
83
103
|
const GitHubFilesPageWire = Schema.Array(GitHubFileWire);
|
|
84
104
|
|
|
105
|
+
const GitHubActorWire = Schema.Struct({ node_id: Schema.String });
|
|
106
|
+
|
|
85
107
|
const GitHubReviewWire = Schema.Struct({
|
|
86
108
|
id: Schema.Int,
|
|
87
109
|
html_url: Schema.String,
|
|
110
|
+
user: Schema.NullOr(GitHubActorWire),
|
|
111
|
+
submitted_at: Schema.NullOr(Schema.String),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const GitHubRetirableReviewWire = Schema.Struct({
|
|
115
|
+
id: Schema.Int,
|
|
116
|
+
body: Schema.NullOr(Schema.String),
|
|
117
|
+
commit_id: Schema.String,
|
|
118
|
+
user: Schema.NullOr(GitHubActorWire),
|
|
119
|
+
submitted_at: Schema.NullOr(Schema.String),
|
|
120
|
+
});
|
|
121
|
+
const GitHubRetirableReviewsPageWire = Schema.Array(GitHubRetirableReviewWire);
|
|
122
|
+
|
|
123
|
+
const GitHubReviewCommentWire = Schema.Struct({
|
|
124
|
+
node_id: Schema.String,
|
|
125
|
+
path: Schema.String,
|
|
126
|
+
body: Schema.String,
|
|
127
|
+
line: Schema.NullOr(Schema.Int),
|
|
128
|
+
original_line: Schema.NullOr(Schema.Int),
|
|
129
|
+
start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
130
|
+
original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
|
|
131
|
+
});
|
|
132
|
+
const GitHubReviewCommentsPageWire = Schema.Array(GitHubReviewCommentWire);
|
|
133
|
+
|
|
134
|
+
const GitHubMinimizeCommentWire = Schema.Struct({
|
|
135
|
+
data: Schema.optionalKey(
|
|
136
|
+
Schema.NullOr(
|
|
137
|
+
Schema.Struct({
|
|
138
|
+
minimizeComment: Schema.NullOr(
|
|
139
|
+
Schema.Struct({
|
|
140
|
+
minimizedComment: Schema.NullOr(Schema.Struct({ isMinimized: Schema.Boolean })),
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
errors: Schema.optionalKey(Schema.Array(Schema.Struct({ message: Schema.String }))),
|
|
88
147
|
});
|
|
89
148
|
|
|
149
|
+
/** Decode GitHub's external timestamp before it participates in mutation ordering. */
|
|
150
|
+
export const parseGitHubSubmittedAt = (value: string | null): DateTime.Utc | null =>
|
|
151
|
+
value === null ? null : Option.getOrNull(DateTime.make(value));
|
|
152
|
+
|
|
90
153
|
/** The publication receipt callers report back to the operator. */
|
|
91
154
|
export class PublishedReview extends Schema.Class<PublishedReview>(
|
|
92
155
|
"@effect-agent/pr-review/PublishedReview",
|
|
@@ -95,6 +158,9 @@ export class PublishedReview extends Schema.Class<PublishedReview>(
|
|
|
95
158
|
url: Schema.String,
|
|
96
159
|
event: Schema.String,
|
|
97
160
|
inlineComments: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
161
|
+
/** Actor and ordering boundary returned by the create-review response. */
|
|
162
|
+
authorNodeId: Schema.NullOr(Schema.NonEmptyString.check(Schema.isMaxLength(200))),
|
|
163
|
+
submittedAt: Schema.NullOr(Schema.DateTimeUtc),
|
|
98
164
|
}) {}
|
|
99
165
|
|
|
100
166
|
/** Posts one planned review; the ONLY mutating operation in this package. */
|
|
@@ -335,12 +401,176 @@ export const gitHubReviewPublisherLayer: Layer.Layer<
|
|
|
335
401
|
url: wire.html_url,
|
|
336
402
|
event: plan.event,
|
|
337
403
|
inlineComments: plan.comments.length,
|
|
404
|
+
authorNodeId: wire.user?.node_id ?? null,
|
|
405
|
+
submittedAt: parseGitHubSubmittedAt(wire.submitted_at),
|
|
338
406
|
});
|
|
339
407
|
}),
|
|
340
408
|
});
|
|
341
409
|
}),
|
|
342
410
|
);
|
|
343
411
|
|
|
412
|
+
// --- Live ReviewRetirementHost ----------------------------------------------
|
|
413
|
+
|
|
414
|
+
const MAX_RETIREMENT_PAGES = 5;
|
|
415
|
+
const MINIMIZE_REVIEW_COMMENT_MUTATION = `mutation MinimizeReviewComment($subjectId: ID!) {
|
|
416
|
+
minimizeComment(input: { subjectId: $subjectId, classifier: OUTDATED }) {
|
|
417
|
+
minimizedComment { isMinimized }
|
|
418
|
+
}
|
|
419
|
+
}`;
|
|
420
|
+
|
|
421
|
+
/** GitHub-backed host operations for cosmetic retirement after publication. */
|
|
422
|
+
export const gitHubReviewRetirementHostLayer: Layer.Layer<
|
|
423
|
+
ReviewRetirementHost,
|
|
424
|
+
never,
|
|
425
|
+
GitHubReviewTarget | HttpClient.HttpClient
|
|
426
|
+
> = Layer.effect(ReviewRetirementHost)(
|
|
427
|
+
Effect.gen(function* () {
|
|
428
|
+
const target = yield* GitHubReviewTarget;
|
|
429
|
+
const client = yield* HttpClient.HttpClient;
|
|
430
|
+
const prefix = `${target.apiUrl}/repos/${target.repository}/pulls/${target.number}`;
|
|
431
|
+
const asRetirementFailure =
|
|
432
|
+
(operation: string) =>
|
|
433
|
+
(error: { readonly _tag: string; readonly message?: string }): ReviewRetirementFailure =>
|
|
434
|
+
ReviewRetirementFailure.make({
|
|
435
|
+
operation,
|
|
436
|
+
reason: `${error._tag}: ${error.message ?? "request failed"}`.slice(0, 2_048),
|
|
437
|
+
});
|
|
438
|
+
const executeRetirement = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
|
|
439
|
+
HttpClient.execute(request).pipe(
|
|
440
|
+
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
|
441
|
+
Effect.mapError(asRetirementFailure(operation)),
|
|
442
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
443
|
+
);
|
|
444
|
+
const decodeRetirement = <S extends Schema.Top>(schema: S, operation: string) => {
|
|
445
|
+
const decode = Schema.decodeUnknownEffect(schema);
|
|
446
|
+
return (response: HttpClientResponse.HttpClientResponse) =>
|
|
447
|
+
response.json.pipe(
|
|
448
|
+
Effect.mapError(asRetirementFailure(operation)),
|
|
449
|
+
Effect.flatMap((body) =>
|
|
450
|
+
decode(body).pipe(Effect.mapError(asRetirementFailure(operation))),
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
};
|
|
454
|
+
const listPaged = <A>(input: {
|
|
455
|
+
readonly operation: string;
|
|
456
|
+
readonly url: string;
|
|
457
|
+
readonly decode: (
|
|
458
|
+
response: HttpClientResponse.HttpClientResponse,
|
|
459
|
+
) => Effect.Effect<ReadonlyArray<A>, ReviewRetirementFailure>;
|
|
460
|
+
}) =>
|
|
461
|
+
Effect.gen(function* () {
|
|
462
|
+
const values: Array<A> = [];
|
|
463
|
+
const perPage = 100;
|
|
464
|
+
for (let page = 1; page <= MAX_RETIREMENT_PAGES; page += 1) {
|
|
465
|
+
const response = yield* executeRetirement(
|
|
466
|
+
input.operation,
|
|
467
|
+
withCommonHeaders(
|
|
468
|
+
HttpClientRequest.get(input.url).pipe(
|
|
469
|
+
HttpClientRequest.acceptJson,
|
|
470
|
+
HttpClientRequest.setUrlParams({
|
|
471
|
+
per_page: String(perPage),
|
|
472
|
+
page: String(page),
|
|
473
|
+
}),
|
|
474
|
+
),
|
|
475
|
+
target.token,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
const pageValues = yield* input.decode(response);
|
|
479
|
+
values.push(...pageValues);
|
|
480
|
+
if (pageValues.length < perPage) return values;
|
|
481
|
+
}
|
|
482
|
+
return yield* ReviewRetirementFailure.make({
|
|
483
|
+
operation: input.operation,
|
|
484
|
+
reason: `history exceeds the bounded ${MAX_RETIREMENT_PAGES * 100}-item lookup`,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return ReviewRetirementHost.of({
|
|
489
|
+
listReviews: listPaged({
|
|
490
|
+
operation: "listReviewsForRetirement",
|
|
491
|
+
url: `${prefix}/reviews`,
|
|
492
|
+
decode: decodeRetirement(GitHubRetirableReviewsPageWire, "listReviewsForRetirement"),
|
|
493
|
+
}).pipe(
|
|
494
|
+
Effect.map((reviews) =>
|
|
495
|
+
reviews.map((review) =>
|
|
496
|
+
RetirableReview.make({
|
|
497
|
+
reviewId: review.id,
|
|
498
|
+
body: review.body ?? "",
|
|
499
|
+
commitSha: review.commit_id,
|
|
500
|
+
authorNodeId: review.user?.node_id ?? null,
|
|
501
|
+
submittedAt: parseGitHubSubmittedAt(review.submitted_at),
|
|
502
|
+
}),
|
|
503
|
+
),
|
|
504
|
+
),
|
|
505
|
+
),
|
|
506
|
+
listComments: (reviewId) =>
|
|
507
|
+
listPaged({
|
|
508
|
+
operation: "listReviewCommentsForRetirement",
|
|
509
|
+
url: `${prefix}/reviews/${reviewId}/comments`,
|
|
510
|
+
decode: decodeRetirement(GitHubReviewCommentsPageWire, "listReviewCommentsForRetirement"),
|
|
511
|
+
}).pipe(
|
|
512
|
+
Effect.map((comments) =>
|
|
513
|
+
comments.map((comment) => {
|
|
514
|
+
const endLine = comment.line ?? comment.original_line;
|
|
515
|
+
const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
|
|
516
|
+
return RetirableReviewComment.make({
|
|
517
|
+
nodeId: comment.node_id,
|
|
518
|
+
path: comment.path,
|
|
519
|
+
startLine,
|
|
520
|
+
endLine,
|
|
521
|
+
body: comment.body,
|
|
522
|
+
});
|
|
523
|
+
}),
|
|
524
|
+
),
|
|
525
|
+
),
|
|
526
|
+
updateBody: (reviewId, body) =>
|
|
527
|
+
executeRetirement(
|
|
528
|
+
"updateReview",
|
|
529
|
+
withCommonHeaders(
|
|
530
|
+
HttpClientRequest.put(`${prefix}/reviews/${reviewId}`).pipe(
|
|
531
|
+
HttpClientRequest.acceptJson,
|
|
532
|
+
HttpClientRequest.bodyJsonUnsafe({ body }),
|
|
533
|
+
),
|
|
534
|
+
target.token,
|
|
535
|
+
),
|
|
536
|
+
).pipe(Effect.asVoid),
|
|
537
|
+
minimizeComment: (nodeId) =>
|
|
538
|
+
Effect.gen(function* () {
|
|
539
|
+
const response = yield* executeRetirement(
|
|
540
|
+
"minimizeComment",
|
|
541
|
+
withCommonHeaders(
|
|
542
|
+
HttpClientRequest.post(target.graphqlUrl).pipe(
|
|
543
|
+
HttpClientRequest.acceptJson,
|
|
544
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
545
|
+
query: MINIMIZE_REVIEW_COMMENT_MUTATION,
|
|
546
|
+
variables: { subjectId: nodeId },
|
|
547
|
+
}),
|
|
548
|
+
),
|
|
549
|
+
target.token,
|
|
550
|
+
),
|
|
551
|
+
);
|
|
552
|
+
const wire = yield* decodeRetirement(
|
|
553
|
+
GitHubMinimizeCommentWire,
|
|
554
|
+
"minimizeComment",
|
|
555
|
+
)(response);
|
|
556
|
+
if (
|
|
557
|
+
(wire.errors?.length ?? 0) > 0 ||
|
|
558
|
+
wire.data?.minimizeComment?.minimizedComment?.isMinimized !== true
|
|
559
|
+
) {
|
|
560
|
+
return yield* ReviewRetirementFailure.make({
|
|
561
|
+
operation: "minimizeComment",
|
|
562
|
+
reason:
|
|
563
|
+
wire.errors
|
|
564
|
+
?.map((error) => error.message)
|
|
565
|
+
.join("; ")
|
|
566
|
+
.slice(0, 2_048) ?? "GitHub did not confirm comment minimization",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}),
|
|
570
|
+
});
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
|
|
344
574
|
// --- Prior reviews (fingerprint deduplication) ---------------------------------
|
|
345
575
|
|
|
346
576
|
/** Reading the pull request's previously posted reviews failed. */
|