#!/usr/bin/env bash # Publish โ€” PR (dev agents) or comment (pm/qa), always reply in the issue. # # Required env (provided by the workflow step): # GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA # NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY # IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR) 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";; ops) TOK="$TOKEN_OPS";; *) TOK="";; esac [ -z "$TOK" ] && TOK="$GT" # Trigger token: comments that must FIRE the next workflow (delegation, autopilot) and PR merges # cannot use the built-in GITEA_TOKEN (Gitea won't start new runs from it) โ€” they need a real PAT. # Every agent now has its own token, so TTOK is just the agent's token. If an agent somehow has none # (TOK fell back to the built-in GT), TTOK is left empty so the trigger/merge is skipped rather than # silently no-op'ing under the built-in token. TTOK="$TOK" [ "$TTOK" = "$GT" ] && TTOK="" 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}')"; } # Remove the 'autopilot' label from an issue by resolving its ID first (Gitea's DELETE label # endpoint is by ID, not name). Arg $1 = issue number. Used as the autopilot kill switch. del_autopilot_label() { local iss="$1" lid lid=$(curl -sS "${hdr[@]}" "$API/issues/$iss/labels" 2>/dev/null \ | jq -r 'if type=="array" then ([.[]|select(.name=="autopilot")][0].id // empty) else empty end') if [ -n "$lid" ]; then curl -sS -X DELETE "${hdr[@]}" "$API/issues/$iss/labels/$lid" \ -w '\nunlabel -> HTTP %{http_code}\n' || true else echo "no 'autopilot' label found on #$iss to remove" fi } # drop machine-readable markers: DELEGATE / CLOSE_ISSUE / MERGE_PR / HALT_AUTOPILOT, and the # BEGIN_SUBTASKS..END_SUBTASKS and BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR # description is published separately). reply=$(awk ' /^[[:space:]]*BEGIN_SUBTASKS/{s=1} /^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1} /^[[:space:]]*DELEGATE:[[:space:]]*@/{next} /^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next} /^[[:space:]]*MERGE_PR[[:space:]]*$/{next} /^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$/{next} /^[[:space:]]*BOUNCE:[[:space:]]*@/{next} s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next } p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next } {print} ' /tmp/agent_out.md 2>/dev/null) # Strip a leading self-header the model sometimes emits ("๐Ÿค– **@pm**" on its own line) so we don't # double it when we prepend our own. Removes a leading run of such header lines and blank lines. reply=$(printf '%s' "$reply" | awk ' BEGIN{s=1} s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next} s && /^[[:space:]]*$/ {next} {s=0; print} ') [ -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 # 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. # Process subtasks first so we can append the created-issues list to the SAME comment as # the reply (issue #38 โ€” one comment per run). subtext="" 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 subtext=$(printf '\n\n---\n๐Ÿค– **@%s** โ€” created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links") fi post "$(printf '๐Ÿค– **@%s**\n\n%s%s' "$NAME" "$msg" "$subtext")" # --- AUTOPILOT: @qa's narrow, label-gated merge / halt authority --- # Only @qa, only when 'autopilot' is set, and only on a PR thread. The MERGE_PR / HALT_AUTOPILOT # markers come from the QA prompt. Merge + label ops use TOKEN_QA (the QA user's PAT, which the # maintainer must grant write+merge scope). ISSNUM is the origin issue (resolved from the branch). if [ "$NAME" = "qa" ] && [ "$AUTOPILOT" = "true" ]; then if grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then if [ -z "$IS_PR" ]; then echo "MERGE_PR marker but this run is not on a PR thread โ€” skipping merge" else echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})" # Merge with TOK (a PAT) โ€” NOT the built-in Actions token โ€” so the resulting push to # main TRIGGERS downstream workflows (e.g. deploy). A merge made with the built-in GITEA_TOKEN # does not fire new runs (loop-prevention), which silently skips the deploy. Fall back to the # agent's own token only if TOK isn't set (then the deploy would need a manual run). mtok="$TTOK" mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \ -H "Authorization: token $mtok" -H "Content-Type: application/json" \ "$API/pulls/$NUM/merge" -d '{"Do":"merge"}') echo "merge -> HTTP $mc"; cat /tmp/merge_resp.txt 2>/dev/null || true case "$mc" in 200|201|204) echo "closing origin issue #${ISSNUM:-$NUM}" curl -sS -X PATCH "${hdr[@]}" "$API/issues/${ISSNUM:-$NUM}" \ -d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true post "$(printf '๐Ÿค– **@qa** โ€” โœ… verified & merged PR #%s (autopilot). Closed issue #%s.' "$NUM" "${ISSNUM:-$NUM}")" ;; *) # Merge failed (checks not green, conflicts, or TOKEN_QA lacks merge scope) โ€” do NOT # silently proceed: drop the label so it reverts to human control and report. del_autopilot_label "${ISSNUM:-$NUM}" post "$(printf '๐Ÿค– **@qa** โ€” โš ๏ธ tried to merge PR #%s but the API returned HTTP %s (checks not green, a conflict, or missing merge permission on TOKEN_QA). Removed the `autopilot` label โ€” @ffaerber please take a look.' "$NUM" "$mc")" ;; esac fi elif grep -qiE '^[[:space:]]*BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md; then # @qa wants the dev to fix something. Send it back โ€” never fix it ourselves. After 3 bounces, # stop and hand to the human. QA's feedback is already posted (the reply comment above). if [ -z "$IS_PR" ]; then echo "BOUNCE marker but this run is not on a PR thread โ€” skipping" else target=$(grep -oiE 'BOUNCE:[[:space:]]*@(junior|senior|lead)' /tmp/agent_out.md | head -1 \ | grep -oiE '(junior|senior|lead)' | tr '[:upper:]' '[:lower:]') [ -z "$target" ] && target=$(curl -sS "${hdr[@]}" "$API/pulls/$NUM" | jq -r '.user.login // "junior"') # Count how many times this PR has already been bounced (marker in the trigger comment). prior=$(curl -sS "${hdr[@]}" "$API/issues/$NUM/comments?limit=100" \ | jq -r 'if type=="array" then [.[]|select(.body|test("autopilot fix attempt"))]|length else 0 end' 2>/dev/null) prior=${prior:-0} if [ "$prior" -ge 3 ]; then echo "@qa autopilot: 3 bounces already โ€” halting" del_autopilot_label "${ISSNUM:-$NUM}" post "$(printf '๐Ÿค– **@qa** โ€” ๐Ÿ›‘ still not right after 3 fix attempts. Stopping autopilot (removed the `autopilot` label). @ffaerber please take over โ€” details in the comments above.')" else n=$((prior + 1)) echo "@qa autopilot: bounce $n/3 -> @$target" curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \ "$API/issues/$NUM/comments" \ -d "$(jq -nc --arg b "@$target please address @qa's feedback above and update this PR (autopilot fix attempt $n/3)." '{body:$b}')" \ -w '\nbounce -> HTTP %{http_code}\n' || true fi fi elif grep -qiE '^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$' /tmp/agent_out.md; then echo "@qa autopilot: HALT โ€” removing 'autopilot' label from #${ISSNUM:-$NUM}" del_autopilot_label "${ISSNUM:-$NUM}" post "$(printf '๐Ÿค– **@qa** โ€” ๐Ÿ›‘ this needs a human decision (not a dev fix). Removed the `autopilot` label (back to human control). @ffaerber please decide next steps (details above).')" fi exit 0 fi # Auto-delegate: if the plan names a teammate, trigger them via TOK (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 "$TTOK" ]; 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 $TTOK" -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 # Scrub the runtime scripts checkout (.agents-workflow) from the tree so it never lands in a # commit/PR and never confuses the git ops below (issue #33). The scripts we run live outside the # workspace ($SCRIPTS -> runner.temp), so removing this in-tree copy is always safe. Handle every # way an agent might have left it: untracked dir, tracked files, or a committed gitlink/submodule. if git ls-files --error-unmatch .agents-workflow >/dev/null 2>&1 || \ [ -n "$(git ls-files .agents-workflow 2>/dev/null)" ]; then git rm -r --cached --quiet --ignore-unmatch .agents-workflow 2>/dev/null || true fi git config -f .gitmodules --remove-section submodule..agents-workflow 2>/dev/null || true [ -s .gitmodules ] || rm -f .gitmodules 2>/dev/null || true rm -rf .agents-workflow 2>/dev/null || true # 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%%/*} # Post the agent's activity trail (tool calls + reasoning) inline in the same comment so # each run produces exactly ONE comment (issue #38). Computed once here so every dev-agent # exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment. activity="" 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) activity=$(printf '\n\n
\n๐Ÿ”ง activity โ€” %s tool calls\n\n%s\n\n
' "$entries" "$log") fi # One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling # ai/issue-N-* branches. This removes the multi-PR ambiguity that left the # activity log stranded on the triggering issue instead of the PR thread. br="$BRANCH" ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0) if [ "${ahead:-0}" -eq 0 ]; then # No changes on this branch โ€” a plan / questions / analysis only. post "$(printf '๐Ÿค– **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")" exit 0 fi # NOTE: Gitea ignores the ?head= filter, so match the head branch client-side. resp=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \ | jq -r --arg br "$br" 'if type=="array" then (map(select(.head.ref==$br)) | .[0] // empty) else empty end' 2>/dev/null) url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null) prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) if [ -z "$url" ]; then title="@$NAME: $TITLE" resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \ -d "$(jq -nc --arg t "$title" --arg h "$br" --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) prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null) fi [ -z "$url" ] && { echo "PR open/lookup failed for $br โ€” posting reply on issue instead"; post "$(printf '๐Ÿค– **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")"; exit 0; } # Posts to the PR thread when we have a PR number, else to the origin issue ($NUM). prpost() { local n="$1"; shift; local t="$NUM" [ -n "$n" ] && [ "$n" != "$NUM" ] && t="$n" echo "posting to #$t" curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \ "$API/issues/$t/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')" } if [ "$NEW" = "true" ]; then prpost "$prnum" "$(printf '๐Ÿค– **@%s** โ€” โœ… PR ready for review โ€” @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")" # AUTOPILOT: hand the fresh PR to @qa automatically (via TOK, so it fires a new run). # @qa then verifies and โ€” if green โ€” merges + closes via its MERGE_PR marker. The comment lands # on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch # name. The '๐Ÿค–' guard on the trigger gate stops status-comment loops. if [ "$AUTOPILOT" = "true" ] && [ -n "$TTOK" ] && [ -n "$prnum" ]; then echo "autopilot: auto-triggering @qa to review PR #$prnum" curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \ "$API/issues/$prnum/comments" \ -d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled autopilot). Merge it if correct, or bounce it back to the dev with exactly what needs fixing." '{body:$b}')" \ -w '\ntrigger-qa -> HTTP %{http_code}\n' || true fi else # Resume: just link the PR โ€” its body and the diff already carry the description, so we don't # repeat the full write-up in the comment (the reasoning trail below shows what this run did). prpost "$prnum" "$(printf '๐Ÿค– **@%s** โ€” pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$activity")" # AUTOPILOT: after a dev pushes a fix (e.g. following a @qa bounce), hand back to @qa to re-verify. if [ "$AUTOPILOT" = "true" ] && [ -n "$TTOK" ] && [ -n "$prnum" ]; then case "$NAME" in junior|senior|lead) echo "autopilot: dev pushed a fix โ€” re-triggering @qa to re-verify PR #$prnum" curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \ "$API/issues/$prnum/comments" \ -d "$(jq -nc --arg b "@qa please re-verify this PR (autopilot). Merge it if now correct, or bounce it back with exactly what still needs fixing." '{body:$b}')" \ -w '\ntrigger-qa -> HTTP %{http_code}\n' || true ;; esac fi fi