Skip to content

fix: 任务要求使用 CLI 但 CLI 被禁用,且缺少替代方案引导 #326

fix: 任务要求使用 CLI 但 CLI 被禁用,且缺少替代方案引导

fix: 任务要求使用 CLI 但 CLI 被禁用,且缺少替代方案引导 #326

name: 🤖 Issue Auto Processor
on:
schedule:
- cron: '0 */4 * * *'
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number to process immediately (optional)'
required: false
type: string
issue_comment:
types: [created]
concurrency:
group: issue-auto-processor
cancel-in-progress: false
env:
DELAY_HOURS: '4'
MAX_ISSUES_PER_RUN: '5'
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
jobs:
process-issues:
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install CodeBuddy CLI
run: npm install -g @tencent-ai/codebuddy-code
- name: Validate CodeBuddy credentials
env:
CODEBUDDY_AUTH_TOKEN: ${{ secrets.CODEBUDDY_AUTH_TOKEN }}
CODEBUDDY_API_KEY: ${{ secrets.CODEBUDDY_API_KEY }}
CODEBUDDY_INTERNET_ENVIRONMENT: ${{ vars.CODEBUDDY_INTERNET_ENVIRONMENT }}
run: |
set -euo pipefail
if [ -z "${CODEBUDDY_AUTH_TOKEN:-}" ] && [ -z "${CODEBUDDY_API_KEY:-}" ]; then
echo "::error::Set CODEBUDDY_AUTH_TOKEN or CODEBUDDY_API_KEY in repository secrets before enabling this workflow."
exit 1
fi
if [ -n "${CODEBUDDY_API_KEY:-}" ] && [ -z "${CODEBUDDY_INTERNET_ENVIRONMENT:-}" ]; then
echo "::warning::CODEBUDDY_API_KEY is set but CODEBUDDY_INTERNET_ENVIRONMENT is empty. This is fine for codebuddy.ai, but for 中国版请将 repository variable CODEBUDDY_INTERNET_ENVIRONMENT 设为 internal。"
fi
- name: Ensure AI labels exist
uses: actions/github-script@v7
with:
script: |
const labels = [
{ name: 'ai-processing', color: 'FBCA04', description: 'AI automation is processing this issue' },
{ name: 'ai-processed', color: '0E8A16', description: 'AI automation already processed this issue' },
{ name: 'ai-failed', color: 'D93F0B', description: 'AI automation failed to process this issue' },
{ name: 'ai-fix', color: '0052CC', description: 'AI automation created a fix PR for this issue' },
{ name: 'no-ai', color: '666666', description: 'Skip AI automation for this issue' }
];
for (const label of labels) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name
});
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
...label
});
}
}
- name: Collect eligible issues
id: collect
uses: actions/github-script@v7
env:
DELAY_HOURS: ${{ env.DELAY_HOURS }}
MAX_ISSUES_PER_RUN: ${{ env.MAX_ISSUES_PER_RUN }}
with:
script: |
const fs = require('fs');
const path = require('path');
const { parseIssueCommentCommand } = require(path.join(process.cwd(), 'scripts', 'issue-auto-processor.cjs'));
const delayHours = Number(process.env.DELAY_HOURS || '4');
const maxIssues = Number(process.env.MAX_ISSUES_PER_RUN || '5');
const cutoffMs = Date.now() - delayHours * 60 * 60 * 1000;
async function fetchComments(issueNumber) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
return comments.map((comment) => ({
id: comment.id,
author: comment.user?.login || 'unknown',
authorAssociation: comment.author_association || 'NONE',
body: comment.body || '',
createdAt: comment.created_at,
url: comment.html_url,
}));
}
async function normalizeIssue(issue, extra = {}) {
return {
number: issue.number,
title: issue.title,
body: issue.body || '',
url: issue.html_url,
createdAt: issue.created_at,
labels: (issue.labels || []).map((label) => typeof label === 'string' ? label : label.name),
comments: await fetchComments(issue.number),
requestedAction: extra.requestedAction || '',
command: extra.command || '',
commandCommentAuthor: extra.commandCommentAuthor || '',
commandCommentUrl: extra.commandCommentUrl || '',
};
}
let eligible = [];
const manualIssueNumber = context.payload.inputs?.issue_number?.trim();
if (context.eventName === 'issue_comment') {
const issuePayload = context.payload.issue;
const commentPayload = context.payload.comment;
const parsedCommand = parseIssueCommentCommand({
body: commentPayload?.body || '',
authorAssociation: commentPayload?.author_association || '',
hasPullRequest: Boolean(issuePayload?.pull_request),
});
if (parsedCommand) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issuePayload.number,
});
if (!issue.pull_request) {
eligible = [await normalizeIssue(issue, {
requestedAction: parsedCommand.action,
command: parsedCommand.command,
commandCommentAuthor: commentPayload.user?.login || '',
commandCommentUrl: commentPayload.html_url || '',
})];
}
}
} else if (manualIssueNumber) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(manualIssueNumber),
});
if (!issue.pull_request) {
eligible = [await normalizeIssue(issue)];
}
} else {
const allIssues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'created',
direction: 'asc',
per_page: 100,
});
const scheduledIssues = allIssues
.filter((issue) => !issue.pull_request)
.filter((issue) => new Date(issue.created_at).getTime() <= cutoffMs)
.filter((issue) => {
const labels = (issue.labels || []).map((label) => typeof label === 'string' ? label : label.name);
return !labels.includes('ai-processed') && !labels.includes('ai-processing') && !labels.includes('ai-failed') && !labels.includes('no-ai');
})
.slice(0, maxIssues);
eligible = await Promise.all(scheduledIssues.map((issue) => normalizeIssue(issue)));
}
fs.writeFileSync('.issue-auto-processor-issues.json', JSON.stringify(eligible, null, 2));
core.setOutput('count', String(eligible.length));
await core.summary
.addHeading('Issue Auto Processor')
.addRaw(`Event: ${context.eventName}\nEligible issues: ${eligible.length}`)
.write();
- name: Process issues with CodeBuddy headless mode
if: steps.collect.outputs.count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CODEBUDDY_AUTH_TOKEN: ${{ secrets.CODEBUDDY_AUTH_TOKEN }}
CODEBUDDY_API_KEY: ${{ secrets.CODEBUDDY_API_KEY }}
CODEBUDDY_INTERNET_ENVIRONMENT: ${{ vars.CODEBUDDY_INTERNET_ENVIRONMENT }}
shell: bash
run: |
set -euo pipefail
BASE_REF="${GITHUB_SHA}"
cleanup_repo() {
git reset --hard "$BASE_REF" >/dev/null 2>&1 || true
git clean -fd >/dev/null 2>&1 || true
git switch --detach "$BASE_REF" >/dev/null 2>&1 || true
}
truncate_text_for_pr_body() {
local max_chars="${1:-12000}"
python3 -c 'import sys; limit = int(sys.argv[1]); text = sys.stdin.read(); sys.stdout.write(text if len(text) <= limit else text[:limit].rstrip() + "\n\n_[truncated by automation to keep PR creation stable]_")' "$max_chars"
}
lookup_open_pr_url() {
local branch="$1"
gh pr list --head "$branch" --state open --json url --jq '.[0].url'
}
build_bug_prompt() {
node scripts/issue-auto-processor.cjs build-bug-prompt /tmp/issue.json > /tmp/codebuddy-prompt.txt
}
build_analysis_prompt() {
node scripts/issue-auto-processor.cjs build-analysis-prompt /tmp/issue.json > /tmp/codebuddy-prompt.txt
}
detect_bug_issue() {
node scripts/issue-auto-processor.cjs is-bug /tmp/issue.json
}
extract_result_text() {
node scripts/issue-auto-processor.cjs extract-result
}
has_nonempty_text() {
[ -n "$(printf '%s' "$1" | tr -d '[:space:]')" ]
}
issue_has_label() {
local label="$1"
jq -e --arg label "$label" '((.labels // []) | map(ascii_downcase)) | any(. == ($label | ascii_downcase))' /tmp/issue.json >/dev/null 2>&1
}
sync_issue_json_label() {
local action="$1"
local label="$2"
if [ "$action" = "add" ]; then
jq --arg label "$label" '
if ((.labels // []) | map(ascii_downcase) | any(. == ($label | ascii_downcase)))
then .
else .labels = ((.labels // []) + [$label])
end
' /tmp/issue.json > /tmp/issue.json.next
else
jq --arg label "$label" '
.labels = ((.labels // []) | map(select(ascii_downcase != ($label | ascii_downcase))))
' /tmp/issue.json > /tmp/issue.json.next
fi
mv /tmp/issue.json.next /tmp/issue.json
}
update_issue_labels() {
local issue_number="$1"
shift
local -a args=()
local spec=""
local action=""
local label=""
for spec in "$@"; do
action="${spec%%:*}"
label="${spec#*:}"
case "$action" in
add)
if ! issue_has_label "$label"; then
args+=(--add-label "$label")
sync_issue_json_label add "$label"
fi
;;
remove)
if issue_has_label "$label"; then
args+=(--remove-label "$label")
sync_issue_json_label remove "$label"
fi
;;
*)
echo "Unknown label action: $action" >&2
return 1
;;
esac
done
if [ ${#args[@]} -gt 0 ]; then
gh issue edit "$issue_number" "${args[@]}" >/dev/null
fi
}
write_issue_comment_file() {
local heading="$1"
local body="$2"
local footer="$3"
{
printf '%s\n\n%s' "$heading" "$body"
if [ -n "$footer" ]; then
printf '\n\n---\n%s\n' "$footer"
else
printf '\n'
fi
} > /tmp/issue-comment.md
}
write_pr_body_file() {
local issue_number="$1"
local issue_url="$2"
local result_text="$3"
local summary=""
summary=$(printf '%s' "$result_text" | truncate_text_for_pr_body 12000)
{
printf '%s\n\n' '## 🤖 Automated fix attempt'
printf 'Fixes #%s\n\n' "$issue_number"
printf 'Source issue: %s\n\n' "$issue_url"
printf '### Summary\n%s\n' "$summary"
} > /tmp/pr-body.md
}
post_file_comment() {
local issue_number="$1"
local file_path="$2"
gh issue comment "$issue_number" --body-file "$file_path" >/dev/null
}
fail_with_comment() {
local issue_number="$1"
local heading="$2"
local body="$3"
write_issue_comment_file "$heading" "$body" ''
post_file_comment "$issue_number" /tmp/issue-comment.md
update_issue_labels "$issue_number" remove:ai-processing add:ai-failed remove:ai-processed
cleanup_repo
return 0
}
process_issue() {
local issue_json="$1"
local raw_output=""
local result_text=""
local exit_code=0
local branch=""
local number=""
local title=""
local issue_url=""
local pr_url=""
local pr_output=""
local pr_lookup_exit_code=0
local is_bug="false"
local requested_action=""
local command=""
local command_comment_author=""
cleanup_repo
printf '%s' "$issue_json" > /tmp/issue.json
number=$(jq -r '.number' /tmp/issue.json)
title=$(jq -r '.title' /tmp/issue.json)
issue_url=$(jq -r '.url' /tmp/issue.json)
requested_action=$(jq -r '.requestedAction // ""' /tmp/issue.json)
command=$(jq -r '.command // ""' /tmp/issue.json)
command_comment_author=$(jq -r '.commandCommentAuthor // ""' /tmp/issue.json)
is_bug=$(detect_bug_issue)
echo "Processing issue #$number: $title"
if [ "$requested_action" = "skip" ]; then
echo "Route: slash command -> skip"
update_issue_labels "$number" add:no-ai remove:ai-processing remove:ai-failed remove:ai-processed remove:ai-fix
write_issue_comment_file '## 🤖 CloudBase Automation Disabled' "Acknowledged \`$command\` from @$command_comment_author. Automatic processing is now disabled for this issue until a maintainer explicitly re-runs it." ''
post_file_comment "$number" /tmp/issue-comment.md
cleanup_repo
return 0
fi
if [ "$requested_action" = "fix" ]; then
is_bug="true"
fi
update_issue_labels "$number" remove:no-ai remove:ai-failed remove:ai-processed remove:ai-fix add:ai-processing
if [ "$is_bug" = "true" ]; then
echo "Route: bug -> attempt fix"
branch="ai-fix/issue-$number"
git fetch origin "$DEFAULT_BRANCH"
git switch -C "$branch" "origin/$DEFAULT_BRANCH"
build_bug_prompt
set +e
raw_output=$(timeout 1200s codebuddy -p "$(cat /tmp/codebuddy-prompt.txt)" -y --output-format json --permission-mode acceptEdits --model hy3-preview-ioa </dev/null 2>&1)
exit_code=$?
set -e
result_text=$(printf '%s' "$raw_output" | extract_result_text || true)
if [ $exit_code -ne 0 ]; then
local failure_detail="CodeBuddy exited with status $exit_code before producing a usable patch."
if has_nonempty_text "$result_text"; then
failure_detail=$(printf '%s\n\nAutomation output:\n\n```\n%s\n```' "$failure_detail" "$result_text")
fi
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' "$failure_detail"
return 0
fi
if ! has_nonempty_text "$result_text" && git diff --quiet; then
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation ran, but the AI response was empty or could not be parsed into a usable fix summary. Please inspect the workflow logs before retrying.'
return 0
fi
if git diff --quiet; then
write_issue_comment_file '## 🤖 AI Bug Analysis' "AI reviewed this bug but did not produce a safe patch.\n\n$result_text" ''
post_file_comment "$number" /tmp/issue-comment.md
update_issue_labels "$number" remove:ai-processing add:ai-failed remove:ai-processed
cleanup_repo
return 0
fi
git add -A
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
set +e
git commit -m "fix(issue-auto): 🤖 attempt fix for issue #$number"
exit_code=$?
set -e
if [ $exit_code -ne 0 ]; then
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation created a branch diff, but git commit failed before a PR could be opened. Please inspect the workflow logs before retrying.'
return 0
fi
set +e
git push origin "$branch" --force
exit_code=$?
set -e
if [ $exit_code -ne 0 ]; then
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation created a commit, but pushing the fix branch failed before a PR could be opened. Please inspect the workflow logs before retrying.'
return 0
fi
set +e
pr_url=$(lookup_open_pr_url "$branch")
exit_code=$?
set -e
if [ $exit_code -ne 0 ]; then
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation pushed a fix branch, but failed while checking for an existing PR. Please inspect the workflow logs before retrying.'
return 0
fi
if [ -z "$pr_url" ]; then
write_pr_body_file "$number" "$issue_url" "$result_text"
set +e
pr_output=$(gh pr create --base "$DEFAULT_BRANCH" --head "$branch" --title "fix: 🤖 attempt fix for issue #$number" --body-file /tmp/pr-body.md 2>&1)
exit_code=$?
set -e
pr_url=$(printf '%s' "$pr_output" | node scripts/issue-auto-processor.cjs extract-pr-url || true)
if ! has_nonempty_text "$pr_url"; then
set +e
pr_url=$(lookup_open_pr_url "$branch")
pr_lookup_exit_code=$?
set -e
fi
if ! has_nonempty_text "$pr_url"; then
if [ $exit_code -ne 0 ]; then
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation created and pushed a fix branch, but PR creation failed before a valid PR URL could be resolved. Please inspect the workflow logs before retrying.'
elif [ $pr_lookup_exit_code -ne 0 ]; then
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation created and pushed a fix branch, but the workflow could not verify the PR URL after creation. Please inspect the workflow logs before retrying.'
else
fail_with_comment "$number" '## 🤖 AI Fix Attempt Failed' 'Automation created and pushed a fix branch, but PR creation did not return a valid URL and no open PR was found for the branch. Please inspect the workflow logs before retrying.'
fi
return 0
fi
fi
local comment_body=""
comment_body=$(printf 'I created a PR for this bug: %s\n\nPlease review the generated changes before merging.' "$pr_url")
write_issue_comment_file '## 🤖 AI Fix Attempt' "$comment_body" ''
post_file_comment "$number" /tmp/issue-comment.md
update_issue_labels "$number" remove:ai-processing add:ai-processed add:ai-fix remove:ai-failed
cleanup_repo
return 0
fi
echo "Route: non-bug -> analysis only"
build_analysis_prompt
set +e
raw_output=$(timeout 1200s codebuddy -p "$(cat /tmp/codebuddy-prompt.txt)" -y --output-format json --permission-mode acceptEdits --model hy3-preview-ioa </dev/null 2>&1)
exit_code=$?
set -e
result_text=$(printf '%s' "$raw_output" | extract_result_text || true)
if [ $exit_code -ne 0 ]; then
local failure_detail="CodeBuddy exited with status $exit_code before producing a comment."
if has_nonempty_text "$result_text"; then
failure_detail=$(printf '%s\n\n```\n%s\n```' "$failure_detail" "$result_text")
fi
fail_with_comment "$number" '## 🤖 AI Analysis Failed' "$failure_detail"
return 0
fi
if ! has_nonempty_text "$result_text"; then
fail_with_comment "$number" '## 🤖 AI Analysis Failed' 'Automation ran, but the AI response was empty or could not be parsed into a usable comment. Please inspect the workflow logs before retrying.'
return 0
fi
write_issue_comment_file '## 🤖 AI Analysis' "$result_text" 'Generated automatically by CodeBuddy CLI headless mode.'
post_file_comment "$number" /tmp/issue-comment.md
update_issue_labels "$number" remove:ai-processing add:ai-processed remove:ai-failed
cleanup_repo
}
mapfile -t issues < <(jq -c ".[]" .issue-auto-processor-issues.json)
for issue in "${issues[@]}"; do
if ! process_issue "$issue"; then
number=$(printf '%s' "$issue" | jq -r '.number')
title=$(printf '%s' "$issue" | jq -r '.title')
echo "Unexpected failure while processing issue #$number: $title"
cleanup_repo
printf '%s' "$issue" > /tmp/issue.json
update_issue_labels "$number" remove:ai-processing add:ai-failed remove:ai-processed || true
write_issue_comment_file '## 🤖 AI Automation Error' 'The issue auto processor hit an unexpected workflow error before completion. Please inspect the workflow logs for details.' ''
post_file_comment "$number" /tmp/issue-comment.md || true
fi
done
- name: No eligible issues
if: steps.collect.outputs.count == '0'
run: echo 'No eligible issues found.'