175 lines
6.3 KiB
Bash
Executable File
175 lines
6.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# dev-task.sh — invoked inside a dev LXC. Picks up a Gitea issue, runs
|
|
# claude-code headless to implement it, and opens a PR back to the repo.
|
|
#
|
|
# Usage: dev-task.sh <owner>/<repo> <issue-id>
|
|
# Env knobs (all optional):
|
|
# MAX_WALLCLOCK default 30m — coreutils-timeout duration string
|
|
# MODEL default sonnet — overridden by "Model: opus" line in issue body
|
|
# BASE_BRANCH default main
|
|
# GITEA_URL default https://gitea.dannyhaslund.dk
|
|
# GITEA_TOKEN_FILE default /etc/agent/gitea-token
|
|
# WORK_ROOT default /var/agent/workspaces
|
|
# LOG_ROOT default /var/agent/logs
|
|
|
|
set -euo pipefail
|
|
|
|
REPO="${1:-}"
|
|
ISSUE="${2:-}"
|
|
[[ -z "$REPO" || -z "$ISSUE" ]] && { echo "usage: $0 <owner>/<repo> <issue-id>" >&2; exit 64; }
|
|
|
|
# Serialize: only one dev-task active at a time (Max-plan rate-limit pool).
|
|
# Wait up to 60s for the lock; if still held, exit 75 so the webhook retries.
|
|
LOCK_FILE="${LOCK_FILE:-/var/agent/dev-task.lock}"
|
|
exec 200>"$LOCK_FILE"
|
|
if ! flock -w 60 200; then
|
|
echo "[dev-task] another dev-task is running; backing off" >&2
|
|
exit 75
|
|
fi
|
|
|
|
MAX_WALLCLOCK="${MAX_WALLCLOCK:-30m}"
|
|
MODEL_DEFAULT="${MODEL:-sonnet}"
|
|
BASE_BRANCH="${BASE_BRANCH:-main}"
|
|
GITEA_URL="${GITEA_URL:-https://gitea.dannyhaslund.dk}"
|
|
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/agent/gitea-token}"
|
|
WORK_ROOT="${WORK_ROOT:-/var/agent/workspaces}"
|
|
LOG_ROOT="${LOG_ROOT:-/var/agent/logs}"
|
|
|
|
[[ -r "$GITEA_TOKEN_FILE" ]] || { echo "missing $GITEA_TOKEN_FILE" >&2; exit 65; }
|
|
GITEA_TOKEN="$(cat "$GITEA_TOKEN_FILE")"
|
|
|
|
mkdir -p "$WORK_ROOT" "$LOG_ROOT"
|
|
LOG_FILE="$LOG_ROOT/issue-${ISSUE}-$(date +%Y%m%dT%H%M%S).log"
|
|
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; }
|
|
|
|
# ---------- 1. Fetch issue ----------
|
|
log "Fetching issue $REPO#$ISSUE"
|
|
issue_json="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_URL/api/v1/repos/$REPO/issues/$ISSUE")"
|
|
title="$(jq -r .title <<<"$issue_json")"
|
|
body="$(jq -r .body <<<"$issue_json")"
|
|
|
|
# ---------- 2. Pick model from body if specified ----------
|
|
if echo "$body" | grep -qiE '^\s*model:\s*opus' ; then
|
|
MODEL="opus"
|
|
else
|
|
MODEL="$MODEL_DEFAULT"
|
|
fi
|
|
log "Using model: $MODEL (default=$MODEL_DEFAULT)"
|
|
|
|
# ---------- 3. Workspace + branch ----------
|
|
slug="$(echo "$title" | tr '[:upper:]' '[:lower:]' \
|
|
| sed 's/[^a-z0-9]/-/g; s/--*/-/g; s/^-//; s/-$//' \
|
|
| cut -c1-50)"
|
|
[[ -z "$slug" ]] && slug="task"
|
|
BRANCH="agent/issue-${ISSUE}-${slug}"
|
|
|
|
WORKDIR="$WORK_ROOT/${REPO//\//_}-issue-${ISSUE}"
|
|
rm -rf "$WORKDIR"
|
|
log "Cloning into $WORKDIR"
|
|
git clone --quiet \
|
|
"https://oauth2:${GITEA_TOKEN}@${GITEA_URL#https://}/${REPO}.git" \
|
|
"$WORKDIR"
|
|
|
|
cd "$WORKDIR"
|
|
git config user.email "agent-dev01@dannyhaslund.dk"
|
|
git config user.name "agent dev-01"
|
|
git checkout -q "$BASE_BRANCH"
|
|
git checkout -q -b "$BRANCH"
|
|
|
|
# ---------- 4. Build prompt ----------
|
|
PROMPT_FILE="$(mktemp)"
|
|
cat > "$PROMPT_FILE" <<EOF
|
|
You are an autonomous developer agent. Implement the following Gitea issue in
|
|
the current working directory. The repository \`$REPO\` is already cloned and
|
|
checked out on branch \`$BRANCH\` (based on \`$BASE_BRANCH\`).
|
|
|
|
# Issue #$ISSUE — $title
|
|
|
|
$body
|
|
|
|
# Working constraints
|
|
- Make atomic git commits with clear messages. Use \`git\` directly via Bash.
|
|
- Do NOT open the pull request yourself — this wrapper opens the PR after you
|
|
finish, based on the commits on this branch.
|
|
- If the issue is impossible or under-specified, leave the branch in whatever
|
|
partial state best documents your investigation, summarize what is missing
|
|
in your final response, and stop.
|
|
- Do not push the branch — the wrapper pushes after you exit.
|
|
- Stop as soon as the Done criteria in the issue are satisfied. Do not
|
|
refactor unrelated code, add features beyond the issue, or restructure the
|
|
repo.
|
|
EOF
|
|
|
|
# ---------- 5. Invoke claude-code ----------
|
|
log "Starting claude (model=$MODEL, timeout=$MAX_WALLCLOCK)"
|
|
set +e
|
|
timeout --signal=INT --kill-after=30s "$MAX_WALLCLOCK" \
|
|
claude \
|
|
-p "$(cat "$PROMPT_FILE")" \
|
|
--model "$MODEL" \
|
|
--permission-mode bypassPermissions \
|
|
--output-format text \
|
|
--add-dir "$WORKDIR" \
|
|
>>"$LOG_FILE" 2>&1
|
|
claude_rc=$?
|
|
set -e
|
|
rm -f "$PROMPT_FILE"
|
|
log "claude exited with rc=$claude_rc"
|
|
|
|
if [[ $claude_rc -eq 124 || $claude_rc -eq 137 ]]; then
|
|
log "TIMEOUT — claude was killed at wallclock $MAX_WALLCLOCK"
|
|
elif [[ $claude_rc -ne 0 ]]; then
|
|
log "claude exited non-zero (see $LOG_FILE)"
|
|
fi
|
|
|
|
# ---------- 6. Inspect git state ----------
|
|
if [[ -n "$(git status --porcelain)" ]]; then
|
|
log "Uncommitted changes found — committing as WIP"
|
|
git add -A
|
|
git commit -q -m "WIP: leftover changes from claude session"
|
|
fi
|
|
|
|
commits_ahead="$(git rev-list --count "$BASE_BRANCH..HEAD")"
|
|
log "Commits ahead of $BASE_BRANCH: $commits_ahead"
|
|
|
|
if [[ "$commits_ahead" -eq 0 ]]; then
|
|
log "No commits produced. Posting comment on issue and exiting."
|
|
body_json="$(jq -nc --arg b "agent dev-01: no commits produced for issue #$ISSUE. Log: \`$LOG_FILE\`. claude rc=$claude_rc" '{body:$b}')"
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
|
-d "$body_json" \
|
|
"$GITEA_URL/api/v1/repos/$REPO/issues/$ISSUE/comments" >/dev/null
|
|
exit 2
|
|
fi
|
|
|
|
# ---------- 7. Push branch ----------
|
|
log "Pushing branch $BRANCH"
|
|
git push --quiet -u origin "$BRANCH"
|
|
|
|
# ---------- 8. Open PR via Gitea API ----------
|
|
pr_body="$(printf 'Resolves #%s\n\nAutomated PR from agent dev-01.\n\n- model: `%s`\n- log: `%s`\n- claude rc: `%s`\n' "$ISSUE" "$MODEL" "$LOG_FILE" "$claude_rc")"
|
|
pr_json="$(jq -nc \
|
|
--arg title "$title" \
|
|
--arg body "$pr_body" \
|
|
--arg head "$BRANCH" \
|
|
--arg base "$BASE_BRANCH" \
|
|
'{title:$title, body:$body, head:$head, base:$base}')"
|
|
pr_resp="$(curl -fsS -X POST \
|
|
-H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
|
-d "$pr_json" \
|
|
"$GITEA_URL/api/v1/repos/$REPO/pulls")"
|
|
pr_number="$(jq -r .number <<<"$pr_resp")"
|
|
pr_url="$(jq -r .html_url <<<"$pr_resp")"
|
|
log "PR opened: #$pr_number → $pr_url"
|
|
|
|
# ---------- 9. Comment on the issue ----------
|
|
comment_body="$(jq -nc --arg b "agent dev-01: PR opened → $pr_url" '{body:$b}')"
|
|
curl -fsS -X POST \
|
|
-H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \
|
|
-d "$comment_body" \
|
|
"$GITEA_URL/api/v1/repos/$REPO/issues/$ISSUE/comments" >/dev/null
|
|
|
|
log "Done."
|
|
echo "$pr_url"
|