@effect-agent/pr-review 0.1.0-beta.28 → 0.1.0-beta.30

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 (52) hide show
  1. package/README.md +11 -204
  2. package/dist/index.d.mts +92 -914
  3. package/dist/index.mjs +176 -71
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +3 -18
  6. package/src/index.ts +1 -25
  7. package/src/review.ts +244 -0
  8. package/dist/action.d.mts +0 -215
  9. package/dist/action.mjs +0 -505
  10. package/dist/action.mjs.map +0 -1
  11. package/dist/cli.d.mts +0 -1
  12. package/dist/cli.mjs +0 -106
  13. package/dist/cli.mjs.map +0 -1
  14. package/dist/fan-out-C3yG1cx3.d.mts +0 -1526
  15. package/dist/github-CCuLgyqb.mjs +0 -3437
  16. package/dist/github-CCuLgyqb.mjs.map +0 -1
  17. package/dist/logging-Q4j0oub-.mjs +0 -75
  18. package/dist/logging-Q4j0oub-.mjs.map +0 -1
  19. package/dist/providers-Br9FRn7j.mjs +0 -1349
  20. package/dist/providers-Br9FRn7j.mjs.map +0 -1
  21. package/dist/testing.d.mts +0 -86
  22. package/dist/testing.mjs +0 -184
  23. package/dist/testing.mjs.map +0 -1
  24. package/src/action.ts +0 -906
  25. package/src/cli.ts +0 -235
  26. package/src/internal/action-entry.ts +0 -45
  27. package/src/internal/adjudication.ts +0 -415
  28. package/src/internal/anchors.ts +0 -20
  29. package/src/internal/coverage.ts +0 -357
  30. package/src/internal/diff.ts +0 -193
  31. package/src/internal/effort.ts +0 -86
  32. package/src/internal/factory.ts +0 -357
  33. package/src/internal/fan-out-scripted.ts +0 -77
  34. package/src/internal/fan-out.ts +0 -1148
  35. package/src/internal/fingerprint.ts +0 -89
  36. package/src/internal/fixtures.ts +0 -148
  37. package/src/internal/github-env.ts +0 -164
  38. package/src/internal/github.ts +0 -1218
  39. package/src/internal/ignore.ts +0 -88
  40. package/src/internal/logging.ts +0 -124
  41. package/src/internal/profiles.ts +0 -91
  42. package/src/internal/progress.ts +0 -433
  43. package/src/internal/providers.ts +0 -133
  44. package/src/internal/render.ts +0 -819
  45. package/src/internal/retirement.ts +0 -337
  46. package/src/internal/review-agent.ts +0 -543
  47. package/src/internal/review-state.ts +0 -782
  48. package/src/internal/review-units.ts +0 -493
  49. package/src/internal/run.ts +0 -611
  50. package/src/internal/scripted.ts +0 -108
  51. package/src/internal/source.ts +0 -110
  52. package/src/testing.ts +0 -8
@@ -1,89 +0,0 @@
1
- import { Crypto, Effect, Encoding } from "effect";
2
-
3
- import type { ChangedFile } from "./diff.ts";
4
-
5
- // ---------------------------------------------------------------------------
6
- // Changeset fingerprinting: dedupe re-reviews of an UNCHANGED effective diff.
7
- // Repositories that auto-merge the base branch into open pull requests fire
8
- // `synchronize` on every base update; the head SHA moves but the three-dot
9
- // changeset the reviewer reads is byte-identical. The fingerprint hashes the
10
- // (ignore-filtered) changeset together with a prompt signature — everything
11
- // that shapes the review — so a rebase with no content change skips, while a
12
- // real change, a conflict resolution, or a guidance change reviews again.
13
- //
14
- // The reviewer is deployment class E and owns no storage: the fingerprint is
15
- // embedded in the posted review body as an invisible HTML comment, so the
16
- // published review itself is the deduplication state.
17
- // ---------------------------------------------------------------------------
18
-
19
- const MARKER_PREFIX = "<!-- effect-agent-pr-review fingerprint=sha256:";
20
- const MARKER_SUFFIX = " -->";
21
- const MARKER_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:([0-9a-f]{64}) -->/g;
22
-
23
- /** Render the invisible review-body marker for one fingerprint. */
24
- export const renderFingerprintMarker = (fingerprint: string): string =>
25
- `${MARKER_PREFIX}${fingerprint}${MARKER_SUFFIX}`;
26
-
27
- /** The rendered marker length is fixed; publication reserves room for it. */
28
- export const FINGERPRINT_MARKER_LENGTH = renderFingerprintMarker("0".repeat(64)).length;
29
-
30
- /** Extract the last fingerprint marker in one review body, if any. */
31
- export const extractFingerprint = (body: string): string | undefined => {
32
- let last: string | undefined;
33
- for (const match of body.matchAll(MARKER_PATTERN)) {
34
- last = match[1];
35
- }
36
- return last;
37
- };
38
-
39
- /** SHA-256 via `Crypto.Crypto`; unavailable crypto is a defect, not an expected failure. */
40
- const sha256Hex = Effect.fn("sha256Hex")(function* (
41
- text: string,
42
- ): Effect.fn.Return<string, never, Crypto.Crypto> {
43
- const crypto = yield* Crypto.Crypto;
44
- const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.orDie);
45
- return Encoding.encodeHex(digest);
46
- });
47
-
48
- const FIELD = "\u0000";
49
- const RECORD = "\u0001";
50
- const SECTION = "\u0002";
51
-
52
- /**
53
- * Unified-diff hunk coordinates describe where a patch applies, not what it
54
- * changes. A content-equivalent rebase can shift both coordinates while
55
- * leaving every context/addition/deletion line unchanged, so exclude only
56
- * those coordinates from the canonical patch representation.
57
- */
58
- const canonicalPatch = (patch: string): string =>
59
- patch.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/gm, "@@ -_ +_ @@");
60
-
61
- /**
62
- * Canonical changeset encoding: sorted by path so provider ordering never
63
- * matters, with every review-relevant field of every file.
64
- */
65
- const canonicalChangeset = (files: ReadonlyArray<ChangedFile>): string =>
66
- files
67
- .map(
68
- (file) =>
69
- `${file.path}${FIELD}${file.status}${FIELD}${String(file.additions)}${FIELD}${String(file.deletions)}${FIELD}${file.patch === undefined ? "" : canonicalPatch(file.patch)}${FIELD}${file.reviewBaseContent ?? ""}${FIELD}${file.reviewHeadContent ?? ""}`,
70
- )
71
- .sort()
72
- .join(RECORD);
73
-
74
- /**
75
- * Fingerprint one review's complete input surface: the (already
76
- * ignore-filtered) changeset plus the caller's prompt signature — the
77
- * rendered instructions and any review-shaping options the instructions do
78
- * not carry.
79
- */
80
- export const computeChangesetFingerprint = (
81
- files: ReadonlyArray<ChangedFile>,
82
- signature: string,
83
- ): Effect.Effect<string, never, Crypto.Crypto> =>
84
- sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
85
-
86
- /** Profile fingerprints are SHA-256 over configuration-only signatures. */
87
- export const computeProfileFingerprint = (
88
- signature: string,
89
- ): Effect.Effect<string, never, Crypto.Crypto> => sha256Hex(signature);
@@ -1,148 +0,0 @@
1
- import { DateTime, Effect, Layer, Option, Ref, Schema } from "effect";
2
-
3
- import { ChangedFile } from "./diff.ts";
4
- import {
5
- PriorReviewLookupFailure,
6
- PriorReviews,
7
- PublishedReview,
8
- ReviewPublisher,
9
- } from "./github.ts";
10
- import type { ReviewPublicationPlan } from "./render.ts";
11
- import type { ReviewHeadComparison, ReviewState, ReviewTreeComparison } from "./review-state.ts";
12
- import {
13
- MAX_CHANGED_FILES,
14
- MAX_FILE_CHARS,
15
- normalizeRepoRelativePath,
16
- PullRequestMetadata,
17
- PullRequestSource,
18
- ReviewInputViolation,
19
- } from "./source.ts";
20
-
21
- // ---------------------------------------------------------------------------
22
- // Deterministic in-memory adapters for both ports: a fixture pull request
23
- // serving the PullRequestSource, and a collecting ReviewPublisher recording
24
- // every plan. Tests, dry runs, and live smokes run against these with no
25
- // network and no credentials.
26
- // ---------------------------------------------------------------------------
27
-
28
- /** One fixture file: its changeset entry plus optional head content. */
29
- export class FixtureFile extends Schema.Class<FixtureFile>("@effect-agent/pr-review/FixtureFile")({
30
- file: ChangedFile,
31
- baseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
32
- headContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
33
- }) {}
34
-
35
- /** A complete in-memory pull request for tests, dry runs, and live smokes. */
36
- export class FixturePullRequest extends Schema.Class<FixturePullRequest>(
37
- "@effect-agent/pr-review/FixturePullRequest",
38
- )({
39
- metadata: PullRequestMetadata,
40
- files: Schema.Array(FixtureFile).check(Schema.isMaxLength(MAX_CHANGED_FILES)),
41
- }) {}
42
-
43
- const requireChanged = (
44
- fixture: FixturePullRequest,
45
- path: string,
46
- ): Effect.Effect<FixtureFile, ReviewInputViolation> => {
47
- const entry = fixture.files.find((candidate) => candidate.file.path === path);
48
- return entry === undefined
49
- ? Effect.fail(
50
- ReviewInputViolation.make({
51
- input: path,
52
- reason: "Path is not part of this pull request's changeset.",
53
- }),
54
- )
55
- : Effect.succeed(entry);
56
- };
57
-
58
- /** Deterministic `PullRequestSource` over one fixture pull request. */
59
- export const fixturePullRequestSourceLayer = (
60
- fixture: FixturePullRequest,
61
- ): Layer.Layer<PullRequestSource> => {
62
- const files = fixture.files.map((entry) =>
63
- entry.file.patch !== undefined
64
- ? entry.file
65
- : ChangedFile.make({
66
- ...entry.file,
67
- ...(entry.baseContent === undefined ? {} : { reviewBaseContent: entry.baseContent }),
68
- ...(entry.headContent === undefined ? {} : { reviewHeadContent: entry.headContent }),
69
- }),
70
- );
71
- return Layer.succeed(PullRequestSource)(
72
- PullRequestSource.of({
73
- metadata: Effect.succeed(fixture.metadata),
74
- changedFiles: Effect.succeed(files),
75
- anchorFiles: Effect.succeed(files),
76
- readFile: (path) =>
77
- Effect.gen(function* () {
78
- const relative = yield* normalizeRepoRelativePath(path);
79
- const entry = yield* requireChanged(fixture, relative);
80
- if (entry.headContent === undefined) {
81
- return yield* ReviewInputViolation.make({
82
- input: relative,
83
- reason: "No head content is available for this file.",
84
- });
85
- }
86
- return entry.headContent;
87
- }),
88
- }),
89
- );
90
- };
91
-
92
- /** In-memory publisher: records every plan and mints a deterministic receipt. */
93
- export const collectingReviewPublisherLayer = (
94
- published: Ref.Ref<ReadonlyArray<ReviewPublicationPlan>>,
95
- ): Layer.Layer<ReviewPublisher> =>
96
- Layer.succeed(ReviewPublisher)(
97
- ReviewPublisher.of({
98
- publish: (plan) =>
99
- Ref.update(published, (plans) => [...plans, plan]).pipe(
100
- Effect.flatMap(() => Ref.get(published)),
101
- Effect.map((plans) =>
102
- PublishedReview.make({
103
- reviewId: plans.length,
104
- url: `memory://review/${plans.length}`,
105
- event: plan.event,
106
- inlineComments: plan.comments.length,
107
- authorNodeId: "BOT_memory-reviewer",
108
- submittedAt: DateTime.makeUnsafe(
109
- `2026-01-01T00:00:${String(plans.length).padStart(2, "0")}Z`,
110
- ),
111
- }),
112
- ),
113
- ),
114
- }),
115
- );
116
-
117
- /** Static `PriorReviews` service for tests: fixed history and comparisons. */
118
- export const staticPriorReviews = (
119
- fingerprint: Option.Option<string>,
120
- options: {
121
- readonly state?: Option.Option<ReviewState> | undefined;
122
- readonly comparison?: ReviewHeadComparison | undefined;
123
- readonly treeComparison?: ReviewTreeComparison | undefined;
124
- } = {},
125
- ): PriorReviews["Service"] =>
126
- PriorReviews.of({
127
- latestFingerprint: Effect.succeed(fingerprint),
128
- latestState: Effect.succeed(options.state ?? Option.none()),
129
- compareHeads: () =>
130
- options.comparison === undefined
131
- ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture comparison" }))
132
- : Effect.succeed(options.comparison),
133
- compareTrees: () =>
134
- options.treeComparison === undefined
135
- ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture tree comparison" }))
136
- : Effect.succeed(options.treeComparison),
137
- });
138
-
139
- /** Layer form for consumers whose Effect explicitly requires `PriorReviews`. */
140
- export const staticPriorReviewsLayer = (
141
- fingerprint: Option.Option<string>,
142
- options: {
143
- readonly state?: Option.Option<ReviewState> | undefined;
144
- readonly comparison?: ReviewHeadComparison | undefined;
145
- readonly treeComparison?: ReviewTreeComparison | undefined;
146
- } = {},
147
- ): Layer.Layer<PriorReviews> =>
148
- Layer.succeed(PriorReviews)(staticPriorReviews(fingerprint, options));
@@ -1,164 +0,0 @@
1
- import { Config, Effect, FileSystem, Layer, Option, Schema } from "effect";
2
- import type { HttpClient } from "effect/unstable/http";
3
-
4
- import type { ReviewAdjudicationHost } from "./adjudication.ts";
5
- import type { PriorReviews, ReviewPublisher } from "./github.ts";
6
- import {
7
- DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN,
8
- GitHubReviewTarget,
9
- gitHubPriorReviewsLayer,
10
- gitHubPullRequestSourceLayer,
11
- gitHubReviewAdjudicationHostLayer,
12
- gitHubReviewPublisherLayer,
13
- gitHubReviewRetirementHostLayer,
14
- } from "./github.ts";
15
- import type { ReviewProgressReporter } from "./progress.ts";
16
- import { gitHubReviewProgressLayer } from "./progress.ts";
17
- import type { ReviewRetirementHost } from "./retirement.ts";
18
- import type { PullRequestSource } from "./source.ts";
19
-
20
- // ---------------------------------------------------------------------------
21
- // GitHub Actions environment resolution: which pull request to review, from
22
- // explicit values first and the standard Actions environment second
23
- // (GITHUB_REPOSITORY, GITHUB_EVENT_PATH, GITHUB_API_URL, GITHUB_TOKEN).
24
- // Platform-free: FileSystem and Config are Effect services supplied by the
25
- // host entrypoint.
26
- // ---------------------------------------------------------------------------
27
-
28
- /** The pull request could not be resolved from options or the environment. */
29
- export class ReviewTargetUnresolved extends Schema.TaggedError<ReviewTargetUnresolved>()(
30
- "ReviewTargetUnresolved",
31
- {
32
- reason: Schema.String,
33
- },
34
- ) {
35
- override get message() {
36
- return this.reason;
37
- }
38
- }
39
-
40
- /** The slice of a GitHub Actions event payload this package understands. */
41
- export const GitHubEventWire = Schema.Struct({
42
- pull_request: Schema.optionalKey(
43
- Schema.Struct({
44
- number: Schema.Int,
45
- draft: Schema.optionalKey(Schema.Boolean),
46
- }),
47
- ),
48
- repository: Schema.optionalKey(Schema.Struct({ full_name: Schema.String })),
49
- });
50
- export type GitHubEventWire = typeof GitHubEventWire.Type;
51
-
52
- const decodeEvent = Schema.decodeUnknownEffect(Schema.fromJsonString(GitHubEventWire));
53
-
54
- /** Read and decode the GITHUB_EVENT_PATH payload, or none outside Actions. */
55
- export const readGitHubEvent = Effect.fn("readGitHubEvent")(function* () {
56
- const eventPath = yield* Config.string("GITHUB_EVENT_PATH").pipe(Config.withDefault(""));
57
- if (eventPath === "") return Option.none<GitHubEventWire>();
58
- const fs = yield* FileSystem.FileSystem;
59
- const raw = yield* fs
60
- .readFileString(eventPath)
61
- .pipe(
62
- Effect.mapError((error) =>
63
- ReviewTargetUnresolved.make({ reason: `Cannot read event payload: ${error.message}` }),
64
- ),
65
- );
66
- const event = yield* decodeEvent(raw).pipe(
67
- Effect.mapError((error) =>
68
- ReviewTargetUnresolved.make({ reason: `Cannot decode event payload: ${error.message}` }),
69
- ),
70
- );
71
- return Option.some(event);
72
- });
73
-
74
- export interface ResolvedReviewTarget {
75
- readonly repository: string;
76
- readonly number: number;
77
- }
78
-
79
- /**
80
- * Resolve the review target: explicit values win, then GITHUB_REPOSITORY and
81
- * the pull_request event payload. Fails typed when no target can be named.
82
- */
83
- export const resolveReviewTarget = Effect.fn("resolveReviewTarget")(function* (options: {
84
- readonly repository?: string | undefined;
85
- readonly number?: number | undefined;
86
- }) {
87
- let repository = options.repository ?? "";
88
- if (repository === "") {
89
- repository = yield* Config.string("GITHUB_REPOSITORY").pipe(Config.withDefault(""));
90
- }
91
- let number = options.number;
92
- if (number === undefined || repository === "") {
93
- const event = yield* readGitHubEvent();
94
- if (Option.isSome(event)) {
95
- number ??= event.value.pull_request?.number;
96
- if (repository === "") repository = event.value.repository?.full_name ?? "";
97
- }
98
- }
99
- if (repository === "" || number === undefined) {
100
- return yield* ReviewTargetUnresolved.make({
101
- reason:
102
- "No pull request to review: pass an explicit repository and number, or run inside a GitHub Actions pull_request event.",
103
- });
104
- }
105
- return { repository, number } satisfies ResolvedReviewTarget;
106
- });
107
-
108
- /**
109
- * Build the GitHub source and publisher Layers for one resolved target,
110
- * reading GITHUB_API_URL, GITHUB_TOKEN, and PR_REVIEW_AUTHOR_LOGIN from
111
- * configuration. The returned Layer is the complete GitHub side of a review
112
- * run.
113
- */
114
- export const gitHubReviewLayers = (
115
- target: ResolvedReviewTarget,
116
- ): Layer.Layer<
117
- | PullRequestSource
118
- | ReviewPublisher
119
- | PriorReviews
120
- | ReviewRetirementHost
121
- | ReviewAdjudicationHost
122
- | ReviewProgressReporter,
123
- Config.ConfigError,
124
- HttpClient.HttpClient
125
- > =>
126
- Layer.unwrap(
127
- Effect.gen(function* () {
128
- const apiUrl = yield* Config.string("GITHUB_API_URL").pipe(
129
- Config.withDefault("https://api.github.com"),
130
- );
131
- const graphqlUrl = yield* Config.string("GITHUB_GRAPHQL_URL").pipe(
132
- Config.withDefault(
133
- apiUrl === "https://api.github.com"
134
- ? "https://api.github.com/graphql"
135
- : apiUrl.replace(/\/api\/v3$/, "/api/graphql"),
136
- ),
137
- );
138
- const token = yield* Config.option(Config.redacted("GITHUB_TOKEN"));
139
- const reviewAuthorLogin = yield* Config.nonEmptyString("PR_REVIEW_AUTHOR_LOGIN").pipe(
140
- Config.withDefault(DEFAULT_GITHUB_REVIEW_AUTHOR_LOGIN),
141
- );
142
- const targetLayer = GitHubReviewTarget.layer({
143
- apiUrl,
144
- graphqlUrl,
145
- repository: target.repository,
146
- number: target.number,
147
- token,
148
- reviewAuthorLogin,
149
- });
150
- const adjudicationHostLayer = gitHubReviewAdjudicationHostLayer.pipe(
151
- Layer.provide(targetLayer),
152
- );
153
- return Layer.mergeAll(
154
- gitHubPullRequestSourceLayer.pipe(Layer.provide(targetLayer)),
155
- gitHubReviewPublisherLayer.pipe(Layer.provide(targetLayer)),
156
- gitHubPriorReviewsLayer.pipe(Layer.provide(targetLayer)),
157
- gitHubReviewRetirementHostLayer.pipe(Layer.provide(targetLayer)),
158
- // Adjudication is an unconditional run dependency, so the public
159
- // GitHub bundle owns its target-bound live host explicitly.
160
- adjudicationHostLayer,
161
- gitHubReviewProgressLayer.pipe(Layer.provide(targetLayer)),
162
- );
163
- }),
164
- );