co-maintainer 0.4.0-beta.1
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 +105 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +9 -0
- package/dist/mod.d.ts +4 -0
- package/dist/mod.js +2 -0
- package/dist/package.json +54 -0
- package/dist/src/ai/batch.d.ts +16 -0
- package/dist/src/ai/batch.js +117 -0
- package/dist/src/ai/fake.d.ts +7 -0
- package/dist/src/ai/fake.js +40 -0
- package/dist/src/ai/hetzner.d.ts +16 -0
- package/dist/src/ai/hetzner.js +69 -0
- package/dist/src/ai/mermaid.d.ts +27 -0
- package/dist/src/ai/mermaid.js +457 -0
- package/dist/src/ai/mermaid_loop.d.ts +12 -0
- package/dist/src/ai/mermaid_loop.js +100 -0
- package/dist/src/ai/openrouter.d.ts +9 -0
- package/dist/src/ai/openrouter.js +29 -0
- package/dist/src/ai/provider.d.ts +4 -0
- package/dist/src/ai/provider.js +63 -0
- package/dist/src/cli/args.d.ts +5 -0
- package/dist/src/cli/args.js +252 -0
- package/dist/src/cli/commands/probe.d.ts +2 -0
- package/dist/src/cli/commands/probe.js +122 -0
- package/dist/src/cli/commands/review.d.ts +3 -0
- package/dist/src/cli/commands/review.js +84 -0
- package/dist/src/cli/commands/serve.d.ts +18 -0
- package/dist/src/cli/commands/serve.js +183 -0
- package/dist/src/cli/commands/set.d.ts +7 -0
- package/dist/src/cli/commands/set.js +171 -0
- package/dist/src/cli/main.d.ts +1 -0
- package/dist/src/cli/main.js +27 -0
- package/dist/src/cli/prompt.d.ts +4 -0
- package/dist/src/cli/prompt.js +26 -0
- package/dist/src/cli/review_args.d.ts +28 -0
- package/dist/src/cli/review_args.js +84 -0
- package/dist/src/cli/review_output.d.ts +2 -0
- package/dist/src/cli/review_output.js +10 -0
- package/dist/src/cli/review_result.d.ts +81 -0
- package/dist/src/cli/review_result.js +281 -0
- package/dist/src/config.d.ts +108 -0
- package/dist/src/config.js +138 -0
- package/dist/src/git/default_branch.d.ts +5 -0
- package/dist/src/git/default_branch.js +22 -0
- package/dist/src/github/app.d.ts +43 -0
- package/dist/src/github/app.js +120 -0
- package/dist/src/github/client.d.ts +17 -0
- package/dist/src/github/client.js +66 -0
- package/dist/src/github/collect.d.ts +7 -0
- package/dist/src/github/collect.js +368 -0
- package/dist/src/github/gh.d.ts +8 -0
- package/dist/src/github/gh.js +63 -0
- package/dist/src/github/jwt.d.ts +12 -0
- package/dist/src/github/jwt.js +84 -0
- package/dist/src/github/oauth.d.ts +9 -0
- package/dist/src/github/oauth.js +51 -0
- package/dist/src/github/pat.d.ts +10 -0
- package/dist/src/github/pat.js +60 -0
- package/dist/src/knowledge/facts.d.ts +3 -0
- package/dist/src/knowledge/facts.js +325 -0
- package/dist/src/knowledge/guide.d.ts +6 -0
- package/dist/src/knowledge/guide.js +82 -0
- package/dist/src/knowledge/probe.d.ts +12 -0
- package/dist/src/knowledge/probe.js +144 -0
- package/dist/src/knowledge/sections.d.ts +2 -0
- package/dist/src/knowledge/sections.js +13 -0
- package/dist/src/knowledge/skill.d.ts +10 -0
- package/dist/src/knowledge/skill.js +106 -0
- package/dist/src/knowledge/synthesis.d.ts +7 -0
- package/dist/src/knowledge/synthesis.js +315 -0
- package/dist/src/knowledge/types.d.ts +49 -0
- package/dist/src/knowledge/types.js +1 -0
- package/dist/src/knowledge/validate.d.ts +6 -0
- package/dist/src/knowledge/validate.js +134 -0
- package/dist/src/local/carry_over_store.d.ts +18 -0
- package/dist/src/local/carry_over_store.js +40 -0
- package/dist/src/local/codegraph_exclude.d.ts +3 -0
- package/dist/src/local/codegraph_exclude.js +26 -0
- package/dist/src/local/codegraph_prepare.d.ts +17 -0
- package/dist/src/local/codegraph_prepare.js +51 -0
- package/dist/src/local/git_ops.d.ts +12 -0
- package/dist/src/local/git_ops.js +98 -0
- package/dist/src/local/git_parse.d.ts +27 -0
- package/dist/src/local/git_parse.js +131 -0
- package/dist/src/local/git_revision.d.ts +15 -0
- package/dist/src/local/git_revision.js +141 -0
- package/dist/src/local/git_untracked.d.ts +21 -0
- package/dist/src/local/git_untracked.js +79 -0
- package/dist/src/local/review_local.d.ts +4 -0
- package/dist/src/local/review_local.js +273 -0
- package/dist/src/local/review_lock.d.ts +7 -0
- package/dist/src/local/review_lock.js +45 -0
- package/dist/src/pr/checkout.d.ts +25 -0
- package/dist/src/pr/checkout.js +136 -0
- package/dist/src/pr/codegraph_tool_args.d.ts +8 -0
- package/dist/src/pr/codegraph_tool_args.js +34 -0
- package/dist/src/pr/codegraph_tools.d.ts +15 -0
- package/dist/src/pr/codegraph_tools.js +408 -0
- package/dist/src/pr/diff_summary.d.ts +6 -0
- package/dist/src/pr/diff_summary.js +52 -0
- package/dist/src/pr/findings.d.ts +14 -0
- package/dist/src/pr/findings.js +94 -0
- package/dist/src/pr/github_revision.d.ts +4 -0
- package/dist/src/pr/github_revision.js +39 -0
- package/dist/src/pr/hunks.d.ts +18 -0
- package/dist/src/pr/hunks.js +73 -0
- package/dist/src/pr/reviewer.d.ts +40 -0
- package/dist/src/pr/reviewer.js +582 -0
- package/dist/src/pr/rounds.d.ts +17 -0
- package/dist/src/pr/rounds.js +43 -0
- package/dist/src/pr/scope.d.ts +35 -0
- package/dist/src/pr/scope.js +84 -0
- package/dist/src/pr/snapshot.d.ts +11 -0
- package/dist/src/pr/snapshot.js +1 -0
- package/dist/src/pr/suggestion.d.ts +17 -0
- package/dist/src/pr/suggestion.js +70 -0
- package/dist/src/remote/client.d.ts +4 -0
- package/dist/src/remote/client.js +199 -0
- package/dist/src/remote/client_tools.d.ts +13 -0
- package/dist/src/remote/client_tools.js +30 -0
- package/dist/src/remote/fixtures.d.ts +1 -0
- package/dist/src/remote/fixtures.js +30 -0
- package/dist/src/remote/revision_from_submit.d.ts +2 -0
- package/dist/src/remote/revision_from_submit.js +26 -0
- package/dist/src/remote/schema.d.ts +14 -0
- package/dist/src/remote/schema.js +30 -0
- package/dist/src/remote/server/body.d.ts +18 -0
- package/dist/src/remote/server/body.js +73 -0
- package/dist/src/remote/server/routes.d.ts +1 -0
- package/dist/src/remote/server/routes.js +150 -0
- package/dist/src/remote/server/sessions.d.ts +16 -0
- package/dist/src/remote/server/sessions.js +48 -0
- package/dist/src/remote/server/sync.d.ts +3 -0
- package/dist/src/remote/server/sync.js +90 -0
- package/dist/src/remote/tool_bridge.d.ts +15 -0
- package/dist/src/remote/tool_bridge.js +84 -0
- package/dist/src/remote/validate.d.ts +8 -0
- package/dist/src/remote/validate.js +248 -0
- package/dist/src/review/carry_over.d.ts +63 -0
- package/dist/src/review/carry_over.js +372 -0
- package/dist/src/review/engine.d.ts +13 -0
- package/dist/src/review/engine.js +4 -0
- package/dist/src/review/guides.d.ts +12 -0
- package/dist/src/review/guides.js +44 -0
- package/dist/src/review/revision.d.ts +26 -0
- package/dist/src/review/revision.js +57 -0
- package/dist/src/server/api/activity.d.ts +1 -0
- package/dist/src/server/api/activity.js +12 -0
- package/dist/src/server/api/analytics.d.ts +1 -0
- package/dist/src/server/api/analytics.js +9 -0
- package/dist/src/server/api/installations.d.ts +4 -0
- package/dist/src/server/api/installations.js +24 -0
- package/dist/src/server/api/jobs.d.ts +1 -0
- package/dist/src/server/api/jobs.js +86 -0
- package/dist/src/server/api/json_body.d.ts +1 -0
- package/dist/src/server/api/json_body.js +14 -0
- package/dist/src/server/api/remote_tokens.d.ts +1 -0
- package/dist/src/server/api/remote_tokens.js +71 -0
- package/dist/src/server/api/repos.d.ts +4 -0
- package/dist/src/server/api/repos.js +136 -0
- package/dist/src/server/api/settings.d.ts +1 -0
- package/dist/src/server/api/settings.js +210 -0
- package/dist/src/server/app.d.ts +27 -0
- package/dist/src/server/app.js +147 -0
- package/dist/src/server/assets.d.ts +3 -0
- package/dist/src/server/assets.js +21 -0
- package/dist/src/server/auth.d.ts +44 -0
- package/dist/src/server/auth.js +188 -0
- package/dist/src/server/errors.d.ts +8 -0
- package/dist/src/server/errors.js +10 -0
- package/dist/src/server/http.d.ts +8 -0
- package/dist/src/server/http.js +117 -0
- package/dist/src/server/logo.d.ts +1 -0
- package/dist/src/server/logo.js +2 -0
- package/dist/src/server/pages/activity.d.ts +4 -0
- package/dist/src/server/pages/activity.js +200 -0
- package/dist/src/server/pages/add_repo.d.ts +9 -0
- package/dist/src/server/pages/add_repo.js +56 -0
- package/dist/src/server/pages/analytics.d.ts +2 -0
- package/dist/src/server/pages/analytics.js +193 -0
- package/dist/src/server/pages/client.d.ts +2 -0
- package/dist/src/server/pages/client.js +2 -0
- package/dist/src/server/pages/home.d.ts +16 -0
- package/dist/src/server/pages/home.js +87 -0
- package/dist/src/server/pages/knowledge.d.ts +2 -0
- package/dist/src/server/pages/knowledge.js +64 -0
- package/dist/src/server/pages/layout.d.ts +32 -0
- package/dist/src/server/pages/layout.js +230 -0
- package/dist/src/server/pages/pr.d.ts +2 -0
- package/dist/src/server/pages/pr.js +109 -0
- package/dist/src/server/pages/repo.d.ts +2 -0
- package/dist/src/server/pages/repo.js +92 -0
- package/dist/src/server/pages/repo_prs.d.ts +2 -0
- package/dist/src/server/pages/repo_prs.js +75 -0
- package/dist/src/server/pages/repo_remote.d.ts +2 -0
- package/dist/src/server/pages/repo_remote.js +38 -0
- package/dist/src/server/pages/repo_settings.d.ts +3 -0
- package/dist/src/server/pages/repo_settings.js +127 -0
- package/dist/src/server/pages/router.d.ts +17 -0
- package/dist/src/server/pages/router.js +312 -0
- package/dist/src/server/pages/settings.d.ts +2 -0
- package/dist/src/server/pages/settings.js +337 -0
- package/dist/src/server/pages/setup.d.ts +5 -0
- package/dist/src/server/pages/setup.js +38 -0
- package/dist/src/server/pages/styles.d.ts +2 -0
- package/dist/src/server/pages/styles.js +2 -0
- package/dist/src/server/webhook/index.d.ts +1 -0
- package/dist/src/server/webhook/index.js +64 -0
- package/dist/src/server/webhook/signature.d.ts +7 -0
- package/dist/src/server/webhook/signature.js +41 -0
- package/dist/src/services/credentials.d.ts +19 -0
- package/dist/src/services/credentials.js +146 -0
- package/dist/src/services/dashboard.d.ts +165 -0
- package/dist/src/services/dashboard.js +200 -0
- package/dist/src/services/drift.d.ts +13 -0
- package/dist/src/services/drift.js +76 -0
- package/dist/src/services/jobs.d.ts +34 -0
- package/dist/src/services/jobs.js +222 -0
- package/dist/src/services/remote_review.d.ts +10 -0
- package/dist/src/services/remote_review.js +274 -0
- package/dist/src/services/remote_review_abort.d.ts +3 -0
- package/dist/src/services/remote_review_abort.js +11 -0
- package/dist/src/services/remote_token_jobs.d.ts +1 -0
- package/dist/src/services/remote_token_jobs.js +20 -0
- package/dist/src/services/remote_tokens.d.ts +13 -0
- package/dist/src/services/remote_tokens.js +57 -0
- package/dist/src/services/replies.d.ts +7 -0
- package/dist/src/services/replies.js +248 -0
- package/dist/src/services/review.d.ts +35 -0
- package/dist/src/services/review.js +671 -0
- package/dist/src/services/setup.d.ts +29 -0
- package/dist/src/services/setup.js +364 -0
- package/dist/src/services/webhook.d.ts +22 -0
- package/dist/src/services/webhook.js +401 -0
- package/dist/src/store/app_db.d.ts +10 -0
- package/dist/src/store/app_db.js +85 -0
- package/dist/src/store/cache_db.d.ts +4 -0
- package/dist/src/store/cache_db.js +63 -0
- package/dist/src/store/deliveries.d.ts +12 -0
- package/dist/src/store/deliveries.js +23 -0
- package/dist/src/store/drift.d.ts +4 -0
- package/dist/src/store/drift.js +22 -0
- package/dist/src/store/findings.d.ts +31 -0
- package/dist/src/store/findings.js +64 -0
- package/dist/src/store/installations.d.ts +9 -0
- package/dist/src/store/installations.js +35 -0
- package/dist/src/store/job_logs.d.ts +5 -0
- package/dist/src/store/job_logs.js +17 -0
- package/dist/src/store/jobs.d.ts +27 -0
- package/dist/src/store/jobs.js +86 -0
- package/dist/src/store/migrations.d.ts +5 -0
- package/dist/src/store/migrations.js +258 -0
- package/dist/src/store/remote_review_inputs.d.ts +11 -0
- package/dist/src/store/remote_review_inputs.js +25 -0
- package/dist/src/store/remote_tokens.d.ts +10 -0
- package/dist/src/store/remote_tokens.js +45 -0
- package/dist/src/store/replies.d.ts +24 -0
- package/dist/src/store/replies.js +79 -0
- package/dist/src/store/repos.d.ts +11 -0
- package/dist/src/store/repos.js +59 -0
- package/dist/src/store/reviews.d.ts +86 -0
- package/dist/src/store/reviews.js +220 -0
- package/dist/src/store/rows.d.ts +179 -0
- package/dist/src/store/rows.js +4 -0
- package/dist/src/store/sessions.d.ts +8 -0
- package/dist/src/store/sessions.js +34 -0
- package/dist/src/store/skill_state.d.ts +3 -0
- package/dist/src/store/skill_state.js +24 -0
- package/dist/src/store/sqlite.d.ts +39 -0
- package/dist/src/store/sqlite.js +77 -0
- package/dist/src/store/subjects.d.ts +15 -0
- package/dist/src/store/subjects.js +78 -0
- package/dist/src/tools/codegraph.d.ts +58 -0
- package/dist/src/tools/codegraph.js +165 -0
- package/dist/src/tools/codegraph_exec.d.ts +13 -0
- package/dist/src/tools/codegraph_exec.js +66 -0
- package/dist/src/types.d.ts +89 -0
- package/dist/src/types.js +1 -0
- package/dist/src/util/atomic.d.ts +4 -0
- package/dist/src/util/atomic.js +78 -0
- package/dist/src/util/keyed_lock.d.ts +3 -0
- package/dist/src/util/keyed_lock.js +33 -0
- package/dist/src/util/log.d.ts +11 -0
- package/dist/src/util/log.js +42 -0
- package/dist/src/util/redact.d.ts +5 -0
- package/dist/src/util/redact.js +30 -0
- package/dist/src/util/runtime.d.ts +90 -0
- package/dist/src/util/runtime.js +206 -0
- package/dist/src/util/time.d.ts +9 -0
- package/dist/src/util/time.js +36 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +5 -0
- package/package.json +54 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
/** Review jobs: run the model, persist findings, post to GitHub. */
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
import { createAiProvider } from "../ai/provider.js";
|
|
4
|
+
import { FakeAiProvider } from "../ai/fake.js";
|
|
5
|
+
import { AppClient, findInstallationForRepo } from "../github/app.js";
|
|
6
|
+
import { GitHubHttpError, isAccessDenied } from "../github/client.js";
|
|
7
|
+
import { parseFindings } from "../pr/findings.js";
|
|
8
|
+
import { anchorFor } from "../pr/hunks.js";
|
|
9
|
+
import { stripSuggestion, suggestionAnchor, withoutSuggestionLine, } from "../pr/suggestion.js";
|
|
10
|
+
import { githubPullRequestToRevision } from "../pr/github_revision.js";
|
|
11
|
+
import { anchorTextFromPatch, buildCarryPromptSection, classifyCarryItems, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
|
|
12
|
+
import { runReviewEngine } from "../review/engine.js";
|
|
13
|
+
import { loadGuides } from "../review/guides.js";
|
|
14
|
+
import { matchRepeat } from "../pr/rounds.js";
|
|
15
|
+
import { readConfig } from "../config.js";
|
|
16
|
+
import { outsideCode, redact } from "../util/redact.js";
|
|
17
|
+
import { getRepo, setInstallationId } from "../store/repos.js";
|
|
18
|
+
import { getReviewByJobId, insertReview, latestPostedReview, setReviewScope, setReviewStatus, } from "../store/reviews.js";
|
|
19
|
+
import { insertFinding, listCarryableFindingsForReview, listFindingsForPr, listFindingsForReview, setFindingPosted, } from "../store/findings.js";
|
|
20
|
+
import { getOrCreatePrSubject, getSubjectRevision, parseRevisionFiles, parseVisiblePaths, saveSubjectRevision, } from "../store/subjects.js";
|
|
21
|
+
import { enqueue, registerHandler } from "./jobs.js";
|
|
22
|
+
import { getEnv } from "../util/runtime.js";
|
|
23
|
+
const ACCESS_DENIED_BODY = "The App does not have access to review this pull request.";
|
|
24
|
+
export function humanCopy(text) {
|
|
25
|
+
return outsideCode(redact(text), (prose) => prose.replaceAll(";", "."));
|
|
26
|
+
}
|
|
27
|
+
function hasForbiddenCopy(text) {
|
|
28
|
+
return outsideCode(text, (prose) => prose.replaceAll(";", "")) !== text;
|
|
29
|
+
}
|
|
30
|
+
function severityOf(heading) {
|
|
31
|
+
return heading.match(/\bP[0-3]\b/)?.[0] ?? "P2";
|
|
32
|
+
}
|
|
33
|
+
function post(client, endpoint, body) {
|
|
34
|
+
if (!client.write) {
|
|
35
|
+
throw new Error("this GitHub client cannot post");
|
|
36
|
+
}
|
|
37
|
+
return client.write(endpoint, body);
|
|
38
|
+
}
|
|
39
|
+
export function splitInline(findings, files) {
|
|
40
|
+
const patches = new Map(files.map((file) => [
|
|
41
|
+
String(file.filename ?? ""),
|
|
42
|
+
String(file.patch ?? ""),
|
|
43
|
+
]));
|
|
44
|
+
const inline = [];
|
|
45
|
+
const leftover = [];
|
|
46
|
+
for (const finding of findings) {
|
|
47
|
+
const patch = patches.get(finding.path);
|
|
48
|
+
if (patch === undefined || finding.from <= 0) {
|
|
49
|
+
leftover.push(finding);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const fix = suggestionAnchor(finding, patch);
|
|
53
|
+
const anchor = fix ?? anchorFor(patch, finding.from, finding.to);
|
|
54
|
+
if (anchor)
|
|
55
|
+
inline.push({ finding, anchor, suggestion: fix !== undefined });
|
|
56
|
+
else
|
|
57
|
+
leftover.push(finding);
|
|
58
|
+
}
|
|
59
|
+
return { inline, leftover };
|
|
60
|
+
}
|
|
61
|
+
export function reviewEvent(findingsCount) {
|
|
62
|
+
return findingsCount > 0 ? "REQUEST_CHANGES" : "COMMENT";
|
|
63
|
+
}
|
|
64
|
+
export function formatDuration(ms) {
|
|
65
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
66
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
67
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
68
|
+
const seconds = totalSeconds % 60;
|
|
69
|
+
if (hours > 0)
|
|
70
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
71
|
+
if (minutes > 0)
|
|
72
|
+
return `${minutes}m ${seconds}s`;
|
|
73
|
+
return `${seconds}s`;
|
|
74
|
+
}
|
|
75
|
+
function metadataBlock({ jobId, model, durationMs }) {
|
|
76
|
+
return (`\n\n<details>\n<summary>Metadata</summary>\n\n` +
|
|
77
|
+
`- Activity: ${jobId}\n` +
|
|
78
|
+
`- co-maintainer version: ${VERSION}\n` +
|
|
79
|
+
`- Model: ${model}\n` +
|
|
80
|
+
`- Duration: ${formatDuration(durationMs)}\n` +
|
|
81
|
+
`</details>`);
|
|
82
|
+
}
|
|
83
|
+
/** Short summary on the review itself. Findings that map to the diff go
|
|
84
|
+
* inline. Leftovers are listed here as one line each, not as a CLI dump. */
|
|
85
|
+
export function reviewBody(findings, metadata, _inlineCount = 0) {
|
|
86
|
+
if (findings.length === 0) {
|
|
87
|
+
return `No actionable findings.${metadataBlock(metadata)}`;
|
|
88
|
+
}
|
|
89
|
+
const lines = ["Review summary", ""];
|
|
90
|
+
for (const finding of findings) {
|
|
91
|
+
const title = humanCopy(finding.heading || finding.path);
|
|
92
|
+
const summary = humanCopy(finding.summary ??
|
|
93
|
+
finding.excerpt.split(/\r?\n/).find((line) => line.trim()) ??
|
|
94
|
+
"");
|
|
95
|
+
lines.push(`- ${title}${summary ? `: ${summary}` : ""}`);
|
|
96
|
+
}
|
|
97
|
+
return lines.join("\n") + metadataBlock(metadata);
|
|
98
|
+
}
|
|
99
|
+
function inlineCommentBody(finding, suggestion = false) {
|
|
100
|
+
const heading = humanCopy(finding.heading);
|
|
101
|
+
const excerpt = humanCopy(suggestion
|
|
102
|
+
? withoutSuggestionLine(finding.excerpt)
|
|
103
|
+
: stripSuggestion(finding.excerpt));
|
|
104
|
+
const marker = `${finding.path}:${finding.from}`;
|
|
105
|
+
const at = excerpt.indexOf(marker);
|
|
106
|
+
const rest = at >= 0 ? excerpt.slice(at + marker.length).trim() : excerpt.trim();
|
|
107
|
+
if (!rest)
|
|
108
|
+
return heading;
|
|
109
|
+
return `${heading}\n\n${rest}`;
|
|
110
|
+
}
|
|
111
|
+
export function reviewOptions(repo, prNumber) {
|
|
112
|
+
const config = readConfig();
|
|
113
|
+
return {
|
|
114
|
+
command: "review",
|
|
115
|
+
repo,
|
|
116
|
+
prNumber,
|
|
117
|
+
debug: false,
|
|
118
|
+
logTime: false,
|
|
119
|
+
improveMatrix: 1,
|
|
120
|
+
ghConcurrent: 1,
|
|
121
|
+
aiConcurrent: 1,
|
|
122
|
+
auth: "gh",
|
|
123
|
+
ai: config.ai && config.ai !== "none" ? config.ai : "openrouter",
|
|
124
|
+
aiToken: config.token,
|
|
125
|
+
highModel: config.highModel,
|
|
126
|
+
lowModel: config.lowModel,
|
|
127
|
+
synthesisVersion: 16,
|
|
128
|
+
includeCodebase: true,
|
|
129
|
+
includePullRequests: true,
|
|
130
|
+
includePullRequestChanges: true,
|
|
131
|
+
includeCommitHistory: true,
|
|
132
|
+
includeHowRepoWorks: true,
|
|
133
|
+
useCodegraph: getRepo(repo)?.use_codegraph === 1,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function aiFor(options) {
|
|
137
|
+
if (getEnv("CM_FAKE_AI") === "1")
|
|
138
|
+
return new FakeAiProvider();
|
|
139
|
+
const provider = createAiProvider(options, options.highModel ?? "");
|
|
140
|
+
if (!provider) {
|
|
141
|
+
throw new Error("review needs an AI provider. run: co-maintainer set --ai=openrouter --token=...");
|
|
142
|
+
}
|
|
143
|
+
return provider;
|
|
144
|
+
}
|
|
145
|
+
export function clientFor(installationId) {
|
|
146
|
+
const config = readConfig();
|
|
147
|
+
if (!config.githubAppId || !config.githubAppPrivateKey) {
|
|
148
|
+
throw new Error("review jobs need the GitHub App configured");
|
|
149
|
+
}
|
|
150
|
+
return new AppClient(config.githubAppId, config.githubAppPrivateKey, installationId);
|
|
151
|
+
}
|
|
152
|
+
export async function resolveInstallationId(fullName) {
|
|
153
|
+
const current = getRepo(fullName)?.installation_id;
|
|
154
|
+
if (current)
|
|
155
|
+
return current;
|
|
156
|
+
const config = readConfig();
|
|
157
|
+
if (!config.githubAppId || !config.githubAppPrivateKey)
|
|
158
|
+
return undefined;
|
|
159
|
+
const installationId = await findInstallationForRepo(config.githubAppId, config.githubAppPrivateKey, fullName);
|
|
160
|
+
if (installationId !== undefined)
|
|
161
|
+
setInstallationId(fullName, installationId);
|
|
162
|
+
return installationId;
|
|
163
|
+
}
|
|
164
|
+
async function denyAccess(client, job, reviewId, log, checkRunId) {
|
|
165
|
+
log("info", "pull request files were denied, posting one issue comment");
|
|
166
|
+
setReviewStatus(reviewId, "posting");
|
|
167
|
+
const posted = await post(client, `repos/${job.repo}/issues/${job.pr_number}/comments`, {
|
|
168
|
+
body: ACCESS_DENIED_BODY,
|
|
169
|
+
});
|
|
170
|
+
setReviewStatus(reviewId, "access_denied", {
|
|
171
|
+
posted_review_id: String(posted.id ?? ""),
|
|
172
|
+
posted_fallback: 1,
|
|
173
|
+
});
|
|
174
|
+
await finishCheck(client, job, checkRunId, "failure", "Review could not access the pull request files.", [], log);
|
|
175
|
+
}
|
|
176
|
+
function checkAnnotations(findings) {
|
|
177
|
+
return findings
|
|
178
|
+
.filter((finding) => finding.path)
|
|
179
|
+
.slice(0, 50)
|
|
180
|
+
.map((finding) => {
|
|
181
|
+
const startLine = Math.max(1, finding.line_from ?? finding.line_to ?? 1);
|
|
182
|
+
const endLine = Math.max(startLine, finding.line_to ?? startLine);
|
|
183
|
+
return {
|
|
184
|
+
path: finding.path,
|
|
185
|
+
start_line: startLine,
|
|
186
|
+
end_line: endLine,
|
|
187
|
+
start_column: 1,
|
|
188
|
+
end_column: 1,
|
|
189
|
+
annotation_level: finding.severity === "P1"
|
|
190
|
+
? "failure"
|
|
191
|
+
: finding.severity === "P2"
|
|
192
|
+
? "warning"
|
|
193
|
+
: "notice",
|
|
194
|
+
message: humanCopy(stripSuggestion(finding.body_md)),
|
|
195
|
+
title: humanCopy(finding.title),
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function startCheck(client, job, reviewId, headSha, log) {
|
|
200
|
+
if (!client.createCheckRun)
|
|
201
|
+
return undefined;
|
|
202
|
+
try {
|
|
203
|
+
const check = await client.createCheckRun(`repos/${job.repo}/check-runs`, {
|
|
204
|
+
name: "co-maintainer review",
|
|
205
|
+
head_sha: headSha,
|
|
206
|
+
status: "queued",
|
|
207
|
+
external_id: reviewId,
|
|
208
|
+
});
|
|
209
|
+
const id = String(check.id ?? "");
|
|
210
|
+
if (!id) {
|
|
211
|
+
log("info", "GitHub Check Run did not return an id");
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
setReviewStatus(reviewId, "drafting", { check_run_id: id });
|
|
215
|
+
return id;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
log("info", `GitHub Check Run unavailable: ${redact(String(error))}`);
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function updateCheck(client, job, checkRunId, body, log) {
|
|
223
|
+
if (!checkRunId || !client.updateCheckRun)
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
await client.updateCheckRun(`repos/${job.repo}/check-runs/${checkRunId}`, body);
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
log("info", `GitHub Check Run update failed: ${redact(String(error))}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function finishCheck(client, job, checkRunId, conclusion, title, findings, log) {
|
|
233
|
+
await updateCheck(client, job, checkRunId, {
|
|
234
|
+
status: "completed",
|
|
235
|
+
conclusion,
|
|
236
|
+
completed_at: new Date().toISOString(),
|
|
237
|
+
output: {
|
|
238
|
+
title: humanCopy(title),
|
|
239
|
+
summary: humanCopy(findings.length === 0
|
|
240
|
+
? "No actionable findings."
|
|
241
|
+
: `${findings.length} finding(s) reported.`),
|
|
242
|
+
annotations: checkAnnotations(findings),
|
|
243
|
+
},
|
|
244
|
+
}, log);
|
|
245
|
+
}
|
|
246
|
+
async function publish(client, job, reviewId, headSha, files, log, metadata) {
|
|
247
|
+
const stored = listFindingsForReview(reviewId);
|
|
248
|
+
const replies = stored.filter((row) => row.thread_comment_id);
|
|
249
|
+
const freshRows = stored.filter((row) => row.state === "new" && !row.thread_comment_id);
|
|
250
|
+
const fresh = freshRows.map((row) => ({
|
|
251
|
+
path: row.path ?? "",
|
|
252
|
+
from: row.line_from ?? 0,
|
|
253
|
+
to: row.line_to ?? row.line_from ?? 0,
|
|
254
|
+
heading: row.title,
|
|
255
|
+
excerpt: row.body_md,
|
|
256
|
+
}));
|
|
257
|
+
const { inline, leftover } = splitInline(fresh, files);
|
|
258
|
+
const anchorByRow = new Map();
|
|
259
|
+
for (const { finding, anchor } of inline) {
|
|
260
|
+
const row = freshRows.find((item) => item.path === finding.path &&
|
|
261
|
+
item.line_from === finding.from &&
|
|
262
|
+
item.line_to === finding.to);
|
|
263
|
+
if (row)
|
|
264
|
+
anchorByRow.set(row.id, anchor);
|
|
265
|
+
}
|
|
266
|
+
const freshSummaryKeys = new Set([...leftover, ...inline.map(({ finding }) => finding)].map((finding) => `${finding.path}\0${finding.from}\0${finding.to}`));
|
|
267
|
+
const summaryFindings = [
|
|
268
|
+
...leftover,
|
|
269
|
+
...inline.map(({ finding }) => finding),
|
|
270
|
+
...stored
|
|
271
|
+
.filter((row) => row.state !== "closed")
|
|
272
|
+
.filter((row) => {
|
|
273
|
+
const key = `${row.path ?? ""}\0${row.line_from ?? 0}\0${row.line_to ?? row.line_from ?? 0}`;
|
|
274
|
+
return !freshSummaryKeys.has(key);
|
|
275
|
+
})
|
|
276
|
+
.map((row) => ({
|
|
277
|
+
path: row.path ?? "",
|
|
278
|
+
from: row.line_from ?? 0,
|
|
279
|
+
to: row.line_to ?? row.line_from ?? 0,
|
|
280
|
+
heading: row.title,
|
|
281
|
+
excerpt: row.body_md,
|
|
282
|
+
})),
|
|
283
|
+
];
|
|
284
|
+
const body = reviewBody(summaryFindings, metadata, inline.length + replies.length);
|
|
285
|
+
const comments = inline.map(({ finding, anchor, suggestion }) => ({
|
|
286
|
+
path: finding.path,
|
|
287
|
+
body: inlineCommentBody(finding, suggestion),
|
|
288
|
+
line: anchor.line,
|
|
289
|
+
side: "RIGHT",
|
|
290
|
+
...(anchor.start_line === undefined
|
|
291
|
+
? {}
|
|
292
|
+
: { start_line: anchor.start_line, start_side: "RIGHT" }),
|
|
293
|
+
}));
|
|
294
|
+
if (hasForbiddenCopy(body) ||
|
|
295
|
+
hasForbiddenCopy(ACCESS_DENIED_BODY) ||
|
|
296
|
+
comments.some((comment) => hasForbiddenCopy(comment.body))) {
|
|
297
|
+
throw new Error("review body contains forbidden copy characters");
|
|
298
|
+
}
|
|
299
|
+
for (const row of replies) {
|
|
300
|
+
const text = inlineCommentBody({
|
|
301
|
+
path: row.path ?? "",
|
|
302
|
+
from: row.line_from ?? 0,
|
|
303
|
+
to: row.line_to ?? row.line_from ?? 0,
|
|
304
|
+
heading: row.title,
|
|
305
|
+
excerpt: row.body_md,
|
|
306
|
+
});
|
|
307
|
+
const posted = await post(client, `repos/${job.repo}/pulls/${job.pr_number}/comments`, {
|
|
308
|
+
body: text,
|
|
309
|
+
in_reply_to: Number(row.thread_comment_id),
|
|
310
|
+
});
|
|
311
|
+
setFindingPosted(row.id, String(posted.id ?? ""), row.thread_comment_id ?? undefined);
|
|
312
|
+
}
|
|
313
|
+
const payload = {
|
|
314
|
+
commit_id: headSha,
|
|
315
|
+
body,
|
|
316
|
+
event: reviewEvent(stored.length),
|
|
317
|
+
comments,
|
|
318
|
+
};
|
|
319
|
+
setReviewStatus(reviewId, "posting");
|
|
320
|
+
try {
|
|
321
|
+
const posted = await post(client, `repos/${job.repo}/pulls/${job.pr_number}/reviews`, payload);
|
|
322
|
+
setReviewStatus(reviewId, "posted", {
|
|
323
|
+
posted_review_id: String(posted.id ?? ""),
|
|
324
|
+
findings_count: stored.length,
|
|
325
|
+
duration_ms: metadata.durationMs,
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
const listed = await client.pages(`repos/${job.repo}/pulls/${job.pr_number}/comments`);
|
|
329
|
+
for (const row of stored) {
|
|
330
|
+
const anchor = anchorByRow.get(row.id);
|
|
331
|
+
if (row.posted_comment_id || !row.path || !anchor)
|
|
332
|
+
continue;
|
|
333
|
+
const hit = listed.find((item) => String(item.path ?? "") === row.path &&
|
|
334
|
+
Number(item.line ?? item.original_line ?? 0) === anchor.line);
|
|
335
|
+
if (hit?.id)
|
|
336
|
+
setFindingPosted(row.id, String(hit.id));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Comment ids are used for later replies. Missing them does not
|
|
341
|
+
// fail the review that already posted.
|
|
342
|
+
}
|
|
343
|
+
log("info", `posted review ${posted.id}`);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
if (error instanceof GitHubHttpError && error.status === 422) {
|
|
347
|
+
log("info", "inline comments rejected, falling back to an issue comment");
|
|
348
|
+
const posted = await post(client, `repos/${job.repo}/issues/${job.pr_number}/comments`, {
|
|
349
|
+
body: reviewBody(summaryFindings, metadata),
|
|
350
|
+
});
|
|
351
|
+
setReviewStatus(reviewId, "posted", {
|
|
352
|
+
posted_review_id: String(posted.id ?? ""),
|
|
353
|
+
findings_count: stored.length,
|
|
354
|
+
posted_fallback: 1,
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
setReviewStatus(reviewId, "failed");
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
export async function reconcileReview(job, log, client) {
|
|
363
|
+
const review = getReviewByJobId(job.id);
|
|
364
|
+
if (!review || review.status === "drafting" || review.status === "failed") {
|
|
365
|
+
await runReviewJob(job, log, client);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (review.status === "posted" || review.status === "access_denied") {
|
|
369
|
+
log("info", `already ${review.status}, nothing to post`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (review.status !== "posting" || job.pr_number === null) {
|
|
373
|
+
log("info", `unhandled status ${review.status}, not posting again`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const listed = await client.pages(`repos/${job.repo}/pulls/${job.pr_number}/reviews`);
|
|
377
|
+
const match = listed.find((item) => {
|
|
378
|
+
const user = item.user;
|
|
379
|
+
return (String(item.commit_id ?? "") === review.head_sha &&
|
|
380
|
+
String(user?.type ?? "") === "Bot");
|
|
381
|
+
});
|
|
382
|
+
if (match) {
|
|
383
|
+
setReviewStatus(review.id, "posted", {
|
|
384
|
+
posted_review_id: String(match.id ?? ""),
|
|
385
|
+
});
|
|
386
|
+
log("info", `reconciled existing GitHub review ${match.id}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
setReviewStatus(review.id, "failed");
|
|
390
|
+
log("info", "posting crashed and no matching GitHub review was found");
|
|
391
|
+
}
|
|
392
|
+
export async function runReviewJob(job, log, client, ai) {
|
|
393
|
+
try {
|
|
394
|
+
await runReviewJobCore(job, log, client, ai);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
const review = getReviewByJobId(job.id);
|
|
398
|
+
let github = client;
|
|
399
|
+
if (!github && review?.check_run_id) {
|
|
400
|
+
const repo = getRepo(job.repo);
|
|
401
|
+
if (repo?.installation_id) {
|
|
402
|
+
try {
|
|
403
|
+
github = clientFor(repo.installation_id);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// The original review error is more useful to the job log.
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (github && review?.check_run_id) {
|
|
411
|
+
await finishCheck(github, job, review.check_run_id, "failure", "Review job failed.", listFindingsForReview(review.id), log);
|
|
412
|
+
}
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function runReviewJobCore(job, log, client, ai) {
|
|
417
|
+
const startedAt = Date.now();
|
|
418
|
+
if (job.pr_number === null) {
|
|
419
|
+
throw new Error("review job is missing a pull request number");
|
|
420
|
+
}
|
|
421
|
+
const repo = getRepo(job.repo);
|
|
422
|
+
const installationId = repo?.installation_id ?? (await resolveInstallationId(job.repo));
|
|
423
|
+
if (!client && installationId === undefined) {
|
|
424
|
+
throw new Error(`no GitHub App installation stored for ${job.repo}`);
|
|
425
|
+
}
|
|
426
|
+
const github = client ?? clientFor(installationId);
|
|
427
|
+
const existing = getReviewByJobId(job.id);
|
|
428
|
+
if (existing &&
|
|
429
|
+
(existing.status === "posting" ||
|
|
430
|
+
existing.status === "posted" ||
|
|
431
|
+
existing.status === "access_denied")) {
|
|
432
|
+
await reconcileReview(job, log, github);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const options = reviewOptions(job.repo, job.pr_number);
|
|
436
|
+
const args = JSON.parse(job.args || "{}");
|
|
437
|
+
const pr = await github.request(`repos/${job.repo}/pulls/${job.pr_number}`);
|
|
438
|
+
const headSha = String(pr.head?.sha ?? "");
|
|
439
|
+
const mergeBase = String(pr.base?.sha ?? "");
|
|
440
|
+
let scope = args.scope === "incremental" ? "incremental" : "whole-pr";
|
|
441
|
+
if (scope === "incremental" && !args.sinceCommit) {
|
|
442
|
+
scope = "whole-pr";
|
|
443
|
+
}
|
|
444
|
+
let reviewBase = scope === "incremental" && args.sinceCommit ? args.sinceCommit : mergeBase;
|
|
445
|
+
const subject = getOrCreatePrSubject(job.repo, job.pr_number);
|
|
446
|
+
const reviewId = existing?.id ?? crypto.randomUUID();
|
|
447
|
+
if (!existing) {
|
|
448
|
+
insertReview({
|
|
449
|
+
id: reviewId,
|
|
450
|
+
repo: job.repo,
|
|
451
|
+
prNumber: job.pr_number,
|
|
452
|
+
jobId: job.id,
|
|
453
|
+
headSha,
|
|
454
|
+
baseSha: reviewBase,
|
|
455
|
+
scope,
|
|
456
|
+
model: options.highModel ?? "unknown",
|
|
457
|
+
trigger: args.trigger,
|
|
458
|
+
round: args.round ?? 1,
|
|
459
|
+
subjectId: subject.id,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
let checkRunId = existing?.check_run_id;
|
|
463
|
+
if (!checkRunId) {
|
|
464
|
+
checkRunId = await startCheck(github, job, reviewId, headSha, log);
|
|
465
|
+
}
|
|
466
|
+
let snapshot;
|
|
467
|
+
let files = [];
|
|
468
|
+
let publishFiles = [];
|
|
469
|
+
try {
|
|
470
|
+
if (scope === "incremental" && args.sinceCommit) {
|
|
471
|
+
try {
|
|
472
|
+
const compare = await github.request(`repos/${job.repo}/compare/${args.sinceCommit}...${headSha}`);
|
|
473
|
+
files = compare.files ?? [];
|
|
474
|
+
snapshot = {
|
|
475
|
+
base: args.sinceCommit,
|
|
476
|
+
commit: headSha,
|
|
477
|
+
before: "9999-12-31T23:59:59.999Z",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
if (isAccessDenied(error) ||
|
|
482
|
+
(error instanceof GitHubHttpError &&
|
|
483
|
+
(error.status === 403 || error.status === 404))) {
|
|
484
|
+
log("info", "incremental compare unavailable, reviewing whole PR");
|
|
485
|
+
scope = "whole-pr";
|
|
486
|
+
reviewBase = mergeBase;
|
|
487
|
+
snapshot = undefined;
|
|
488
|
+
files = await github.pages(`repos/${job.repo}/pulls/${job.pr_number}/files`);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
files = await github.pages(`repos/${job.repo}/pulls/${job.pr_number}/files`);
|
|
497
|
+
}
|
|
498
|
+
publishFiles = snapshot
|
|
499
|
+
? await github.pages(`repos/${job.repo}/pulls/${job.pr_number}/files`)
|
|
500
|
+
: files;
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (isAccessDenied(error)) {
|
|
504
|
+
await denyAccess(github, job, reviewId, log, checkRunId);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
throw error;
|
|
508
|
+
}
|
|
509
|
+
if (!existing) {
|
|
510
|
+
setReviewScope(reviewId, scope, reviewBase);
|
|
511
|
+
}
|
|
512
|
+
await updateCheck(github, job, checkRunId, {
|
|
513
|
+
status: "in_progress",
|
|
514
|
+
started_at: new Date().toISOString(),
|
|
515
|
+
}, log);
|
|
516
|
+
log("info", `reviewing ${job.repo}#${job.pr_number}`);
|
|
517
|
+
const baseLabel = `origin/${String(pr.base?.ref ?? "main")}`;
|
|
518
|
+
const revision = githubPullRequestToRevision(publishFiles, pr, baseLabel);
|
|
519
|
+
const guidesBefore = await loadGuides(job.repo);
|
|
520
|
+
let guideBuiltAt = guidesBefore.guideBuiltAt;
|
|
521
|
+
const subjectRevision = getSubjectRevision(subject.id);
|
|
522
|
+
let carryItems = [];
|
|
523
|
+
let carryPrevious = null;
|
|
524
|
+
const extras = {};
|
|
525
|
+
if (subjectRevision) {
|
|
526
|
+
const prevFiles = parseRevisionFiles(subjectRevision);
|
|
527
|
+
const { unchanged } = incrementalDiffPaths(revision, prevFiles);
|
|
528
|
+
extras.unchangedPaths = scope === "incremental" ? unchanged : undefined;
|
|
529
|
+
const storedRows = listCarryableFindingsForReview(subjectRevision.review_id);
|
|
530
|
+
carryPrevious = {
|
|
531
|
+
files: prevFiles,
|
|
532
|
+
visiblePaths: parseVisiblePaths(subjectRevision),
|
|
533
|
+
findings: storedRows.map((row) => ({
|
|
534
|
+
id: row.id,
|
|
535
|
+
path: row.path,
|
|
536
|
+
lineFrom: row.line_from,
|
|
537
|
+
lineTo: row.line_to,
|
|
538
|
+
title: row.title,
|
|
539
|
+
bodyMd: row.body_md,
|
|
540
|
+
anchorText: row.anchor_text,
|
|
541
|
+
severity: row.severity,
|
|
542
|
+
firstSeenReviewId: row.first_seen_review_id ?? subjectRevision.review_id,
|
|
543
|
+
})),
|
|
544
|
+
guideBuiltAt: subjectRevision.guide_built_at,
|
|
545
|
+
};
|
|
546
|
+
carryItems = classifyCarryItems(carryPrevious, revision, carryPrevious.visiblePaths, guidesBefore.guideBuiltAt);
|
|
547
|
+
extras.carryPrompt = buildCarryPromptSection(carryItems, revision);
|
|
548
|
+
}
|
|
549
|
+
const response = await runReviewEngine(github, options, undefined, snapshot, ai ?? aiFor(options), (message) => log("info", message), extras);
|
|
550
|
+
guideBuiltAt = response.guideBuiltAt;
|
|
551
|
+
const visiblePaths = new Set(response.visiblePaths);
|
|
552
|
+
if (carryPrevious) {
|
|
553
|
+
carryItems = classifyCarryItems(carryPrevious, revision, visiblePaths, guideBuiltAt);
|
|
554
|
+
}
|
|
555
|
+
const parsed = parseFindings(response.text);
|
|
556
|
+
log("info", `parsed ${parsed.length} new finding(s) from the AI response`);
|
|
557
|
+
let totalFindings = parsed.length;
|
|
558
|
+
let openCount = 0;
|
|
559
|
+
let closedCount = 0;
|
|
560
|
+
if (carryPrevious) {
|
|
561
|
+
const postedById = new Map(listCarryableFindingsForReview(subjectRevision.review_id).map((row) => [
|
|
562
|
+
row.id,
|
|
563
|
+
row.posted_comment_id,
|
|
564
|
+
]));
|
|
565
|
+
const verdicts = parsePreviousVerdicts(response.text);
|
|
566
|
+
const resolved = resolveCarryOutcomes(carryItems, revision, visiblePaths, verdicts, parsed, guideBuiltAt, carryPrevious);
|
|
567
|
+
totalFindings = resolved.length;
|
|
568
|
+
for (const row of resolved) {
|
|
569
|
+
if (row.state === "open")
|
|
570
|
+
openCount++;
|
|
571
|
+
if (row.state === "closed")
|
|
572
|
+
closedCount++;
|
|
573
|
+
const patch = revision.files.find((file) => file.path === row.path)?.patch ?? "";
|
|
574
|
+
insertFinding({
|
|
575
|
+
id: row.id,
|
|
576
|
+
reviewId,
|
|
577
|
+
severity: row.severity,
|
|
578
|
+
path: row.path ?? undefined,
|
|
579
|
+
lineFrom: row.lineFrom ?? undefined,
|
|
580
|
+
lineTo: row.lineTo ?? undefined,
|
|
581
|
+
title: row.title,
|
|
582
|
+
bodyMd: redact(row.bodyMd),
|
|
583
|
+
anchorText: row.anchorText ??
|
|
584
|
+
(row.path && row.lineFrom
|
|
585
|
+
? anchorTextFromPatch(patch, row.lineFrom, row.lineTo ?? row.lineFrom)
|
|
586
|
+
: null),
|
|
587
|
+
state: row.state,
|
|
588
|
+
carriedFromFindingId: row.carriedFromId,
|
|
589
|
+
closeReason: row.closeReason ?? undefined,
|
|
590
|
+
firstSeenReviewId: row.firstSeenReviewId ?? undefined,
|
|
591
|
+
threadCommentId: row.state === "open" && row.carriedFromId
|
|
592
|
+
? (postedById.get(row.carriedFromId) ?? undefined)
|
|
593
|
+
: undefined,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
const previous = listFindingsForPr(job.repo, job.pr_number).filter((row) => row.review_id !== reviewId);
|
|
599
|
+
for (const finding of parsed) {
|
|
600
|
+
const repeat = matchRepeat(finding, previous);
|
|
601
|
+
insertFinding({
|
|
602
|
+
id: crypto.randomUUID(),
|
|
603
|
+
reviewId,
|
|
604
|
+
severity: finding.severity ?? severityOf(finding.heading),
|
|
605
|
+
path: finding.path,
|
|
606
|
+
lineFrom: finding.from,
|
|
607
|
+
lineTo: finding.to,
|
|
608
|
+
title: finding.heading || finding.path,
|
|
609
|
+
bodyMd: redact(finding.excerpt),
|
|
610
|
+
state: "new",
|
|
611
|
+
firstSeenReviewId: repeat
|
|
612
|
+
? (repeat.first_seen_review_id ?? repeat.review_id)
|
|
613
|
+
: undefined,
|
|
614
|
+
threadCommentId: repeat?.posted_comment_id ?? undefined,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
setReviewStatus(reviewId, "drafting", {
|
|
619
|
+
findings_count: totalFindings,
|
|
620
|
+
open_count: openCount,
|
|
621
|
+
closed_count: closedCount,
|
|
622
|
+
tokens_in: response.tokensIn,
|
|
623
|
+
tokens_out: response.tokensOut,
|
|
624
|
+
cost: response.cost,
|
|
625
|
+
});
|
|
626
|
+
saveSubjectRevision({
|
|
627
|
+
subjectId: subject.id,
|
|
628
|
+
reviewId,
|
|
629
|
+
files: revision.files,
|
|
630
|
+
visiblePaths: [...visiblePaths],
|
|
631
|
+
guideBuiltAt,
|
|
632
|
+
});
|
|
633
|
+
log("info", `publishing ${totalFindings} finding(s) to GitHub`);
|
|
634
|
+
await publish(github, job, reviewId, headSha, publishFiles, log, {
|
|
635
|
+
jobId: job.id,
|
|
636
|
+
model: options.highModel ?? "unknown",
|
|
637
|
+
durationMs: Date.now() - startedAt,
|
|
638
|
+
});
|
|
639
|
+
await finishCheck(github, job, checkRunId, totalFindings === 0 ? "success" : "neutral", totalFindings === 0 ? "Review completed" : "Review findings", listFindingsForReview(reviewId), log);
|
|
640
|
+
}
|
|
641
|
+
export function registerReviewJobHandler() {
|
|
642
|
+
registerHandler("review", {
|
|
643
|
+
run(job, log, _signal) {
|
|
644
|
+
return runReviewJob(job, log);
|
|
645
|
+
},
|
|
646
|
+
reconcile(job, log) {
|
|
647
|
+
return resolveInstallationId(job.repo).then((installationId) => {
|
|
648
|
+
if (installationId === undefined) {
|
|
649
|
+
throw new Error(`no GitHub App installation stored for ${job.repo}`);
|
|
650
|
+
}
|
|
651
|
+
return reconcileReview(job, log, clientFor(installationId));
|
|
652
|
+
});
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
export function enqueueManualReview(repo, prNumber) {
|
|
657
|
+
const last = latestPostedReview(repo, prNumber);
|
|
658
|
+
const row = getRepo(repo);
|
|
659
|
+
const incremental = row?.review_scope === "incremental" && Boolean(last?.head_sha);
|
|
660
|
+
return enqueue({
|
|
661
|
+
type: "review",
|
|
662
|
+
repo,
|
|
663
|
+
prNumber,
|
|
664
|
+
args: {
|
|
665
|
+
trigger: "manual",
|
|
666
|
+
scope: incremental ? "incremental" : "whole-pr",
|
|
667
|
+
sinceCommit: incremental ? last.head_sha : undefined,
|
|
668
|
+
round: last ? last.round + 1 : 1,
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
}
|