diff --git a/.github/workflows/claude-automerge.yml b/.github/workflows/claude-automerge.yml new file mode 100644 index 0000000..7d60a87 --- /dev/null +++ b/.github/workflows/claude-automerge.yml @@ -0,0 +1,144 @@ +# Claude 改动的自动合并兜底。 +# +# 处理对象:head 分支以 claude/issue- 或 claude/pr- 开头(claude-code-action 自动起的分支名)、 +# 来自本仓库、目标是默认分支、作者是 Claude App(claude[bot]) / mrhy7176 / github-actions[bot] 的 PR。 +# 其它 claude/ 分支(比如手工建的 claude/cloudgpu-takeover-*)不会被自动合并。 +# +# 两种触发: +# 1. pull_request:Claude 用 Claude App 令牌开的 PR 会触发(GITHUB_TOKEN 开的 PR 不会触发 workflow)。 +# 2. workflow_dispatch:claude.yml 的 handoff job 在 Claude 跑完后触发;如果 Claude 没开 PR, +# 这里用 GITHUB_TOKEN 补开一个(需要仓库设置里允许 Actions 创建 PR)。 +# 流程:等除本 job 以外的检查全部跑完且通过 → squash 合并 → 删分支 → 在 issue 里回帖。 +# Claude 已经自己合并的话,这里发现没有打开的 PR,直接结束。 +# 注意:用 GITHUB_TOKEN 合并不会触发默认分支上的 push workflow;改了 .github/workflows 的 PR 也合不了(没有 workflows 权限),需要人工处理。 +name: Claude Auto-merge + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: + inputs: + branch: + description: "Claude 的分支名(claude/issue-* 或 claude/pr-*)" + required: true + type: string + issue: + description: "对应的 issue 号(可选,用于 PR 正文和回帖)" + required: false + type: string + +concurrency: + group: claude-automerge-${{ github.head_ref || inputs.branch }} + cancel-in-progress: false + +permissions: {} + +jobs: + automerge: + name: automerge + if: | + (github.event_name == 'workflow_dispatch' && (startsWith(inputs.branch, 'claude/issue-') || startsWith(inputs.branch, 'claude/pr-'))) || + (github.event_name == 'pull_request' && !github.event.pull_request.draft && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.base.ref == github.event.repository.default_branch && + (startsWith(github.head_ref, 'claude/issue-') || startsWith(github.head_ref, 'claude/pr-')) && + contains(fromJSON('["claude[bot]", "mrhy7176", "github-actions[bot]"]'), github.event.pull_request.user.login)) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + issues: write + checks: read + statuses: read + steps: + - name: 等检查通过后 squash 合并 + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + BASE: ${{ github.event.repository.default_branch }} + BRANCH: ${{ github.head_ref || inputs.branch }} + ISSUE_INPUT: ${{ inputs.issue }} + run: | + set -euo pipefail + + # issue 号:优先用输入,否则从 claude/issue-<号>-... 分支名里解析 + ISSUE="$ISSUE_INPUT" + if [ -z "$ISSUE" ] && [[ "$BRANCH" =~ ^claude/issue-([0-9]+)- ]]; then ISSUE="${BASH_REMATCH[1]}"; fi + + comment() { # 在 issue 回帖;没有 issue 号就回在 PR 上 + local target="${ISSUE:-${PR:-}}" + [ -n "$target" ] && gh api -X POST "repos/$REPO/issues/$target/comments" -f body="$1" --silent || true + } + + # 1. 找这个分支打开着的 PR;没有就看要不要补开一个 + PR=$(gh api "repos/$REPO/pulls?state=open&base=$BASE&head=$OWNER:$BRANCH" --jq '.[0].number // empty') + if [ -z "$PR" ]; then + if ! gh api "repos/$REPO/branches/$BRANCH" --silent 2>/dev/null; then + echo "分支 $BRANCH 不存在(多半是 Claude 已经合并并删除了),无事可做"; exit 0 + fi + if [ "$(gh api "repos/$REPO/pulls?state=closed&head=$OWNER:$BRANCH" --jq 'length')" != "0" ]; then + echo "分支 $BRANCH 已经有关闭/合并过的 PR,不重复开"; exit 0 + fi + AHEAD=$(gh api "repos/$REPO/compare/$BASE...$BRANCH" --jq '.ahead_by') + if [ "$AHEAD" = "0" ]; then echo "分支 $BRANCH 没有新提交,无事可做"; exit 0; fi + TITLE=$(gh api "repos/$REPO/commits/$BRANCH" --jq '.commit.message | split("\n")[0]') + BODY="Claude Code 的改动(Claude 没有自己开 PR,由 claude-automerge 兜底创建)。" + [ -n "$ISSUE" ] && BODY="$BODY + + Closes #$ISSUE" + PR=$(gh api -X POST "repos/$REPO/pulls" -f title="$TITLE" -f head="$BRANCH" -f base="$BASE" -f body="$BODY" --jq '.number') + echo "已补开 PR #$PR" + fi + + read -r AUTHOR DRAFT < <(gh api "repos/$REPO/pulls/$PR" --jq '[.user.login, (.draft|tostring)] | @tsv') + case "$AUTHOR" in + "claude[bot]"|mrhy7176|"github-actions[bot]") ;; + *) echo "PR #$PR 作者是 $AUTHOR,不自动合并"; exit 0 ;; + esac + if [ "$DRAFT" = "true" ]; then echo "PR #$PR 是草稿,不自动合并"; exit 0; fi + + # 2. 等除本 job 以外的检查(check runs + commit statuses)全部完成 + DEADLINE=$((SECONDS + 1500)) + while :; do + STATE=$(gh api "repos/$REPO/pulls/$PR" --jq '.state') + if [ "$STATE" != "open" ]; then echo "PR #$PR 已经是 $STATE(可能 Claude 已经合并了),结束"; exit 0; fi + SHA=$(gh api "repos/$REPO/pulls/$PR" --jq '.head.sha') + RUNS=$(gh api "repos/$REPO/commits/$SHA/check-runs?per_page=100" --jq '[.check_runs[] | select(.name != "automerge")]') + PENDING=$(jq '[.[] | select(.status != "completed")] | length' <<<"$RUNS") + FAILED=$(jq -r '[.[] | select(.status == "completed" and ((.conclusion == "success" or .conclusion == "neutral" or .conclusion == "skipped") | not)) | .name] | join(", ")' <<<"$RUNS") + read -r CSTATE CCOUNT < <(gh api "repos/$REPO/commits/$SHA/status" --jq '[.state, (.total_count|tostring)] | @tsv') + if [ -n "$FAILED" ] || { [ "$CCOUNT" != "0" ] && { [ "$CSTATE" = "failure" ] || [ "$CSTATE" = "error" ]; }; }; then + comment "⚠️ PR #$PR 有检查没通过(${FAILED:-commit status $CSTATE}),没有自动合并,需要人工看一下。" + echo "检查失败:${FAILED:-commit status $CSTATE}"; exit 1 + fi + if [ "$PENDING" = "0" ] && { [ "$CCOUNT" = "0" ] || [ "$CSTATE" = "success" ]; }; then break; fi + if [ $SECONDS -gt $DEADLINE ]; then + comment "⚠️ PR #$PR 的检查 25 分钟还没跑完,没有自动合并。" + echo "等检查超时"; exit 1 + fi + echo "还有检查在跑(check runs 未完成 $PENDING 个,commit status $CSTATE),30 秒后再看"; sleep 30 + done + + # 3. squash 合并(带上 sha,保证合并的就是刚检查过的那个提交),再删分支 + for i in 1 2 3 4 5; do + if gh api -X PUT "repos/$REPO/pulls/$PR/merge" -f merge_method=squash -f sha="$SHA" --silent; then MERGED=1; break; fi + if [ "$(gh api "repos/$REPO/pulls/$PR" --jq '.merged')" = "true" ]; then echo "PR #$PR 已被合并"; exit 0; fi + echo "合并失败,第 $i 次,15 秒后重试"; sleep 15 + done + if [ -z "${MERGED:-}" ]; then + comment "⚠️ PR #$PR 自动合并失败(可能有冲突,或改动涉及 .github/workflows),需要人工处理。" + exit 1 + fi + gh api -X DELETE "repos/$REPO/git/refs/heads/$BRANCH" --silent 2>/dev/null || true + echo "已 squash 合并 PR #$PR 并删除分支 $BRANCH" + comment "✅ claude-automerge:PR #$PR 已 squash 合并到 \`$BASE\`,分支已删除。" + + # 4. GITHUB_TOKEN 合并时 GitHub 不会按 Closes #号 自动关 issue,这里手动关掉 PR 关联的 issue + LINKED=$(gh api graphql -F owner="$OWNER" -F name="${REPO#*/}" -F pr="$PR" \ + -f query='query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){closingIssuesReferences(first:10){nodes{number state}}}}}' \ + --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] | select(.state == "OPEN") | .number' || true) + for n in $LINKED; do + gh api -X PATCH "repos/$REPO/issues/$n" -f state=closed -f state_reason=completed --silent && echo "已关闭 issue #$n" || true + done diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..8ce207f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,97 @@ +# Claude Code GitHub Action:在 issue / PR 里 @claude 时让 Claude 干活。 +# 文档:https://code.claude.com/docs/en/github-actions +# +# 只认仓库所有者 mrhy7176:外人在公开 issue 里 @claude 不会启动 runner,也不会消耗额度。 +# 凭证放在仓库 Secrets 里,二选一: +# ANTHROPIC_API_KEY —— Claude Console 的 API key(按 token 计费) +# CLAUDE_CODE_OAUTH_TOKEN —— 本地 `claude setup-token` 生成的订阅令牌(走订阅额度) +# 没设置的那个是空字符串,action 会用另一个。 +# +# 全自动流程:issue 里 @claude 让它改代码 → Claude 在 claude/issue-* 分支提交 → 自己 gh pr create +# → 检查通过后 gh pr merge --squash --delete-branch → 在 issue 评论里写 PR 链接和合并结果。 +# Claude 用的是 Claude GitHub App 的令牌(不是 GITHUB_TOKEN),所以它开的 PR 会触发 +# claude-automerge.yml;万一 Claude 没开 PR / 没合并,下面的 handoff job 会把分支交给 +# claude-automerge.yml 兜底(开 PR + 等检查 + squash 合并)。 +name: Claude Code + +on: + issues: + types: [opened, assigned] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + github.actor == 'mrhy7176' && ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ) + # 同一个 issue / PR 一次只跑一个,后来的排队,不并发烧额度。 + # 放在 job 级别:Claude 自己的回帖触发的 run 会被 if 跳过,不会占队列把你的下一条 @claude 挤掉。 + concurrency: + group: claude-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + outputs: + branch_name: ${{ steps.claude.outputs.branch_name }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + - id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # 每次最多 30 轮,防止一个请求跑飞。 + # allowedTools:在 action 自带的 git add/commit/push(包装脚本,禁止任何 flag,所以无法 force push) + # 之外,放开开 PR / 合并 PR / 看检查所需的 gh 命令,以及常见的测试、构建命令。 + claude_args: | + --max-turns 30 + --allowedTools "Bash(gh pr create:*),Bash(gh pr merge:*),Bash(gh pr view:*),Bash(gh pr checks:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git branch:*),Bash(npm ci:*),Bash(npm install:*),Bash(npm test:*),Bash(npm run:*),Bash(npx tsc:*),Bash(pip install:*),Bash(pytest:*),Bash(python3 -m pytest:*),Bash(python -m pytest:*),Bash(mvn:*)" + --disallowedTools "Bash(git push:*),Bash(git reset:*),Bash(git rebase:*),Bash(gh api:*),Bash(gh secret:*),Bash(gh workflow:*),Bash(gh repo:*)" + --append-system-prompt "仓库所有者 mrhy7176 的额外规则(优先于默认说明)。一:被要求改代码时,只在本次运行给你的 claude/ 分支上提交,并且只用系统提供的 git-push 包装脚本推送这个分支;绝不 force push,绝不直接推送或提交到默认分支 master,绝不修改 .github/workflows 目录下的任何文件,绝不读取、打印或改动 secrets 和任何凭证。二:推送前,如果仓库有现成的测试或构建命令(package.json 里的 test、build、lint 脚本,pytest,mvn test 等),先跑;失败就修,修不好就不要合并,在评论里说明原因。三:在 issue 里改完并推送后,你被明确授权自己开 PR 并合并,这一条覆盖默认说明里只给 Create a PR 链接、不能合并分支的限制:先用 gh pr create --base master --head 你的分支 --title 简短标题 --body 改动说明,正文里必须写 Closes #issue号;再用 gh pr checks PR号 --watch 等检查跑完(报 no checks 说明仓库没有检查,可以直接继续);检查全部通过后用 gh pr merge PR号 --squash --delete-branch 合并。四:最后在你的进度评论里写上 PR 链接和合并结果(已合并,或未合并及原因)。五:只是提问、没有改代码时,正常回答,不要开 PR。六:在已有 PR 上被 @ 时,把提交推到该 PR 的分支即可,不要另开 PR;只有分支名以 claude/issue- 或 claude/pr- 开头的 PR 才可以由你合并。" + + # 兜底:issue 里触发、Claude 推了分支但可能没开 PR / 没合并时,交给 claude-automerge.yml。 + # 用 GITHUB_TOKEN 触发 workflow_dispatch 是允许的(GITHUB_TOKEN 只有 dispatch 类事件能触发新的 workflow)。 + handoff: + needs: claude + if: always() && needs.claude.result != 'skipped' && needs.claude.outputs.branch_name != '' && github.event.issue && !github.event.issue.pull_request + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: 把 Claude 的分支交给 claude-automerge.yml + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + BRANCH: ${{ needs.claude.outputs.branch_name }} + ISSUE: ${{ github.event.issue.number }} + run: | + case "$BRANCH" in + claude/issue-*|claude/pr-*) ;; + *) echo "分支 $BRANCH 不是 Claude 的 issue 分支,跳过"; exit 0 ;; + esac + if gh api "repos/$REPO/branches/$BRANCH" --silent 2>/dev/null; then + gh workflow run claude-automerge.yml -R "$REPO" --ref "$DEFAULT_BRANCH" -f branch="$BRANCH" -f issue="$ISSUE" + echo "已触发 claude-automerge:$BRANCH(issue #$ISSUE)" + else + echo "分支 $BRANCH 不存在(没改代码,或 Claude 已合并并删除分支),无需兜底" + fi