diffcop 0.0.0-stage → 0.1.0

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 (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -2
  3. package/package.json +66 -4
  4. package/src/agent/claude-code/claude-code-agent.ts +174 -0
  5. package/src/agent/explored.ts +93 -0
  6. package/src/agent/reviewer-agent.ts +51 -0
  7. package/src/cli.ts +21 -0
  8. package/src/commands/review.ts +220 -0
  9. package/src/context/git/git-repository.ts +74 -0
  10. package/src/context/github/pull-request-context.ts +81 -0
  11. package/src/context/github/pull-request-data.ts +80 -0
  12. package/src/context/github/pull-request-fetcher.ts +149 -0
  13. package/src/context/repository.ts +16 -0
  14. package/src/context/review-context-collector.ts +106 -0
  15. package/src/github/github-client.ts +23 -0
  16. package/src/github/unwrap-response.ts +15 -0
  17. package/src/prompt/github/pull-request-formatter.ts +96 -0
  18. package/src/prompt/github/sanitize-content.ts +108 -0
  19. package/src/prompt/review-prompt-builder.ts +206 -0
  20. package/src/reporter/console/console-reporter.ts +72 -0
  21. package/src/reporter/console/json-reporter.ts +37 -0
  22. package/src/reporter/github/github-reporter.ts +93 -0
  23. package/src/reporter/github/review-comment-formatter.ts +144 -0
  24. package/src/reporter/github/sticky-comment.ts +181 -0
  25. package/src/reporter/progress.ts +12 -0
  26. package/src/reporter/review-layout.ts +359 -0
  27. package/src/reporter/review-reporter.ts +19 -0
  28. package/src/reporter/stats.ts +41 -0
  29. package/src/review/change-size-limit.ts +92 -0
  30. package/src/review/code-reviewer.ts +120 -0
  31. package/src/review/findings.ts +125 -0
  32. package/src/util/errors.ts +7 -0
  33. package/src/util/json.ts +7 -0
  34. package/tsconfig.json +34 -0
@@ -0,0 +1,220 @@
1
+ import { defineCommand } from "citty";
2
+ import { z } from "zod";
3
+ import {
4
+ ClaudeCodeReviewerAgent,
5
+ EFFORTS,
6
+ } from "@/agent/claude-code/claude-code-agent";
7
+ import { GitRepository } from "@/context/git/git-repository";
8
+ import { readPullRequestContext } from "@/context/github/pull-request-context";
9
+ import type { PullRequestContext } from "@/context/github/pull-request-context";
10
+ import { PullRequestFetcher } from "@/context/github/pull-request-fetcher";
11
+ import { ReviewContextCollector } from "@/context/review-context-collector";
12
+ import { ReviewPromptBuilder } from "@/prompt/review-prompt-builder";
13
+ import { ConsoleReporter } from "@/reporter/console/console-reporter";
14
+ import { JsonReporter } from "@/reporter/console/json-reporter";
15
+ import { GitHubClient } from "@/github/github-client";
16
+ import { GitHubReporter } from "@/reporter/github/github-reporter";
17
+ import { ReviewCommentFormatter } from "@/reporter/github/review-comment-formatter";
18
+ import { StickyComment } from "@/reporter/github/sticky-comment";
19
+ import type { ReviewReporter } from "@/reporter/review-reporter";
20
+ import { ChangeSizeLimit } from "@/review/change-size-limit";
21
+ import { CodeReviewer } from "@/review/code-reviewer";
22
+
23
+ const REPORTERS = ["console", "json", "github"] as const;
24
+
25
+ type ReporterName = (typeof REPORTERS)[number];
26
+
27
+ function parsePositiveInt(flag: string, value: string) {
28
+ const result = z.coerce.number().int().positive().safeParse(value);
29
+ if (!result.success) {
30
+ throw new Error(`--${flag} must be a positive integer, not "${value}".`);
31
+ }
32
+ return result.data;
33
+ }
34
+
35
+ function createGitHubClient(pr: PullRequestContext | undefined) {
36
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
37
+ if (!pr || !token) {
38
+ return undefined;
39
+ }
40
+ return new GitHubClient({
41
+ auth: token,
42
+ baseUrl: pr.apiUrl,
43
+ });
44
+ }
45
+
46
+ function createPullRequestSource(
47
+ pr: PullRequestContext | undefined,
48
+ github: GitHubClient | undefined,
49
+ ) {
50
+ if (!pr) {
51
+ return undefined;
52
+ }
53
+ if (!github) {
54
+ process.stderr.write(
55
+ "No GITHUB_TOKEN or GH_TOKEN, so the review won't see the PR's description, comments or reviews.\n",
56
+ );
57
+ return undefined;
58
+ }
59
+ return new PullRequestFetcher(pr, github);
60
+ }
61
+
62
+ function createGitHubReporter(
63
+ pr: PullRequestContext | undefined,
64
+ github: GitHubClient | undefined,
65
+ debug: boolean,
66
+ ) {
67
+ if (!pr) {
68
+ throw new Error(
69
+ "--reporter github only works in a GitHub Actions pull_request run.",
70
+ );
71
+ }
72
+ if (!github) {
73
+ throw new Error(
74
+ "--reporter github needs GITHUB_TOKEN or GH_TOKEN to post the review.",
75
+ );
76
+ }
77
+ return new GitHubReporter(
78
+ new StickyComment(pr, github),
79
+ new ReviewCommentFormatter(pr, {
80
+ secrets: [
81
+ process.env.GITHUB_TOKEN,
82
+ process.env.GH_TOKEN,
83
+ process.env.ANTHROPIC_API_KEY,
84
+ process.env.CLAUDE_CODE_OAUTH_TOKEN,
85
+ ],
86
+ }),
87
+ process.stderr,
88
+ { debug: debug },
89
+ );
90
+ }
91
+
92
+ function createReporter(
93
+ name: ReporterName,
94
+ pr: PullRequestContext | undefined,
95
+ github: GitHubClient | undefined,
96
+ debug: boolean,
97
+ ): ReviewReporter {
98
+ switch (name) {
99
+ case "console":
100
+ return new ConsoleReporter(process.stdout, process.stderr, {
101
+ colors: process.stdout.isTTY,
102
+ columns: process.stdout.columns,
103
+ });
104
+ case "json":
105
+ return new JsonReporter(process.stdout);
106
+ case "github":
107
+ return createGitHubReporter(pr, github, debug);
108
+ }
109
+ }
110
+
111
+ export default defineCommand({
112
+ meta: {
113
+ name: "review",
114
+ description: "Review local changes against a base branch",
115
+ },
116
+ args: {
117
+ prompt: {
118
+ type: "positional",
119
+ description: "What to ask the reviewer",
120
+ required: true,
121
+ },
122
+ base: {
123
+ type: "string",
124
+ description:
125
+ "Base ref to diff against (default: the PR's base in GitHub Actions, else origin/HEAD, else main)",
126
+ },
127
+ cwd: {
128
+ type: "string",
129
+ description: "Repository directory",
130
+ default: ".",
131
+ },
132
+ model: {
133
+ type: "string",
134
+ description: "Model override",
135
+ },
136
+ maxTurns: {
137
+ type: "string",
138
+ description: "Maximum agent turns per pass (default: Claude Code's)",
139
+ },
140
+ maxLines: {
141
+ type: "string",
142
+ description:
143
+ "Warn when the change has more added plus deleted lines than this, lockfiles aside",
144
+ default: "1000",
145
+ },
146
+ maxFiles: {
147
+ type: "string",
148
+ description:
149
+ "Warn when the change touches more files than this, lockfiles aside",
150
+ default: "20",
151
+ },
152
+ effort: {
153
+ type: "enum",
154
+ options: [...EFFORTS],
155
+ description: "How hard the model thinks (default: Claude Code's)",
156
+ },
157
+ reporter: {
158
+ type: "enum",
159
+ options: [...REPORTERS],
160
+ default: "console",
161
+ description:
162
+ "Where the review goes: console shows progress then the findings, json prints one object when done, github keeps it in a PR comment (GitHub Actions only; needs GITHUB_TOKEN or GH_TOKEN)",
163
+ },
164
+ debug: {
165
+ type: "boolean",
166
+ description:
167
+ "With --reporter github, also log tool calls and thinking time",
168
+ },
169
+ },
170
+ // Composition root: the only place that picks concrete implementations.
171
+ run: async function ({ args }) {
172
+ let maxTurns: number | undefined;
173
+ if (args.maxTurns !== undefined) {
174
+ maxTurns = parsePositiveInt("maxTurns", args.maxTurns);
175
+ }
176
+ const sizeLimit = new ChangeSizeLimit({
177
+ maxLines: parsePositiveInt("maxLines", args.maxLines),
178
+ maxFiles: parsePositiveInt("maxFiles", args.maxFiles),
179
+ });
180
+ const pr = await readPullRequestContext(process.env);
181
+ const github = createGitHubClient(pr);
182
+ // Built before the review runs so a misconfigured job fails fast.
183
+ const reporter = createReporter(
184
+ args.reporter,
185
+ pr,
186
+ github,
187
+ args.debug === true,
188
+ );
189
+
190
+ const reviewer = new CodeReviewer(
191
+ new ReviewContextCollector(
192
+ new GitRepository(args.cwd),
193
+ createPullRequestSource(pr, github),
194
+ ),
195
+ new ReviewPromptBuilder(),
196
+ new ClaudeCodeReviewerAgent({
197
+ model: args.model,
198
+ maxTurns: maxTurns,
199
+ effort: args.effort,
200
+ }),
201
+ reporter,
202
+ sizeLimit,
203
+ );
204
+
205
+ // Actions sends SIGINT, then SIGTERM, when a job is cancelled or times out.
206
+ const abort = new AbortController();
207
+ process.once("SIGINT", () => {
208
+ abort.abort();
209
+ });
210
+ process.once("SIGTERM", () => {
211
+ abort.abort();
212
+ });
213
+
214
+ await reviewer.review({
215
+ request: args.prompt,
216
+ base: args.base,
217
+ abortSignal: abort.signal,
218
+ });
219
+ },
220
+ });
@@ -0,0 +1,74 @@
1
+ import { $ } from "bun";
2
+ import type { Repository } from "@/context/repository";
3
+
4
+ function lines(text: string) {
5
+ return text.split("\n").filter(Boolean);
6
+ }
7
+
8
+ async function git(cwd: string, args: string[]) {
9
+ return (await $`git ${args}`.cwd(cwd).quiet().text()).trim();
10
+ }
11
+
12
+ export class GitRepository implements Repository {
13
+ readonly #cwd: string;
14
+ #root: Promise<string> | undefined;
15
+
16
+ constructor(cwd: string) {
17
+ this.#cwd = cwd;
18
+ }
19
+
20
+ // Every command runs from here, no matter where diffcop was started.
21
+ async root() {
22
+ this.#root ??= git(this.#cwd, ["rev-parse", "--show-toplevel"]).catch(
23
+ () => {
24
+ throw new Error(`Not a git repository: ${this.#cwd}`);
25
+ },
26
+ );
27
+ return this.#root;
28
+ }
29
+
30
+ async defaultBase() {
31
+ // Resolve the root outside the fallback so "not a git repository" isn't
32
+ // mistaken for a missing origin/HEAD.
33
+ const root = await this.root();
34
+ return git(root, ["rev-parse", "--abbrev-ref", "origin/HEAD"]).catch(() => {
35
+ return "main";
36
+ });
37
+ }
38
+
39
+ async mergeBase(ref: string) {
40
+ return this.#run("merge-base", "HEAD", ref);
41
+ }
42
+
43
+ async currentBranch() {
44
+ return this.#run("rev-parse", "--abbrev-ref", "HEAD");
45
+ }
46
+
47
+ async commitsSince(ref: string) {
48
+ return lines(await this.#run("log", "--oneline", `${ref}..HEAD`));
49
+ }
50
+
51
+ // Covers the working tree, not just commits, so uncommitted edits count.
52
+ async changedFilesSince(ref: string) {
53
+ return lines(await this.#run("diff", "--numstat", ref)).map((line) => {
54
+ const [additions = "0", deletions = "0", path = ""] = line.split("\t");
55
+ return {
56
+ path: path,
57
+ additions: Number(additions) || 0,
58
+ deletions: Number(deletions) || 0,
59
+ };
60
+ });
61
+ }
62
+
63
+ async diffSince(ref: string) {
64
+ return this.#run("diff", ref);
65
+ }
66
+
67
+ async hashObject(path: string) {
68
+ return this.#run("hash-object", "--", path);
69
+ }
70
+
71
+ async #run(...args: string[]) {
72
+ return git(await this.root(), args);
73
+ }
74
+ }
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+
3
+ export interface PullRequestContext {
4
+ apiUrl: string;
5
+ repo: string;
6
+ number: number;
7
+ headSha: string;
8
+ // Title and body as of the event, so edits made after it can't reach the prompt.
9
+ title: string;
10
+ body: string;
11
+ action?: string;
12
+ // Comments and reviews created or edited from this time on are left out.
13
+ triggerTime?: string;
14
+ runId?: string;
15
+ runUrl?: string;
16
+ }
17
+
18
+ const pullRequestEvent = z.object({
19
+ action: z.string().optional(),
20
+ pull_request: z.object({
21
+ number: z.number(),
22
+ title: z.string(),
23
+ body: z.string().nullish(),
24
+ created_at: z.string().optional(),
25
+ updated_at: z.string().optional(),
26
+ head: z.object({ sha: z.string() }),
27
+ }),
28
+ });
29
+
30
+ // Same rule as claude-code-action's extractTriggerTimestamp for pull_request
31
+ // events: an opened PR's creation, otherwise its last update (the push).
32
+ function triggerTime(
33
+ action: string | undefined,
34
+ pullRequest: { created_at?: string; updated_at?: string },
35
+ ) {
36
+ if (action === "opened") {
37
+ return pullRequest.created_at ?? pullRequest.updated_at;
38
+ }
39
+ return pullRequest.updated_at ?? pullRequest.created_at;
40
+ }
41
+
42
+ export async function readPullRequestContext(
43
+ env: Record<string, string | undefined> = process.env,
44
+ ): Promise<PullRequestContext | undefined> {
45
+ const { GITHUB_ACTIONS, GITHUB_EVENT_PATH, GITHUB_REPOSITORY } = env;
46
+ if (GITHUB_ACTIONS !== "true" || !GITHUB_EVENT_PATH || !GITHUB_REPOSITORY) {
47
+ return undefined;
48
+ }
49
+
50
+ // An unreadable or malformed payload counts as "not a pull request" too.
51
+ const event = pullRequestEvent.safeParse(
52
+ await Bun.file(GITHUB_EVENT_PATH)
53
+ .json()
54
+ .catch(() => {
55
+ return undefined;
56
+ }),
57
+ );
58
+ if (!event.success) {
59
+ return undefined;
60
+ }
61
+
62
+ const { action, pull_request: pullRequest } = event.data;
63
+ const runId = env.GITHUB_RUN_ID;
64
+ const serverUrl = env.GITHUB_SERVER_URL ?? "https://github.com";
65
+ let runUrl: string | undefined;
66
+ if (runId) {
67
+ runUrl = `${serverUrl}/${GITHUB_REPOSITORY}/actions/runs/${runId}`;
68
+ }
69
+ return {
70
+ apiUrl: env.GITHUB_API_URL ?? "https://api.github.com",
71
+ repo: GITHUB_REPOSITORY,
72
+ number: pullRequest.number,
73
+ headSha: pullRequest.head.sha,
74
+ title: pullRequest.title,
75
+ body: pullRequest.body ?? "",
76
+ action: action,
77
+ triggerTime: triggerTime(action, pullRequest),
78
+ runId: runId,
79
+ runUrl: runUrl,
80
+ };
81
+ }
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+
3
+ // The shape of claude-code-action's PR_QUERY, trimmed to the fields its prompt uses.
4
+
5
+ const author = z.object({ login: z.string() }).nullable();
6
+
7
+ const pullRequestComment = z.object({
8
+ body: z.string(),
9
+ author: author,
10
+ createdAt: z.string(),
11
+ updatedAt: z.string().optional(),
12
+ lastEditedAt: z.string().nullish(),
13
+ isMinimized: z.boolean(),
14
+ });
15
+
16
+ export type PullRequestComment = z.infer<typeof pullRequestComment>;
17
+
18
+ const pullRequestReviewComment = pullRequestComment.extend({
19
+ path: z.string(),
20
+ line: z.number().nullable(),
21
+ diffHunk: z.string().optional(),
22
+ });
23
+
24
+ export type PullRequestReviewComment = z.infer<typeof pullRequestReviewComment>;
25
+
26
+ const pullRequestReview = z.object({
27
+ author: author,
28
+ body: z.string(),
29
+ state: z.string(),
30
+ submittedAt: z.string(),
31
+ updatedAt: z.string().optional(),
32
+ lastEditedAt: z.string().nullish(),
33
+ comments: z.object({ nodes: z.array(pullRequestReviewComment) }),
34
+ });
35
+
36
+ export type PullRequestReview = z.infer<typeof pullRequestReview>;
37
+
38
+ const pullRequestFile = z.object({
39
+ path: z.string(),
40
+ additions: z.number(),
41
+ deletions: z.number(),
42
+ changeType: z.string(),
43
+ });
44
+
45
+ export type PullRequestFile = z.infer<typeof pullRequestFile>;
46
+
47
+ export type PullRequestFileWithSha = PullRequestFile & { sha: string };
48
+
49
+ const pullRequestFiles = z.object({
50
+ totalCount: z.number(),
51
+ nodes: z.array(pullRequestFile),
52
+ });
53
+
54
+ export type PullRequestFiles = z.infer<typeof pullRequestFiles>;
55
+
56
+ export const pullRequestData = z.object({
57
+ repo: z.string(),
58
+ number: z.number(),
59
+ action: z.string().optional(),
60
+ title: z.string(),
61
+ body: z.string(),
62
+ author: author,
63
+ headRefName: z.string(),
64
+ baseRefName: z.string(),
65
+ state: z.string(),
66
+ additions: z.number(),
67
+ deletions: z.number(),
68
+ labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
69
+ commits: z.object({ totalCount: z.number() }),
70
+ // null when GitHub won't list the files, usually because the diff is too large.
71
+ files: pullRequestFiles.nullable(),
72
+ comments: z.object({ nodes: z.array(pullRequestComment) }),
73
+ reviews: z.object({ nodes: z.array(pullRequestReview) }),
74
+ });
75
+
76
+ export type PullRequestData = z.infer<typeof pullRequestData>;
77
+
78
+ export interface PullRequestSource {
79
+ fetch(): Promise<PullRequestData>;
80
+ }
@@ -0,0 +1,149 @@
1
+ import { z } from "zod";
2
+ import type { GitHubClient } from "@/github/github-client";
3
+ import { unwrapResponse } from "@/github/unwrap-response";
4
+ import type { PullRequestContext } from "@/context/github/pull-request-context";
5
+ import { pullRequestData } from "@/context/github/pull-request-data";
6
+ import type {
7
+ PullRequestData,
8
+ PullRequestSource,
9
+ } from "@/context/github/pull-request-data";
10
+
11
+ const COMMENT_FIELDS = `
12
+ body
13
+ author { login }
14
+ createdAt
15
+ updatedAt
16
+ lastEditedAt
17
+ isMinimized`;
18
+
19
+ const PR_QUERY = `
20
+ query($owner: String!, $repo: String!, $number: Int!) {
21
+ repository(owner: $owner, name: $repo) {
22
+ pullRequest(number: $number) {
23
+ author { login }
24
+ baseRefName
25
+ headRefName
26
+ additions
27
+ deletions
28
+ state
29
+ labels(first: 100) { nodes { name } }
30
+ commits(first: 100) { totalCount }
31
+ files(first: 100) {
32
+ totalCount
33
+ nodes { path additions deletions changeType }
34
+ }
35
+ comments(first: 100) { nodes { ${COMMENT_FIELDS} } }
36
+ reviews(first: 100) {
37
+ nodes {
38
+ author { login }
39
+ body
40
+ state
41
+ submittedAt
42
+ updatedAt
43
+ lastEditedAt
44
+ comments(first: 100) {
45
+ nodes { ${COMMENT_FIELDS} path line diffHunk }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ `;
53
+
54
+ const queryResult = z.object({
55
+ repository: z.object({
56
+ pullRequest: pullRequestData
57
+ .omit({
58
+ repo: true,
59
+ number: true,
60
+ action: true,
61
+ title: true,
62
+ body: true,
63
+ })
64
+ .nullable(),
65
+ }),
66
+ });
67
+
68
+ interface Timestamps {
69
+ updatedAt?: string;
70
+ lastEditedAt?: string | null;
71
+ }
72
+
73
+ // Port of claude-code-action's filterCommentsToTriggerTime: anything created or
74
+ // edited at or after the trigger could have been planted after the push.
75
+ function existedBefore(
76
+ createdAt: string,
77
+ { updatedAt, lastEditedAt }: Timestamps,
78
+ triggerTime: string | undefined,
79
+ ) {
80
+ if (!triggerTime) {
81
+ return true;
82
+ }
83
+ const trigger = new Date(triggerTime).getTime();
84
+ if (new Date(createdAt).getTime() >= trigger) {
85
+ return false;
86
+ }
87
+ const lastEdit = lastEditedAt || updatedAt;
88
+ return !lastEdit || new Date(lastEdit).getTime() < trigger;
89
+ }
90
+
91
+ // claude-code-action's fetchGitHubData, for a pull_request event.
92
+ export class PullRequestFetcher implements PullRequestSource {
93
+ readonly #pr: PullRequestContext;
94
+ readonly #github: GitHubClient;
95
+
96
+ constructor(pr: PullRequestContext, github: GitHubClient) {
97
+ this.#pr = pr;
98
+ this.#github = github;
99
+ }
100
+
101
+ async fetch(): Promise<PullRequestData> {
102
+ const { repo, number, action, title, body, triggerTime } = this.#pr;
103
+ const [owner = "", name = ""] = repo.split("/");
104
+ const result = unwrapResponse(
105
+ queryResult.safeParse(
106
+ await this.#github.graphql<unknown>(PR_QUERY, {
107
+ owner: owner,
108
+ repo: name,
109
+ number: number,
110
+ }),
111
+ ),
112
+ "GitHub's pull request query",
113
+ );
114
+ const pullRequest = result.repository.pullRequest;
115
+ if (!pullRequest) {
116
+ throw new Error(`Pull request ${repo}#${number} not found.`);
117
+ }
118
+
119
+ const reviews = pullRequest.reviews.nodes
120
+ .filter((review) => {
121
+ return existedBefore(review.submittedAt, review, triggerTime);
122
+ })
123
+ .map((review) => {
124
+ return {
125
+ ...review,
126
+ comments: {
127
+ nodes: review.comments.nodes.filter((comment) => {
128
+ return existedBefore(comment.createdAt, comment, triggerTime);
129
+ }),
130
+ },
131
+ };
132
+ });
133
+
134
+ return {
135
+ ...pullRequest,
136
+ repo: repo,
137
+ number: number,
138
+ action: action,
139
+ title: title,
140
+ body: body,
141
+ comments: {
142
+ nodes: pullRequest.comments.nodes.filter((comment) => {
143
+ return existedBefore(comment.createdAt, comment, triggerTime);
144
+ }),
145
+ },
146
+ reviews: { nodes: reviews },
147
+ };
148
+ }
149
+ }
@@ -0,0 +1,16 @@
1
+ export interface ChangedFile {
2
+ path: string;
3
+ additions: number;
4
+ deletions: number;
5
+ }
6
+
7
+ export interface Repository {
8
+ root(): Promise<string>;
9
+ defaultBase(): Promise<string>;
10
+ mergeBase(ref: string): Promise<string>;
11
+ currentBranch(): Promise<string>;
12
+ commitsSince(ref: string): Promise<string[]>;
13
+ changedFilesSince(ref: string): Promise<ChangedFile[]>;
14
+ diffSince(ref: string): Promise<string>;
15
+ hashObject(path: string): Promise<string>;
16
+ }