diff --git a/.gitea/workflows/agent.yml b/.gitea/workflows/agent.yml new file mode 100644 index 0000000..ec73452 --- /dev/null +++ b/.gitea/workflows/agent.yml @@ -0,0 +1,418 @@ +name: agent +# Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues +# and invokes this via: uses: ffaerber/agents/.gitea/workflows/agent.yml@main (secrets: inherit). +# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's). +on: + workflow_call: + +jobs: + agent: + # Trusted author only, and only when a known agent is mentioned. This gate is the main + # defense against malicious-issue prompt injection โ€” do not loosen it. + if: > + (github.event_name == 'issues' && github.event.issue.user.login == 'ffaerber') || + (github.event_name == 'issue_comment' && github.event.comment.user.login == 'ffaerber' && + !contains(github.event.comment.body, '๐Ÿค–') && + (contains(github.event.comment.body, '@pm') || + contains(github.event.comment.body, '@junior') || + contains(github.event.comment.body, '@senior') || + contains(github.event.comment.body, '@lead') || + contains(github.event.comment.body, '@qa'))) + runs-on: ci-runner + steps: + - name: Acknowledge with ๐Ÿ‘€ + env: + GT: ${{ secrets.GITEA_TOKEN }} + CID: ${{ github.event.comment.id }} + NUM: ${{ github.event.issue.number }} + run: | + B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues" + if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi + curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \ + "$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: Route agent + prepare branch + id: prep + env: + BODY: ${{ github.event.comment.body }} # event text via env, never inline in shell + IBODY: ${{ github.event.issue.body }} + EVENT: ${{ github.event_name }} + IS_PR: ${{ github.event.issue.pull_request }} + NUM: ${{ github.event.issue.number }} + GT: ${{ secrets.GITEA_TOKEN }} + TOKEN_PM: ${{ secrets.TOKEN_PM }} + TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }} + TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }} + TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }} + TOKEN_QA: ${{ secrets.TOKEN_QA }} + run: | + # --- agent registry: model + capabilities + mode + role --- + cat > /tmp/agents.json <<'JSON' + { + "pm": {"model":"anthropic/claude-sonnet-5","vision":true, "mode":"comment","desc":"Product manager โ€” research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."}, + "junior": {"model":"ollama/ornith:35b", "vision":false,"mode":"pr", "desc":"Junior dev โ€” small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."}, + "senior": {"model":"ollama-cloud/glm-5.2:cloud","vision":false,"mode":"pr", "desc":"Senior dev โ€” complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."}, + "lead": {"model":"anthropic/claude-opus-4-8","vision":true, "mode":"pr", "desc":"Tech lead โ€” the hardest problems, architecture, and final calls."}, + "qa": {"model":"anthropic/claude-sonnet-5","vision":true, "mode":"comment","desc":"QA โ€” verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."} + } + JSON + # On a new issue, @pm auto-assesses. On a comment, route by the @mention. + scan="$BODY"; [ "$EVENT" = "issues" ] && scan="$IBODY" + name="" + for a in pm junior senior lead qa; do + case "$scan" in *"@$a"*) name=$a; break;; esac + done + if [ -z "$name" ]; then + if [ "$EVENT" = "issues" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi + fi + model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json) + vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json) + mode=$(jq -r --arg a "$name" '.[$a].mode' /tmp/agents.json) + echo "Routing to @$name (model=$model vision=$vision mode=$mode)" + { echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; } >> "$GITHUB_OUTPUT" + + # Act as the agent's own Gitea user when its token is set; else the built-in bot. + case "$name" in + pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";; + lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";; + esac + [ -z "$TOK" ] && TOK="$GT" + git config user.name "$name" + git config user.email "$name@ffaerber.duckdns.org" + API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") + if [ -n "$IS_PR" ]; then # comment on a PR -> resume its branch + ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref) + git fetch origin "$ref" && git checkout "$ref" + { echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT" + else # comment on an issue -> new branch + git checkout -b "ai/issue-$NUM" + { echo "branch=ai/issue-$NUM"; echo "new=true"; } >> "$GITHUB_OUTPUT" + # For dev agents, publish the branch immediately and tell the maintainer where to watch. + if [ "$mode" = "pr" ]; then + git push -u origin "HEAD:ai/issue-$NUM" || true + url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/src/branch/ai/issue-$NUM" + curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \ + -d "$(jq -nc --arg b "๐Ÿ”จ **@$name** is on it โ€” building on branch [\`ai/issue-$NUM\`]($url). I'll open a PR when it's ready." '{body:$b}')" >/dev/null || true + fi + fi + + - name: Install opencode + provider config (+ Playwright MCP for browser agents) + env: + OLLAMA_URL: ${{ secrets.OLLAMA_URL }} + OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }} + NAME: ${{ steps.prep.outputs.name }} + run: | + curl -fsSL https://opencode.ai/install | bash + echo "$HOME/.opencode/bin" >> "$GITHUB_PATH" + mkdir -p ~/.config/opencode + # Playwright browser MCP only for agents that need to drive a web app + MCP='{}' + case "$NAME" in + senior|lead|qa) + echo "Enabling Playwright MCP for @$NAME" + MCP='{"playwright":{"type":"local","command":["npx","-y","@playwright/mcp@latest","--headless"],"enabled":true}}' + npx -y playwright install --with-deps chromium || npx -y playwright install chromium || true + ;; + esac + # Two ollama providers: local self-hosted (ornith) + Ollama Cloud (GLM). + jq -n --argjson mcp "$MCP" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{ + provider: { + ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:{"ornith:35b":{}}}, + "ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:{"glm-5.2:cloud":{}}} + }, + mcp: $mcp + }' > ~/.config/opencode/opencode.json + echo "opencode config (secrets masked):"; cat ~/.config/opencode/opencode.json + + - name: Inspect / fetch image attachments (download only for vision agents) + id: imgs + env: + GT: ${{ secrets.GITEA_TOKEN }} + NUM: ${{ github.event.issue.number }} + VISION: ${{ steps.prep.outputs.vision }} + run: | + API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + mkdir -p /tmp/att + curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/assets" > /tmp/att/list.json || echo '[]' > /tmp/att/list.json + imgcount=$(jq '[.[]? | select(.name|test("\\.(png|jpe?g|gif|webp)$";"i"))] | length' /tmp/att/list.json 2>/dev/null || echo 0) + echo "has_images=$imgcount" >> "$GITHUB_OUTPUT" + files="" + if [ "$VISION" = "true" ] && [ "${imgcount:-0}" -gt 0 ]; then + i=0 + while IFS=$'\t' read -r url name; do + [ -z "$url" ] && continue + ext="${name##*.}" + case "$ext" in + png|jpg|jpeg|gif|webp|PNG|JPG|JPEG|GIF|WEBP) + i=$((i+1)); out="/tmp/att/img_$i.${ext,,}" + if curl -sSL -H "Authorization: token $GT" -o "$out" "$url" && [ -s "$out" ]; then + files="$files -f $out"; echo "saved '$name' -> $out" + fi ;; + esac + done < <(jq -r '.[]? | "\(.browser_download_url)\t\(.name)"' /tmp/att/list.json 2>/dev/null) + fi + echo "files=$files" >> "$GITHUB_OUTPUT" + + - name: Fetch the full issue thread (shared memory) + env: + GT: ${{ secrets.GITEA_TOKEN }} + NUM: ${{ github.event.issue.number }} + run: | + API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + curl -sS -H "Authorization: token $GT" "$API/issues/$NUM/comments?limit=100" 2>/dev/null \ + | jq -r '.[] | + ( if (.body | test("delegated by")) then "an automated delegation" + elif (.user.login == "ffaerber") then "ffaerber (the maintainer / you)" + else "an AI teammate โ€” the specific one is named in the ๐Ÿค– @name line at the top of the comment" + end ) as $who | + "### comment by \($who):\n\(.body)\n"' > /tmp/thread.md 2>/dev/null || true + echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0)" + + - name: Run agent + id: run + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + NAME: ${{ steps.prep.outputs.name }} + MODEL: ${{ steps.prep.outputs.model }} + VISION: ${{ steps.prep.outputs.vision }} + MODE: ${{ steps.prep.outputs.mode }} + HAS_IMAGES: ${{ steps.imgs.outputs.has_images }} + BRANCH: ${{ steps.prep.outputs.branch }} + NUM: ${{ github.event.issue.number }} + TITLE: ${{ github.event.issue.title }} + IBODY: ${{ github.event.issue.body }} + CMT: ${{ github.event.comment.body }} + run: | + [ -z "$CMT" ] && CMT="(a new issue was just opened โ€” assess it)" + THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)" + DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json) + ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision))") | join("\n")' /tmp/agents.json) + if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images โ€” you are a text-only model."; fi + NOTE="" + if [ "$VISION" != "true" ] && [ "${HAS_IMAGES:-0}" -gt 0 ]; then + NOTE="IMPORTANT: this issue has image attachment(s) you cannot read. Do NOT guess their contents โ€” say so and tell the maintainer to re-run with a vision-capable teammate (@senior, @lead, or @pm)." + fi + if [ "$MODE" = "comment" ]; then + ACTION="You do NOT edit files, create branches, or write a PR description. Respond with your analysis, + plan, research, or clarifying questions โ€” your reply becomes a comment on the issue. + To hand work to a teammate, end your reply with EXACTLY one line: 'DELEGATE: @' (one of + @junior @senior @lead @qa) โ€” but ONLY when you are ready to hand off AND need nothing further from the + maintainer. If you are asking @ffaerber to confirm or decide ANYTHING, do NOT include a DELEGATE line; + just ask and wait. Never ask for confirmation and delegate in the same reply. Mentioning a teammate in + prose does NOT delegate โ€” only the DELEGATE line does. + To CLOSE the issue (the maintainer says it is not needed / a duplicate / won't-do), briefly note why + and end your reply with EXACTLY one line: 'CLOSE_ISSUE'. Only close when clearly instructed or it is + obviously not needed; when in doubt, ask instead." + if [ "$NAME" = "pm" ]; then + ACTION="$ACTION + As PM you work in two phases and NEVER skip the approval gate: + PLAN โ€” when the task is clear, present a SHORT plan naming which teammate should build it + (@junior for small/low-risk, @senior/@lead for complex, @qa to verify), then END by asking + '@ffaerber ready to start building? reply yes to proceed.' Do NOT include a DELEGATE line yet. + DELEGATE โ€” ONLY after the maintainer has explicitly approved starting in the thread (a clear + 'yes' / 'go' / 'proceed' / 'start building' answering your ready-to-build question) do you end + your reply with a 'DELEGATE: @' line to hand off. + Never present a plan and delegate on the same turn. If anything is unclear or needs a decision, + START your reply with '@ffaerber', ask specific questions, and do NOT delegate." + fi + else + ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured. + FIRST read AGENTS.md at the repo root and FOLLOW IT EXACTLY โ€” it defines the golden rules, + branch naming, how to split work into multiple small independently-mergeable PRs, commit/push + style, and the required PR-description format (the BEGIN_PR_DESCRIPTION block the automation + extracts). Do all work on branches (never in the issue), commit and push as you go, and do NOT + open pull requests yourself โ€” that is automated for every branch you push. + If the task is genuinely unclear, make NO changes and reply with specific questions instead." + fi + PROMPT="You are @${NAME}, a member of an AI dev team working on this Gitea repository. + YOUR ROLE: ${DESC} + YOUR CAPABILITIES: model ${MODEL}. ${CAP} + ${NOTE} + + TEAM ROSTER (who does what โ€” hand off if a task isn't yours): + ${ROSTER} + + ${ACTION} + If a task needs expertise or a capability you lack, do NOT guess โ€” say which + teammate should handle it. The task is fully described below; do not search the + repo for an 'issue' file. + + TASK (issue #${NUM} \"${TITLE}\"): + ${IBODY} + + FULL CONVERSATION THREAD SO FAR (every comment on this issue, oldest first โ€” including your + OWN previous replies and the maintainer's answers). READ IT CAREFULLY. Do NOT repeat questions + that have already been answered; build on what has already been decided. If the maintainer has + answered your earlier questions, ACT on those answers โ€” do not re-ask. + ${THREAD} + + LATEST INSTRUCTION FROM MAINTAINER: + ${CMT}" + echo "opencode version: $(opencode --version 2>&1)" + # Success is exit code 0 โ€” the agent may make tool-only changes with no text summary, + # so DO NOT treat empty stdout as failure. Only retry a non-zero exit that looks transient. + rc=1 + for attempt in 1 2 3; do + echo "opencode attempt $attempt/3 for @$NAME ($MODEL)" + rc=0 + opencode run --model "$MODEL" --auto "$PROMPT" ${{ steps.imgs.outputs.files }} \ + >/tmp/agent_out.md 2>/tmp/agent_err.log || rc=$? + echo "rc=$rc"; echo "--- stdout (reply) ---"; cat /tmp/agent_out.md; echo "--- stderr (trace) ---"; cat /tmp/agent_err.log + [ $rc -eq 0 ] && break + if grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN' /tmp/agent_out.md /tmp/agent_err.log; then + echo "transient error โ€” backing off $((attempt*20))s"; sleep $((attempt * 20)); continue + fi + echo "non-transient failure (rc=$rc) โ€” not retrying"; break + done + [ $rc -eq 0 ] || { echo "agent failed"; exit 1; } + + - name: Publish โ€” PR (dev agents) or comment (pm), always reply in the issue + env: + GT: ${{ secrets.GITEA_TOKEN }} + AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} + TOKEN_PM: ${{ secrets.TOKEN_PM }} + TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }} + TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }} + TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }} + TOKEN_QA: ${{ secrets.TOKEN_QA }} + NAME: ${{ steps.prep.outputs.name }} + MODE: ${{ steps.prep.outputs.mode }} + NUM: ${{ github.event.issue.number }} + TITLE: ${{ github.event.issue.title }} + BRANCH: ${{ steps.prep.outputs.branch }} + NEW: ${{ steps.prep.outputs.new }} + run: | + set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step + # Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot. + case "$NAME" in + pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";; + lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";; + esac + [ -z "$TOK" ] && TOK="$GT" + git config user.name "$NAME" + git config user.email "$NAME@ffaerber.duckdns.org" + API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" + hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json") + post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \ + "$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"; } + + # drop the machine-readable marker lines (DELEGATE / CLOSE_ISSUE) from the human-facing reply + reply=$(grep -viE '^[[:space:]]*(DELEGATE:[[:space:]]*@|CLOSE_ISSUE[[:space:]]*$)' /tmp/agent_out.md 2>/dev/null) + [ -z "$reply" ] && reply="_(Made changes without a text summary โ€” see the diff below.)_" + # Prefer the agent's clean delimited PR description; fall back to the whole reply. + prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md) + [ -z "$prdesc" ] && prdesc="$reply" + + # comment-only roles (pm/qa): never change files + if [ "$MODE" != "pr" ]; then + git checkout -- . 2>/dev/null || true + git clean -fd 2>/dev/null || true + target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null | head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]') + # Visible comment: the reply text, or a sensible line if the agent only emitted a marker. + msg="$reply" + case "$msg" in ""|"_(Made changes"*) msg=$([ -n "$target" ] && echo "Handing off to @$target." || echo "_(no further comment)_") ;; esac + post "$(printf '๐Ÿค– **@%s**\n\n%s' "$NAME" "$msg")" + # Close the issue if the agent flagged it (maintainer said it's not needed / duplicate). + if grep -qiE '^[[:space:]]*CLOSE_ISSUE[[:space:]]*$' /tmp/agent_out.md; then + echo "closing issue #$NUM" + curl -sS -X PATCH "${hdr[@]}" "$API/issues/$NUM" \ + -d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true + fi + # Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it + # fires a new workflow run โ€” the built-in token cannot). Never targets @pm or self, so the + # chain always terminates at a dev. The '๐Ÿค–' guard on the trigger stops status-comment loops. + if [ -n "$AGENT_TOKEN" ]; then + # Only delegate on an explicit "DELEGATE: @" line โ€” never on a prose mention, + # so an agent that is asking the maintainer a question does not hand off prematurely. + target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null \ + | head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]') + if [ -n "$target" ] && [ "$target" != "$NAME" ]; then + echo "auto-delegating to @$target" + curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ + "$API/issues/$NUM/comments" \ + -d "$(jq -nc --arg b "@$target please proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \ + -w '\ndelegate -> HTTP %{http_code}\n' || true + else + echo "no DELEGATE marker โ€” not delegating (agent is asking or finished)" + fi + fi + exit 0 + fi + + # The agent may have committed on the starting branch AND/OR created extra + # ai/issue-N- branches. Commit any leftover on the current branch, push it, then + # open a PR for EVERY ai/issue-N* branch that has commits beyond main. + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "@$NAME: issue #$NUM" + fi + git push origin "HEAD:$BRANCH" || true + git fetch -q origin 2>/dev/null || true + + prbody=$(printf '%s\n\n---\nResolves #%s ยท ๐Ÿค– @%s' "$prdesc" "$NUM" "$NAME") + owner=${GITHUB_REPOSITORY%%/*} + mapfile -t brs < <(git ls-remote --heads origin "ai/issue-$NUM" "ai/issue-$NUM-*" 2>/dev/null \ + | sed -E 's#.*refs/heads/##' | sort -u) + + links=""; count=0 + for br in "${brs[@]}"; do + [ -z "$br" ] && continue + ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0) + [ "${ahead:-0}" -eq 0 ] && continue + # NOTE: Gitea ignores the ?head= filter, so match the head branch client-side. + url=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \ + | jq -r --arg br "$br" 'if type=="array" then (map(select(.head.ref==$br)) | .[0].html_url // empty) else empty end' 2>/dev/null) + if [ -z "$url" ]; then + slug=${br#ai/issue-$NUM}; slug=${slug#-} + [ -z "$slug" ] && title="@$NAME: $TITLE" || title="@$NAME: $slug" + resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \ + -d "$(jq -nc --arg t "$title" --arg h "$br" --arg n "$NUM" --arg b "$prbody" \ + '{title:$t, head:$h, base:"main", body:$b}')") + echo "PR create ($br): $resp" + url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null) + fi + [ -n "$url" ] && { links="$links\n- $url"; count=$((count+1)); } + done + + if [ "$count" -eq 0 ]; then + # No branch had changes โ€” a plan / questions / analysis only. + post "$(printf '๐Ÿค– **@%s**\n\n%s' "$NAME" "$reply")" + exit 0 + fi + + if [ "$NEW" = "true" ]; then + noun="PR ready for review"; [ "$count" -gt 1 ] && noun="PRs ready for review (merge sequentially)" + post "$(printf '๐Ÿค– **@%s** โ€” โœ… %s %s โ€” @ffaerber please review & merge:%b' "$NAME" "$count" "$noun" "$links")" + else + # Resume (comment is on a PR thread): include the write-up here too. + post "$(printf '๐Ÿค– **@%s** โ€” updated %s branch/PR:%b\n\n%s' "$NAME" "$count" "$links" "$prdesc")" + fi + + - name: Mark done with ๐Ÿš€ (remove ๐Ÿ‘€) + env: + GT: ${{ secrets.GITEA_TOKEN }} + CID: ${{ github.event.comment.id }} + NUM: ${{ github.event.issue.number }} + run: | + B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues" + if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi + curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true + curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true + + - name: Mark failed with ๐Ÿ˜• (remove ๐Ÿ‘€) + if: failure() + env: + GT: ${{ secrets.GITEA_TOKEN }} + CID: ${{ github.event.comment.id }} + NUM: ${{ github.event.issue.number }} + run: | + B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues" + if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi + curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true + curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true diff --git a/AGENTS.template.md b/AGENTS.template.md new file mode 100644 index 0000000..c70274b --- /dev/null +++ b/AGENTS.template.md @@ -0,0 +1,65 @@ +# AGENTS.md โ€” how changes are made in this repo + +This file is read by AI agents (via opencode) **and** by human developers. Follow it. + +## What this repo is +A Docker Swarm homelab stack. `docker-compose.yml` is the stack; **merging to `main` deploys it to +node1 automatically** (GitOps). So every change that lands on `main` ships to production, one at a +time (deploys are serialized). + +## Golden rules +- You may edit **any file** in this repo (including `ci/` and `.gitea/`). +- **NEVER push to `main`, and NEVER merge a pull request.** All work goes on a branch and becomes a + PR that a human reviews and merges. You do not have merge authority โ€” do not attempt it. +- **Never print, exfiltrate, or invent secret values.** You may edit config that *references* secrets, + but never paste real secret values into comments, PRs, logs, or code. +- Keep changes **minimal** and match the conventions already in the file you're editing. +- Do the work on a **branch** โ€” never paste code or diffs into the issue thread. + +## Branches & pull requests +You start on branch `ai/issue-` (N = the issue number). + +**Split independent changes into separate PRs.** Infrastructure changes must be small and +independently mergeable, so they can be reviewed and deployed one at a time. For each independent +change, use its own branch: + +``` +git checkout main +git checkout -b ai/issue-- # e.g. ai/issue-12-healthcheck, ai/issue-12-limits +# make just that one change +git add -A && git commit -m "" && git push -u origin HEAD +``` + +- Only keep changes together on one branch if they genuinely must ship as a unit. +- Commit and **push incrementally** as you work, so progress is visible on the branch. +- **Do not open pull requests yourself** โ€” the automation opens one PR per branch you push, and + the maintainer merges them sequentially. + +## PR description +End your reply with a clean pull-request description wrapped EXACTLY between these markers. +Everything before `BEGIN_PR_DESCRIPTION` is treated as working notes and discarded: + +``` +BEGIN_PR_DESCRIPTION +## Summary +<1-2 sentences: what changed and why> +## Changes + +END_PR_DESCRIPTION +``` + +Do not paste full files or large diffs โ€” the review happens in the PR diff. + +## Migrations (imperative changes) +`docker stack deploy` is **additive** โ€” it will NOT remove a service, run a one-off command, prune, +or do anything imperative. For those, add a script under `migrations/` and the deploy runs it on the +swarm manager (after the stack deploy), exactly once, tracked in a ledger on the node. + +- Name files `migrations/NNNN-short-description.sh` (zero-padded; applied in sorted order). +- Make them **idempotent** โ€” guard with existence checks (the ledger can be lost on a node rebuild). +- Example: if you delete a service from `docker-compose.yml`, also add a migration that runs + `docker service rm homelab_` โ€” otherwise the old service keeps running after deploy. + +## When unsure +If the task is genuinely unclear or missing details you cannot reasonably assume, make **no** +changes and reply with specific questions instead. diff --git a/README.md b/README.md index 9e84d71..41fe8d3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ # agents +Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It gives any repo the +`@pm` / `@junior` / `@senior` / `@lead` / `@qa` agents driven from issues and comments. + +## Use it in a repo + +Add `.gitea/workflows/ai-agent.yml` to the consuming repo: + +```yaml +name: ai-agent +on: + issue_comment: + types: [created] + issues: + types: [opened] +jobs: + agent: + uses: ffaerber/agents/.gitea/workflows/agent.yml@main + secrets: inherit +``` + +That's the whole per-repo footprint. All the logic (agent registry, routing, delegation, +reactions, PR/issue plumbing) lives here in `agent.yml`. + +## Required secrets (per repo, or org-level for all) + +| Secret | For | +|--------|-----| +| `ANTHROPIC_API_KEY` | `@lead`/`@qa` (and `@pm`/`@senior` if on Claude) | +| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / GLM cloud | +| `AGENT_TOKEN` | PAT (issue+repo write) used to post the delegation comment that fires the next agent | +| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | optional โ€” post/commit as each agent's own Gitea user (falls back to the bot) | + +`GITEA_TOKEN` is auto-provided. Tip: set these once at the **org** level so every repo inherits +them via `secrets: inherit`. + +## Also add to each consuming repo + +- **`AGENTS.md`** โ€” the repo's own conventions (copy `AGENTS.template.md` from here and adapt). The + agent reads the *caller* repo's `AGENTS.md`, so each repo can differ. +- The bot users (`pm`,`senior`,โ€ฆ) as **collaborators** (needed on private repos, and enables + `@name` autocomplete). + +## Maintaining + +Change agent behavior once, here. Callers pin `@main` (or pin a tag for stability). History is the +changelog โ€” see `git log`.