Repository navigation
Expand file tree
/
Copy pathfirst-pass.mjs
More file actions
249 lines (234 loc) · 9 KB
/
Copy pathfirst-pass.mjs
File metadata and controls
249 lines (234 loc) · 9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// Controlled, comment-only first pass for new issues and external pull requests.
// Public text remains untrusted: this fixed runner has no agent tools, executes
// no contributor code, reads only validated base-repository text, and posts at
// most one comment. Model output can still be wrong and is never authoritative.
import {
closeSync,
constants,
fstatSync,
openSync,
readSync,
} from 'node:fs';
import { resolve } from 'node:path';
import {
createRepoReadSession,
formatAutomatedComment,
formatFirstPassFallback,
hasTrustedFirstPass,
parseFirstPassResponse,
resolveFirstPassCandidates,
resolveGeneratorAttribution,
validateFirstPassEnvironment,
} from './first-pass-lib.mjs';
const TRANSPORT = 'anthropic-messages';
const REPO_ROOT = resolve(process.cwd());
const env = process.env;
const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '');
const githubApi = (env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, '');
const model = env.CCU_BOT_MODEL || 'deepseek-flash';
const modelPro = env.CCU_BOT_MODEL_PRO || 'deepseek-v4-pro';
const fail = (message) => {
console.error(message);
process.exit(1);
};
let preflight;
let cheapGenerator;
let proGenerator;
try {
preflight = validateFirstPassEnvironment(env);
cheapGenerator = resolveGeneratorAttribution(env.CCU_BOT_GENERATOR, model, TRANSPORT);
proGenerator = resolveGeneratorAttribution(env.CCU_BOT_GENERATOR_PRO, modelPro, TRANSPORT);
} catch (error) {
fail(`Invalid first-pass configuration: ${error.message}`);
}
const { isPr, itemNumber: num, owner, repo } = preflight;
const kind = isPr ? 'review' : 'reply';
const repoReader = createRepoReadSession({ repoRoot: REPO_ROOT });
const docs = repoReader.read([
'AGENTS.md', // rendered in the prompt as "# AGENTS.md"
'ARCHITECTURE.md',
'CLAUDE.md',
'CONTRIBUTING.md',
]).text;
function readBoundedFixedFile(path, maxBytes) {
let descriptor;
try {
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
if (!fstatSync(descriptor).isFile()) return '';
const buffer = Buffer.alloc(maxBytes);
const bytesRead = readSync(descriptor, buffer, 0, maxBytes, 0);
const bytes = buffer.subarray(0, bytesRead);
const decoder = new TextDecoder('utf-8', { fatal: true });
for (let end = bytes.length; end >= Math.max(0, bytes.length - 4); end -= 1) {
try {
return decoder.decode(bytes.subarray(0, end));
} catch {
// Backtrack if the byte cap split the final UTF-8 code point.
}
}
return '';
} catch {
return '';
} finally {
if (descriptor !== undefined) {
try {
closeSync(descriptor);
} catch {
// The caller fails closed on an empty result.
}
}
}
}
let diff = '';
if (isPr) {
diff = readBoundedFixedFile(preflight.diffFile, 40_000).trim();
if (!diff) fail('Pull request diff is missing or empty');
}
const buildSystem = (final) => [
'You are the ClaudeCodeUsage repository assistant.',
`Write one concise, concrete first-pass ${kind} for the ${isPr ? 'pull request' : 'issue'} below.`,
isPr
? '- Focus on correctness risks, convention/i18n/CHANGELOG gaps, and merge-readiness grounded in the diff and supplied base-repository text.'
: '- Identify the request, where it relates to the architecture, and a concrete direction or a specific clarifying question.',
'- Answer only from the supplied material. Never invent code facts.',
'- Reply in English by default. Only when the author wrote in Chinese, reply bilingually with English first and a Chinese translation under each section. Never reply in Chinese only. Be specific and concise.',
'- Return body markdown only inside <reply>; code will add the trusted header and attribution.',
'- Use English headings: **TL;DR**, **Analysis**, and **Suggested next step(s)** when they help scanning. For a Chinese author, add the translated body under each English section.',
final
? '- If the answer is still unknown, say so and request the exact missing reproduction details, logs, versions, or configuration.'
: '- If more base-repository source is required, request only the smallest relevant allowlisted files in the control block.',
'',
'Before the reply, emit exactly one control line and nothing before it:',
'<control>{"answerable": <true if the supplied material is enough, false if source files are required>, "want_files": [<up to 6 repo-relative paths>]}</control>',
'<reply>',
'...body markdown...',
'</reply>',
].join('\n');
const buildUser = (extraFiles) =>
(isPr
? `PR #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no description)'}\n\n--- DIFF (truncated) ---\n${diff}`
: `Issue #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no body)'}`) +
`\n\n--- PROJECT DOCS ---\n${docs}` +
(extraFiles ? `\n\n--- REPO SOURCE FILES (read-only) ---\n${extraFiles}` : '');
async function askModel(useModel, generator, system, userText, think = false) {
const body = {
model: useModel,
max_tokens: think ? 8192 : 1800,
system,
messages: [{ role: 'user', content: userText }],
};
// DeepSeek defaults to reasoning even on the cheap tier. Reserve that tier's
// output budget for the actual reply; the pro tier may reason first.
if (generator.id === 'deepseek') {
body.thinking = { type: think ? 'enabled' : 'disabled' };
} else if (think) {
body.thinking = { type: 'enabled', budget_tokens: 6000 };
}
let response = await fetch(`${base}/v1/messages`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
if (!response.ok && think) {
const errorText = await response.text();
if (/think|budget|adaptive|reason/i.test(errorText)) {
delete body.thinking;
response = await fetch(`${base}/v1/messages`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
} else {
throw Object.assign(new Error('Model API request failed'), { status: response.status });
}
}
if (!response.ok) {
throw Object.assign(new Error('Model API request failed'), { status: response.status });
}
const data = await response.json();
return (data.content || [])
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('')
.trim();
}
const commentsUrl = `${githubApi}/repos/${owner}/${repo}/issues/${num}/comments`;
const githubHeaders = {
authorization: `Bearer ${env.GH_TOKEN}`,
accept: 'application/vnd.github+json',
'content-type': 'application/json',
'user-agent': 'ccu-bot',
};
try {
const response = await fetch(`${commentsUrl}?per_page=100`, { headers: githubHeaders });
if (!response.ok) {
fail(`Comment lookup failed ${response.status}: ${(await response.text()).slice(0, 300)}`);
}
if (hasTrustedFirstPass(await response.json(), kind)) {
console.log(`First-pass ${kind} already exists on #${num}; skipping duplicate.`);
process.exit(0);
}
} catch (error) {
fail(`Comment lookup failed: ${error.message}`);
}
const modelFailures = [];
const selected = await resolveFirstPassCandidates({
cheap: async () => ({
...parseFirstPassResponse(await askModel(model, cheapGenerator, buildSystem(false), buildUser(''))),
generator: cheapGenerator,
}),
pro: async (cheapCandidate) => {
const extra = repoReader.read(cheapCandidate?.want_files || []).text;
return {
...parseFirstPassResponse(
await askModel(modelPro, proGenerator, buildSystem(true), buildUser(extra), true),
),
generator: proGenerator,
};
},
onFailure: (event) => modelFailures.push(event),
});
let commentBody;
if (selected) {
try {
commentBody = formatAutomatedComment(selected.reply, {
kind,
generator: selected.generator,
});
} catch (error) {
fail(`First-pass formatting failed: ${error.message}`);
}
} else {
const summary = ['cheap', 'pro']
.map((tier) => `${tier}=${modelFailures.find((event) => event.tier === tier)?.reason || 'no-usable-reply'}`)
.join(' ');
if (isPr) {
fail(`::error::First-pass PR review unavailable: ${summary}. No comment was posted.`);
}
const authorWroteInChinese = /[\u3400-\u9fff]/u.test(
`${env.ITEM_TITLE || ''}\n${env.ITEM_BODY || ''}`,
);
commentBody = formatFirstPassFallback({ kind, isChinese: authorWroteInChinese });
console.warn(`Both model tiers failed to return a usable first-pass ${kind} (${summary}); posting fallback.`);
}
try {
const response = await fetch(commentsUrl, {
method: 'POST',
headers: githubHeaders,
body: JSON.stringify({ body: commentBody }),
});
if (!response.ok) {
fail(`Comment post failed ${response.status}: ${(await response.text()).slice(0, 300)}`);
}
} catch (error) {
fail(`Comment post failed: ${error.message}`);
}
console.log(`Posted first-pass ${kind} on #${num}.`);