PM-trigger via Gitea: pm-task.sh + webhook routing on pm:plan label + idempotency
This commit is contained in:
190
pm/pm-task.sh
Executable file
190
pm/pm-task.sh
Executable file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# pm-task.sh <owner>/<repo> <parent-issue-id>
|
||||
#
|
||||
# Webhook-triggered: an issue acquired the label "pm:plan". Reads the parent
|
||||
# issue body as a free-form goal, asks claude to decompose into 1–5 structured
|
||||
# agent-issues, creates each as a child issue with label "agent:assign", and
|
||||
# comments on the parent linking to the children.
|
||||
#
|
||||
# Env knobs:
|
||||
# MODEL default sonnet
|
||||
# GITEA_URL default https://gitea.dannyhaslund.dk
|
||||
# GITEA_TOKEN_FILE default /etc/agent/gitea-token
|
||||
# PM_PLAN_LABEL_ID default 16 (the pm:plan label id on the target repo)
|
||||
# PM_PLANNED_LABEL_ID default 17 (the pm:planned label id)
|
||||
# AGENT_LABEL_ID default 15 (the agent:assign label id)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${1:-}"
|
||||
PARENT="${2:-}"
|
||||
[[ -z "$REPO" || -z "$PARENT" ]] && { echo "usage: $0 <owner>/<repo> <parent-issue-id>" >&2; exit 64; }
|
||||
|
||||
MODEL="${MODEL:-sonnet}"
|
||||
GITEA_URL="${GITEA_URL:-https://gitea.dannyhaslund.dk}"
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-/etc/agent/gitea-token}"
|
||||
PM_PLAN_LABEL_ID="${PM_PLAN_LABEL_ID:-16}"
|
||||
PM_PLANNED_LABEL_ID="${PM_PLANNED_LABEL_ID:-17}"
|
||||
AGENT_LABEL_ID="${AGENT_LABEL_ID:-15}"
|
||||
|
||||
[[ -r "$GITEA_TOKEN_FILE" ]] || { echo "missing $GITEA_TOKEN_FILE" >&2; exit 65; }
|
||||
GITEA_TOKEN="$(cat "$GITEA_TOKEN_FILE")"
|
||||
|
||||
# Per-issue flock so duplicate webhook deliveries (Gitea fires both 'opened'
|
||||
# and 'label_updated' for an issue opened with a label) don't double-plan.
|
||||
LOCK_DIR="${LOCK_DIR:-/var/lib/agent-pm}"
|
||||
mkdir -p "$LOCK_DIR"
|
||||
LOCK_FILE="$LOCK_DIR/${REPO//\//_}-${PARENT}.lock"
|
||||
exec 201>"$LOCK_FILE"
|
||||
if ! flock -n 201; then
|
||||
echo "[pm-task] another pm-task already running for $REPO#$PARENT; exiting" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LOG_FILE="/var/log/agent-webhook/pm-task-${REPO//\//_}-${PARENT}-$(date -u +%Y%m%dT%H%M%S).log"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
api() { curl -fsS -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" "$@"; }
|
||||
|
||||
# ---------- 1. Fetch parent issue + idempotency check ----------
|
||||
log "Fetching parent $REPO#$PARENT"
|
||||
parent_json="$(api "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT")"
|
||||
parent_title="$(jq -r .title <<<"$parent_json")"
|
||||
goal_body="$(jq -r .body <<<"$parent_json")"
|
||||
parent_labels="$(jq -r '.labels[].name' <<<"$parent_json" | tr '\n' ' ')"
|
||||
|
||||
if echo "$parent_labels" | grep -qw "pm:planned"; then
|
||||
log "Parent already has pm:planned label — refusing to re-plan. Remove pm:planned first if you want to re-plan."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- 2. Probe repo state ----------
|
||||
# Cheap context: list the top-level file names on the default branch so the
|
||||
# PM doesn't propose duplicating things that already exist.
|
||||
default_branch="$(api "$GITEA_URL/api/v1/repos/$REPO" | jq -r .default_branch)"
|
||||
contents_json="$(api "$GITEA_URL/api/v1/repos/$REPO/contents?ref=$default_branch")"
|
||||
file_list="$(jq -r '.[].name' <<<"$contents_json" 2>/dev/null | head -25 || echo "(empty repo)")"
|
||||
|
||||
# Open PRs as context (may be relevant to whether work is in flight)
|
||||
open_prs="$(api "$GITEA_URL/api/v1/repos/$REPO/pulls?state=open&limit=10" | \
|
||||
jq -r '.[] | " #\(.number) — \(.title) (branch \(.head.ref))"' || echo " (none)")"
|
||||
|
||||
REPO_STATE="default branch: $default_branch
|
||||
top-level files on $default_branch:
|
||||
$(printf '%s\n' "$file_list" | sed 's/^/ - /')
|
||||
open PRs:
|
||||
$open_prs"
|
||||
|
||||
# ---------- 3. Decompose via claude ----------
|
||||
SCHEMA='{
|
||||
"type": "object",
|
||||
"required": ["issues"],
|
||||
"properties": {
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 5,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["title", "goal", "done_criteria", "model"],
|
||||
"properties": {
|
||||
"title": { "type": "string", "maxLength": 80 },
|
||||
"goal": { "type": "string", "minLength": 20 },
|
||||
"done_criteria": { "type": "array", "minItems": 1, "items": { "type": "string" } },
|
||||
"hints": { "type": "string" },
|
||||
"model": { "type": "string", "enum": ["sonnet", "opus"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
PROMPT="You are the project manager for an autonomous coding empire. The human just opened parent issue #$PARENT in repo '$REPO' with the following goal. Decompose it into 1 to 5 structured agent-issues that, implemented in order, achieve the goal.
|
||||
|
||||
# Parent issue title
|
||||
|
||||
$parent_title
|
||||
|
||||
# Parent issue body (the goal)
|
||||
|
||||
$goal_body
|
||||
|
||||
# Current repo state
|
||||
|
||||
$REPO_STATE
|
||||
|
||||
# Constraints on the issues you produce
|
||||
|
||||
- Each issue must fit in <30 minutes of autonomous agent time on Sonnet.
|
||||
- Done criteria must be specific and mechanically verifiable (file exists,
|
||||
endpoint returns X, HTTP code, test passes). No taste calls.
|
||||
- Order issues by dependency (foundation first).
|
||||
- Use 'opus' only for tasks requiring design tradeoffs; routine
|
||||
scaffolding/CRUD/refactors are 'sonnet'.
|
||||
- Prefer fewer larger issues over many trivial ones, as long as each fits in
|
||||
30 min.
|
||||
- Avoid issues that require human judgment (\"make it look good\").
|
||||
- Do NOT call any tools — produce JSON from the prompt text alone.
|
||||
|
||||
Return only the JSON matching the schema."
|
||||
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
log "Decomposing via claude (model=$MODEL)"
|
||||
claude -p "$PROMPT" \
|
||||
--model "$MODEL" \
|
||||
--output-format json \
|
||||
--json-schema "$SCHEMA" \
|
||||
--allowedTools "" \
|
||||
< /dev/null > "$TMP" 2>>"$LOG_FILE"
|
||||
|
||||
issues_json="$(jq -c '.structured_output.issues // empty' "$TMP")"
|
||||
if [[ -z "$issues_json" || "$issues_json" == "null" ]]; then
|
||||
log "ERROR: claude did not return structured_output.issues"
|
||||
api -X POST -d "$(jq -nc --arg b "agent PM: failed to decompose. See log \`$LOG_FILE\` and remove + re-add the pm:plan label to retry." '{body:$b}')" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/comments" >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count="$(jq 'length' <<<"$issues_json")"
|
||||
log "claude proposed $count child issue(s)"
|
||||
|
||||
# ---------- 4. Create child issues with agent:assign label ----------
|
||||
created=()
|
||||
for i in $(seq 0 $((count - 1))); do
|
||||
issue="$(jq -c ".[$i]" <<<"$issues_json")"
|
||||
title="$(jq -r .title <<<"$issue")"
|
||||
goal_t="$(jq -r .goal <<<"$issue")"
|
||||
done_b="$(jq -r '.done_criteria | map("- [ ] " + .) | join("\n")' <<<"$issue")"
|
||||
hints="$(jq -r '.hints // ""' <<<"$issue")"
|
||||
model_t="$(jq -r '.model // "sonnet"' <<<"$issue")"
|
||||
|
||||
body="$(printf 'Parent: #%s\n\n## Goal\n\n%s\n\n## Done criteria\n\n%s\n\n## Hints\n\n%s\n\n## Model\n\n%s\n' \
|
||||
"$PARENT" "$goal_t" "$done_b" "${hints:-(none)}" "$model_t")"
|
||||
|
||||
resp="$(api -X POST -d "$(jq -nc \
|
||||
--arg t "$title" --arg b "$body" --argjson lid "$AGENT_LABEL_ID" \
|
||||
'{title:$t, body:$b, labels:[$lid]}')" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/issues")"
|
||||
num="$(jq -r .number <<<"$resp")"
|
||||
created+=("$num")
|
||||
log " created #$num — $title"
|
||||
done
|
||||
|
||||
# ---------- 5. Update parent: remove pm:plan, add pm:planned, comment ----------
|
||||
api -X DELETE "$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/labels/$PM_PLAN_LABEL_ID" >/dev/null 2>&1 || true
|
||||
api -X POST -d "$(jq -nc --argjson lid "$PM_PLANNED_LABEL_ID" '{labels:[$lid]}')" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/labels" >/dev/null
|
||||
|
||||
comment_lines=""
|
||||
for n in "${created[@]}"; do
|
||||
comment_lines+="- #$n\n"
|
||||
done
|
||||
parent_comment="$(printf 'agent PM: decomposed into %d child issue(s):\n\n%b\nEach is labeled `agent:assign` and will be picked up by a dev agent (one at a time).' "${#created[@]}" "$comment_lines")"
|
||||
|
||||
api -X POST -d "$(jq -nc --arg b "$parent_comment" '{body:$b}')" \
|
||||
"$GITEA_URL/api/v1/repos/$REPO/issues/$PARENT/comments" >/dev/null
|
||||
|
||||
log "Done. created=${created[*]}"
|
||||
Reference in New Issue
Block a user