Merge pull request 'fix: feat: gardener defers all repo actions to a manifest — review gate covers grooming decisions, not just docs (#572)' (#575) from fix/issue-572 into main

This commit is contained in:
johba 2026-03-23 01:05:22 +01:00
commit a2438cb580
3 changed files with 237 additions and 86 deletions

View file

@ -37,6 +37,9 @@ Set up the working environment for this gardener run.
3. Record the current HEAD SHA for AGENTS.md watermarks:
HEAD_SHA=$(git rev-parse HEAD)
echo "$HEAD_SHA" > /tmp/gardener-head-sha
4. Initialize the pending-actions manifest (JSONL, converted to JSON at commit time):
printf '' > "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
"""
# ─────────────────────────────────────────────────────────────────────
@ -108,16 +111,10 @@ Sibling dependency rule (CRITICAL):
Read AGENTS.md and extract the AD table. For each backlog issue,
compare the issue title and body against each AD. If an issue
clearly violates an AD:
a. Post a comment explaining the violation:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/issues/<number>/comments" \
-d '{"body":"Closing: violates AD-NNN (<decision summary>). See AGENTS.md § Architecture Decisions."}'
b. Close the issue:
curl -sf -X PATCH -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/issues/<number>" \
-d '{"state":"closed"}'
a. Write a comment action to the manifest:
echo '{"action":"comment","issue":NNN,"body":"Closing: violates AD-NNN (<decision summary>). See AGENTS.md § Architecture Decisions."}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
b. Write a close action to the manifest:
echo '{"action":"close","issue":NNN,"reason":"violates AD-NNN"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
c. Log to the result file:
echo "ACTION: closed #NNN — violates AD-NNN" >> "$RESULT_FILE"
@ -134,20 +131,13 @@ Sibling dependency rule (CRITICAL):
"## Affected files" section with at least one file path
If either section is missing:
a. Look up the 'backlog' label ID:
BACKLOG_LABEL_ID=$(curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/labels" | jq -r '.[] | select(.name == "backlog") | .id')
b. Post a comment listing what's missing:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/issues/<number>/comments" \
-d '{"body":"This issue is missing required sections. Please use the issue templates at `.codeberg/ISSUE_TEMPLATE/` — needs: <missing items>."}'
a. Write a comment action to the manifest:
echo '{"action":"comment","issue":NNN,"body":"This issue is missing required sections. Please use the issue templates at `.codeberg/ISSUE_TEMPLATE/` — needs: <missing items>."}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
Where <missing items> is a comma-separated list of what's absent
(e.g. "acceptance criteria, affected files" or just "affected files").
c. Remove the 'backlog' label:
curl -sf -X DELETE -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/issues/<number>/labels/$BACKLOG_LABEL_ID"
d. Log to the result file:
b. Write a remove_label action to the manifest:
echo '{"action":"remove_label","issue":NNN,"label":"backlog"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
c. Log to the result file:
echo "ACTION: stripped backlog from #NNN — missing: <missing items>" >> "$RESULT_FILE"
Well-structured issues (both sections present) are left untouched
@ -203,16 +193,11 @@ session, so changes there would be lost.
jq -r '[.group, (.issue | tostring)] | join("\\t")' "$DUST_FILE" | sort -u | cut -f1 | sort | uniq -c | sort -rn
b. For each group with count >= 3:
- Collect issue details and distinct issue numbers for the group
- Look up the backlog label ID:
BACKLOG_LABEL_ID=$(curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/labels" | jq -r '.[] | select(.name == "backlog") | .id')
- Create a bundled backlog issue:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" "$CODEBERG_API/issues" \
-d '{"title":"fix: bundled dust cleanup — GROUP","body":"...","labels":[LABEL_ID]}'
- Close each source issue with a cross-reference comment:
curl ... "$CODEBERG_API/issues/NNN/comments" -d '{"body":"Bundled into #NEW"}'
curl ... "$CODEBERG_API/issues/NNN" -d '{"state":"closed"}'
- Write a create_issue action to the manifest:
echo '{"action":"create_issue","title":"fix: bundled dust cleanup — GROUP","body":"...","labels":["backlog"]}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
- Write comment + close actions for each source issue:
echo '{"action":"comment","issue":NNN,"body":"Bundled into dust cleanup issue for GROUP"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
echo '{"action":"close","issue":NNN,"reason":"bundled into dust cleanup for GROUP"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
- Remove bundled items from dust.jsonl:
jq -c --arg g "GROUP" 'select(.group != $g)' "$DUST_FILE" > "${DUST_FILE}.tmp" && mv "${DUST_FILE}.tmp" "$DUST_FILE"
@ -233,57 +218,42 @@ description = """
Review all issues labeled 'blocked' and decide their fate.
(See issue #352 for the blocked label convention.)
1. Look up the 'blocked' label ID (Gitea needs integer IDs for label removal):
BLOCKED_LABEL_ID=$(curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/labels" | jq -r '.[] | select(.name == "blocked") | .id')
If the lookup fails, skip label removal and just post comments.
2. Fetch all blocked issues:
1. Fetch all blocked issues:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/issues?state=open&type=issues&labels=blocked&limit=50"
3. For each blocked issue, read the full body and comments:
2. For each blocked issue, read the full body and comments:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/issues/<number>"
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/issues/<number>/comments"
4. Check dependencies extract issue numbers from ## Dependencies /
3. Check dependencies extract issue numbers from ## Dependencies /
## Depends on / ## Blocked by sections. For each dependency:
curl -sf -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/issues/<dep_number>"
Check if the dependency is now closed.
5. For each blocked issue, choose ONE action:
4. For each blocked issue, choose ONE action:
UNBLOCK all dependencies are now closed or the blocking condition resolved:
a. Remove the 'blocked' label (using ID from step 1):
curl -sf -X DELETE -H "Authorization: token $CODEBERG_TOKEN" \
"$CODEBERG_API/issues/<number>/labels/$BLOCKED_LABEL_ID"
b. Add context comment explaining what changed:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/issues/<number>/comments" \
-d '{"body":"Unblocked: <explanation of what resolved the blocker>"}'
a. Write a remove_label action to the manifest:
echo '{"action":"remove_label","issue":NNN,"label":"blocked"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
b. Write a comment action to the manifest:
echo '{"action":"comment","issue":NNN,"body":"Unblocked: <explanation of what resolved the blocker>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
NEEDS HUMAN blocking condition is ambiguous, requires architectural
decision, or involves external factors:
a. Post a diagnostic comment explaining what you found and what
decision is needed
a. Write a comment action to the manifest:
echo '{"action":"comment","issue":NNN,"body":"<diagnostic: what you found and what decision is needed>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
b. Leave the 'blocked' label in place
CLOSE issue is stale (blocked 30+ days with no progress on blocker),
the blocker is wontfix, or the issue is no longer relevant:
a. Post a comment explaining why:
curl -sf -X POST -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/issues/<number>/comments" \
-d '{"body":"Closing: <reason — stale blocker, no longer relevant, etc.>"}'
b. Close the issue:
curl -sf -X PATCH -H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/issues/<number>" \
-d '{"state":"closed"}'
a. Write a comment action to the manifest:
echo '{"action":"comment","issue":NNN,"body":"Closing: <reason — stale blocker, no longer relevant, etc.>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
b. Write a close action to the manifest:
echo '{"action":"close","issue":NNN,"reason":"<stale blocker / no longer relevant / etc.>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
CRITICAL: If this step fails, log the failure and move on.
"""
@ -421,49 +391,63 @@ needs = ["blocked-review"]
id = "commit-and-pr"
title = "One commit with all file changes, push, create PR, monitor to merge"
description = """
Collect all file changes from this run (AGENTS.md updates) into a single commit.
API calls (issue creation, PR comments, closures) already happened during the
run only file changes need the PR.
Collect all file changes from this run (AGENTS.md updates + pending-actions
manifest) into a single commit. All repo mutation API calls (comments, closures,
label changes, issue creation) are deferred to the manifest the orchestrator
executes them after the PR merges.
1. Check for staged or unstaged changes:
1. Convert the JSONL manifest to a JSON array:
cd "$PROJECT_REPO_ROOT"
JSONL_FILE="$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
JSON_FILE="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
if [ -s "$JSONL_FILE" ]; then
jq -s '.' "$JSONL_FILE" > "$JSON_FILE"
else
echo '[]' > "$JSON_FILE"
fi
rm -f "$JSONL_FILE"
2. Check for staged or unstaged changes:
git status --porcelain
If there are no file changes, skip to step 3 no commit, no PR needed.
If there are no file changes (no AGENTS.md updates AND manifest is empty []),
skip to step 4 no commit, no PR needed.
2. If there are changes:
3. If there are changes:
a. Create a branch:
BRANCH="chore/gardener-$(date -u +%Y%m%d-%H%M)"
git checkout -B "$BRANCH"
b. Stage all modified AGENTS.md files:
find . -name "AGENTS.md" -not -path "./.git/*" -exec git add {} +
c. Also stage any other files the gardener modified (if any):
c. Stage the pending-actions manifest:
git add gardener/pending-actions.json
d. Also stage any other files the gardener modified (if any):
git add -u
d. Commit:
e. Commit:
git commit -m "chore: gardener housekeeping $(date -u +%Y-%m-%d)"
e. Push:
f. Push:
git push -u origin "$BRANCH"
f. Create a PR:
g. Create a PR:
PR_RESPONSE=$(curl -sf -X POST \
-H "Authorization: token $CODEBERG_TOKEN" \
-H "Content-Type: application/json" \
"$CODEBERG_API/pulls" \
-d '{"title":"chore: gardener housekeeping",
"head":"'"$BRANCH"'","base":"'"$PRIMARY_BRANCH"'",
"body":"Automated gardener housekeeping — AGENTS.md updates.\\n\\nReview-agent fast-tracks doc-only PRs."}')
"body":"Automated gardener housekeeping — AGENTS.md updates + pending actions manifest.\\n\\nReview `gardener/pending-actions.json` for proposed grooming actions (label changes, closures, comments). These execute after merge."}')
PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number')
g. Save PR number for orchestrator tracking:
h. Save PR number for orchestrator tracking:
echo "$PR_NUMBER" > /tmp/gardener-pr-${PROJECT_NAME}.txt
h. Signal the orchestrator to monitor CI:
i. Signal the orchestrator to monitor CI:
echo "PHASE:awaiting_ci" > "$PHASE_FILE"
i. STOP and WAIT. Do NOT return to the primary branch.
j. STOP and WAIT. Do NOT return to the primary branch.
The orchestrator polls CI, injects results and review feedback.
When you receive injected CI or review feedback, follow its
instructions, then write PHASE:awaiting_ci and wait again.
3. If no file changes existed (step 1 found nothing):
4. If no file changes existed (step 2 found nothing):
echo "PHASE:done" > "$PHASE_FILE"
4. If PR creation fails, log the error and write PHASE:failed.
5. If PR creation fails, log the error and write PHASE:failed.
"""
needs = ["agents-update"]

View file

@ -17,8 +17,11 @@ runs directly from cron like the planner, predictor, and supervisor.
- `gardener/gardener-run.sh` — Cron wrapper + orchestrator: lock, memory guard,
consumes escalation replies, sources disinto project config, creates tmux session,
injects formula prompt, monitors phase file, handles crash recovery via
`run_formula_and_monitor`
`run_formula_and_monitor`, executes pending-actions manifest after PR merge
- `formulas/run-gardener.toml` — Execution spec: preflight, grooming, dust-bundling, blocked-review, agents-update, commit-and-pr
- `gardener/pending-actions.json` — Manifest of deferred repo actions (label changes,
closures, comments, issue creation). Written during grooming steps, committed to the
PR, reviewed alongside AGENTS.md changes, executed by gardener-run.sh after merge.
**Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT`
@ -27,5 +30,7 @@ runs directly from cron like the planner, predictor, and supervisor.
**Lifecycle**: gardener-run.sh (cron 0,6,12,18) → lock + memory guard →
consume escalation replies → load formula + context → create tmux session →
Claude grooms backlog, bundles dust, reviews blocked issues, updates AGENTS.md,
commits and creates PR → `PHASE:done`.
Claude grooms backlog (writes proposed actions to manifest), bundles dust,
reviews blocked issues, updates AGENTS.md, commits manifest + docs to PR →
review-agent reviews all proposed actions → after merge, gardener-run.sh
executes manifest actions via API → `PHASE:done`.

View file

@ -66,13 +66,24 @@ build_context_block AGENTS.md
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
# ── Build prompt (gardener needs extra API endpoints for issue management)
# ── Build prompt (manifest format reference for deferred actions) ────────
GARDENER_API_EXTRA="
Relabel: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PUT -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/labels' -d '{\"labels\":[LABEL_ID]}'
Comment: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X POST -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}/comments' -d '{\"body\":\"...\"}'
Close: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"state\":\"closed\"}'
Edit body: curl -sf -H \"Authorization: token \$CODEBERG_TOKEN\" -X PATCH -H 'Content-Type: application/json' '${CODEBERG_API}/issues/{number}' -d '{\"body\":\"new body\"}'
"
## Pending-actions manifest (REQUIRED)
All repo mutations (comments, closures, label changes, issue creation) MUST be
written to the JSONL manifest instead of calling APIs directly. Append one JSON
object per line to: \$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl
Supported actions:
{\"action\":\"add_label\", \"issue\":NNN, \"label\":\"priority\"}
{\"action\":\"remove_label\", \"issue\":NNN, \"label\":\"backlog\"}
{\"action\":\"close\", \"issue\":NNN, \"reason\":\"already implemented\"}
{\"action\":\"comment\", \"issue\":NNN, \"body\":\"Relates to issue 1031\"}
{\"action\":\"create_issue\", \"title\":\"...\", \"body\":\"...\", \"labels\":[\"backlog\"]}
{\"action\":\"edit_body\", \"issue\":NNN, \"body\":\"new body\"}
The commit-and-pr step converts JSONL to JSON array. The orchestrator executes
actions after the PR merges. Do NOT call mutation APIs directly during the run."
build_prompt_footer "$GARDENER_API_EXTRA"
# Extend phase protocol with merge-through instructions for compaction survival
@ -121,6 +132,154 @@ ${PROMPT_FOOTER}"
# Handles CI polling, review injection, merge, and cleanup after PR creation.
# Lighter than dev/phase-handler.sh — tailored for gardener doc-only PRs.
# ── Post-merge manifest execution ─────────────────────────────────────
# Reads gardener/pending-actions.json and executes each action via API.
# Failed actions are logged but do not block completion.
# shellcheck disable=SC2317 # called indirectly via _gardener_merge
_gardener_execute_manifest() {
local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
if [ ! -f "$manifest_file" ]; then
log "manifest: no pending-actions.json — skipping"
return 0
fi
local count
count=$(jq 'length' "$manifest_file" 2>/dev/null || echo 0)
if [ "$count" -eq 0 ]; then
log "manifest: empty — skipping"
return 0
fi
log "manifest: executing ${count} actions"
local i=0
while [ "$i" -lt "$count" ]; do
local action issue
action=$(jq -r ".[$i].action" "$manifest_file")
issue=$(jq -r ".[$i].issue // empty" "$manifest_file")
case "$action" in
add_label)
local label label_id
label=$(jq -r ".[$i].label" "$manifest_file")
label_id=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${CODEBERG_API}/labels" | jq -r --arg n "$label" \
'.[] | select(.name == $n) | .id') || true
if [ -n "$label_id" ]; then
if curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \
-H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}/labels" \
-d "{\"labels\":[${label_id}]}" >/dev/null 2>&1; then
log "manifest: add_label '${label}' to #${issue}"
else
log "manifest: FAILED add_label '${label}' to #${issue}"
fi
else
log "manifest: FAILED add_label — label '${label}' not found"
fi
;;
remove_label)
local label label_id
label=$(jq -r ".[$i].label" "$manifest_file")
label_id=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${CODEBERG_API}/labels" | jq -r --arg n "$label" \
'.[] | select(.name == $n) | .id') || true
if [ -n "$label_id" ]; then
if curl -sf -X DELETE -H "Authorization: token ${CODEBERG_TOKEN}" \
"${CODEBERG_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1; then
log "manifest: remove_label '${label}' from #${issue}"
else
log "manifest: FAILED remove_label '${label}' from #${issue}"
fi
else
log "manifest: FAILED remove_label — label '${label}' not found"
fi
;;
close)
local reason
reason=$(jq -r ".[$i].reason // empty" "$manifest_file")
if curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
-H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}" \
-d '{"state":"closed"}' >/dev/null 2>&1; then
log "manifest: closed #${issue} (${reason})"
else
log "manifest: FAILED close #${issue}"
fi
;;
comment)
local body escaped_body
body=$(jq -r ".[$i].body" "$manifest_file")
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
if curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \
-H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}/comments" \
-d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then
log "manifest: commented on #${issue}"
else
log "manifest: FAILED comment on #${issue}"
fi
;;
create_issue)
local title body labels escaped_title escaped_body label_ids
title=$(jq -r ".[$i].title" "$manifest_file")
body=$(jq -r ".[$i].body" "$manifest_file")
labels=$(jq -r ".[$i].labels // [] | .[]" "$manifest_file")
escaped_title=$(printf '%s' "$title" | jq -Rs '.')
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
# Resolve label names to IDs
label_ids="[]"
if [ -n "$labels" ]; then
local all_labels ids_json=""
all_labels=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
"${CODEBERG_API}/labels") || true
while IFS= read -r lname; do
local lid
lid=$(echo "$all_labels" | jq -r --arg n "$lname" \
'.[] | select(.name == $n) | .id') || true
[ -n "$lid" ] && ids_json="${ids_json:+${ids_json},}${lid}"
done <<< "$labels"
[ -n "$ids_json" ] && label_ids="[${ids_json}]"
fi
if curl -sf -X POST -H "Authorization: token ${CODEBERG_TOKEN}" \
-H 'Content-Type: application/json' \
"${CODEBERG_API}/issues" \
-d "{\"title\":${escaped_title},\"body\":${escaped_body},\"labels\":${label_ids}}" >/dev/null 2>&1; then
log "manifest: created issue '${title}'"
else
log "manifest: FAILED create_issue '${title}'"
fi
;;
edit_body)
local body escaped_body
body=$(jq -r ".[$i].body" "$manifest_file")
escaped_body=$(printf '%s' "$body" | jq -Rs '.')
if curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
-H 'Content-Type: application/json' \
"${CODEBERG_API}/issues/${issue}" \
-d "{\"body\":${escaped_body}}" >/dev/null 2>&1; then
log "manifest: edited body of #${issue}"
else
log "manifest: FAILED edit_body #${issue}"
fi
;;
*)
log "manifest: unknown action '${action}' — skipping"
;;
esac
i=$((i + 1))
done
log "manifest: execution complete (${count} actions processed)"
}
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
_gardener_merge() {
local merge_response merge_http_code
@ -133,6 +292,7 @@ _gardener_merge() {
if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then
log "gardener PR #${_GARDENER_PR} merged"
_gardener_execute_manifest
printf 'PHASE:done\n' > "$PHASE_FILE"
return 0
fi
@ -144,6 +304,7 @@ _gardener_merge() {
"${CODEBERG_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true
if [ "$pr_merged" = "true" ]; then
log "gardener PR #${_GARDENER_PR} already merged"
_gardener_execute_manifest
printf 'PHASE:done\n' > "$PHASE_FILE"
return 0
fi
@ -422,6 +583,7 @@ Then stop and wait."
if [ "$pr_merged" = "true" ]; then
log "gardener PR #${_GARDENER_PR} merged externally"
_gardener_execute_manifest
printf 'PHASE:done\n' > "$PHASE_FILE"
return 0
fi