mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
ci: add workflow for agentic issue triage (#19839)
Adds a GH workflow to start a workspace with a pre-determined template, perform a first pass over a given GitHub issue, and persist the changes in a GCS bucket for later refining. Tested locally with `nektos/act`. Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
This commit is contained in:
co-authored by
Mathias Fredriksson
parent
5c2b9a5b82
commit
12496830d6
@@ -0,0 +1,155 @@
|
||||
name: AI Triage Automation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_url:
|
||||
description: 'GitHub Issue URL to process'
|
||||
required: true
|
||||
type: string
|
||||
template_name:
|
||||
description: 'Coder template to use for workspace'
|
||||
required: true
|
||||
default: 'ai-workspace'
|
||||
type: string
|
||||
prefix:
|
||||
description: 'Prefix for workspace name'
|
||||
required: false
|
||||
default: 'traiage'
|
||||
type: string
|
||||
persistence_mode:
|
||||
description: 'Persistence mode (push or archive)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- push
|
||||
- archive
|
||||
default: 'archive'
|
||||
|
||||
jobs:
|
||||
traiage:
|
||||
name: Triage GitHub Issue with Claude Code
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
TEMPLATE_NAME: ${{ inputs.template_name }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract context key from issue
|
||||
id: extract-context
|
||||
env:
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
run: |
|
||||
issue_number="$(gh issue view "${ISSUE_URL}" --json number --jq '.number')"
|
||||
context_key="gh-${issue_number}"
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CONTEXT_KEY=${context_key}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Download and install Coder binary
|
||||
shell: bash
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
run: |
|
||||
if [ "${{ runner.arch }}" == "ARM64" ]; then
|
||||
ARCH="arm64"
|
||||
else
|
||||
ARCH="amd64"
|
||||
fi
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
curl -fsSL --compressed "$CODER_URL/bin/coder-linux-${ARCH}" -o "${HOME}/.local/bin/coder"
|
||||
chmod +x "${HOME}/.local/bin/coder"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
coder version
|
||||
coder whoami
|
||||
echo "$HOME/.local/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Create Coder workspace
|
||||
id: create-workspace
|
||||
env:
|
||||
PREFIX: ${{ inputs.prefix }}
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
TEMPLATE_PARAMETERS: ${{ secrets.TRAIAGE_TEMPLATE_PARAMETERS }}
|
||||
run: |
|
||||
export WORKSPACE_NAME="${PREFIX}-${CONTEXT_KEY}-${RUN_ID}"
|
||||
echo "Creating workspace: $WORKSPACE_NAME"
|
||||
./scripts/traiage.sh create
|
||||
echo "workspace_name=$WORKSPACE_NAME" >> "${GITHUB_OUTPUT}"
|
||||
echo "WORKSPACE_NAME=${WORKSPACE_NAME}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Send prompt to AI agent inside workspace
|
||||
id: prepare-prompt
|
||||
env:
|
||||
WORKSPACE_NAME: ${{ steps.create-workspace.outputs.workspace_name }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
run: |
|
||||
PROMPT_FILE=/tmp/prompt.txt
|
||||
trap 'rm -f "${PROMPT_FILE}"' EXIT
|
||||
|
||||
# Fetch issue description using `gh` CLI
|
||||
issue_description=$(gh issue view "${ISSUE_URL}")
|
||||
|
||||
# Write a prompt to PROMPT_FILE
|
||||
cat > "${PROMPT_FILE}" <<EOF
|
||||
Analyze the below GitHub issue description, understand the root cause, and make appropriate changes to resolve the issue.
|
||||
|
||||
ISSUE URL: ${ISSUE_URL}
|
||||
ISSUE DESCRIPTION BELOW:
|
||||
|
||||
${issue_description}
|
||||
EOF
|
||||
|
||||
echo "WORKSPACE_NAME: ${WORKSPACE_NAME}"
|
||||
# This command will run the prompt inside the workspace
|
||||
# and exit once the agent has completed the task.
|
||||
PROMPT=$(cat $PROMPT_FILE) ./scripts/traiage.sh prompt
|
||||
|
||||
- name: Commit and push changes
|
||||
id: commit-push
|
||||
if: ${{ inputs.persistence_mode == 'push' }}
|
||||
run: |
|
||||
echo "Committing and pushing changes in workspace: $WORKSPACE_NAME"
|
||||
./scripts/traiage.sh commit-push
|
||||
|
||||
- name: Create and upload archive
|
||||
id: create-archive
|
||||
if: ${{ inputs.persistence_mode == 'archive' }}
|
||||
env:
|
||||
DESTINATION_PREFIX: ${{ secrets.TRAIAGE_DESTINATION_PREFIX }}
|
||||
run: |
|
||||
echo "Creating archive for workspace: $WORKSPACE_NAME"
|
||||
./scripts/traiage.sh archive
|
||||
echo "archive_url=${DESTINATION_PREFIX%%/}/$WORKSPACE_NAME.tar.gz" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Report results
|
||||
env:
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
WORKSPACE_NAME: ${{ steps.create-workspace.outputs.workspace_name }}
|
||||
ARCHIVE_URL: ${{ steps.create-archive.outputs.archive_url }}
|
||||
run: |
|
||||
{
|
||||
echo "## TrAIage Results";
|
||||
echo "- **Issue URL:** ${ISSUE_URL}";
|
||||
echo "- **Context Key:** ${CONTEXT_KEY}";
|
||||
echo "- **Workspace:** ${WORKSPACE_NAME}";
|
||||
echo "- **Archive URL:** ${ARCHIVE_URL}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Cleanup workspace
|
||||
if: steps.create-workspace.outputs.workspace_name != '' && (inputs.persistence_mode == 'archive' && steps.create-archive.outputs.archive_url != '')
|
||||
run: |
|
||||
echo "Cleaning up workspace: $WORKSPACE_NAME"
|
||||
./scripts/traiage.sh delete || true
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}")
|
||||
# shellcheck source=scripts/lib.sh
|
||||
source "${SCRIPT_DIR}/lib.sh"
|
||||
|
||||
CODER_BIN=${CODER_BIN:-"$(which coder)"}
|
||||
AGENTAPI_SLUG=${AGENTAPI_SLUG:-""}
|
||||
|
||||
TEMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "${TEMPDIR}"' EXIT
|
||||
|
||||
[[ -n ${VERBOSE:-} ]] && set -x
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <options>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
create() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME TEMPLATE_NAME TEMPLATE_PARAMETERS
|
||||
"${CODER_BIN}" \
|
||||
--url "${CODER_URL}" \
|
||||
--token "${CODER_SESSION_TOKEN}" \
|
||||
create \
|
||||
--template "${TEMPLATE_NAME}" \
|
||||
--stop-after 30m \
|
||||
--parameter "${TEMPLATE_PARAMETERS}" \
|
||||
--yes \
|
||||
"${WORKSPACE_NAME}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
prompt() {
|
||||
if [[ -z "${AGENTAPI_SLUG}" ]]; then
|
||||
prompt_ssh
|
||||
else
|
||||
prompt_agentapi
|
||||
fi
|
||||
}
|
||||
|
||||
ssh_config() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME
|
||||
|
||||
if [[ -n "${OPENSSH_CONFIG_FILE:-}" ]]; then
|
||||
echo "Using existing SSH config file: ${OPENSSH_CONFIG_FILE}"
|
||||
return
|
||||
fi
|
||||
|
||||
OPENSSH_CONFIG_FILE="${TEMPDIR}/coder-ssh.config"
|
||||
"${CODER_BIN}" \
|
||||
config-ssh \
|
||||
--url "${CODER_URL}" \
|
||||
--token "${CODER_SESSION_TOKEN}" \
|
||||
--ssh-config-file="${OPENSSH_CONFIG_FILE}" \
|
||||
--yes
|
||||
export OPENSSH_CONFIG_FILE
|
||||
}
|
||||
|
||||
prompt_ssh() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME PROMPT
|
||||
|
||||
ssh_config
|
||||
|
||||
# Execute claude over SSH and provide prompt via stdin
|
||||
# Note: use of cat to work around claude-code#7357
|
||||
ssh \
|
||||
-F "${OPENSSH_CONFIG_FILE}" \
|
||||
"${WORKSPACE_NAME}.coder" \
|
||||
-- \
|
||||
"cat | \"\${HOME}\"/.local/bin/claude --dangerously-skip-permissions --print --verbose --output-format=stream-json" \
|
||||
<<<"${PROMPT}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
prompt_agentapi() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME AGENTAPI_SLUG PROMPT
|
||||
|
||||
wait_agentapi_stable
|
||||
|
||||
username=$(curl \
|
||||
--fail \
|
||||
--header "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \
|
||||
--location \
|
||||
--show-error \
|
||||
--silent \
|
||||
"${CODER_URL}/api/v2/users/me" | jq -r '.username')
|
||||
|
||||
payload="{
|
||||
\"content\": \"${PROMPT}\",
|
||||
\"type\": \"user\"
|
||||
}"
|
||||
|
||||
response=$(curl \
|
||||
--data-raw "${payload}" \
|
||||
--fail \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \
|
||||
--location \
|
||||
--request POST \
|
||||
--show-error \
|
||||
--silent \
|
||||
"${CODER_URL}/@${username}/${WORKSPACE_NAME}/apps/${AGENTAPI_SLUG}/message" | jq -r '.ok')
|
||||
if [[ "${response}" != "true" ]]; then
|
||||
echo "Failed to send prompt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait_agentapi_stable
|
||||
}
|
||||
|
||||
wait_agentapi_stable() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME PROMPT
|
||||
username=$(curl \
|
||||
--fail \
|
||||
--header "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \
|
||||
--location \
|
||||
--show-error \
|
||||
--silent \
|
||||
"${CODER_URL}/api/v2/users/me" | jq -r '.username')
|
||||
|
||||
for attempt in {1..120}; do
|
||||
response=$(curl \
|
||||
--fail \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "Coder-Session-Token: ${CODER_SESSION_TOKEN}" \
|
||||
--location \
|
||||
--request GET \
|
||||
--show-error \
|
||||
--silent \
|
||||
"${CODER_URL}/@${username}/${WORKSPACE_NAME}/apps/agentapi/status" | jq -r '.status')
|
||||
if [[ "${response}" == "stable" ]]; then
|
||||
echo "AgentAPI stable"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for AgentAPI to report stable status (attempt ${attempt}/120)"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
archive() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME DESTINATION_PREFIX
|
||||
ssh_config
|
||||
|
||||
# We want the heredoc to be expanded locally and not remotely.
|
||||
# shellcheck disable=SC2087
|
||||
ARCHIVE_DEST=$(
|
||||
ssh -F "${OPENSSH_CONFIG_FILE}" \
|
||||
"${WORKSPACE_NAME}.coder" \
|
||||
bash <<-EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ARCHIVE_PATH=\$(coder-archive-create)
|
||||
ARCHIVE_NAME=\$(basename "\${ARCHIVE_PATH}")
|
||||
ARCHIVE_DEST="${DESTINATION_PREFIX%%/}/\${ARCHIVE_NAME}"
|
||||
if [[ ! -f "\${ARCHIVE_PATH}" ]]; then
|
||||
echo "FATAL: Archive not found at expected path: \${ARCHIVE_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
gcloud storage cp "\${ARCHIVE_PATH}" "\${ARCHIVE_DEST}"
|
||||
echo "\${ARCHIVE_DEST}"
|
||||
exit 0
|
||||
EOF
|
||||
)
|
||||
|
||||
echo "${ARCHIVE_DEST}"
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
commit_push() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME
|
||||
ssh_config
|
||||
|
||||
# We want the heredoc to be expanded locally and not remotely.
|
||||
# shellcheck disable=SC2087
|
||||
ssh \
|
||||
-F "${OPENSSH_CONFIG_FILE}" \
|
||||
"${WORKSPACE_NAME}.coder" \
|
||||
-- \
|
||||
bash <<-EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
BRANCH="traiage/${WORKSPACE_NAME}"
|
||||
if [[ \$(git branch --show-current) != "\${BRANCH}" ]]; then
|
||||
git checkout -b "\${BRANCH}"
|
||||
fi
|
||||
|
||||
if [[ -z \$(git status --porcelain) ]]; then
|
||||
echo "FATAL: No changes to commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git add -A
|
||||
commit_msg=\$(echo -n 'You are a CLI utility that generates a commit message. Generate a concise git commit message for the currently staged changes. Print ONLY the commit message and nothing else.' | \${HOME}/.local/bin/claude --print)
|
||||
if [[ -z "\${commit_msg}" ]]; then
|
||||
commit_msg="Default commit message"
|
||||
fi
|
||||
git commit -am "\${commit_msg}"
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
exit $?
|
||||
}
|
||||
|
||||
delete() {
|
||||
requiredenvs CODER_URL CODER_SESSION_TOKEN WORKSPACE_NAME
|
||||
"${CODER_BIN}" \
|
||||
--url "${CODER_URL}" \
|
||||
--token "${CODER_SESSION_TOKEN}" \
|
||||
delete \
|
||||
"${WORKSPACE_NAME}" \
|
||||
--yes
|
||||
exit 0
|
||||
}
|
||||
|
||||
main() {
|
||||
dependencies coder
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
create)
|
||||
create
|
||||
;;
|
||||
prompt)
|
||||
prompt
|
||||
;;
|
||||
archive)
|
||||
archive
|
||||
;;
|
||||
commit-push)
|
||||
commit_push
|
||||
;;
|
||||
delete)
|
||||
delete
|
||||
;;
|
||||
wait-agentapi-stable)
|
||||
wait_agentapi_stable
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user