Add reusable AI-agent workflow + docs + AGENTS template
Reusable workflow (on: workflow_call) moved from homelab; consuming repos add a thin caller (uses: ffaerber/agents/.gitea/workflows/agent.yml@main, secrets: inherit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a14d33bb8d
commit
f6df3f24af
@@ -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: @<agent>' (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: @<agent>' 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: @<agent>" 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-<slug> 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
|
||||||
@@ -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>` (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-<N>-<short-slug> # e.g. ai/issue-12-healthcheck, ai/issue-12-limits
|
||||||
|
# make just that one change
|
||||||
|
git add -A && git commit -m "<what changed>" && 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
|
||||||
|
<short bullet list of the 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_<name>` — 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.
|
||||||
@@ -1,2 +1,48 @@
|
|||||||
# agents
|
# 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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user