diff --git a/.github/workflows/issue-manage.yml b/.github/workflows/issue-manage.yml index 6bae7ff..0467fd6 100644 --- a/.github/workflows/issue-manage.yml +++ b/.github/workflows/issue-manage.yml @@ -6,8 +6,6 @@ permissions: on: issues: types: [opened, labeled] - issue_comment: - types: [created] jobs: welcome-new-issues: @@ -28,61 +26,6 @@ jobs: - 💬 加入[QQ交流群](https://qm.qq.com/q/TpeP9Uv2yk) - 🔍 搜索[现有issues](https://github.com/neteasecloudmusicapienhanced/api-enhanced/issues) 看是否有类似问题 - triage: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: write - pull-requests: write - issues: write - steps: - - name: Check account age - id: check - uses: actions/github-script@v7 - with: - script: | - const user = await github.rest.users.getByUsername({ - username: context.payload.issue.user.login - }); - const created = new Date(user.data.created_at); - const days = (Date.now() - created) / (1000 * 60 * 60 * 24); - return days >= 30; - result-encoding: string - - - name: Generate bot token - id: token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - token: ${{ steps.token.outputs.token }} - - - name: Configure git identity - run: | - git config --global user.name "takanashi-hoshino-agent[bot]" - git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com" - - - name: Run opencode - uses: anomalyco/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - with: - model: opencode/deepseek-v4-flash-free - use_github_token: true - prompt: | - Review this issue. If there's a clear fix or relevant docs: - - Provide documentation links - - Add error handling guidance for code examples - Otherwise, do not comment. - ${{ secrets.OPENCODE_PROMPT }} - stale-issues: if: github.event.action == 'opened' runs-on: ubuntu-latest diff --git a/.github/workflows/opencode-auto.yml b/.github/workflows/opencode-auto.yml new file mode 100644 index 0000000..eadcf95 --- /dev/null +++ b/.github/workflows/opencode-auto.yml @@ -0,0 +1,59 @@ +name: OpenCode Automation + +on: + issues: + types: [opened] + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: auto-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + automate: + # 排除机器人自己的触发,防止自我循环 + if: | + github.actor != 'takanashi-hoshino-agent[bot]' && + github.actor != 'github-actions[bot]' && + github.actor != 'opencode-agent[bot]' + runs-on: ubuntu-latest + steps: + - name: Generate bot token + id: token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ steps.token.outputs.token }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install opencode + run: npm i -g opencode-ai@latest + + - name: Run automation + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + MODEL: opencode/deepseek-v4-flash-free + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + ISSUE_PROMPT: ${{ secrets.OPENCODE_PROMPT }} + PR_PROMPT: | + ${{ secrets.OPENCODE_PROMPT }} + ${{ secrets.PR_REVIEW_PROMPT }} + run: node scripts/opencode-automation.mjs diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 5d289db..9972ac7 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -14,10 +14,13 @@ permissions: jobs: bot: if: | - contains(github.event.comment.body, ' /oc') || - startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /opencode') || - startsWith(github.event.comment.body, '/opencode') + (contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode')) && + github.actor != 'opencode-agent[bot]' && + github.actor != 'takanashi-hoshino-agent[bot]' && + github.actor != 'github-actions[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/reviewer.yml b/.github/workflows/reviewer.yml deleted file mode 100644 index f6818eb..0000000 --- a/.github/workflows/reviewer.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: PR Reviewer - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - review: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - pull-requests: read - issues: read - steps: - - name: Generate bot token - id: token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - - name: Checkout - uses: actions/checkout@v6 - with: - persist-credentials: false - token: ${{ steps.token.outputs.token }} - - - name: Configure git identity - run: | - git config --global user.name "takanashi-hoshino-agent[bot]" - git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com" - - - name: Run opencode - uses: anomalyco/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ steps.token.outputs.token }} - with: - model: opencode/deepseek-v4-flash-free - use_github_token: true - prompt: | - ${{ secrets.PR_REVIEW_PROMPT }} - ${{ secrets.OPENCODE_PROMPT }} \ No newline at end of file diff --git a/scripts/opencode-automation.mjs b/scripts/opencode-automation.mjs new file mode 100644 index 0000000..654dc25 --- /dev/null +++ b/scripts/opencode-automation.mjs @@ -0,0 +1,204 @@ +#!/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) +})