diff --git a/.github/workflows/ai-issue-analysis-cursor.yml b/.github/workflows/ai-issue-analysis-cursor.yml new file mode 100644 index 0000000..9ff7a9d --- /dev/null +++ b/.github/workflows/ai-issue-analysis-cursor.yml @@ -0,0 +1,43 @@ +name: AI Issue Analysis (Cursor) + +on: + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to analyze" + required: true + type: number + +jobs: + cursor-analysis: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + # 本仓库 CI 使用本地 action;接入方请改为 MistEO/ai-issue-analysis@main + - name: Analyze issue with Cursor + id: analysis + uses: ./ + with: + ai-provider: cursor + cursor-api-key: ${{ secrets.CURSOR_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + bot-name: "@github-actions" + + - name: Print outputs + if: always() + env: + ANALYSIS_PROMPT: ${{ steps.analysis.outputs.analysis-prompt }} + ISSUE_NUMBER: ${{ steps.analysis.outputs.issue-number }} + COMMENT_ID: ${{ steps.analysis.outputs.comment-id }} + COMMENT_URL: ${{ steps.analysis.outputs.comment-url }} + COPILOT_OUTPUT: ${{ steps.analysis.outputs.copilot-output }} + FINAL_CONCLUSION: ${{ steps.analysis.outputs.final-conclusion }} + run: | + printf '%s\n' "$ANALYSIS_PROMPT" + echo "issue_number=$ISSUE_NUMBER" + echo "comment_id=$COMMENT_ID" + echo "comment_url=$COMMENT_URL" + printf '%s\n' "$COPILOT_OUTPUT" + printf '%s\n' "$FINAL_CONCLUSION" diff --git a/.github/workflows/ai-issue-analysis.yml b/.github/workflows/ai-issue-analysis.yml index 41d2a55..98accc0 100644 --- a/.github/workflows/ai-issue-analysis.yml +++ b/.github/workflows/ai-issue-analysis.yml @@ -30,7 +30,7 @@ jobs: # - .claude/skills/generic-issue-log-analysis/SKILL.md - name: Analyze issue with AI id: analysis - uses: Misteo/ai-issue-analysis@main + uses: MistEO/ai-issue-analysis@main with: # 请在 https://github.com/settings/personal-access-tokens 添加一个token,填写到仓库 secret 里(请确保你有 Copilot Pro) copilot-github-token: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/README.md b/README.md index 05d1d75..49d26e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ai-issue-analysis -一个通用的 GitHub composite action,用来在 Issue 打开或被评论时调用 Copilot CLI 做分析,并把分析过程和最终结论持续回写到同一条评论里。 +一个通用的 GitHub composite action,用来在 Issue 打开或被评论时调用 **Copilot CLI** 或 **Cursor CLI** 做分析,并把分析过程和最终结论持续回写到同一条评论里。 实战效果展示: @@ -9,7 +9,9 @@ ## 快速接入 -1. 请确保你有 Copilot Pro (当前仅支持 Copilot,以后可能适配 codex 等更多工具,欢迎 ISSUE 催更~) +### 使用 GitHub Copilot + +1. 请确保你有 Copilot Pro 2. 前往 [GitHub PAT](https://github.com/settings/personal-access-tokens) 新增一个 token - Expiration (过期时间): 设为一年以内(太长反而会报错) @@ -21,12 +23,28 @@ - Name: `COPILOT_GITHUB_TOKEN` - Secret: 上一步中生成的那个 -5. 把下面两个文件拷贝到你的仓库里,文件夹不要变 +4. 把下面两个文件拷贝到你的仓库里,文件夹不要变 - [`.github/workflows/ai-issue-analysis.yml`](.github/workflows/ai-issue-analysis.yml) - [`.claude/skills/generic-issue-log-analysis/SKILL.md`](.claude/skills/generic-issue-log-analysis/SKILL.md) -6. 新提个 issue 测试下能否正常运行了,或者在以前的 issue 里 `@github-actions` +5. 新提个 issue 测试下能否正常运行了,或者在以前的 issue 里 `@github-actions` + +### 使用 Cursor CLI(可选) + +1. 前往 [Cursor Dashboard](https://cursor.com/dashboard/integrations) 创建 API Key +2. 在仓库 Secrets 中添加 `CURSOR_API_KEY` +3. 调用 action 时指定 `ai-provider: cursor` 并传入 `cursor-api-key`: + +```yaml +- uses: MistEO/ai-issue-analysis@main + with: + ai-provider: cursor + github-token: ${{ secrets.GITHUB_TOKEN }} + cursor-api-key: ${{ secrets.CURSOR_API_KEY }} +``` + +> 不传 `ai-provider` 时默认使用 Copilot,现有 workflow 无需改动。 > [!TIP] > @@ -44,7 +62,10 @@ 如果你的 workflow_dispatch 输入名不是 `issue_number`,或者你在其他事件里调用这个 action,就显式传 `issue-number`。 - `github-token`: 用于创建和更新 Issue 评论 -- `copilot-github-token`: Copilot CLI 使用的 Fine-grained token,支持传多个 token,每行一个,action 会随机选择一个使用 +- `copilot-github-token`: Copilot CLI 使用的 Fine-grained token;`ai-provider=copilot` 时在运行时必填,支持传多个 token,每行一个,action 会随机选择一个使用 +- `ai-provider`: AI 后端,`copilot`(默认)或 `cursor` +- `cursor-api-key`: Cursor CLI 使用的 API Key;`ai-provider=cursor` 时在运行时必填,支持多 key 换行随机选择 +- `cursor-model`: Cursor CLI 模型名,默认 `composer-2.5` - `bot-name`: 从 `issue_comment` 正文中剥离掉的 bot mention,比如 `@YourBot` - `initial-comment-body`: 开始分析时先发出的评论正文 - `action-link-text`: 评论里展示的运行链接文字 @@ -62,19 +83,19 @@ - `issue-number`: 本次运行实际解析出的 Issue 编号 - `comment-id`: 创建并持续更新的评论 ID - `comment-url`: 创建并持续更新的评论 URL -- `analysis-prompt`: 本次最终传给 Copilot 的 prompt -- `copilot-output`: 完整执行日志,包含 Copilot 启动前的参数打印、prompt 正文,以及 Copilot CLI 输出 -- `final-conclusion`: Copilot 写入 `copilot-answer-file` 的最终结论 +- `analysis-prompt`: 本次最终传给 AI 后端的 prompt +- `copilot-output`: 完整执行日志,包含启动前参数、prompt 正文,以及 Copilot CLI / Cursor CLI 输出 +- `final-conclusion`: 写入 `copilot-answer-file` 的最终结论 - `analysis-prompt`、`copilot-output` 和 `final-conclusion` 在过长时会为适配 GitHub Actions output 大小限制而被截断;完整内容优先从 artifacts 读取 ## 上传产物 -- `copilot-output-issue--comment-`: 完整执行日志,包含启动前参数、prompt 正文和 Copilot CLI 输出 +- `copilot-output-issue--comment-`: 完整执行日志,包含启动前参数、prompt 正文和 AI CLI 输出 - `final-conclusion-issue--comment-`: 最终结论文本 ## Skill 配合 -- 这个 action 只负责 GitHub Actions 编排、评论更新、Copilot CLI 调用和 prompt 拼接,不内置项目领域知识 +- 这个 action 只负责 GitHub Actions 编排、评论更新、AI CLI 调用和 prompt 拼接,不内置项目领域知识 - 对需要分析 issue 附件、日志包、运行时配置、跨仓库代码路径的项目,建议配套提供项目自己的 issue 分析 skill - 一个可行的 skill 一般至少会覆盖这些步骤:读取 issue 正文和评论、定位并下载日志附件、先建立时间线再筛证据、最后回溯到代码和文档做归因 - 如果没有这层 skill,action 仍然能运行,但对日志包、截图、跨模块调用链这类问题,分析质量通常会明显下降 @@ -93,11 +114,12 @@ - action 内部会自动 `checkout` 调用方仓库 - 如果调用方已经自己 checkout,或者前置步骤会生成工作区文件,可以把 `checkout-repository` 设为 `false` -- 会自动安装 `@github/copilot` +- `ai-provider` 省略时默认 `copilot`,会自动安装 `@github/copilot` +- `ai-provider=cursor` 时会通过官方 [cursor.com/install](https://cursor.com/install) 安装 Cursor CLI 并调用 `cursor-agent` - 会先创建一条评论,然后持续更新这条评论 - 会导出 `comment-id`、`comment-url`、`analysis-prompt`、`copilot-output`、`final-conclusion` 等 action outputs -- `copilot-output` 会包含 Copilot 启动前的参数打印和 prompt 正文,不再只是 Copilot 进程本身的 stdout/stderr -- 会上传 Copilot 原始输出和最终结论两个 artifacts +- `copilot-output` 会包含启动前的参数打印和 prompt 正文,不再只是 AI CLI 进程本身的 stdout/stderr +- 会上传 AI 原始输出和最终结论两个 artifacts - 最终评论会包含最终结论、完整分析过程折叠块,以及当前 Actions 运行链接 - `copilot-github-token` 兼容单个 token,也兼容多个 token 按行填写;传多个时每次运行会随机选一个 diff --git a/action.yml b/action.yml index 301bf66..6f7c0fb 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: AI Issue Analysis -description: Analyze a GitHub issue with Copilot CLI and keep a comment updated with progress and the final result. +description: Analyze a GitHub issue with Copilot CLI or Cursor CLI and keep a comment updated with progress and the final result. author: MistEO inputs: @@ -11,8 +11,9 @@ inputs: description: GitHub token used to create and update issue comments. required: true copilot-github-token: - description: Fine-grained GitHub token with Copilot access for the Copilot CLI. Supports multiple tokens, one per line, and randomly picks one. - required: true + description: Fine-grained GitHub token with Copilot access for the Copilot CLI. Required at runtime when ai-provider is copilot. Supports multiple tokens, one per line, and randomly picks one. + required: false + default: "" bot-name: description: Bot mention to strip from issue_comment messages before appending extra requirements. required: false @@ -80,6 +81,18 @@ inputs: description: Extra content always appended at the bottom of every comment update. Empty by default. required: false default: "" + ai-provider: + description: AI backend to use. Supported values are copilot and cursor. Defaults to copilot when empty. + required: false + default: copilot + cursor-api-key: + description: Cursor API key for Cursor CLI. Required at runtime when ai-provider is cursor. Supports multiple keys, one per line, and randomly picks one. + required: false + default: "" + cursor-model: + description: Cursor CLI model name passed to cursor-agent --model. + required: false + default: composer-2.5 outputs: issue-number: @@ -92,13 +105,13 @@ outputs: description: The URL of the issue comment that was created and then updated. value: ${{ steps.export_results.outputs.comment-url }} analysis-prompt: - description: Final rendered prompt passed to Copilot for this run. + description: Final rendered prompt passed to the AI backend for this run. value: ${{ steps.export_results.outputs.analysis-prompt }} copilot-output: - description: Execution log including pre-run parameters, prompt, and Copilot stdout and stderr. + description: Execution log including pre-run parameters, prompt, and AI CLI stdout and stderr. value: ${{ steps.export_results.outputs.copilot-output }} final-conclusion: - description: Final conclusion content produced by Copilot. + description: Final conclusion content written to copilot-answer-file. value: ${{ steps.export_results.outputs.final-conclusion }} runs: @@ -137,6 +150,47 @@ runs: fh.write(f"issue_number={issue_number}\n") PY + - name: Resolve AI provider + id: provider + shell: bash + env: + INPUT_AI_PROVIDER: ${{ inputs.ai-provider }} + run: | + python <<'PY' + import os + + provider = os.environ["INPUT_AI_PROVIDER"].strip().lower() or "copilot" + if provider not in {"copilot", "cursor"}: + raise SystemExit(f"Unsupported ai-provider: {provider}. Supported values: copilot, cursor.") + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write(f"provider={provider}\n") + PY + + - name: Validate provider credentials + shell: bash + env: + AI_PROVIDER: ${{ steps.provider.outputs.provider }} + INPUT_CURSOR_API_KEY: ${{ inputs.cursor-api-key }} + INPUT_COPILOT_GITHUB_TOKEN: ${{ inputs.copilot-github-token }} + run: | + python <<'PY' + import os + + provider = os.environ["AI_PROVIDER"] + + if provider == "cursor": + raw_value = os.environ["INPUT_CURSOR_API_KEY"] + keys = [line.strip() for line in raw_value.splitlines() if line.strip()] + if not keys: + raise SystemExit("Input cursor-api-key is required when ai-provider=cursor.") + elif provider == "copilot": + raw_value = os.environ["INPUT_COPILOT_GITHUB_TOKEN"] + tokens = [line.strip() for line in raw_value.splitlines() if line.strip()] + if not tokens: + raise SystemExit("Input copilot-github-token is required when ai-provider=copilot.") + PY + - name: Prepare runtime files id: runtime shell: bash @@ -232,11 +286,21 @@ runs: ${{ steps.runtime.outputs.action_link }} - name: Install Copilot CLI + if: ${{ steps.provider.outputs.provider == 'copilot' }} shell: bash run: npm install -g "${{ inputs.copilot-package }}" + - name: Install Cursor CLI + if: ${{ steps.provider.outputs.provider == 'cursor' }} + shell: bash + run: | + curl https://cursor.com/install -fsS | bash + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + cursor-agent --version + - name: Resolve Copilot token id: resolve_copilot_token + if: ${{ steps.provider.outputs.provider == 'copilot' }} shell: bash env: INPUT_COPILOT_GITHUB_TOKEN: ${{ inputs.copilot-github-token }} @@ -253,15 +317,45 @@ runs: selected_token = secrets.choice(tokens) + print(f"::add-mask::{selected_token}") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as fh: + fh.write(f"COPILOT_GITHUB_TOKEN={selected_token}\n") + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: - fh.write(f"copilot_token={selected_token}\n") fh.write(f"copilot_token_count={len(tokens)}\n") PY - - name: Run Copilot analysis with streaming updates + - name: Resolve Cursor API key + id: resolve_cursor_api_key + if: ${{ steps.provider.outputs.provider == 'cursor' }} shell: bash env: - COPILOT_GITHUB_TOKEN: ${{ steps.resolve_copilot_token.outputs.copilot_token }} + INPUT_CURSOR_API_KEY: ${{ inputs.cursor-api-key }} + run: | + python <<'PY' + import os + import secrets + + raw_value = os.environ["INPUT_CURSOR_API_KEY"] + keys = [line.strip() for line in raw_value.splitlines() if line.strip()] + + if not keys: + raise SystemExit("Input cursor-api-key is required when ai-provider=cursor.") + + selected_key = secrets.choice(keys) + + print(f"::add-mask::{selected_key}") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as fh: + fh.write(f"CURSOR_API_KEY={selected_key}\n") + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write(f"cursor_api_key_count={len(keys)}\n") + PY + + - name: Run AI analysis with streaming updates + shell: bash + env: + AI_PROVIDER: ${{ steps.provider.outputs.provider }} COMMENT_GITHUB_TOKEN: ${{ inputs.github-token }} GITHUB_SERVER_URL: ${{ github.server_url }} COMMENT_ID: ${{ steps.initial_comment.outputs.comment-id }} @@ -277,6 +371,7 @@ runs: DETAILS_END: ${{ steps.runtime.outputs.details_end }} COPILOT_MODEL: ${{ inputs.copilot-model }} COPILOT_REASONING_EFFORT: ${{ inputs.copilot-reasoning-effort }} + CURSOR_MODEL: ${{ inputs.cursor-model }} STREAM_UPDATE_INTERVAL: ${{ inputs.stream-update-interval-seconds }} EXTRA_COMMENT_CONTENT: ${{ inputs.extra-comment-content }} run: | @@ -284,29 +379,57 @@ runs: : > "$OUTPUT_FILE" : > "$EXECUTION_LOG_FILE" - { - echo "Copilot invocation parameters:" - echo " repo: $REPO" - echo " issue-number: $ISSUE_NUMBER" - echo " comment-id: $COMMENT_ID" - echo " comment-url: $COMMENT_URL" - echo " model: $COPILOT_MODEL" - echo " reasoning-effort: $COPILOT_REASONING_EFFORT" - echo " copilot-token-count: ${{ steps.resolve_copilot_token.outputs.copilot_token_count }}" - echo " stream-update-interval-seconds: $STREAM_UPDATE_INTERVAL" - echo " analysis-prompt-file: $ANALYSIS_PROMPT_FILE" - echo " output-file: $OUTPUT_FILE" - echo " body-file: $BODY_FILE" - echo " command: copilot --yolo --model \"$COPILOT_MODEL\" --reasoning-effort \"$COPILOT_REASONING_EFFORT\" --prompt \"\"" - echo "Prompt content begins" - cat "$ANALYSIS_PROMPT_FILE" - echo "Prompt content ends" - } | tee -a "$EXECUTION_LOG_FILE" - - copilot --yolo --model "$COPILOT_MODEL" --reasoning-effort "$COPILOT_REASONING_EFFORT" \ - --prompt "$(cat "$ANALYSIS_PROMPT_FILE")" > "$OUTPUT_FILE" 2>&1 & - COPILOT_PID=$! - echo "Copilot started with PID $COPILOT_PID" | tee -a "$EXECUTION_LOG_FILE" + + if [ "$AI_PROVIDER" = "cursor" ]; then + { + echo "Cursor invocation parameters:" + echo " repo: $REPO" + echo " issue-number: $ISSUE_NUMBER" + echo " comment-id: $COMMENT_ID" + echo " comment-url: $COMMENT_URL" + echo " model: $CURSOR_MODEL" + echo " cursor-api-key-count: ${{ steps.resolve_cursor_api_key.outputs.cursor_api_key_count }}" + echo " stream-update-interval-seconds: $STREAM_UPDATE_INTERVAL" + echo " analysis-prompt-file: $ANALYSIS_PROMPT_FILE" + echo " output-file: $OUTPUT_FILE" + echo " body-file: $BODY_FILE" + echo " command: cursor-agent -p \"\" --force --model \"$CURSOR_MODEL\" --output-format text" + echo "Prompt content begins" + cat "$ANALYSIS_PROMPT_FILE" + echo "Prompt content ends" + } | tee -a "$EXECUTION_LOG_FILE" + + cursor-agent -p "$(cat "$ANALYSIS_PROMPT_FILE")" \ + --force \ + --model "$CURSOR_MODEL" \ + --output-format text > "$OUTPUT_FILE" 2>&1 & + AGENT_PID=$! + echo "Cursor Agent started with PID $AGENT_PID" | tee -a "$EXECUTION_LOG_FILE" + else + { + echo "Copilot invocation parameters:" + echo " repo: $REPO" + echo " issue-number: $ISSUE_NUMBER" + echo " comment-id: $COMMENT_ID" + echo " comment-url: $COMMENT_URL" + echo " model: $COPILOT_MODEL" + echo " reasoning-effort: $COPILOT_REASONING_EFFORT" + echo " copilot-token-count: ${{ steps.resolve_copilot_token.outputs.copilot_token_count }}" + echo " stream-update-interval-seconds: $STREAM_UPDATE_INTERVAL" + echo " analysis-prompt-file: $ANALYSIS_PROMPT_FILE" + echo " output-file: $OUTPUT_FILE" + echo " body-file: $BODY_FILE" + echo " command: copilot --yolo --model \"$COPILOT_MODEL\" --reasoning-effort \"$COPILOT_REASONING_EFFORT\" --prompt \"\"" + echo "Prompt content begins" + cat "$ANALYSIS_PROMPT_FILE" + echo "Prompt content ends" + } | tee -a "$EXECUTION_LOG_FILE" + + copilot --yolo --model "$COPILOT_MODEL" --reasoning-effort "$COPILOT_REASONING_EFFORT" \ + --prompt "$(cat "$ANALYSIS_PROMPT_FILE")" > "$OUTPUT_FILE" 2>&1 & + AGENT_PID=$! + echo "Copilot started with PID $AGENT_PID" | tee -a "$EXECUTION_LOG_FILE" + fi last_content="" @@ -314,9 +437,9 @@ runs: sleep "$STREAM_UPDATE_INTERVAL" & sleep_pid=$! - wait -n "$COPILOT_PID" "$sleep_pid" 2>/dev/null || true + wait -n "$AGENT_PID" "$sleep_pid" 2>/dev/null || true - if ! kill -0 "$COPILOT_PID" 2>/dev/null; then + if ! kill -0 "$AGENT_PID" 2>/dev/null; then kill "$sleep_pid" 2>/dev/null || true break fi @@ -357,16 +480,24 @@ runs: fi done - wait "$COPILOT_PID" + wait "$AGENT_PID" { echo - echo "Copilot output begins" + if [ "$AI_PROVIDER" = "cursor" ]; then + echo "Cursor output begins" + else + echo "Copilot output begins" + fi if [ -f "$OUTPUT_FILE" ]; then cat "$OUTPUT_FILE" fi echo - echo "Copilot output ends" + if [ "$AI_PROVIDER" = "cursor" ]; then + echo "Cursor output ends" + else + echo "Copilot output ends" + fi } >> "$EXECUTION_LOG_FILE" - name: Full analysis