diff --git a/agents/audit-task.sh b/agents/audit-task.sh new file mode 100755 index 0000000..cc40aa9 --- /dev/null +++ b/agents/audit-task.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# audit-task.sh / +# +# Reads a Gitea PR, finds the linked issue (via "Resolves #N" in the PR body), +# asks claude to audit the diff against the issue's done criteria, and posts +# a structured review comment on the PR. +# +# Env knobs: +# MODEL default sonnet (the auditor's model) +# MAX_WALLCLOCK default 10m +# GITEA_URL default https://gitea.dannyhaslund.dk +# GITEA_TOKEN_FILE default /etc/agent/gitea-token + +set -euo pipefail + +REPO="${1:-}" +PR_NUM="${2:-}" +[[ -z "$REPO" || -z "$PR_NUM" ]] && { echo "usage: $0 / " >&2; exit 64; } + +MODEL="${MODEL:-sonnet}" +MAX_WALLCLOCK="${MAX_WALLCLOCK:-10m}" +GITEA_URL="${GITEA_URL:-https://gitea.dannyhaslund.dk}" +GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/agent/gitea-token}" +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 "$LOG_ROOT" +LOG_FILE="$LOG_ROOT/audit-pr${PR_NUM}-$(date -u +%Y%m%dT%H%M%S).log" +log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; } + +# ---------- 1. Fetch PR ---------- +log "Fetching PR $REPO#$PR_NUM" +pr="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$GITEA_URL/api/v1/repos/$REPO/pulls/$PR_NUM")" +pr_title="$(jq -r .title <<<"$pr")" +pr_body="$(jq -r .body <<<"$pr")" +pr_head="$(jq -r .head.ref <<<"$pr")" +pr_base="$(jq -r .base.ref <<<"$pr")" + +# ---------- 2. Find linked issue ---------- +issue_num="$(grep -oiE '(resolves|closes|fixes)\s+#[0-9]+' <<<"$pr_body" | head -1 | grep -oE '[0-9]+' || true)" +if [[ -n "$issue_num" ]]; then + log "Linked issue: #$issue_num" + issue_body="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/repos/$REPO/issues/$issue_num" | jq -r .body)" +else + log "WARN: no linked issue found in PR body; auditing against PR description only" + issue_body="(no linked issue; audit against PR description and general code quality)" +fi + +# ---------- 3. Get diff ---------- +DIFF_FILE="$(mktemp)" +trap 'rm -f "$DIFF_FILE" "$AUDIT_OUT" 2>/dev/null' EXIT + +curl -fsS -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/repos/$REPO/pulls/$PR_NUM.diff" > "$DIFF_FILE" + +diff_size="$(wc -c < "$DIFF_FILE")" +log "Diff size: $diff_size bytes" + +# Truncate to 32 KB if too large +if [[ $diff_size -gt 32768 ]]; then + log "Truncating diff to 32 KB for claude" + head -c 32768 "$DIFF_FILE" > "${DIFF_FILE}.trim" + echo -e "\n\n[... diff truncated at 32KB ...]" >> "${DIFF_FILE}.trim" + mv "${DIFF_FILE}.trim" "$DIFF_FILE" +fi + +# ---------- 4. Audit via claude ---------- +SCHEMA='{ + "type": "object", + "required": ["verdict", "summary", "criteria"], + "properties": { + "verdict": { "type": "string", "enum": ["approve", "request_changes", "comment"] }, + "summary": { "type": "string", "minLength": 10 }, + "criteria": { + "type": "array", + "items": { + "type": "object", + "required": ["criterion", "status"], + "properties": { + "criterion": { "type": "string" }, + "status": { "type": "string", "enum": ["pass", "fail", "unclear"] }, + "note": { "type": "string" } + } + } + }, + "issues_found": { "type": "array", "items": { "type": "string" } } + } +}' + +PROMPT="You are the auditor agent in an autonomous coding pipeline. Review the pull request against the linked issue's Done criteria. + +Be strict but fair: + - pass = the diff clearly satisfies the criterion + - fail = the diff clearly does not, or contradicts it + - unclear = cannot determine from the diff alone (e.g. requires running tests) + +Set verdict: + - approve = all criteria pass, no significant issues + - comment = mostly fine but some criteria are unclear or minor issues + - request_changes = at least one criterion fails, or critical issues + +issues_found is for code-quality concerns the issue did NOT specify but that +matter for merge: security holes, broken syntax, obvious bugs, dependency +problems. Do NOT bikeshed style. + +Do not call any tools. Produce the JSON from the prompt text alone. + +# Linked issue body + +$issue_body + +# PR title + +$pr_title + +# PR body + +$pr_body + +# Diff ($pr_head into $pr_base) + +$(cat "$DIFF_FILE") + +Return only the JSON matching the schema." + +AUDIT_OUT="$(mktemp)" +log "Invoking claude (model=$MODEL, timeout=$MAX_WALLCLOCK)" +set +e +timeout --signal=INT --kill-after=30s "$MAX_WALLCLOCK" \ + claude -p "$PROMPT" \ + --model "$MODEL" \ + --output-format json \ + --json-schema "$SCHEMA" \ + --allowedTools "" \ + < /dev/null > "$AUDIT_OUT" 2>>"$LOG_FILE" +rc=$? +set -e +log "claude exited rc=$rc" + +if [[ $rc -ne 0 ]]; then + log "ERROR — auditor failed; posting fallback comment" + curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + -d "$(jq -nc --arg b "agent auditor: failed to produce an audit (claude rc=$rc). Log: \`$LOG_FILE\`" '{body:$b}')" \ + "$GITEA_URL/api/v1/repos/$REPO/issues/$PR_NUM/comments" > /dev/null + exit 1 +fi + +# ---------- 5. Render review comment ---------- +audit="$(jq -c '.structured_output' "$AUDIT_OUT")" +verdict="$(jq -r .verdict <<<"$audit")" +summary="$(jq -r .summary <<<"$audit")" +criteria_md="$(jq -r '.criteria // [] | map("- **" + (.status|ascii_upcase) + "** — " + .criterion + (if .note then " _" + .note + "_" else "" end)) | join("\n")' <<<"$audit")" +issues_md="$(jq -r '.issues_found // [] | if length == 0 then "_none_" else map("- " + .) | join("\n") end' <<<"$audit")" + +# Verdict badge +case "$verdict" in + approve) badge=":white_check_mark: APPROVE" ;; + comment) badge=":speech_balloon: COMMENT" ;; + request_changes) badge=":no_entry: REQUEST_CHANGES" ;; + *) badge=":grey_question: $verdict" ;; +esac + +review_body="$(printf '## Auditor review — %s\n\n%s\n\n### Criteria\n\n%s\n\n### Other issues found\n\n%s\n\n---\n_model: %s · log: `%s`_\n' \ + "$badge" "$summary" "$criteria_md" "$issues_md" "$MODEL" "$LOG_FILE")" + +# ---------- 6. Post comment ---------- +log "Posting review (verdict=$verdict)" +curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + -d "$(jq -nc --arg b "$review_body" '{body:$b}')" \ + "$GITEA_URL/api/v1/repos/$REPO/issues/$PR_NUM/comments" > /dev/null + +log "Done. verdict=$verdict" +echo "$verdict" diff --git a/auditor/workflow-template.yml b/auditor/workflow-template.yml new file mode 100644 index 0000000..d0288bd --- /dev/null +++ b/auditor/workflow-template.yml @@ -0,0 +1,47 @@ +# Drop into each agent-managed project repo as .gitea/workflows/auditor.yml. +# Requires the project to have these Gitea Actions secrets configured: +# AUDITOR_SSH_KEY — private ed25519 key whose public counterpart is in +# agent@dev-01:~/.ssh/authorized_keys +# +# The workflow SSH's into dev-01 (192.168.1.29) and runs audit-task.sh, which +# uses claude headless to review the PR against its linked issue's Done +# criteria, then posts the audit as a PR comment. + +name: Auditor + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + audit: + runs-on: ubuntu-latest + container: + image: debian:bookworm-slim + steps: + - name: Install ssh + curl + run: | + apt-get update -qq + apt-get install -y -qq openssh-client curl jq ca-certificates + + - name: Audit PR via dev-01 + env: + AUDITOR_KEY: ${{ secrets.AUDITOR_SSH_KEY }} + REPO: ${{ github.repository }} + PR_NUM: ${{ github.event.pull_request.number }} + run: | + set -e + [ -n "$AUDITOR_KEY" ] || { echo "ERROR: AUDITOR_SSH_KEY secret not set"; exit 1; } + + mkdir -p ~/.ssh + printf '%s\n' "$AUDITOR_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + # Trust dev-01's host key — collected at runtime; LAN-only path + ssh-keyscan -H 192.168.1.29 >> ~/.ssh/known_hosts 2>/dev/null + + ssh -i ~/.ssh/id_ed25519 \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + agent@192.168.1.29 \ + "PATH=\$HOME/.local/bin:/usr/local/bin:\$PATH MAX_WALLCLOCK=10m /usr/local/bin/audit-task.sh '$REPO' '$PR_NUM'"