diff --git a/.github/workflows/agent-coding.yml b/.github/workflows/agent-coding.yml new file mode 100644 index 0000000..9d9a1cc --- /dev/null +++ b/.github/workflows/agent-coding.yml @@ -0,0 +1,101 @@ +name: Coding Agent + +# Triggers Cursor's Coding Cloud Agent via API when a Linear issue arrives +# in this repo (mirrored as a GitHub issue by Linear's GitHub integration). +# Replaces the manual cursor.com/agents Automation so the trigger config +# lives in code and propagates automatically via gsd new. + +on: + issues: + types: [opened] + +# Concurrency-guard against the rare case where the same issue gets edited +# in a way that re-fires the trigger before the previous run claimed work. +concurrency: + group: coding-agent-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + # Gate the agent on CURSOR_API_KEY being present. Secrets can't be used in + # job-level `if:`, so we surface presence as an output here and skip cleanly + # (neutral, not failed) on repos that haven't set the key. + guard: + name: Check Cursor API key + runs-on: ubuntu-latest + outputs: + has_key: ${{ steps.check.outputs.has_key }} + steps: + - id: check + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + if [ -n "$CURSOR_API_KEY" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Cursor agent skipped::CURSOR_API_KEY is not set on this repo — skipping. Set it with: gh secret set CURSOR_API_KEY --repo /" + fi + + trigger: + name: Launch Cursor Coding Agent + needs: guard + if: > + needs.guard.outputs.has_key == 'true' && + contains(github.event.issue.labels.*.name, 'agent-task') && + startsWith(github.event.issue.title, '[CODING]') + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v7 + + - name: Render prompt and call Cursor API + id: trigger + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + REPO: ${{ github.repository }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + ISSUE_URL: ${{ github.event.issue.html_url }} + run: | + set -euo pipefail + PROMPT=$(jq -Rs \ + --arg repo "$REPO" \ + --arg t "$ISSUE_TITLE" \ + --arg b "${ISSUE_BODY:-}" \ + --arg u "$ISSUE_URL" \ + ' + gsub("\\{\\{repo\\}\\}"; $repo) + | gsub("\\{\\{issue.title\\}\\}"; $t) + | gsub("\\{\\{issue.body\\}\\}"; $b) + | gsub("\\{\\{issue.url\\}\\}"; $u) + ' .cursor/agent-prompts/coding.md) + + PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + --arg repo "$REPO" \ + '{ + prompt: { text: $prompt }, + source: { repository: ("github.com/" + $repo), ref: "staging" }, + target: { autoCreatePr: true, openAsCursorGithubApp: true }, + model: "composer-2.5" + }') + + response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + echo "$response" | jq . + AGENT_ID=$(echo "$response" | jq -er '.id') + echo "agent_id=$AGENT_ID" >> "$GITHUB_OUTPUT" + + - name: Comment agent run link on issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + ISSUE: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: | + gh issue comment "$ISSUE" --repo "$REPO" \ + --body "🤖 Cursor coding agent started: [\`$AGENT_ID\`](https://cursor.com/agents/$AGENT_ID) — agent will open a PR against \`staging\` when done." diff --git a/.github/workflows/agent-devops.yml b/.github/workflows/agent-devops.yml new file mode 100644 index 0000000..b5166ee --- /dev/null +++ b/.github/workflows/agent-devops.yml @@ -0,0 +1,132 @@ +name: DevOps Agent + +# Triggers Cursor's DevOps Cloud Agent via API after a qa-passed PR merges +# into staging. The workflow waits for the agent and posts its report as +# a comment on the merged PR (Cursor's sandbox token can't comment). + +on: + pull_request: + types: [closed] + +jobs: + # Gate the agent on CURSOR_API_KEY being present. Secrets can't be used in + # job-level `if:`, so we surface presence as an output here and skip cleanly + # (neutral, not failed) on repos that haven't set the key. + guard: + name: Check Cursor API key + runs-on: ubuntu-latest + outputs: + has_key: ${{ steps.check.outputs.has_key }} + steps: + - id: check + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + if [ -n "$CURSOR_API_KEY" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Cursor agent skipped::CURSOR_API_KEY is not set on this repo — skipping. Set it with: gh secret set CURSOR_API_KEY --repo /" + fi + + trigger: + name: Run DevOps Agent + needs: guard + if: > + needs.guard.outputs.has_key == 'true' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'staging' && + contains(github.event.pull_request.labels.*.name, 'qa-passed') + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: staging + + - name: Trigger Cursor agent + id: trigger + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + REPO: ${{ github.repository }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + PROMPT=$(jq -Rs \ + --arg repo "$REPO" \ + --arg t "$PR_TITLE" \ + --arg u "$PR_URL" \ + --arg base "$PR_BASE" \ + ' + gsub("\\{\\{repo\\}\\}"; $repo) + | gsub("\\{\\{pr.title\\}\\}"; $t) + | gsub("\\{\\{pr.url\\}\\}"; $u) + | gsub("\\{\\{pr.base_ref\\}\\}"; $base) + ' .cursor/agent-prompts/devops.md) + + PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + --arg repo "$REPO" \ + '{ + prompt: { text: $prompt }, + source: { repository: ("github.com/" + $repo), ref: "staging" }, + target: { autoCreatePr: false, openAsCursorGithubApp: true }, + model: "composer-2.5" + }') + + response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + echo "$response" | jq . + AGENT_ID=$(echo "$response" | jq -er '.id') + echo "agent_id=$AGENT_ID" >> "$GITHUB_OUTPUT" + + - name: Wait for agent to finish + id: wait + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + run: | + set -euo pipefail + for i in $(seq 1 30); do + agent_status=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID" \ + -H "Authorization: Bearer $CURSOR_API_KEY" | jq -r '.status') + echo "[$i/30] status: $agent_status" + if [[ "$agent_status" != "RUNNING" && "$agent_status" != "CREATING" ]]; then + echo "final_status=$agent_status" >> "$GITHUB_OUTPUT" + exit 0 + fi + sleep 30 + done + echo "final_status=TIMEOUT" >> "$GITHUB_OUTPUT" + + - name: Post agent report as PR comment + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + AGENT_STATUS: ${{ steps.wait.outputs.final_status }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + REPORT=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID/conversation" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + | jq -r '[.messages[] | select(.type == "assistant_message") | .text] | last') + + { + echo "## DevOps verification (Cursor DevOps agent — IaC)" + echo "" + echo "**Agent run:** [\`$AGENT_ID\`](https://cursor.com/agents/$AGENT_ID)" + echo "**Status:** \`$AGENT_STATUS\`" + echo "" + echo "$REPORT" + } > /tmp/devops-comment.md + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/devops-comment.md diff --git a/.github/workflows/agent-qa.yml b/.github/workflows/agent-qa.yml new file mode 100644 index 0000000..03cd332 --- /dev/null +++ b/.github/workflows/agent-qa.yml @@ -0,0 +1,145 @@ +name: QA Agent + +# Triggers Cursor's QA Cloud Agent via API when a PR is labeled needs-qa, +# waits for the agent to finish, then posts the report as a PR comment +# and updates labels. The workflow does the comment + label step (not +# the agent) because Cursor's sandbox token lacks pull-requests:write. + +on: + pull_request: + types: [labeled] + +jobs: + # Gate the agent on CURSOR_API_KEY being present. Secrets can't be used in + # job-level `if:`, so we surface presence as an output here and skip cleanly + # (neutral, not failed) on repos that haven't set the key. + guard: + name: Check Cursor API key + runs-on: ubuntu-latest + outputs: + has_key: ${{ steps.check.outputs.has_key }} + steps: + - id: check + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + if [ -n "$CURSOR_API_KEY" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Cursor agent skipped::CURSOR_API_KEY is not set on this repo — skipping. Set it with: gh secret set CURSOR_API_KEY --repo /" + fi + + trigger: + name: Run QA Agent + needs: guard + if: needs.guard.outputs.has_key == 'true' && github.event.label.name == 'needs-qa' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Trigger Cursor agent + id: trigger + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + REPO: ${{ github.repository }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_HEAD: ${{ github.event.pull_request.head.ref }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + PROMPT=$(jq -Rs \ + --arg repo "$REPO" \ + --arg t "$PR_TITLE" \ + --arg b "${PR_BODY:-}" \ + --arg u "$PR_URL" \ + --arg h "$PR_HEAD" \ + --arg base "$PR_BASE" \ + ' + gsub("\\{\\{repo\\}\\}"; $repo) + | gsub("\\{\\{pr.title\\}\\}"; $t) + | gsub("\\{\\{pr.body\\}\\}"; $b) + | gsub("\\{\\{pr.url\\}\\}"; $u) + | gsub("\\{\\{pr.head_ref\\}\\}"; $h) + | gsub("\\{\\{pr.base_ref\\}\\}"; $base) + ' .cursor/agent-prompts/qa.md) + + PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + --arg repo "$REPO" \ + --arg ref "$PR_HEAD" \ + '{ + prompt: { text: $prompt }, + source: { repository: ("github.com/" + $repo), ref: $ref }, + target: { autoCreatePr: false, openAsCursorGithubApp: true }, + model: "composer-2.5" + }') + + response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + echo "$response" | jq . + AGENT_ID=$(echo "$response" | jq -er '.id') + echo "agent_id=$AGENT_ID" >> "$GITHUB_OUTPUT" + + - name: Wait for agent to finish + id: wait + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + run: | + set -euo pipefail + for i in $(seq 1 30); do + agent_status=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID" \ + -H "Authorization: Bearer $CURSOR_API_KEY" | jq -r '.status') + echo "[$i/30] status: $agent_status" + if [[ "$agent_status" != "RUNNING" && "$agent_status" != "CREATING" ]]; then + echo "final_status=$agent_status" >> "$GITHUB_OUTPUT" + exit 0 + fi + sleep 30 + done + echo "final_status=TIMEOUT" >> "$GITHUB_OUTPUT" + + - name: Post agent report as PR comment + update labels + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + AGENT_STATUS: ${{ steps.wait.outputs.final_status }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + REPORT=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID/conversation" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + | jq -r '[.messages[] | select(.type == "assistant_message") | .text] | last') + + { + echo "## QA report (Cursor QA agent — IaC)" + echo "" + echo "**Agent run:** [\`$AGENT_ID\`](https://cursor.com/agents/$AGENT_ID)" + echo "**Status:** \`$AGENT_STATUS\`" + echo "" + echo "$REPORT" + } > /tmp/qa-comment.md + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/qa-comment.md + + # Outcome detection: if the report indicates success, flip labels + if [[ "$AGENT_STATUS" == "FINISHED" ]] && \ + echo "$REPORT" | grep -qiE "all (checks )?(✅|green|passed)"; then + gh pr edit "$PR" --repo "$REPO" --remove-label needs-qa --add-label qa-passed + echo "Labels: needs-qa → qa-passed" + else + gh pr edit "$PR" --repo "$REPO" --add-label needs-human || true + echo "Labels: added needs-human (status=$AGENT_STATUS, not all-green)" + exit 1 + fi diff --git a/.github/workflows/agent-security.yml b/.github/workflows/agent-security.yml new file mode 100644 index 0000000..9e8b6ae --- /dev/null +++ b/.github/workflows/agent-security.yml @@ -0,0 +1,191 @@ +name: Security Agent + +# Triggers Cursor's Security Cloud Agent via API in two modes: +# 1. PR mode — Dependabot/dep-labeled PR → workflow waits + posts report +# 2. Cron mode — weekly Mondays 06:00 UTC, sandbox creates fix PRs/issues +# (Cursor sandbox can create new PRs/issues but cannot comment on existing +# PRs, so PR-mode requires the workflow to post the report itself.) + +on: + pull_request: + types: [opened, synchronize, labeled] + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + # Gate the agent on CURSOR_API_KEY being present. Secrets can't be used in + # job-level `if:`, so we surface presence as an output here and skip cleanly + # (neutral, not failed) on repos that haven't set the key. + guard: + name: Check Cursor API key + runs-on: ubuntu-latest + outputs: + has_key: ${{ steps.check.outputs.has_key }} + steps: + - id: check + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + if [ -n "$CURSOR_API_KEY" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Cursor agent skipped::CURSOR_API_KEY is not set on this repo — skipping. Set it with: gh secret set CURSOR_API_KEY --repo /" + fi + + pr-mode: + name: Run Security Agent (PR mode) + needs: guard + if: > + needs.guard.outputs.has_key == 'true' && + github.event_name == 'pull_request' && ( + github.event.pull_request.user.login == 'dependabot[bot]' || + contains(github.event.pull_request.labels.*.name, 'dependencies') + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Trigger Cursor agent + id: trigger + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + REPO: ${{ github.repository }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ', ') }} + PR_HEAD: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + PROMPT=$(jq -Rs \ + --arg repo "$REPO" \ + --arg mode "dependabot-pr" \ + --arg url "$PR_URL" \ + --arg author "$PR_AUTHOR" \ + --arg labels "$PR_LABELS" \ + ' + gsub("\\{\\{repo\\}\\}"; $repo) + | gsub("\\{\\{mode\\}\\}"; $mode) + | gsub("\\{\\{pr.url\\}\\}"; $url) + | gsub("\\{\\{pr.author\\}\\}"; $author) + | gsub("\\{\\{pr.labels\\}\\}"; $labels) + ' .cursor/agent-prompts/security.md) + + PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + --arg repo "$REPO" \ + --arg ref "$PR_HEAD" \ + '{ + prompt: { text: $prompt }, + source: { repository: ("github.com/" + $repo), ref: $ref }, + target: { autoCreatePr: false, openAsCursorGithubApp: true }, + model: "composer-2.5" + }') + + response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + echo "$response" | jq . + AGENT_ID=$(echo "$response" | jq -er '.id') + echo "agent_id=$AGENT_ID" >> "$GITHUB_OUTPUT" + + - name: Wait for agent to finish + id: wait + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + run: | + set -euo pipefail + for i in $(seq 1 30); do + agent_status=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID" \ + -H "Authorization: Bearer $CURSOR_API_KEY" | jq -r '.status') + echo "[$i/30] status: $agent_status" + if [[ "$agent_status" != "RUNNING" && "$agent_status" != "CREATING" ]]; then + echo "final_status=$agent_status" >> "$GITHUB_OUTPUT" + exit 0 + fi + sleep 30 + done + echo "final_status=TIMEOUT" >> "$GITHUB_OUTPUT" + + - name: Post agent report + label + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + AGENT_STATUS: ${{ steps.wait.outputs.final_status }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + REPORT=$(curl -sS "https://api.cursor.com/v0/agents/$AGENT_ID/conversation" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + | jq -r '[.messages[] | select(.type == "assistant_message") | .text] | last') + + { + echo "## Security review (Cursor Security agent — IaC)" + echo "" + echo "**Agent run:** [\`$AGENT_ID\`](https://cursor.com/agents/$AGENT_ID)" + echo "**Status:** \`$AGENT_STATUS\`" + echo "" + echo "$REPORT" + } > /tmp/sec-comment.md + gh pr comment "$PR" --repo "$REPO" --body-file /tmp/sec-comment.md + + if echo "$REPORT" | grep -qiE "safe to merge"; then + gh pr edit "$PR" --repo "$REPO" --add-label auto-merge-safe || true + elif echo "$REPORT" | grep -qiE "needs.human|risky|breaking"; then + gh pr edit "$PR" --repo "$REPO" --add-label needs-human || true + fi + + cron-mode: + name: Run Security Agent (weekly cron) + needs: guard + if: > + needs.guard.outputs.has_key == 'true' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Trigger Cursor agent + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + PROMPT=$(jq -Rs \ + --arg repo "$REPO" \ + --arg mode "weekly-cron" \ + --arg empty "" \ + ' + gsub("\\{\\{repo\\}\\}"; $repo) + | gsub("\\{\\{mode\\}\\}"; $mode) + | gsub("\\{\\{pr.url\\}\\}"; $empty) + | gsub("\\{\\{pr.author\\}\\}"; $empty) + | gsub("\\{\\{pr.labels\\}\\}"; $empty) + ' .cursor/agent-prompts/security.md) + + PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + --arg repo "$REPO" \ + '{ + prompt: { text: $prompt }, + source: { repository: ("github.com/" + $repo), ref: "main" }, + target: { autoCreatePr: true, openAsCursorGithubApp: true }, + model: "composer-2.5" + }') + + response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + echo "$response" | jq . + echo "$response" | jq -e '.id' >/dev/null diff --git a/.github/workflows/auto-ready-pr.yml b/.github/workflows/auto-ready-pr.yml new file mode 100644 index 0000000..2f332e4 --- /dev/null +++ b/.github/workflows/auto-ready-pr.yml @@ -0,0 +1,33 @@ +name: Auto-prep Cursor PRs + +# Cursor's API-triggered Coding agent opens PRs as Draft AND can't apply +# labels (sandbox integration token lacks pull-requests: write). This +# workflow flips drafts to ready AND adds needs-qa for cursor/* PRs. + +on: + pull_request: + types: [opened, reopened] + +jobs: + prep: + if: startsWith(github.head_ref, 'cursor/') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Mark as Ready for Review + if: github.event.pull_request.draft == true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr ready ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} + + - name: Add needs-qa label + if: ${{ !contains(github.event.pull_request.labels.*.name, 'needs-qa') && !contains(github.event.pull_request.labels.*.name, 'qa-passed') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr edit ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --add-label needs-qa diff --git a/.github/workflows/ci-doctor.yml b/.github/workflows/ci-doctor.yml new file mode 100644 index 0000000..fddd7dd --- /dev/null +++ b/.github/workflows/ci-doctor.yml @@ -0,0 +1,144 @@ +name: CI Doctor + +# When a core CI workflow fails on a mainline branch, capture the failure logs +# and dispatch Cursor's Cloud Agent to inspect and open a minimal fix PR (or an +# issue if it isn't safely auto-fixable). Reuses the same Cursor API pattern as +# the other agent-*.yml workflows. +# +# Scope is deliberately narrow to avoid fix loops: +# - only reacts to FAILED runs (conclusion == failure) +# - only on push/schedule to a mainline branch (not pull_request runs) +# - skips cursor/* branches, so it never doctors an agent's own fix PR +# - one run per branch+workflow at a time (concurrency guard) + +on: + workflow_run: + workflows: ["CI", "E2E Tests", "Security"] + types: [completed] + +concurrency: + group: ci-doctor-${{ github.event.workflow_run.head_branch }}-${{ github.event.workflow_run.name }} + cancel-in-progress: false + +jobs: + # Gate on CURSOR_API_KEY being present. Secrets can't be used in job-level + # `if:`, so we surface presence as an output here and skip cleanly (neutral, + # not failed) on repos that haven't set the key. + guard: + name: Check Cursor API key + runs-on: ubuntu-latest + outputs: + has_key: ${{ steps.check.outputs.has_key }} + steps: + - id: check + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + run: | + if [ -n "$CURSOR_API_KEY" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + echo "::notice title=CI Doctor skipped::CURSOR_API_KEY is not set on this repo — skipping. Set it with: gh secret set CURSOR_API_KEY --repo /" + fi + + diagnose: + name: Diagnose & dispatch fix agent + needs: guard + if: > + needs.guard.outputs.has_key == 'true' && + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.event != 'pull_request' && + !startsWith(github.event.workflow_run.head_branch, 'cursor/') + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + issues: write + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_branch }} + + - name: Capture failure logs + id: logs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + # Grab the failed-step logs, strip ANSI + timestamps, keep the tail. + gh run view "$RUN_ID" --repo "$REPO" --log-failed 2>/dev/null \ + | sed -E 's/\x1b\[[0-9;]*m//g' \ + | sed -E 's/^[0-9T:.Z+-]+ //' \ + | tail -n 300 > /tmp/ci-fail.log || true + if [ ! -s /tmp/ci-fail.log ]; then + echo "(no failed-step logs could be retrieved)" > /tmp/ci-fail.log + fi + echo "Captured $(wc -l < /tmp/ci-fail.log) log lines." + + - name: Dispatch Cursor CI Doctor agent + id: trigger + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + REPO: ${{ github.repository }} + RUN_NAME: ${{ github.event.workflow_run.name }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_EVENT: ${{ github.event.workflow_run.event }} + run: | + set -euo pipefail + LOGS=$(cat /tmp/ci-fail.log) + PROMPT=$(jq -Rs \ + --arg repo "$REPO" \ + --arg name "$RUN_NAME" \ + --arg url "$RUN_URL" \ + --arg branch "$BRANCH" \ + --arg event "$RUN_EVENT" \ + --arg logs "$LOGS" \ + ' + gsub("\\{\\{repo\\}\\}"; $repo) + | gsub("\\{\\{run.name\\}\\}"; $name) + | gsub("\\{\\{run.url\\}\\}"; $url) + | gsub("\\{\\{branch\\}\\}"; $branch) + | gsub("\\{\\{run.event\\}\\}"; $event) + | gsub("\\{\\{logs\\}\\}"; $logs) + ' .cursor/agent-prompts/ci-doctor.md) + + PAYLOAD=$(jq -n \ + --arg prompt "$PROMPT" \ + --arg repo "$REPO" \ + --arg ref "$BRANCH" \ + '{ + prompt: { text: $prompt }, + source: { repository: ("github.com/" + $repo), ref: $ref }, + target: { autoCreatePr: true, openAsCursorGithubApp: true }, + model: "composer-2.5" + }') + + response=$(curl -sS -X POST "https://api.cursor.com/v0/agents" \ + -H "Authorization: Bearer $CURSOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + echo "$response" | jq . + AGENT_ID=$(echo "$response" | jq -er '.id') + echo "agent_id=$AGENT_ID" >> "$GITHUB_OUTPUT" + + - name: Record dispatch in run summary + if: always() && steps.trigger.outputs.agent_id != '' + env: + AGENT_ID: ${{ steps.trigger.outputs.agent_id }} + RUN_NAME: ${{ github.event.workflow_run.name }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + { + echo "## 🔧 CI Doctor dispatched" + echo "" + echo "- **Failed workflow:** \`$RUN_NAME\` on \`$BRANCH\`" + echo "- **Failed run:** $RUN_URL" + echo "- **Cursor agent:** [\`$AGENT_ID\`](https://cursor.com/agents/$AGENT_ID)" + echo "" + echo "The agent will open a fix PR (or an issue if it isn't safely auto-fixable)." + } >> "$GITHUB_STEP_SUMMARY"