Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .github/scripts/automationPolicy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,30 @@ function read(relativePath) {
return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8');
}

test('runner attributes the tier whose reply becomes final and posts only commentBody', () => {
test('runner attributes the selected tier and posts only the trusted comment body', () => {
const runner = read('.github/scripts/first-pass.mjs');
assert.match(runner, /const TRANSPORT = 'anthropic-messages'/);
assert.match(runner, /resolveGeneratorAttribution\(env\.CCU_BOT_GENERATOR, model, TRANSPORT\)/);
assert.match(runner, /resolveGeneratorAttribution\(env\.CCU_BOT_GENERATOR_PRO, modelPro, TRANSPORT\)/);
assert.match(runner, /parseFirstPassResponse/);
assert.match(runner, /chooseFinalReply/);
assert.match(runner, /formatAutomatedComment\(reply, \{ kind, generator: finalGenerator \}\)/);
assert.match(runner, /resolveFirstPassCandidates/);
assert.match(runner, /formatAutomatedComment\(selected\.reply, \{/);
assert.match(runner, /generator: selected\.generator/);
assert.match(runner, /JSON\.stringify\(\{\s*body:\s*commentBody\s*\}\)/);
assert.doesNotMatch(runner, /JSON\.stringify\(\{\s*body:\s*reply\s*\}\)/);
assert.doesNotMatch(runner, /CCU_BOT_TRANSPORT|prompt-injection safe|injection-safe/i);
});

test('issue and PR opened events both use the hardened shared runner', () => {
const issue = read('.github/workflows/issue-first-pass.yml');
const pr = read('.github/workflows/pr-first-pass.yml');
assert.match(issue, /issues:\s*\n\s+types: \[opened\]/);
assert.match(pr, /pull_request_target:\s*\n\s+types: \[opened\]/);
for (const workflow of [issue, pr]) {
assert.match(workflow, /run: node \.github\/scripts\/first-pass\.mjs/);
}
});

test('runner uses one bounded reader for AGENTS grounding and requested source', () => {
const runner = read('.github/scripts/first-pass.mjs');
assert.match(runner, /# AGENTS\.md/);
Expand Down
71 changes: 71 additions & 0 deletions .github/scripts/first-pass-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,77 @@ export function parseFirstPassResponse(raw) {
return { reply, ...control };
}

export function needsProFirstPass(parsed) {
return parsed?.answerable === false || String(parsed?.reply || '').trim() === '';
}

export function formatFirstPassFallback({ kind, isChinese = false }) {
if (!['reply', 'review'].includes(kind)) {
throw new Error(`Unsupported first-pass kind: ${kind}`);
}
const subject = kind === 'reply' ? 'issue' : 'pull request';
const lines = [
`🤖 Automated first-pass ${kind}`,
'',
`The configured analysis model did not return a usable analysis for this ${subject}. ` +
'The item has still been received and remains available for maintainer review.',
'',
kind === 'reply'
? 'To help with triage, please ensure the extension, VS Code, and operating-system versions plus anonymous diagnostic logs are included.'
: 'To help with review, please ensure the change summary, tests, and any user-facing documentation or localization updates are included.',
];
if (isChinese) {
lines.push(
'',
`自动分析服务没有为这个${kind === 'reply' ? '问题' : '拉取请求'}返回可用结果。` +
'该条目已经成功收到,维护者仍可继续审阅。',
'',
kind === 'reply'
? '为便于排查,请确认已提供扩展、VS Code、操作系统版本以及匿名诊断日志。'
: '为便于审阅,请确认已提供改动摘要、测试结果,以及必要的文档和本地化更新。',
);
}
lines.push(
'',
'---',
'🤖 Posted automatically by CCU Bot — not a maintainer decision.',
);
return lines.join('\n');
}

export function hasTrustedFirstPass(comments, kind) {
if (!['reply', 'review'].includes(kind)) return false;
const marker = `🤖 Automated first-pass ${kind}`;
return Array.isArray(comments) && comments.some((comment) =>
comment?.user?.login === 'github-actions[bot]' &&
String(comment?.body || '').trimStart().startsWith(marker)
);
}

export async function resolveFirstPassCandidates({ cheap, pro }) {
let cheapCandidate;
try {
cheapCandidate = await cheap();
} catch {
cheapCandidate = null;
}

let proCandidate;
if (!cheapCandidate || needsProFirstPass(cheapCandidate)) {
try {
proCandidate = await pro(cheapCandidate);
} catch {
proCandidate = null;
}
}

try {
return chooseFinalReply(cheapCandidate, proCandidate);
} catch {
return null;
}
}

export function chooseFinalReply(cheap, pro) {
if (pro && typeof pro.reply === 'string' && pro.reply.trim() !== '') {
return { reply: pro.reply.trim(), generator: pro.generator };
Expand Down
81 changes: 53 additions & 28 deletions .github/scripts/first-pass.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
} from 'node:fs';
import { resolve } from 'node:path';
import {
chooseFinalReply,
createRepoReadSession,
formatAutomatedComment,
formatFirstPassFallback,
hasTrustedFirstPass,
parseFirstPassResponse,
resolveFirstPassCandidates,
resolveGeneratorAttribution,
validateFirstPassEnvironment,
} from './first-pass-lib.mjs';
Expand All @@ -24,6 +26,7 @@ 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-v4-flash';
const modelPro = env.CCU_BOT_MODEL_PRO || 'deepseek-v4-pro';

Expand Down Expand Up @@ -162,43 +165,65 @@ async function askModel(useModel, system, userText, think = false) {
.trim();
}

let selected;
try {
const first = parseFirstPassResponse(await askModel(model, buildSystem(false), buildUser('')));
let proCandidate;
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',
};

if (!first.answerable) {
const extra = repoReader.read(first.want_files).text;
const second = parseFirstPassResponse(
await askModel(modelPro, buildSystem(true), buildUser(extra), true),
);
proCandidate = { reply: second.reply, generator: proGenerator };
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);
}
selected = chooseFinalReply(
{ reply: first.reply, generator: cheapGenerator },
proCandidate,
);
} catch (error) {
fail(`Model call failed: ${error.message}`);
fail(`Comment lookup failed: ${error.message}`);
}
const { reply, generator: finalGenerator } = selected;

const selected = await resolveFirstPassCandidates({
cheap: async () => ({
...parseFirstPassResponse(await askModel(model, buildSystem(false), buildUser(''))),
generator: cheapGenerator,
}),
pro: async (cheapCandidate) => {
const extra = repoReader.read(cheapCandidate?.want_files || []).text;
return {
...parseFirstPassResponse(
await askModel(modelPro, buildSystem(true), buildUser(extra), true),
),
generator: proGenerator,
};
},
});

let commentBody;
try {
commentBody = formatAutomatedComment(reply, { kind, generator: finalGenerator });
} catch (error) {
fail(`First-pass formatting failed: ${error.message}`);
if (selected) {
try {
commentBody = formatAutomatedComment(selected.reply, {
kind,
generator: selected.generator,
});
} catch (error) {
fail(`First-pass formatting failed: ${error.message}`);
}
} else {
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}; posting fallback.`);
}

try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments`, {
const response = await fetch(commentsUrl, {
method: 'POST',
headers: {
authorization: `Bearer ${env.GH_TOKEN}`,
accept: 'application/vnd.github+json',
'content-type': 'application/json',
'user-agent': 'ccu-bot',
},
headers: githubHeaders,
body: JSON.stringify({ body: commentBody }),
});
if (!response.ok) {
Expand Down
Loading
Loading