diff --git a/.github/workflows/triage-via-chat-api.yaml b/.github/workflows/triage-via-chat-api.yaml new file mode 100644 index 0000000000..0131e32384 --- /dev/null +++ b/.github/workflows/triage-via-chat-api.yaml @@ -0,0 +1,295 @@ +# This workflow reimplements the AI Triage Automation using the Coder Chat API +# instead of the Tasks API. The Chat API (/api/experimental/chats) is a simpler +# interface that does not require a dedicated GitHub Action or workspace +# provisioning — we just create a chat, poll for completion, and link the +# result on the issue. All API calls use curl + jq directly. +# +# Key differences from the Tasks API workflow (traiage.yaml): +# - No checkout of coder/create-task-action; everything is inline curl/jq. +# - No template_name / template_preset / prefix inputs — the Chat API handles +# resource allocation internally. +# - Uses POST /api/experimental/chats to create a chat session. +# - Polls GET /api/experimental/chats/ until the agent finishes. +# - Chat URL format: ${CODER_URL}/agents?chat=${CHAT_ID} + +name: AI Triage via Chat API + +on: + issues: + types: + - labeled + workflow_dispatch: + inputs: + issue_url: + description: "GitHub Issue URL to process" + required: true + type: string + +permissions: + contents: read + +jobs: + triage-chat: + name: Triage GitHub Issue via Chat API + runs-on: ubuntu-latest + if: github.event.label.name == 'chat-triage' || github.event_name == 'workflow_dispatch' + timeout-minutes: 30 + env: + CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }} + CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }} + permissions: + contents: read + issues: write + + steps: + # ------------------------------------------------------------------ + # Step 1: Determine the GitHub user and issue URL. + # Identical to the Tasks API workflow — resolve the actor for + # workflow_dispatch or the issue sender for label events. + # ------------------------------------------------------------------ + - name: Determine Inputs + id: determine-inputs + if: always() + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_EVENT_ISSUE_HTML_URL: ${{ github.event.issue.html_url }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }} + GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }} + INPUTS_ISSUE_URL: ${{ inputs.issue_url }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # For workflow_dispatch, use the actor who triggered it. + # For issues events, use the issue sender. + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then + echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}" + exit 1 + fi + echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})" + echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}" + echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}" + + echo "Using issue URL: ${INPUTS_ISSUE_URL}" + echo "issue_url=${INPUTS_ISSUE_URL}" >> "${GITHUB_OUTPUT}" + + exit 0 + elif [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then + GITHUB_USER_ID=${GITHUB_EVENT_USER_ID} + echo "Using issue author: ${GITHUB_EVENT_USER_LOGIN} (ID: ${GITHUB_USER_ID})" + echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}" + echo "github_username=${GITHUB_EVENT_USER_LOGIN}" >> "${GITHUB_OUTPUT}" + + echo "Using issue URL: ${GITHUB_EVENT_ISSUE_HTML_URL}" + echo "issue_url=${GITHUB_EVENT_ISSUE_HTML_URL}" >> "${GITHUB_OUTPUT}" + + exit 0 + else + echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}" + exit 1 + fi + + # ------------------------------------------------------------------ + # Step 2: Verify the triggering user has push access. + # Unchanged from the Tasks API workflow. + # ------------------------------------------------------------------ + - name: Verify push access + env: + GITHUB_REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + GITHUB_USERNAME: ${{ steps.determine-inputs.outputs.github_username }} + GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }} + run: | + set -euo pipefail + + can_push="$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${GITHUB_USERNAME}/permission" --jq '.user.permissions.push')" + if [[ "${can_push}" != "true" ]]; then + echo "::error title=Access Denied::${GITHUB_USERNAME} does not have push access to ${GITHUB_REPOSITORY}" + exit 1 + fi + + # ------------------------------------------------------------------ + # Step 3: Create a chat via the Coder Chat API. + # Unlike the Tasks API which provisions a full workspace, the Chat + # API creates a lightweight chat session. We POST to + # /api/experimental/chats with the triage prompt as the initial + # message and receive a chat ID back. + # ------------------------------------------------------------------ + - name: Create chat via Coder Chat API + id: create-chat + env: + ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Build the same triage prompt used by the Tasks API workflow. + TASK_PROMPT=$(cat <<'EOF' + Fix ${ISSUE_URL} + + 1. Use the gh CLI to read the issue description and comments. + 2. Think carefully and try to understand the root cause. If the issue is unclear or not well defined, ask me to clarify and provide more information. + 3. Write a proposed implementation plan to PLAN.md for me to review before starting implementation. Your plan should use TDD and only make the minimal changes necessary to fix the root cause. + 4. When I approve your plan, start working on it. If you encounter issues with the plan, ask me for clarification and update the plan as required. + 5. When you have finished implementation according to the plan, commit and push your changes, and create a PR using the gh CLI for me to review. + EOF + ) + # Perform variable substitution on the prompt — scoped to $ISSUE_URL only. + # Using envsubst without arguments would expand every env var in scope + # (including CODER_SESSION_TOKEN), so we name the variable explicitly. + TASK_PROMPT=$(echo "${TASK_PROMPT}" | envsubst '$ISSUE_URL') + + echo "Creating chat with prompt:" + echo "${TASK_PROMPT}" + + # POST to the Chat API to create a new chat session. + RESPONSE=$(curl --silent --fail-with-body \ + -X POST \ + -H "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg prompt "${TASK_PROMPT}" \ + '{content: [{type: "text", text: $prompt}]}')" \ + "${CODER_URL}/api/experimental/chats") + + echo "Chat API response:" + echo "${RESPONSE}" | jq . + + CHAT_ID=$(echo "${RESPONSE}" | jq -r '.id') + CHAT_STATUS=$(echo "${RESPONSE}" | jq -r '.status') + + if [[ -z "${CHAT_ID}" || "${CHAT_ID}" == "null" ]]; then + echo "::error::Failed to create chat — no ID returned" + echo "Response: ${RESPONSE}" + exit 1 + fi + + # Validate that CHAT_ID is a UUID before using it in URL paths. + # This guards against unexpected API responses being interpolated + # into subsequent curl calls. + if [[ ! "${CHAT_ID}" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then + echo "::error::CHAT_ID is not a valid UUID: ${CHAT_ID}" + exit 1 + fi + + CHAT_URL="${CODER_URL}/agents?chat=${CHAT_ID}" + + echo "Chat created: ${CHAT_ID} (status: ${CHAT_STATUS})" + echo "Chat URL: ${CHAT_URL}" + + echo "chat_id=${CHAT_ID}" >> "${GITHUB_OUTPUT}" + echo "chat_url=${CHAT_URL}" >> "${GITHUB_OUTPUT}" + + # ------------------------------------------------------------------ + # Step 4: Poll the chat status until the agent finishes. + # The Chat API is asynchronous — after creation the agent begins + # working in the background. We poll GET /api/experimental/chats/ + # every 5 seconds until the status is "waiting" (agent needs input), + # "completed" (agent finished), or "error". Timeout after 10 minutes. + # ------------------------------------------------------------------ + - name: Poll chat status + id: poll-status + env: + CHAT_ID: ${{ steps.create-chat.outputs.chat_id }} + run: | + set -euo pipefail + + POLL_INTERVAL=5 + # 10 minutes = 600 seconds. + TIMEOUT=600 + ELAPSED=0 + + echo "Polling chat ${CHAT_ID} every ${POLL_INTERVAL}s (timeout: ${TIMEOUT}s)..." + + while true; do + RESPONSE=$(curl --silent --fail-with-body \ + -H "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \ + "${CODER_URL}/api/experimental/chats/${CHAT_ID}") + + STATUS=$(echo "${RESPONSE}" | jq -r '.status') + + echo "[${ELAPSED}s] Chat status: ${STATUS}" + + case "${STATUS}" in + waiting|completed) + echo "Chat reached terminal status: ${STATUS}" + echo "final_status=${STATUS}" >> "${GITHUB_OUTPUT}" + exit 0 + ;; + error) + echo "::error::Chat entered error state" + echo "${RESPONSE}" | jq . + echo "final_status=error" >> "${GITHUB_OUTPUT}" + exit 1 + ;; + pending|running) + # Still working — keep polling. + ;; + *) + echo "::warning::Unknown chat status: ${STATUS}" + ;; + esac + + if [[ ${ELAPSED} -ge ${TIMEOUT} ]]; then + echo "::error::Timed out after ${TIMEOUT}s waiting for chat to finish" + echo "final_status=timeout" >> "${GITHUB_OUTPUT}" + exit 1 + fi + + sleep "${POLL_INTERVAL}" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) + done + + # ------------------------------------------------------------------ + # Step 5: Comment on the GitHub issue with a link to the chat. + # Only comment if the issue belongs to this repository (same guard + # as the Tasks API workflow). + # ------------------------------------------------------------------ + - name: Comment on issue + if: startsWith(steps.determine-inputs.outputs.issue_url, format('{0}/{1}', github.server_url, github.repository)) + env: + ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }} + CHAT_URL: ${{ steps.create-chat.outputs.chat_url }} + CHAT_ID: ${{ steps.create-chat.outputs.chat_id }} + FINAL_STATUS: ${{ steps.poll-status.outputs.final_status }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + COMMENT_BODY=$(cat <> "${GITHUB_STEP_SUMMARY}"