Switch the real 'Run agent' step to --format json (replacing the two TEMP/DEBUG schema-discovery commits da6f0d5/d0ddb06), capture the raw event stream, and reconstruct the plain-text reply from the assistant 'text' parts so the existing Publish logic is unchanged. Add a 'Build activity log' step that filters the event stream to tool_use (with truncated title/input) and text (reasoning) events. The Publish step posts that trail as a separate collapsible comment on the PR thread (dev agents only — comment-only roles do no tool calls). This is additive to the existing summary/PR-description comment. Reasoning reuse on a follow-up run is free: the existing 'fetch full issue thread' step already re-feeds all prior comments (including this new log comment) into the next run's prompt, so the agent sees what it did last time.
508 lines
31 KiB
YAML
508 lines
31 KiB
YAML
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.
|
|
BREAKDOWN (for a feature too big for one PR): first PLAN — propose a milestone name and the list
|
|
of sub-tasks (title + one line each), then ask '@ffaerber create these N sub-issues? reply yes.'
|
|
Do NOT emit the block yet. ONLY after the maintainer approves, end your reply with EXACTLY:
|
|
BEGIN_SUBTASKS
|
|
milestone: <feature name>
|
|
- <task title> :: <one-line description>
|
|
- <task title> :: <one-line description>
|
|
END_SUBTASKS
|
|
The automation creates the milestone + one sub-issue per line (each linked to this issue). It
|
|
does NOT auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
|
|
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)"
|
|
# Capture the raw JSON event stream (--format json) so the activity log can be built
|
|
# from it afterwards. The plain --auto reply text == concatenation of all assistant
|
|
# "text" parts, so reconstruct /tmp/agent_out.md from those — the Publish step below
|
|
# keeps reading agent_out.md exactly as before. Success is exit code 0: the agent may
|
|
# make tool-only changes with no text summary, so DO NOT treat empty output as failure.
|
|
rc=1
|
|
for attempt in 1 2 3; do
|
|
echo "opencode attempt $attempt/3 for @$NAME ($MODEL)"
|
|
rc=0
|
|
opencode run --model "$MODEL" --auto --format json "$PROMPT" ${{ steps.imgs.outputs.files }} \
|
|
>/tmp/events.jsonl 2>/tmp/agent_err.log || rc=$?
|
|
echo "rc=$rc"; echo "--- events ($(wc -l < /tmp/events.jsonl 2>/dev/null || echo 0) lines) ---"
|
|
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/events.jsonl /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; }
|
|
# Reconstruct the plain-text reply from assistant text parts (== what plain --auto prints).
|
|
jq -r 'select(.type=="text") | .part.text // ""' /tmp/events.jsonl > /tmp/agent_out.md 2>/dev/null || true
|
|
echo "reconstructed reply ($(wc -l < /tmp/agent_out.md 2>/dev/null || echo 0) lines):"; cat /tmp/agent_out.md
|
|
|
|
- name: Build activity log (tool calls + reasoning) from the event stream
|
|
id: log
|
|
env:
|
|
MODE: ${{ steps.prep.outputs.mode }}
|
|
run: |
|
|
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa)
|
|
# do no tool calls, so a trail would be empty/noise.
|
|
if [ "$MODE" != "pr" ]; then
|
|
echo "skipping activity log for comment-mode agent"; : > /tmp/activity_log.md; exit 0
|
|
fi
|
|
jq -r '
|
|
def trunc(n): if length > n then (.[0:n] + "…") else . end;
|
|
select(.type=="tool_use" or .type=="text") |
|
|
if .type=="text" then
|
|
"💬 " + ((.part.text // "") | trunc(4000))
|
|
else
|
|
(.part.tool // "?") as $t |
|
|
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
|
|
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
|
|
end
|
|
' /tmp/events.jsonl > /tmp/activity_log.md 2>/dev/null || true
|
|
n=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
|
echo "activity log: $n entries"
|
|
[ "$n" -eq 0 ] && : > /tmp/activity_log.md
|
|
head -3 /tmp/activity_log.md
|
|
|
|
- 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 machine-readable markers (DELEGATE / CLOSE_ISSUE / the BEGIN_SUBTASKS..END_SUBTASKS block)
|
|
reply=$(awk '
|
|
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
|
|
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
|
|
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
|
|
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
|
|
{print}
|
|
' /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
|
|
# BREAKDOWN: from a BEGIN_SUBTASKS block, create a milestone + one sub-issue per line
|
|
# (linked to this issue). Sub-issues are NOT auto-started — maintainer mentions agents later.
|
|
if grep -qiE '^[[:space:]]*BEGIN_SUBTASKS' /tmp/agent_out.md; then
|
|
block=$(awk '/^[[:space:]]*BEGIN_SUBTASKS/{f=1;next} /^[[:space:]]*END_SUBTASKS/{f=0} f' /tmp/agent_out.md)
|
|
ms=$(printf '%s\n' "$block" | sed -nE 's/^[[:space:]]*milestone:[[:space:]]*//Ip' | head -1)
|
|
msid=""
|
|
if [ -n "$ms" ]; then
|
|
msid=$(curl -sS "${hdr[@]}" "$API/milestones?state=open&limit=100" | jq -r --arg t "$ms" 'if type=="array" then ([.[]|select(.title==$t)][0].id // empty) else empty end')
|
|
[ -z "$msid" ] && msid=$(curl -sS -X POST "${hdr[@]}" "$API/milestones" -d "$(jq -nc --arg t "$ms" '{title:$t}')" | jq -r '.id // empty')
|
|
echo "milestone '$ms' -> id ${msid:-?}"
|
|
fi
|
|
printf '%s\n' "$block" | grep -E '^[[:space:]]*-[[:space:]]' > /tmp/subtasks.txt || true
|
|
links=""
|
|
while IFS= read -r line; do
|
|
item=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*-[[:space:]]*//')
|
|
title=${item%%::*}; body=${item#*::}; [ "$body" = "$item" ] && body=""
|
|
title=$(printf '%s' "$title" | sed -E 's/[[:space:]]*$//')
|
|
body=$(printf '%s' "$body" | sed -E 's/^[[:space:]]*//')
|
|
[ -z "$title" ] && continue
|
|
ibody=$(printf 'Part of #%s\n\n%s' "$NUM" "$body")
|
|
if [ -n "$msid" ]; then
|
|
payload=$(jq -nc --arg t "$title" --arg b "$ibody" --argjson m "$msid" '{title:$t,body:$b,milestone:$m}')
|
|
else
|
|
payload=$(jq -nc --arg t "$title" --arg b "$ibody" '{title:$t,body:$b}')
|
|
fi
|
|
n=$(curl -sS -X POST "${hdr[@]}" "$API/issues" -d "$payload" | jq -r '.number // empty')
|
|
echo "created sub-issue #${n:-?}: $title"
|
|
[ -n "$n" ] && links="$links\n- #$n — $title"
|
|
done < /tmp/subtasks.txt
|
|
post "$(printf '🤖 **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links")"
|
|
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
|
|
|
|
# Post the agent's activity trail (tool calls + reasoning) as a separate comment so
|
|
# it is visible on the PR thread. Additive — kept here even when nothing changed, so a
|
|
# follow-up run (re-trigger) can see what this run did via the fetched issue thread.
|
|
if [ -s /tmp/activity_log.md ]; then
|
|
entries=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
|
log=$(cat /tmp/activity_log.md)
|
|
post "$(printf '🤖 **@%s** — activity log (%s entries):\n<details>\n<summary>tool calls & reasoning</summary>\n\n%s\n\n</details>' "$NAME" "$entries" "$log")"
|
|
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
|