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.
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/package.json +66 -4
- package/src/agent/claude-code/claude-code-agent.ts +174 -0
- package/src/agent/explored.ts +93 -0
- package/src/agent/reviewer-agent.ts +51 -0
- package/src/cli.ts +21 -0
- package/src/commands/review.ts +220 -0
- package/src/context/git/git-repository.ts +74 -0
- package/src/context/github/pull-request-context.ts +81 -0
- package/src/context/github/pull-request-data.ts +80 -0
- package/src/context/github/pull-request-fetcher.ts +149 -0
- package/src/context/repository.ts +16 -0
- package/src/context/review-context-collector.ts +106 -0
- package/src/github/github-client.ts +23 -0
- package/src/github/unwrap-response.ts +15 -0
- package/src/prompt/github/pull-request-formatter.ts +96 -0
- package/src/prompt/github/sanitize-content.ts +108 -0
- package/src/prompt/review-prompt-builder.ts +206 -0
- package/src/reporter/console/console-reporter.ts +72 -0
- package/src/reporter/console/json-reporter.ts +37 -0
- package/src/reporter/github/github-reporter.ts +93 -0
- package/src/reporter/github/review-comment-formatter.ts +144 -0
- package/src/reporter/github/sticky-comment.ts +181 -0
- package/src/reporter/progress.ts +12 -0
- package/src/reporter/review-layout.ts +359 -0
- package/src/reporter/review-reporter.ts +19 -0
- package/src/reporter/stats.ts +41 -0
- package/src/review/change-size-limit.ts +92 -0
- package/src/review/code-reviewer.ts +120 -0
- package/src/review/findings.ts +125 -0
- package/src/util/errors.ts +7 -0
- package/src/util/json.ts +7 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { toError } from "@/util/errors";
|
|
2
|
+
import type { ReviewResult } from "@/review/findings";
|
|
3
|
+
import { thinkingLine, toolCallLine, verifyingLine } from "@/reporter/progress";
|
|
4
|
+
import type { ReviewReporter, TextSink } from "@/reporter/review-reporter";
|
|
5
|
+
import type { ReviewCommentFormatter } from "@/reporter/github/review-comment-formatter";
|
|
6
|
+
import type { StickyComment } from "@/reporter/github/sticky-comment";
|
|
7
|
+
|
|
8
|
+
export interface GitHubReporterOptions {
|
|
9
|
+
// Also log tool calls and thinking time, to see where a slow run went.
|
|
10
|
+
debug?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class GitHubReporter implements ReviewReporter {
|
|
14
|
+
readonly #comment: StickyComment;
|
|
15
|
+
readonly #formatter: ReviewCommentFormatter;
|
|
16
|
+
readonly #log: TextSink;
|
|
17
|
+
readonly #debug: boolean;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
comment: StickyComment,
|
|
21
|
+
formatter: ReviewCommentFormatter,
|
|
22
|
+
log: TextSink,
|
|
23
|
+
{ debug = false }: GitHubReporterOptions = {},
|
|
24
|
+
) {
|
|
25
|
+
this.#comment = comment;
|
|
26
|
+
this.#formatter = formatter;
|
|
27
|
+
this.#log = log;
|
|
28
|
+
this.#debug = debug;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Only a status update, so a failure here (e.g. a GitHub API blip) doesn't
|
|
32
|
+
// stop the review; the result is still posted when it's done.
|
|
33
|
+
async started() {
|
|
34
|
+
await this.#post(this.#formatter.running()).catch((cause: unknown) => {
|
|
35
|
+
this.#warn(toError(cause));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Re-posts the running status so the warning shows while the review runs.
|
|
40
|
+
async warning(message: string) {
|
|
41
|
+
this.#formatter.addWarning(message);
|
|
42
|
+
await this.started();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
toolCall(toolName: string, input: string) {
|
|
46
|
+
if (this.#debug) {
|
|
47
|
+
this.#log.write(`${toolCallLine(toolName, input)}\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
thinking(durationMs: number) {
|
|
52
|
+
if (this.#debug) {
|
|
53
|
+
this.#log.write(`${thinkingLine(durationMs)}\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Logged even without debug: one line that explains a long second wait.
|
|
58
|
+
async verifying(findings: number) {
|
|
59
|
+
this.#log.write(`${verifyingLine(findings)}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Unlike the status updates, a failed post throws: the run should fail, not
|
|
63
|
+
// pass with the comment stuck on "running".
|
|
64
|
+
async completed(result: ReviewResult) {
|
|
65
|
+
await this.#post(this.#formatter.review(result));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async failed(error: Error, { cancelled }: { cancelled: boolean }) {
|
|
69
|
+
await this.#post(
|
|
70
|
+
this.#formatter.failure(error, { cancelled: cancelled }),
|
|
71
|
+
).catch((cause: unknown) => {
|
|
72
|
+
this.#warn(toError(cause));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every post respects a newer run's claim, the running status included, so a
|
|
77
|
+
// re-run of an older workflow run can't replace the newer run's review.
|
|
78
|
+
async #post(body: string) {
|
|
79
|
+
const posted = await this.#comment.upsert(body, { onlyIfOwned: true });
|
|
80
|
+
const where = this.#comment.location;
|
|
81
|
+
if (posted) {
|
|
82
|
+
this.#log.write(`Updated the diffcop comment on ${where}\n`);
|
|
83
|
+
} else {
|
|
84
|
+
this.#log.write(
|
|
85
|
+
`A newer run has taken over the diffcop comment on ${where}; leaving it alone\n`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#warn(error: Error) {
|
|
91
|
+
this.#log.write(`Couldn't update the diffcop comment: ${String(error)}\n`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { PullRequestContext } from "@/context/github/pull-request-context";
|
|
2
|
+
import type { ReviewResult } from "@/review/findings";
|
|
3
|
+
import { reviewLayout, sectionHeading } from "@/reporter/review-layout";
|
|
4
|
+
import type { ReviewLayout, ReviewSection } from "@/reporter/review-layout";
|
|
5
|
+
import { formatStats } from "@/reporter/stats";
|
|
6
|
+
|
|
7
|
+
// GitHub rejects comment bodies over 65,536 characters.
|
|
8
|
+
const MAX_COMMENT_CHARS = 60_000;
|
|
9
|
+
const MAX_ERROR_CHARS = 2_000;
|
|
10
|
+
|
|
11
|
+
const TRUNCATED = "_Review truncated; see the job log for the rest._";
|
|
12
|
+
const BLOCK_GAP = "\n\n";
|
|
13
|
+
|
|
14
|
+
// Blank lines inside <details> let GitHub render the body as markdown.
|
|
15
|
+
function details(section: ReviewSection, body: string) {
|
|
16
|
+
const tag = section.open ? "<details open>" : "<details>";
|
|
17
|
+
return [
|
|
18
|
+
`${tag}<summary><b>${sectionHeading(section)}</b></summary>`,
|
|
19
|
+
"",
|
|
20
|
+
body,
|
|
21
|
+
"",
|
|
22
|
+
"</details>",
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReviewCommentFormatterOptions {
|
|
27
|
+
// Scrubbed from everything posted, since PR comments aren't masked the way
|
|
28
|
+
// Actions logs are, and the reviewer can read files outside the repo.
|
|
29
|
+
secrets?: (string | undefined)[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ReviewCommentFormatter {
|
|
33
|
+
readonly #pr: PullRequestContext;
|
|
34
|
+
readonly #secrets: string[];
|
|
35
|
+
readonly #warnings: string[] = [];
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
pr: PullRequestContext,
|
|
39
|
+
{ secrets = [] }: ReviewCommentFormatterOptions = {},
|
|
40
|
+
) {
|
|
41
|
+
this.#pr = pr;
|
|
42
|
+
// Longest first, so a secret that contains another isn't left half-redacted.
|
|
43
|
+
this.#secrets = secrets
|
|
44
|
+
.filter((secret): secret is string => {
|
|
45
|
+
return Boolean(secret);
|
|
46
|
+
})
|
|
47
|
+
.toSorted((a, b) => {
|
|
48
|
+
return b.length - a.length;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Shown at the top of every comment formatted after this.
|
|
53
|
+
addWarning(message: string) {
|
|
54
|
+
this.#warnings.push(message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
running() {
|
|
58
|
+
return this.#frame([
|
|
59
|
+
`⏳ Reviewing \`${this.#shortSha()}\`…${this.#runLink("Follow the run")}`,
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
failure(error: Error, { cancelled = false } = {}) {
|
|
64
|
+
if (cancelled) {
|
|
65
|
+
return this.#frame([
|
|
66
|
+
`⚪ The review of \`${this.#shortSha()}\` was cancelled before it finished.${this.#runLink("See the run")}`,
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
return this.#frame([
|
|
70
|
+
`❌ The review of \`${this.#shortSha()}\` failed.${this.#runLink("See the run log")}`,
|
|
71
|
+
"",
|
|
72
|
+
"```",
|
|
73
|
+
this.#errorMessage(error),
|
|
74
|
+
"```",
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
review(result: ReviewResult) {
|
|
79
|
+
const { stats } = result;
|
|
80
|
+
const footer = [`Reviewed ${this.#shortSha()}`, formatStats(stats)].join(
|
|
81
|
+
" · ",
|
|
82
|
+
);
|
|
83
|
+
return this.#frame([
|
|
84
|
+
this.#body(reviewLayout(result)),
|
|
85
|
+
"",
|
|
86
|
+
"---",
|
|
87
|
+
`<sub>${footer}${this.#runLink("run")}</sub>`,
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Truncates inside a section's body, so a cut never leaves <details> open.
|
|
92
|
+
#body({ headline, sections }: ReviewLayout) {
|
|
93
|
+
const top = `${headline.icon} **${headline.text}**`;
|
|
94
|
+
const blocks = [top];
|
|
95
|
+
let room = MAX_COMMENT_CHARS - top.length;
|
|
96
|
+
for (const section of sections) {
|
|
97
|
+
const body = this.#redact(section.body);
|
|
98
|
+
const block = details(section, body);
|
|
99
|
+
if (BLOCK_GAP.length + block.length <= room) {
|
|
100
|
+
blocks.push(block);
|
|
101
|
+
room -= BLOCK_GAP.length + block.length;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const ending = `${BLOCK_GAP}${TRUNCATED}`;
|
|
105
|
+
const overhead = BLOCK_GAP.length + details(section, ending).length;
|
|
106
|
+
const kept = body.slice(0, Math.max(0, room - overhead));
|
|
107
|
+
blocks.push(details(section, `${kept}${ending}`));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
return blocks.join(BLOCK_GAP);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#errorMessage(error: Error) {
|
|
114
|
+
const message = this.#redact(error.message);
|
|
115
|
+
// Keep the message inside its code fence.
|
|
116
|
+
return message.slice(0, MAX_ERROR_CHARS).replaceAll("```", "'''");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#redact(text: string) {
|
|
120
|
+
let redacted = text;
|
|
121
|
+
for (const secret of this.#secrets) {
|
|
122
|
+
redacted = redacted.replaceAll(secret, "***");
|
|
123
|
+
}
|
|
124
|
+
return redacted;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#frame(lines: string[]) {
|
|
128
|
+
const alerts = this.#warnings.flatMap((warning) => {
|
|
129
|
+
return ["> [!WARNING]", `> ${warning}`, ""];
|
|
130
|
+
});
|
|
131
|
+
return ["## diffcop review", "", ...alerts, ...lines].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#shortSha() {
|
|
135
|
+
return this.#pr.headSha.slice(0, 7);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#runLink(label: string) {
|
|
139
|
+
if (!this.#pr.runUrl) {
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
return ` [${label}](${this.#pr.runUrl})`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { RequestError } from "@octokit/request-error";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { PullRequestContext } from "@/context/github/pull-request-context";
|
|
4
|
+
import type { GitHubClient } from "@/github/github-client";
|
|
5
|
+
import { unwrapResponse } from "@/github/unwrap-response";
|
|
6
|
+
|
|
7
|
+
// Retries for creating the comment, on top of the first attempt.
|
|
8
|
+
const CREATE_RETRIES = 3;
|
|
9
|
+
|
|
10
|
+
// Hidden in the comment body so later runs find and edit the same comment.
|
|
11
|
+
const MARKER = "<!-- diffcop:review -->";
|
|
12
|
+
|
|
13
|
+
const issueComments = z.array(
|
|
14
|
+
z.object({
|
|
15
|
+
id: z.number(),
|
|
16
|
+
body: z.string().optional(),
|
|
17
|
+
user: z.object({ login: z.string() }).nullable(),
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const viewerQuery = z.object({ viewer: z.object({ login: z.string() }) });
|
|
22
|
+
|
|
23
|
+
// Records which Actions run last claimed the comment, so a run that was
|
|
24
|
+
// superseded doesn't overwrite the newer run's status.
|
|
25
|
+
function runMarker(runId: string) {
|
|
26
|
+
return `<!-- diffcop:run=${runId} -->`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function claimedRun(body: string) {
|
|
30
|
+
return body.match(/<!-- diffcop:run=(\d+) -->/)?.[1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Octokit's error message is only GitHub's response message, so say which
|
|
34
|
+
// request failed and with what status.
|
|
35
|
+
function describeFailure(cause: unknown) {
|
|
36
|
+
if (!(cause instanceof RequestError)) {
|
|
37
|
+
return cause;
|
|
38
|
+
}
|
|
39
|
+
const { method, url } = cause.request;
|
|
40
|
+
return new Error(
|
|
41
|
+
`GitHub API ${method} ${url} failed: ${cause.status} ${cause.message}`,
|
|
42
|
+
{ cause: cause },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Find-then-create isn't atomic, and GitHub can't create a comment only if absent,
|
|
47
|
+
// so runs for one PR must be serialized (the workflow's `concurrency` group).
|
|
48
|
+
export class StickyComment {
|
|
49
|
+
readonly #pr: PullRequestContext;
|
|
50
|
+
readonly #github: GitHubClient;
|
|
51
|
+
#login: Promise<string> | undefined;
|
|
52
|
+
|
|
53
|
+
constructor(pr: PullRequestContext, github: GitHubClient) {
|
|
54
|
+
this.#pr = pr;
|
|
55
|
+
this.#github = github;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get location() {
|
|
59
|
+
return `${this.#pr.repo}#${this.#pr.number}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// With `onlyIfOwned`, skips the write and returns false if a newer run has
|
|
63
|
+
// claimed the comment; a claim by an older (e.g. cancelled) run is taken over.
|
|
64
|
+
async upsert(body: string, { onlyIfOwned = false } = {}) {
|
|
65
|
+
try {
|
|
66
|
+
return await this.#upsert(body, onlyIfOwned);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw describeFailure(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async #upsert(
|
|
73
|
+
body: string,
|
|
74
|
+
onlyIfOwned: boolean,
|
|
75
|
+
attempt = 1,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
const { runId } = this.#pr;
|
|
78
|
+
const existing = await this.#find();
|
|
79
|
+
const claimedBy = existing?.body ? claimedRun(existing.body) : undefined;
|
|
80
|
+
if (
|
|
81
|
+
onlyIfOwned &&
|
|
82
|
+
runId &&
|
|
83
|
+
claimedBy &&
|
|
84
|
+
Number(claimedBy) > Number(runId)
|
|
85
|
+
) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fullBody = [MARKER, ...(runId ? [runMarker(runId)] : []), body].join(
|
|
90
|
+
"\n",
|
|
91
|
+
);
|
|
92
|
+
if (existing) {
|
|
93
|
+
await this.#github.request(
|
|
94
|
+
"PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}",
|
|
95
|
+
{
|
|
96
|
+
...this.#repo(),
|
|
97
|
+
comment_id: existing.id,
|
|
98
|
+
body: fullBody,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await this.#github.request(
|
|
105
|
+
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
|
|
106
|
+
{
|
|
107
|
+
...this.#repo(),
|
|
108
|
+
issue_number: this.#pr.number,
|
|
109
|
+
body: fullBody,
|
|
110
|
+
// GitHub can create the comment and still fail the response, so a
|
|
111
|
+
// blind retry would post a second one. Retry from the lookup instead.
|
|
112
|
+
request: { retries: 0 },
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (
|
|
117
|
+
attempt > CREATE_RETRIES ||
|
|
118
|
+
!(error instanceof RequestError) ||
|
|
119
|
+
error.status < 500
|
|
120
|
+
) {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
await Bun.sleep(attempt ** 2 * this.#github.retryAfterBaseValue);
|
|
124
|
+
return this.#upsert(body, onlyIfOwned, attempt + 1);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #find() {
|
|
130
|
+
const pages = this.#github.paginate.iterator(
|
|
131
|
+
"GET /repos/{owner}/{repo}/issues/{issue_number}/comments",
|
|
132
|
+
{
|
|
133
|
+
...this.#repo(),
|
|
134
|
+
issue_number: this.#pr.number,
|
|
135
|
+
per_page: 100,
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
// Anyone who can comment can paste the marker, so only our own comment counts.
|
|
139
|
+
const login = await this.#viewerLogin();
|
|
140
|
+
for await (const { data } of pages) {
|
|
141
|
+
const comments = unwrapResponse(
|
|
142
|
+
issueComments.safeParse(data),
|
|
143
|
+
"GitHub's comment list",
|
|
144
|
+
);
|
|
145
|
+
const match = comments.find((c) => {
|
|
146
|
+
return c.user?.login === login && c.body?.includes(MARKER);
|
|
147
|
+
});
|
|
148
|
+
if (match) {
|
|
149
|
+
return match;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// GraphQL, since REST's GET /user rejects the Actions installation token.
|
|
156
|
+
async #viewerLogin() {
|
|
157
|
+
this.#login ??= this.#github
|
|
158
|
+
.graphql<unknown>("query { viewer { login } }")
|
|
159
|
+
.then((result) => {
|
|
160
|
+
const { viewer } = unwrapResponse(
|
|
161
|
+
viewerQuery.safeParse(result),
|
|
162
|
+
"GitHub's viewer query",
|
|
163
|
+
);
|
|
164
|
+
return viewer.login;
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
return await this.#login;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
this.#login = undefined;
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#repo() {
|
|
175
|
+
const [owner = "", repo = ""] = this.#pr.repo.split("/");
|
|
176
|
+
return {
|
|
177
|
+
owner: owner,
|
|
178
|
+
repo: repo,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function toolCallLine(toolName: string, input: string) {
|
|
2
|
+
return `› ${toolName} ${input}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// Shown so slow runs make clear where the time went.
|
|
6
|
+
export function thinkingLine(durationMs: number) {
|
|
7
|
+
return `› thinking (${Math.round(durationMs / 1000)}s)`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function verifyingLine(findings: number) {
|
|
11
|
+
return `› verifying ${findings} finding${findings === 1 ? "" : "s"}…`;
|
|
12
|
+
}
|