@lead: issue #16 — fully-automatic autopilot (label-gated @pm auto-delegate + @qa merge)

This commit is contained in:
2026-07-03 12:33:35 +00:00
parent 25c915f540
commit 97ea6d9b31
2 changed files with 131 additions and 1 deletions
+115 -1
View File
@@ -86,8 +86,10 @@ jobs:
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")
branch_ref=""
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)
branch_ref="$ref"
git fetch origin "$ref" && git checkout "$ref"
{ echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT"
else # comment on an issue -> new branch
@@ -102,6 +104,21 @@ jobs:
fi
fi
# --- Autopilot gate: read the `fully-automatic` label FRESH every run. ---
# Presence of this label is the opt-in switch (and the kill switch: remove it mid-flight
# and the next run reverts to normal human-approval behavior). When @qa is triggered on a
# PR thread, the label lives on the ORIGIN issue (ai/issue-N), so resolve N from the branch.
issnum="$NUM"
case "$IS_PR" in ?*) issnum=$(printf '%s' "$branch_ref" | sed -nE 's,^ai/issue-([0-9]+).*,\1,p');; esac
[ -z "$issnum" ] && issnum="$NUM"
autopilot=false
if curl -sS -H "Authorization: token $GT" "$API/issues/$issnum/labels" 2>/dev/null \
| jq -e 'any(.[]?; .name=="fully-automatic")' >/dev/null 2>&1; then
autopilot=true
fi
echo "autopilot (fully-automatic label on #$issnum)=$autopilot"
{ echo "autopilot=$autopilot"; echo "issnum=$issnum"; } >> "$GITHUB_OUTPUT"
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
env:
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
@@ -392,6 +409,7 @@ jobs:
MODE: ${{ steps.prep.outputs.mode }}
HAS_IMAGES: ${{ steps.imgs.outputs.has_images }}
BRANCH: ${{ steps.prep.outputs.branch }}
AUTOPILOT: ${{ steps.prep.outputs.autopilot }}
NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
IBODY: ${{ github.event.issue.body }}
@@ -438,6 +456,36 @@ jobs:
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."
if [ "$AUTOPILOT" = "true" ]; then
ACTION="$ACTION
AUTOPILOT MODE IS ACTIVE (this issue carries the 'fully-automatic' label). This OVERRIDES the
two-phase approval gate above: do NOT ask '@ffaerber ready to start building?' and do NOT wait
for a 'yes'. When the task is clear, present your SHORT plan naming the best teammate to build it
AND end your reply with a 'DELEGATE: @<agent>' line in the SAME turn to hand off immediately.
Prefer @junior for small/low-risk (mostly YAML/compose/config), @senior/@lead for complex or
multi-file work. Only skip delegating (and instead ask @ffaerber) if the task is genuinely
ambiguous or unsafe — otherwise plan-and-delegate now."
fi
fi
if [ "$NAME" = "qa" ]; then
ACTION="$ACTION
As QA you verify a change works: read the PR/issue, drive the web app with your headless
browser if there is a URL, and report bugs or confirm behavior. You normally do NOT merge —
a human does that."
if [ "$AUTOPILOT" = "true" ]; then
ACTION="$ACTION
AUTOPILOT MODE IS ACTIVE (this issue/PR carries the 'fully-automatic' label). This grants you a
NARROW, one-time merge authority for THIS PR only:
- If, after verifying, the PR is correct and any CI checks are green, end your reply with EXACTLY
one line: 'MERGE_PR'. The automation will then merge the PR and close the linked issue for you.
Do NOT merge via any other means; only the MERGE_PR marker triggers the merge.
- If you find ANY bug, doubt, or the change is not clearly correct, do NOT merge. Instead describe
the problem clearly and end your reply with EXACTLY one line: 'HALT_AUTOPILOT'. The automation
removes the 'fully-automatic' label (returning this issue to normal human control) and leaves
it for @ffaerber to decide next steps. Never auto-bounce back to a dev.
Emit AT MOST one of MERGE_PR or HALT_AUTOPILOT, and only after you have actually verified. When in
doubt, prefer HALT_AUTOPILOT."
fi
fi
else
ACTION="You start on git branch '${BRANCH}', with git and push credentials already configured.
@@ -538,6 +586,9 @@ jobs:
TITLE: ${{ github.event.issue.title }}
BRANCH: ${{ steps.prep.outputs.branch }}
NEW: ${{ steps.prep.outputs.new }}
IS_PR: ${{ github.event.issue.pull_request }}
AUTOPILOT: ${{ steps.prep.outputs.autopilot }}
ISSNUM: ${{ steps.prep.outputs.issnum }}
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.
@@ -552,12 +603,29 @@ jobs:
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 'fully-automatic' label from an issue by resolving its ID first (Gitea's
# DELETE label endpoint is by ID, not name). Arg $1 = issue number.
del_autopilot_label() {
local iss="$1"
local lid
lid=$(curl -sS "${hdr[@]}" "$API/issues/$iss/labels" 2>/dev/null \
| jq -r 'if type=="array" then ([.[]|select(.name=="fully-automatic")][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 'fully-automatic' label found on #$iss to remove"
fi
}
# drop machine-readable markers (DELEGATE / CLOSE_ISSUE / the BEGIN_SUBTASKS..END_SUBTASKS block)
# drop machine-readable markers (DELEGATE / CLOSE_ISSUE / MERGE_PR / HALT_AUTOPILOT /
# the BEGIN_SUBTASKS..END_SUBTASKS block)
reply=$(awk '
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
/^[[:space:]]*MERGE_PR[[:space:]]*$/{next}
/^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$/{next}
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
{print}
' /tmp/agent_out.md 2>/dev/null)
@@ -581,6 +649,41 @@ jobs:
curl -sS -X PATCH "${hdr[@]}" "$API/issues/$NUM" \
-d '{"state":"closed"}' -w '\nclose -> HTTP %{http_code}\n' || true
fi
# --- AUTOPILOT: @qa's narrow, label-gated merge / halt authority ---
# Only @qa, only when 'fully-automatic' is set, and only on a PR thread. The MERGE_PR /
# HALT_AUTOPILOT markers come from the QA prompt. Merge uses TOKEN_QA (the QA user's PAT,
# which the maintainer must grant write+merge scope); label removal uses it too.
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})"
mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST "${hdr[@]}" \
"$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 `fully-automatic` label — @ffaerber please take a look.' "$NUM" "$mc")"
;;
esac
fi
elif grep -qiE '^[[:space:]]*HALT_AUTOPILOT[[:space:]]*$' /tmp/agent_out.md; then
echo "@qa autopilot: HALT — removing 'fully-automatic' label from #${ISSNUM:-$NUM}"
del_autopilot_label "${ISSNUM:-$NUM}"
post "$(printf '🤖 **@qa** — 🛑 found a problem, so I did NOT merge. Removed the `fully-automatic` label (back to human control). @ffaerber please decide next steps (details above).')"
fi
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
@@ -684,6 +787,17 @@ jobs:
if [ "$NEW" = "true" ]; then
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s' "$NAME" "$url")"
# AUTOPILOT: hand the fresh PR to @qa automatically (via AGENT_TOKEN, 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 "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then
echo "autopilot: auto-triggering @qa to review PR #$prnum"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled fully-automatic). Merge it if correct, or halt and remove the label if you find a problem." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true
fi
else
# Resume (comment is on a PR thread): include the write-up here too.
prpost "$prnum" "$(printf '🤖 **@%s** — updated branch/PR:\n- %s\n\n%s' "$NAME" "$url" "$prdesc")"