NeteaseCloudMusicApiEnhanced/scripts/opencode-automation.mjs

205 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)
})