mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-08-12 17:10:37 +00:00
205 lines
6.3 KiB
JavaScript
205 lines
6.3 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { spawnSync } from 'node:child_process'
|
||
import { readFileSync, writeFileSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
|
||
const eventName = process.env.GITHUB_EVENT_NAME
|
||
const eventPath = process.env.GITHUB_EVENT_PATH
|
||
const token = process.env.GITHUB_TOKEN
|
||
const repo = process.env.GITHUB_REPOSITORY
|
||
const model = process.env.MODEL || 'opencode/deepseek-v4-flash-free'
|
||
const botLogin = process.env.BOT_LOGIN || 'takanashi-hoshino-agent[bot]'
|
||
|
||
if (!eventPath || !token || !repo) {
|
||
console.error(
|
||
'Missing required env: GITHUB_EVENT_PATH / GITHUB_TOKEN / GITHUB_REPOSITORY',
|
||
)
|
||
process.exit(1)
|
||
}
|
||
if (!process.env.OPENCODE_API_KEY) {
|
||
console.error('Missing OPENCODE_API_KEY')
|
||
process.exit(1)
|
||
}
|
||
|
||
const event = JSON.parse(readFileSync(eventPath, 'utf8'))
|
||
const [owner, repoName] = repo.split('/')
|
||
const api = `https://api.github.com/repos/${owner}/${repoName}`
|
||
const headers = {
|
||
Authorization: `Bearer ${token}`,
|
||
Accept: 'application/vnd.github+json',
|
||
'X-GitHub-Api-Version': '2022-11-28',
|
||
'User-Agent': 'opencode-automation',
|
||
}
|
||
|
||
async function get(path) {
|
||
const res = await fetch(`${api}${path}`, { headers })
|
||
if (!res.ok) throw new Error(`GET ${path}: HTTP ${res.status}`)
|
||
return res.json()
|
||
}
|
||
|
||
async function post(path, body) {
|
||
const res = await fetch(`${api}${path}`, {
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
})
|
||
const text = await res.text()
|
||
if (!res.ok) throw new Error(`POST ${path}: HTTP ${res.status} ${text}`)
|
||
return text ? JSON.parse(text) : null
|
||
}
|
||
|
||
/** 无头模式跑 opencode,返回模型输出的文本 */
|
||
function runOpencode(prompt, contextFile) {
|
||
const args = ['run', '--auto', '-m', model, '--format', 'default', prompt]
|
||
if (contextFile) args.push('-f', contextFile)
|
||
console.log(
|
||
`Running: opencode run --auto -m ${model} (prompt ${prompt.length} chars${contextFile ? ', context attached' : ''})`,
|
||
)
|
||
const res = spawnSync('opencode', args, {
|
||
encoding: 'utf8',
|
||
maxBuffer: 128 * 1024 * 1024,
|
||
})
|
||
if (res.error) throw res.error
|
||
if (res.status !== 0) {
|
||
if (res.stderr) console.error('opencode stderr:\n' + res.stderr)
|
||
throw new Error(`opencode exited with code ${res.status}`)
|
||
}
|
||
return (res.stdout || '').trim()
|
||
}
|
||
|
||
/** 该 issue/PR 是否已有 bot 评论(用于去重,避免 synchronize 重复评论) */
|
||
async function hasBotComment(target) {
|
||
const comments = await get(`/issues/${target}/comments?per_page=100`)
|
||
return comments.some((c) => c.user?.login === botLogin)
|
||
}
|
||
|
||
async function comment(target, body) {
|
||
if (!body) {
|
||
console.log('No output from agent, skip comment')
|
||
return
|
||
}
|
||
if (body.length > 60000) body = body.slice(0, 60000) + '\n\n_...(truncated)_'
|
||
await post(`/issues/${target}/comments`, { body })
|
||
console.log(`Commented on #${target}`)
|
||
}
|
||
|
||
async function handleIssue(issue) {
|
||
const n = issue.number
|
||
console.log(`Handling issue #${n}`)
|
||
if (await hasBotComment(n)) {
|
||
console.log(`Already handled by bot, skip`)
|
||
return
|
||
}
|
||
|
||
const comments = await get(`/issues/${n}/comments?per_page=100`)
|
||
const thread = comments
|
||
.filter((c) => c.user?.login !== botLogin)
|
||
.map(
|
||
(c) =>
|
||
`- **${c.user?.login}** (${c.created_at}):\n ${(c.body || '').replace(/\n/g, '\n ')}`,
|
||
)
|
||
.join('\n')
|
||
|
||
const ctx = [
|
||
`# Issue #${n}: ${issue.title}`,
|
||
``,
|
||
`- State: ${issue.state}`,
|
||
`- Author: ${issue.user?.login}`,
|
||
`- Created: ${issue.created_at}`,
|
||
``,
|
||
`## Body`,
|
||
``,
|
||
issue.body || '(empty)',
|
||
...(thread ? [`\n## Comments\n\n${thread}`] : []),
|
||
].join('\n')
|
||
|
||
const ctxFile = join(tmpdir(), 'opencode-issue-context.md')
|
||
writeFileSync(ctxFile, ctx)
|
||
|
||
const prompt =
|
||
process.env.ISSUE_PROMPT ||
|
||
[
|
||
`You are a maintainer bot for the ${repo} repository.`,
|
||
`Read the attached file for the issue context.`,
|
||
`If the issue has a clear fix or points to relevant docs, reply with documentation links and/or`,
|
||
`error-handling guidance for code examples. If nothing actionable, reply with exactly "NO_ACTION".`,
|
||
`Do NOT modify any files, do NOT commit, do NOT push. Output only the comment text.`,
|
||
].join('\n')
|
||
|
||
const output = runOpencode(prompt, ctxFile)
|
||
if (output === 'NO_ACTION' || output.startsWith('NO_ACTION')) {
|
||
console.log('Agent decided no action needed')
|
||
return
|
||
}
|
||
await comment(n, output)
|
||
}
|
||
|
||
async function handlePullRequest(pr) {
|
||
const n = pr.number
|
||
console.log(`Handling PR #${n}`)
|
||
if (await hasBotComment(n)) {
|
||
console.log(`Already handled by bot, skip`)
|
||
return
|
||
}
|
||
|
||
let files = []
|
||
try {
|
||
files = await get(`/pulls/${n}/files?per_page=100`)
|
||
} catch (e) {
|
||
console.warn(`Failed to fetch changed files: ${e.message}`)
|
||
}
|
||
const fileList = files
|
||
.map((f) => `- ${f.status} ${f.filename} (+${f.additions}/-${f.deletions})`)
|
||
.join('\n')
|
||
|
||
const ctx = [
|
||
`# PR #${n}: ${pr.title}`,
|
||
``,
|
||
`- State: ${pr.state}`,
|
||
`- Author: ${pr.user?.login}`,
|
||
`- Base: ${pr.base?.ref} <- Head: ${pr.head?.ref}`,
|
||
`- Created: ${pr.created_at}`,
|
||
`- Stats: +${pr.additions}/-${pr.deletions}, ${pr.changed_files} files`,
|
||
``,
|
||
`## Body`,
|
||
``,
|
||
pr.body || '(empty)',
|
||
...(fileList ? [`\n## Changed files\n\n${fileList}`] : []),
|
||
].join('\n')
|
||
|
||
const ctxFile = join(tmpdir(), 'opencode-pr-context.md')
|
||
writeFileSync(ctxFile, ctx)
|
||
|
||
const prompt =
|
||
process.env.PR_PROMPT ||
|
||
[
|
||
`You are a maintainer bot for the ${repo} repository.`,
|
||
`Read the attached file for the PR context. The PR branch is already checked out.`,
|
||
`Run \`git diff HEAD^1 HEAD\` (or \`git diff origin/${pr.base?.ref}...HEAD\`) to see the changes.`,
|
||
`Review the code changes: point out bugs, risks and improvements, and suggest fixes for obvious issues.`,
|
||
`If the PR looks good, say so concisely.`,
|
||
`Do NOT modify any files, do NOT commit, do NOT push. Output only the review comment text.`,
|
||
].join('\n')
|
||
|
||
const output = runOpencode(prompt, ctxFile)
|
||
await comment(n, output)
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`Event: ${eventName} on ${repo}`)
|
||
if (eventName === 'issues') {
|
||
await handleIssue(event.issue)
|
||
} else if (eventName === 'pull_request') {
|
||
await handlePullRequest(event.pull_request)
|
||
} else {
|
||
console.log(`Unhandled event type: ${eventName}`)
|
||
}
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error(`Automation failed: ${e.message}`)
|
||
process.exit(1)
|
||
})
|