fix: refactor: extract setup_forge() from bin/disinto into lib/forge-setup.sh (#298) #307
32 changed files with 1250 additions and 857 deletions
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Disinto — Agent Instructions
|
||||
|
||||
## What this repo is
|
||||
|
|
@ -31,7 +31,7 @@ disinto/ (code repo)
|
|||
│ supervisor-poll.sh — legacy bash orchestrator (superseded)
|
||||
├── architect/ architect-run.sh — strategic decomposition of vision into sprints
|
||||
├── vault/ vault-env.sh — shared env setup (vault redesign in progress, see #73-#77)
|
||||
├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, profile.sh, build-graph.py
|
||||
├── lib/ env.sh, agent-sdk.sh, ci-helpers.sh, ci-debug.sh, load-project.sh, parse-deps.sh, guard.sh, mirrors.sh, pr-lifecycle.sh, issue-lifecycle.sh, worktree.sh, formula-session.sh, stack-lock.sh, build-graph.py
|
||||
├── projects/ *.toml.example — templates; *.toml — local per-box config (gitignored)
|
||||
├── formulas/ Issue templates (TOML specs for multi-step agent tasks)
|
||||
└── docs/ Protocol docs (PHASE-PROTOCOL.md, EVIDENCE-ARCHITECTURE.md)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: auto-generated -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Architect — Agent Instructions
|
||||
|
||||
## What this agent is
|
||||
|
|
|
|||
|
|
@ -49,10 +49,13 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
|||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active architect
|
||||
acquire_cron_lock "/tmp/architect-run.lock"
|
||||
check_memory 2000
|
||||
memory_guard 2000
|
||||
|
||||
log "--- Architect run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_ARCHITECT_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_ARCHITECT_TOKEN}" \
|
||||
|
|
|
|||
479
bin/disinto
479
bin/disinto
|
|
@ -25,19 +25,10 @@ set -euo pipefail
|
|||
|
||||
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
source "${FACTORY_ROOT}/lib/env.sh"
|
||||
source "${FACTORY_ROOT}/lib/forge-setup.sh"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Execute a command in the Forgejo container (for admin operations)
|
||||
_forgejo_exec() {
|
||||
local use_bare="${DISINTO_BARE:-false}"
|
||||
if [ "$use_bare" = true ]; then
|
||||
docker exec -u git disinto-forgejo "$@"
|
||||
else
|
||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T -u git forgejo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
disinto — autonomous code factory CLI
|
||||
|
|
@ -574,447 +565,7 @@ is_compose_mode() {
|
|||
[ -f "${FACTORY_ROOT}/docker-compose.yml" ]
|
||||
}
|
||||
|
||||
# Provision or connect to a local Forgejo instance.
|
||||
# Creates admin + bot users, generates API tokens, stores in .env.
|
||||
# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose.
|
||||
setup_forge() {
|
||||
local forge_url="$1"
|
||||
local repo_slug="$2"
|
||||
local use_bare="${DISINTO_BARE:-false}"
|
||||
|
||||
echo ""
|
||||
echo "── Forge setup ────────────────────────────────────────"
|
||||
|
||||
# Check if Forgejo is already running
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then
|
||||
echo "Forgejo: ${forge_url} (already running)"
|
||||
else
|
||||
echo "Forgejo not reachable at ${forge_url}"
|
||||
echo "Starting Forgejo via Docker..."
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "Error: docker not found — needed to provision Forgejo" >&2
|
||||
echo " Install Docker or start Forgejo manually at ${forge_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract port from forge_url
|
||||
local forge_port
|
||||
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
|
||||
forge_port="${forge_port:-3000}"
|
||||
|
||||
if [ "$use_bare" = true ]; then
|
||||
# Bare-metal mode: standalone docker run
|
||||
mkdir -p "${FORGEJO_DATA_DIR}"
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then
|
||||
docker start disinto-forgejo >/dev/null 2>&1 || true
|
||||
else
|
||||
docker run -d \
|
||||
--name disinto-forgejo \
|
||||
--restart unless-stopped \
|
||||
-p "${forge_port}:3000" \
|
||||
-p 2222:22 \
|
||||
-v "${FORGEJO_DATA_DIR}:/data" \
|
||||
-e "FORGEJO__database__DB_TYPE=sqlite3" \
|
||||
-e "FORGEJO__server__ROOT_URL=${forge_url}/" \
|
||||
-e "FORGEJO__server__HTTP_PORT=3000" \
|
||||
-e "FORGEJO__service__DISABLE_REGISTRATION=true" \
|
||||
codeberg.org/forgejo/forgejo:11.0
|
||||
fi
|
||||
else
|
||||
# Compose mode: start Forgejo via docker compose
|
||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo
|
||||
fi
|
||||
|
||||
# Wait for Forgejo to become healthy
|
||||
echo -n "Waiting for Forgejo to start"
|
||||
local retries=0
|
||||
while ! curl -sf --max-time 3 "${forge_url}/api/v1/version" >/dev/null 2>&1; do
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -gt 60 ]; then
|
||||
echo ""
|
||||
echo "Error: Forgejo did not become ready within 60s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo " ready"
|
||||
fi
|
||||
|
||||
# Wait for Forgejo database to accept writes (API may be ready before DB is)
|
||||
echo -n "Waiting for Forgejo database"
|
||||
local db_ready=false
|
||||
for _i in $(seq 1 30); do
|
||||
if _forgejo_exec forgejo admin user list >/dev/null 2>&1; then
|
||||
db_ready=true
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo ""
|
||||
if [ "$db_ready" != true ]; then
|
||||
echo "Error: Forgejo database not ready after 30s" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create admin user if it doesn't exist
|
||||
local admin_user="disinto-admin"
|
||||
local admin_pass
|
||||
local env_file="${FACTORY_ROOT}/.env"
|
||||
|
||||
# Re-read persisted admin password if available (#158)
|
||||
if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
admin_pass=$(grep '^FORGE_ADMIN_PASS=' "$env_file" | head -1 | cut -d= -f2-)
|
||||
fi
|
||||
# Generate a fresh password only when none was persisted
|
||||
if [ -z "${admin_pass:-}" ]; then
|
||||
admin_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
fi
|
||||
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
|
||||
echo "Creating admin user: ${admin_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--admin \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--email "admin@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create admin user '${admin_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify admin user was actually created
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
|
||||
echo "Error: admin user '${admin_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Persist admin password to .env for idempotent re-runs (#158)
|
||||
if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^FORGE_ADMIN_PASS=.*|FORGE_ADMIN_PASS=${admin_pass}|" "$env_file"
|
||||
else
|
||||
printf 'FORGE_ADMIN_PASS=%s\n' "$admin_pass" >> "$env_file"
|
||||
fi
|
||||
else
|
||||
echo "Admin user: ${admin_user} (already exists)"
|
||||
# Reset password to the persisted value so basic-auth works (#158)
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--must-change-password=false
|
||||
fi
|
||||
# Preserve password for Woodpecker OAuth2 token generation (#779)
|
||||
_FORGE_ADMIN_PASS="$admin_pass"
|
||||
|
||||
# Create human user (disinto-admin) as site admin if it doesn't exist
|
||||
local human_user="disinto-admin"
|
||||
local human_pass
|
||||
human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
echo "Creating human user: ${human_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--admin \
|
||||
--username "${human_user}" \
|
||||
--password "${human_pass}" \
|
||||
--email "admin@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create human user '${human_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${human_user}" \
|
||||
--password "${human_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify human user was actually created
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
echo "Error: human user '${human_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Human user '${human_user}' created as site admin"
|
||||
else
|
||||
echo "Human user: ${human_user} (already exists)"
|
||||
fi
|
||||
|
||||
# Get or create admin token
|
||||
local admin_token
|
||||
admin_token=$(curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" \
|
||||
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || admin_token=""
|
||||
|
||||
if [ -z "$admin_token" ]; then
|
||||
# Token might already exist — try listing
|
||||
admin_token=$(curl -sf \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[0].sha1 // empty') || admin_token=""
|
||||
fi
|
||||
|
||||
if [ -z "$admin_token" ]; then
|
||||
echo "Error: failed to obtain admin API token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get or create human user token
|
||||
local human_token
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
human_token=$(curl -sf -X POST \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens" \
|
||||
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || human_token=""
|
||||
|
||||
if [ -z "$human_token" ]; then
|
||||
# Token might already exist — try listing
|
||||
human_token=$(curl -sf \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[0].sha1 // empty') || human_token=""
|
||||
fi
|
||||
|
||||
if [ -n "$human_token" ]; then
|
||||
# Store human token in .env
|
||||
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
|
||||
else
|
||||
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
|
||||
fi
|
||||
export HUMAN_TOKEN="$human_token"
|
||||
echo " Human token saved (HUMAN_TOKEN)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create bot users and tokens
|
||||
# Each agent gets its own Forgejo account for identity and audit trail (#747).
|
||||
# Map: bot-username -> env-var-name for the token
|
||||
local -A bot_token_vars=(
|
||||
[dev-bot]="FORGE_TOKEN"
|
||||
[review-bot]="FORGE_REVIEW_TOKEN"
|
||||
[planner-bot]="FORGE_PLANNER_TOKEN"
|
||||
[gardener-bot]="FORGE_GARDENER_TOKEN"
|
||||
[vault-bot]="FORGE_VAULT_TOKEN"
|
||||
[supervisor-bot]="FORGE_SUPERVISOR_TOKEN"
|
||||
[predictor-bot]="FORGE_PREDICTOR_TOKEN"
|
||||
[architect-bot]="FORGE_ARCHITECT_TOKEN"
|
||||
)
|
||||
|
||||
local bot_user bot_pass token token_var
|
||||
|
||||
for bot_user in dev-bot review-bot planner-bot gardener-bot vault-bot supervisor-bot predictor-bot architect-bot; do
|
||||
bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
token_var="${bot_token_vars[$bot_user]}"
|
||||
|
||||
# Check if bot user exists
|
||||
local user_exists=false
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
user_exists=true
|
||||
fi
|
||||
|
||||
if [ "$user_exists" = false ]; then
|
||||
echo "Creating bot user: ${bot_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--email "${bot_user}@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create bot user '${bot_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify bot user was actually created
|
||||
if ! curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
echo "Error: bot user '${bot_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ${bot_user} user created"
|
||||
else
|
||||
echo " ${bot_user} user exists (resetting password for token generation)"
|
||||
# User exists but may not have a known password.
|
||||
# Use admin API to reset the password so we can generate a new token.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false || {
|
||||
echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Generate token via API (basic auth as the bot user — Forgejo requires
|
||||
# basic auth on POST /users/{username}/tokens, token auth is rejected)
|
||||
# First, try to delete existing tokens to avoid name collision
|
||||
# Use bot user's own Basic Auth (we just set the password above)
|
||||
local existing_token_ids
|
||||
existing_token_ids=$(curl -sf \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids=""
|
||||
|
||||
# Delete any existing tokens for this user
|
||||
if [ -n "$existing_token_ids" ]; then
|
||||
while IFS= read -r tid; do
|
||||
[ -n "$tid" ] && curl -sf -X DELETE \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true
|
||||
done <<< "$existing_token_ids"
|
||||
fi
|
||||
|
||||
token=$(curl -sf -X POST \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" \
|
||||
-d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || token=""
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "Error: failed to create API token for '${bot_user}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Store token in .env under the per-agent variable name
|
||||
if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${token_var}=.*|${token_var}=${token}|" "$env_file"
|
||||
else
|
||||
printf '%s=%s\n' "$token_var" "$token" >> "$env_file"
|
||||
fi
|
||||
export "${token_var}=${token}"
|
||||
echo " ${bot_user} token generated and saved (${token_var})"
|
||||
|
||||
# Backwards-compat aliases for dev-bot and review-bot
|
||||
if [ "$bot_user" = "dev-bot" ]; then
|
||||
export CODEBERG_TOKEN="$token"
|
||||
elif [ "$bot_user" = "review-bot" ]; then
|
||||
export REVIEW_BOT_TOKEN="$token"
|
||||
fi
|
||||
done
|
||||
|
||||
# Store FORGE_URL in .env if not already present
|
||||
if ! grep -q '^FORGE_URL=' "$env_file" 2>/dev/null; then
|
||||
printf 'FORGE_URL=%s\n' "$forge_url" >> "$env_file"
|
||||
fi
|
||||
|
||||
# Create the repo on Forgejo if it doesn't exist
|
||||
local org_name="${repo_slug%%/*}"
|
||||
local repo_name="${repo_slug##*/}"
|
||||
|
||||
# Check if repo already exists
|
||||
if ! curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then
|
||||
|
||||
# Try creating org first (ignore if exists)
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs" \
|
||||
-d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true
|
||||
|
||||
# Create repo under org
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs/${org_name}/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
# Fallback: create under the human user namespace using admin endpoint
|
||||
if [ -n "${admin_token:-}" ]; then
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users/${org_name}/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
echo "Error: failed to create repo '${repo_slug}' on Forgejo (admin endpoint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ -n "${HUMAN_TOKEN:-}" ]; then
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${HUMAN_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/user/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
echo "Error: failed to create repo '${repo_slug}' on Forgejo (user endpoint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: failed to create repo '${repo_slug}' — no admin or human token available" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add all bot users as collaborators with appropriate permissions
|
||||
# dev-bot: write (PR creation via lib/vault.sh)
|
||||
# review-bot: read (PR review)
|
||||
# planner-bot: write (prerequisites.md, memory)
|
||||
# gardener-bot: write (backlog grooming)
|
||||
# vault-bot: write (vault items)
|
||||
# supervisor-bot: read (health monitoring)
|
||||
# predictor-bot: read (pattern detection)
|
||||
# architect-bot: write (sprint PRs)
|
||||
local bot_user bot_perm
|
||||
declare -A bot_permissions=(
|
||||
[dev-bot]="write"
|
||||
[review-bot]="read"
|
||||
[planner-bot]="write"
|
||||
[gardener-bot]="write"
|
||||
[vault-bot]="write"
|
||||
[supervisor-bot]="read"
|
||||
[predictor-bot]="read"
|
||||
[architect-bot]="write"
|
||||
)
|
||||
for bot_user in "${!bot_permissions[@]}"; do
|
||||
bot_perm="${bot_permissions[$bot_user]}"
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}/collaborators/${bot_user}" \
|
||||
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# Add disinto-admin as admin collaborator
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}/collaborators/disinto-admin" \
|
||||
-d '{"permission":"admin"}' >/dev/null 2>&1 || true
|
||||
|
||||
echo "Repo: ${repo_slug} created on Forgejo"
|
||||
else
|
||||
echo "Repo: ${repo_slug} (already exists on Forgejo)"
|
||||
fi
|
||||
|
||||
echo "Forge: ${forge_url} (ready)"
|
||||
}
|
||||
# setup_forge() is defined in lib/forge-setup.sh
|
||||
|
||||
# Create and seed the {project}-ops repo on Forgejo with initial directory structure.
|
||||
# The ops repo holds operational data: vault items, journals, evidence, prerequisites.
|
||||
|
|
@ -1216,6 +767,9 @@ OPSEOF
|
|||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Export resolved slug for the caller to write back to the project TOML
|
||||
_ACTUAL_OPS_SLUG="${actual_ops_slug}"
|
||||
}
|
||||
|
||||
# Push local clone to the Forgejo remote.
|
||||
|
|
@ -1457,6 +1011,9 @@ create_labels() {
|
|||
["prediction/dismissed"]="#d73a4a"
|
||||
["prediction/actioned"]="#28a745"
|
||||
["bug-report"]="#e11d48"
|
||||
["needs-triage"]="#f9d0c4"
|
||||
["reproduced"]="#0e8a16"
|
||||
["cannot-reproduce"]="#cccccc"
|
||||
)
|
||||
|
||||
echo "Creating labels on ${repo}..."
|
||||
|
|
@ -1470,7 +1027,7 @@ create_labels() {
|
|||
|
||||
local name color
|
||||
local created=0 skipped=0 failed=0
|
||||
for name in backlog in-progress blocked tech-debt underspecified vision action bug-report prediction/unreviewed prediction/dismissed prediction/actioned; do
|
||||
for name in backlog in-progress blocked tech-debt underspecified vision action bug-report prediction/unreviewed prediction/dismissed prediction/actioned needs-triage reproduced cannot-reproduce; do
|
||||
if echo "$existing" | grep -qx "$name"; then
|
||||
echo " . ${name} (already exists)"
|
||||
skipped=$((skipped + 1))
|
||||
|
|
@ -2145,6 +1702,24 @@ p.write_text(text)
|
|||
echo "Created: ${toml_path}"
|
||||
fi
|
||||
|
||||
# Update ops_repo in TOML with the resolved actual ops slug.
|
||||
# Uses in-place substitution to prevent duplicate keys on repeated init runs.
|
||||
# If the key is missing (manually created TOML), it is inserted after the repo line.
|
||||
if [ -n "${_ACTUAL_OPS_SLUG:-}" ] && [ -f "$toml_path" ]; then
|
||||
python3 -c "
|
||||
import sys, re, pathlib
|
||||
p = pathlib.Path(sys.argv[1])
|
||||
text = p.read_text()
|
||||
new_val = 'ops_repo = \"' + sys.argv[2] + '\"'
|
||||
if re.search(r'^ops_repo\s*=', text, re.MULTILINE):
|
||||
text = re.sub(r'^ops_repo\s*=\s*.*\$', new_val, text, flags=re.MULTILINE)
|
||||
else:
|
||||
text = re.sub(r'^(repo\s*=\s*\"[^\"]*\")', r'\1\n' + new_val, text, flags=re.MULTILINE)
|
||||
p.write_text(text)
|
||||
" "$toml_path" "${_ACTUAL_OPS_SLUG}"
|
||||
echo "Updated: ops_repo in ${toml_path}"
|
||||
fi
|
||||
|
||||
# Create OAuth2 app on Forgejo for Woodpecker (before compose up)
|
||||
_WP_REPO_ID=""
|
||||
create_woodpecker_oauth "$forge_url" "$forge_repo"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Dev Agent
|
||||
|
||||
**Role**: Implement issues autonomously — write code, push branches, address
|
||||
|
|
|
|||
|
|
@ -39,6 +39,23 @@ services:
|
|||
depends_on:
|
||||
- forgejo
|
||||
|
||||
reproduce:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/reproduce/Dockerfile
|
||||
image: disinto-reproduce:latest
|
||||
network_mode: host
|
||||
profiles: ["reproduce"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- agent-data:/home/agent/data
|
||||
- project-repos:/home/agent/repos
|
||||
- ${HOME}/.claude:/home/agent/.claude
|
||||
- /usr/local/bin/claude:/usr/local/bin/claude:ro
|
||||
- ${HOME}/.ssh:/home/agent/.ssh:ro
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:1
|
||||
container_name: disinto-forgejo
|
||||
|
|
|
|||
|
|
@ -451,6 +451,129 @@ launch_runner() {
|
|||
return $exit_code
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Reproduce dispatch — launch sidecar for bug-report issues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Check if a reproduce run is already in-flight for a given issue.
|
||||
# Uses a simple pid-file in /tmp so we don't double-launch per dispatcher cycle.
|
||||
_reproduce_lockfile() {
|
||||
local issue="$1"
|
||||
echo "/tmp/reproduce-inflight-${issue}.pid"
|
||||
}
|
||||
|
||||
is_reproduce_running() {
|
||||
local issue="$1"
|
||||
local pidfile
|
||||
pidfile=$(_reproduce_lockfile "$issue")
|
||||
[ -f "$pidfile" ] || return 1
|
||||
local pid
|
||||
pid=$(cat "$pidfile" 2>/dev/null || echo "")
|
||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
# Fetch open issues labelled bug-report that have no outcome label yet.
|
||||
# Returns a newline-separated list of "issue_number:project_toml" pairs.
|
||||
fetch_reproduce_candidates() {
|
||||
# Require FORGE_TOKEN, FORGE_URL, FORGE_REPO
|
||||
[ -n "${FORGE_TOKEN:-}" ] || return 0
|
||||
[ -n "${FORGE_URL:-}" ] || return 0
|
||||
[ -n "${FORGE_REPO:-}" ] || return 0
|
||||
|
||||
local api="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
|
||||
|
||||
local issues_json
|
||||
issues_json=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api}/issues?type=issues&state=open&labels=bug-report&limit=20" 2>/dev/null) || return 0
|
||||
|
||||
# Filter out issues that already carry an outcome label.
|
||||
# Write JSON to a temp file so python3 can read from stdin (heredoc) and
|
||||
# still receive the JSON as an argument (avoids SC2259: pipe vs heredoc).
|
||||
local tmpjson
|
||||
tmpjson=$(mktemp)
|
||||
echo "$issues_json" > "$tmpjson"
|
||||
python3 - "$tmpjson" <<'PYEOF'
|
||||
import sys, json
|
||||
data = json.load(open(sys.argv[1]))
|
||||
skip = {"reproduced", "cannot-reproduce", "needs-triage"}
|
||||
for issue in data:
|
||||
labels = {l["name"] for l in (issue.get("labels") or [])}
|
||||
if labels & skip:
|
||||
continue
|
||||
print(issue["number"])
|
||||
PYEOF
|
||||
rm -f "$tmpjson"
|
||||
}
|
||||
|
||||
# Launch one reproduce container per candidate issue.
|
||||
# project_toml is resolved from FACTORY_ROOT/projects/*.toml (first match).
|
||||
dispatch_reproduce() {
|
||||
local issue_number="$1"
|
||||
|
||||
if is_reproduce_running "$issue_number"; then
|
||||
log "Reproduce already running for issue #${issue_number}, skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find first project TOML available (same convention as dev-poll)
|
||||
local project_toml=""
|
||||
for toml in "${FACTORY_ROOT}"/projects/*.toml; do
|
||||
[ -f "$toml" ] && { project_toml="$toml"; break; }
|
||||
done
|
||||
|
||||
if [ -z "$project_toml" ]; then
|
||||
log "WARNING: no project TOML found under ${FACTORY_ROOT}/projects/ — skipping reproduce for #${issue_number}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Dispatching reproduce-agent for issue #${issue_number} (project: ${project_toml})"
|
||||
|
||||
# Build docker run command using array (safe from injection)
|
||||
local -a cmd=(docker run --rm
|
||||
--name "disinto-reproduce-${issue_number}"
|
||||
--network host
|
||||
-v /var/run/docker.sock:/var/run/docker.sock
|
||||
-v agent-data:/home/agent/data
|
||||
-v project-repos:/home/agent/repos
|
||||
-e "FORGE_URL=${FORGE_URL}"
|
||||
-e "FORGE_TOKEN=${FORGE_TOKEN}"
|
||||
-e "FORGE_REPO=${FORGE_REPO}"
|
||||
-e "PRIMARY_BRANCH=${PRIMARY_BRANCH:-main}"
|
||||
-e DISINTO_CONTAINER=1
|
||||
)
|
||||
|
||||
# Pass through ANTHROPIC_API_KEY if set
|
||||
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
cmd+=(-e "ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}")
|
||||
fi
|
||||
|
||||
# Mount ~/.claude and ~/.ssh from the runtime user's home if available
|
||||
local runtime_home="${HOME:-/home/debian}"
|
||||
if [ -d "${runtime_home}/.claude" ]; then
|
||||
cmd+=(-v "${runtime_home}/.claude:/home/agent/.claude")
|
||||
fi
|
||||
if [ -d "${runtime_home}/.ssh" ]; then
|
||||
cmd+=(-v "${runtime_home}/.ssh:/home/agent/.ssh:ro")
|
||||
fi
|
||||
# Mount claude CLI binary if present on host
|
||||
if [ -f /usr/local/bin/claude ]; then
|
||||
cmd+=(-v /usr/local/bin/claude:/usr/local/bin/claude:ro)
|
||||
fi
|
||||
|
||||
# Mount the project TOML into the container at a stable path
|
||||
local container_toml="/home/agent/project.toml"
|
||||
cmd+=(-v "${project_toml}:${container_toml}:ro")
|
||||
|
||||
cmd+=(disinto-reproduce:latest "$container_toml" "$issue_number")
|
||||
|
||||
# Launch in background; write pid-file so we don't double-launch
|
||||
"${cmd[@]}" &
|
||||
local bg_pid=$!
|
||||
echo "$bg_pid" > "$(_reproduce_lockfile "$issue_number")"
|
||||
log "Reproduce container launched (pid ${bg_pid}) for issue #${issue_number}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main dispatcher loop
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
@ -501,6 +624,16 @@ main() {
|
|||
launch_runner "$toml_file" || true
|
||||
done
|
||||
|
||||
# Reproduce dispatch: check for bug-report issues needing reproduction
|
||||
local candidate_issues
|
||||
candidate_issues=$(fetch_reproduce_candidates) || true
|
||||
if [ -n "$candidate_issues" ]; then
|
||||
while IFS= read -r issue_num; do
|
||||
[ -n "$issue_num" ] || continue
|
||||
dispatch_reproduce "$issue_num" || true
|
||||
done <<< "$candidate_issues"
|
||||
fi
|
||||
|
||||
# Wait before next poll
|
||||
sleep 60
|
||||
done
|
||||
|
|
|
|||
11
docker/reproduce/Dockerfile
Normal file
11
docker/reproduce/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash curl git jq docker.io docker-compose-plugin \
|
||||
nodejs npm chromium \
|
||||
&& npm install -g @anthropic-ai/mcp-playwright \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd -m -u 1000 -s /bin/bash agent
|
||||
COPY docker/reproduce/entrypoint-reproduce.sh /entrypoint-reproduce.sh
|
||||
RUN chmod +x /entrypoint-reproduce.sh
|
||||
WORKDIR /home/agent
|
||||
ENTRYPOINT ["/entrypoint-reproduce.sh"]
|
||||
404
docker/reproduce/entrypoint-reproduce.sh
Normal file
404
docker/reproduce/entrypoint-reproduce.sh
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
#!/usr/bin/env bash
|
||||
# entrypoint-reproduce.sh — Reproduce-agent sidecar entrypoint
|
||||
#
|
||||
# Acquires the stack lock, boots the project stack (if formula declares
|
||||
# stack_script), then drives Claude + Playwright MCP to follow the bug
|
||||
# report's repro steps. Labels the issue based on outcome and posts
|
||||
# findings + screenshots.
|
||||
#
|
||||
# Usage (launched by dispatcher.sh):
|
||||
# entrypoint-reproduce.sh <project_toml> <issue_number>
|
||||
#
|
||||
# Environment (injected by dispatcher via docker run -e):
|
||||
# FORGE_URL, FORGE_TOKEN, FORGE_REPO, PRIMARY_BRANCH, DISINTO_CONTAINER=1
|
||||
#
|
||||
# Volumes expected:
|
||||
# /home/agent/data — agent-data volume (stack-lock files go here)
|
||||
# /home/agent/repos — project-repos volume
|
||||
# /home/agent/.claude — host ~/.claude (OAuth credentials)
|
||||
# /home/agent/.ssh — host ~/.ssh (read-only)
|
||||
# /usr/local/bin/claude — host claude CLI binary (read-only)
|
||||
# /var/run/docker.sock — host docker socket
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DISINTO_DIR="${DISINTO_DIR:-/home/agent/disinto}"
|
||||
REPRODUCE_FORMULA="${DISINTO_DIR}/formulas/reproduce.toml"
|
||||
REPRODUCE_TIMEOUT="${REPRODUCE_TIMEOUT_MINUTES:-15}"
|
||||
LOGFILE="/home/agent/data/logs/reproduce.log"
|
||||
SCREENSHOT_DIR="/home/agent/data/screenshots"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
log() {
|
||||
printf '[%s] reproduce: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument validation
|
||||
# ---------------------------------------------------------------------------
|
||||
PROJECT_TOML="${1:-}"
|
||||
ISSUE_NUMBER="${2:-}"
|
||||
|
||||
if [ -z "$PROJECT_TOML" ] || [ -z "$ISSUE_NUMBER" ]; then
|
||||
log "FATAL: usage: entrypoint-reproduce.sh <project_toml> <issue_number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROJECT_TOML" ]; then
|
||||
log "FATAL: project TOML not found: ${PROJECT_TOML}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap: directories, env
|
||||
# ---------------------------------------------------------------------------
|
||||
mkdir -p /home/agent/data/logs /home/agent/data/locks "$SCREENSHOT_DIR"
|
||||
|
||||
export DISINTO_CONTAINER=1
|
||||
export HOME="${HOME:-/home/agent}"
|
||||
export USER="${USER:-agent}"
|
||||
|
||||
FORGE_API="${FORGE_URL}/api/v1/repos/${FORGE_REPO}"
|
||||
|
||||
# Load project name from TOML
|
||||
PROJECT_NAME=$(python3 -c "
|
||||
import sys, tomllib
|
||||
with open(sys.argv[1], 'rb') as f:
|
||||
print(tomllib.load(f)['name'])
|
||||
" "$PROJECT_TOML" 2>/dev/null) || {
|
||||
log "FATAL: could not read project name from ${PROJECT_TOML}"
|
||||
exit 1
|
||||
}
|
||||
export PROJECT_NAME
|
||||
|
||||
PROJECT_REPO_ROOT="/home/agent/repos/${PROJECT_NAME}"
|
||||
|
||||
log "Starting reproduce-agent for issue #${ISSUE_NUMBER} (project: ${PROJECT_NAME})"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify claude CLI is available (mounted from host)
|
||||
# ---------------------------------------------------------------------------
|
||||
if ! command -v claude &>/dev/null; then
|
||||
log "FATAL: claude CLI not found. Mount the host binary at /usr/local/bin/claude"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source stack-lock library
|
||||
# ---------------------------------------------------------------------------
|
||||
# shellcheck source=/home/agent/disinto/lib/stack-lock.sh
|
||||
source "${DISINTO_DIR}/lib/stack-lock.sh"
|
||||
|
||||
LOCK_HOLDER="reproduce-agent-${ISSUE_NUMBER}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read formula config
|
||||
# ---------------------------------------------------------------------------
|
||||
FORMULA_STACK_SCRIPT=""
|
||||
FORMULA_TIMEOUT_MINUTES="${REPRODUCE_TIMEOUT}"
|
||||
|
||||
if [ -f "$REPRODUCE_FORMULA" ]; then
|
||||
FORMULA_STACK_SCRIPT=$(python3 -c "
|
||||
import sys, tomllib
|
||||
with open(sys.argv[1], 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('stack_script', ''))
|
||||
" "$REPRODUCE_FORMULA" 2>/dev/null || echo "")
|
||||
|
||||
_tm=$(python3 -c "
|
||||
import sys, tomllib
|
||||
with open(sys.argv[1], 'rb') as f:
|
||||
d = tomllib.load(f)
|
||||
print(d.get('timeout_minutes', '${REPRODUCE_TIMEOUT}'))
|
||||
" "$REPRODUCE_FORMULA" 2>/dev/null || echo "${REPRODUCE_TIMEOUT}")
|
||||
FORMULA_TIMEOUT_MINUTES="$_tm"
|
||||
fi
|
||||
|
||||
log "Formula stack_script: '${FORMULA_STACK_SCRIPT}'"
|
||||
log "Formula timeout: ${FORMULA_TIMEOUT_MINUTES}m"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch issue details for repro steps
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Fetching issue #${ISSUE_NUMBER} from ${FORGE_API}..."
|
||||
ISSUE_JSON=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${ISSUE_NUMBER}" 2>/dev/null) || {
|
||||
log "ERROR: failed to fetch issue #${ISSUE_NUMBER}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title // "unknown"')
|
||||
ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')
|
||||
|
||||
log "Issue: ${ISSUE_TITLE}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Acquire stack lock
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Acquiring stack lock for project ${PROJECT_NAME}..."
|
||||
stack_lock_acquire "$LOCK_HOLDER" "$PROJECT_NAME" 900
|
||||
trap 'stack_lock_release "$PROJECT_NAME" "$LOCK_HOLDER"; log "Stack lock released (trap)"' EXIT
|
||||
log "Stack lock acquired."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start heartbeat in background (every 2 minutes)
|
||||
# ---------------------------------------------------------------------------
|
||||
heartbeat_loop() {
|
||||
while true; do
|
||||
sleep 120
|
||||
stack_lock_heartbeat "$LOCK_HOLDER" "$PROJECT_NAME" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
heartbeat_loop &
|
||||
HEARTBEAT_PID=$!
|
||||
trap 'kill "$HEARTBEAT_PID" 2>/dev/null; stack_lock_release "$PROJECT_NAME" "$LOCK_HOLDER"; log "Stack lock released (trap)"' EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot the project stack if formula declares stack_script
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -n "$FORMULA_STACK_SCRIPT" ] && [ -d "$PROJECT_REPO_ROOT" ]; then
|
||||
log "Running stack_script: ${FORMULA_STACK_SCRIPT}"
|
||||
# Run in project repo root; script path is relative to project repo.
|
||||
# Read stack_script into array to allow arguments (e.g. "scripts/dev.sh restart --full").
|
||||
read -ra _stack_cmd <<< "$FORMULA_STACK_SCRIPT"
|
||||
(cd "$PROJECT_REPO_ROOT" && bash "${_stack_cmd[@]}") || {
|
||||
log "WARNING: stack_script exited non-zero — continuing anyway"
|
||||
}
|
||||
# Give the stack a moment to stabilise
|
||||
sleep 5
|
||||
elif [ -n "$FORMULA_STACK_SCRIPT" ]; then
|
||||
log "WARNING: PROJECT_REPO_ROOT not found at ${PROJECT_REPO_ROOT} — skipping stack_script"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build Claude prompt for reproduction
|
||||
# ---------------------------------------------------------------------------
|
||||
TIMESTAMP=$(date -u '+%Y%m%d-%H%M%S')
|
||||
SCREENSHOT_PREFIX="${SCREENSHOT_DIR}/issue-${ISSUE_NUMBER}-${TIMESTAMP}"
|
||||
|
||||
CLAUDE_PROMPT=$(cat <<PROMPT
|
||||
You are the reproduce-agent. Your task is to reproduce the bug described in issue #${ISSUE_NUMBER} and report your findings.
|
||||
|
||||
## Issue title
|
||||
${ISSUE_TITLE}
|
||||
|
||||
## Issue body
|
||||
${ISSUE_BODY}
|
||||
|
||||
## Your task
|
||||
|
||||
1. **Reproduce the bug** — Use Playwright to navigate the application and follow the reproduction steps from the issue. Take screenshots at each key step and save them to: ${SCREENSHOT_PREFIX}-step-N.png
|
||||
|
||||
2. **Determine outcome** — Did the bug reproduce?
|
||||
- YES: Proceed to step 3
|
||||
- NO: Write OUTCOME=cannot-reproduce and skip to step 5
|
||||
|
||||
3. **Check logs** — Run: docker compose -f ${PROJECT_REPO_ROOT}/docker-compose.yml logs --tail=200
|
||||
Look for: stack traces, error messages, wrong addresses, missing config, HTTP error codes.
|
||||
|
||||
4. **Assess root cause** — Based on logs + browser observations:
|
||||
- FOUND: Write OUTCOME=reproduced and ROOT_CAUSE=<one-line summary>
|
||||
- INCONCLUSIVE: Write OUTCOME=needs-triage
|
||||
|
||||
5. **Write findings** — Write a markdown report to: /tmp/reproduce-findings-${ISSUE_NUMBER}.md
|
||||
Include:
|
||||
- Steps you followed
|
||||
- What you observed (screenshots referenced by path)
|
||||
- Log excerpts (truncated to relevant lines)
|
||||
- OUTCOME line (one of: reproduced, cannot-reproduce, needs-triage)
|
||||
- ROOT_CAUSE line (if outcome is reproduced)
|
||||
|
||||
6. **Write outcome file** — Write ONLY the outcome word to: /tmp/reproduce-outcome-${ISSUE_NUMBER}.txt
|
||||
(one of: reproduced, cannot-reproduce, needs-triage)
|
||||
|
||||
## Notes
|
||||
- The application is accessible at localhost (network_mode: host)
|
||||
- Take screenshots liberally — they are evidence
|
||||
- If the app is not running or not reachable, write outcome: cannot-reproduce with reason "stack not reachable"
|
||||
- Timeout: ${FORMULA_TIMEOUT_MINUTES} minutes total
|
||||
|
||||
Begin now.
|
||||
PROMPT
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run Claude with Playwright MCP
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Starting Claude reproduction session (timeout: ${FORMULA_TIMEOUT_MINUTES}m)..."
|
||||
|
||||
CLAUDE_EXIT=0
|
||||
timeout "$(( FORMULA_TIMEOUT_MINUTES * 60 ))" \
|
||||
claude -p "$CLAUDE_PROMPT" \
|
||||
--mcp-server playwright \
|
||||
--output-format text \
|
||||
--max-turns 40 \
|
||||
> "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>&1 || CLAUDE_EXIT=$?
|
||||
|
||||
if [ $CLAUDE_EXIT -eq 124 ]; then
|
||||
log "WARNING: Claude session timed out after ${FORMULA_TIMEOUT_MINUTES}m"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read outcome
|
||||
# ---------------------------------------------------------------------------
|
||||
OUTCOME="needs-triage"
|
||||
if [ -f "/tmp/reproduce-outcome-${ISSUE_NUMBER}.txt" ]; then
|
||||
_raw=$(tr -d '[:space:]' < "/tmp/reproduce-outcome-${ISSUE_NUMBER}.txt" | tr '[:upper:]' '[:lower:]')
|
||||
case "$_raw" in
|
||||
reproduced|cannot-reproduce|needs-triage)
|
||||
OUTCOME="$_raw"
|
||||
;;
|
||||
*)
|
||||
log "WARNING: unexpected outcome '${_raw}' — defaulting to needs-triage"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
log "WARNING: outcome file not found — defaulting to needs-triage"
|
||||
fi
|
||||
|
||||
log "Outcome: ${OUTCOME}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read findings
|
||||
# ---------------------------------------------------------------------------
|
||||
FINDINGS=""
|
||||
if [ -f "/tmp/reproduce-findings-${ISSUE_NUMBER}.md" ]; then
|
||||
FINDINGS=$(cat "/tmp/reproduce-findings-${ISSUE_NUMBER}.md")
|
||||
else
|
||||
FINDINGS="Reproduce-agent completed but did not write a findings report. Claude output:\n\`\`\`\n$(tail -100 "/tmp/reproduce-claude-output-${ISSUE_NUMBER}.txt" 2>/dev/null || echo '(no output)')\n\`\`\`"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collect screenshot paths for comment
|
||||
# ---------------------------------------------------------------------------
|
||||
SCREENSHOT_LIST=""
|
||||
if find "$(dirname "${SCREENSHOT_PREFIX}")" -name "$(basename "${SCREENSHOT_PREFIX}")-*.png" -maxdepth 1 2>/dev/null | grep -q .; then
|
||||
SCREENSHOT_LIST="\n\n**Screenshots taken:**\n"
|
||||
for f in "${SCREENSHOT_PREFIX}"-*.png; do
|
||||
SCREENSHOT_LIST="${SCREENSHOT_LIST}- \`$(basename "$f")\`\n"
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
_label_id() {
|
||||
local name="$1" color="$2"
|
||||
local id
|
||||
id=$(curl -sf \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/labels" 2>/dev/null \
|
||||
| jq -r --arg n "$name" '.[] | select(.name == $n) | .id' 2>/dev/null || echo "")
|
||||
if [ -z "$id" ]; then
|
||||
id=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/labels" \
|
||||
-d "{\"name\":\"${name}\",\"color\":\"${color}\"}" 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
_add_label() {
|
||||
local issue="$1" label_id="$2"
|
||||
[ -z "$label_id" ] && return 0
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/labels" \
|
||||
-d "{\"labels\":[${label_id}]}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
_remove_label() {
|
||||
local issue="$1" label_id="$2"
|
||||
[ -z "$label_id" ] && return 0
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_API}/issues/${issue}/labels/${label_id}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
_post_comment() {
|
||||
local issue="$1" body="$2"
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues/${issue}/comments" \
|
||||
-d "$(jq -nc --arg b "$body" '{body:$b}')" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply labels and post findings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Remove bug-report label (we are resolving it)
|
||||
BUG_REPORT_ID=$(_label_id "bug-report" "#e4e669")
|
||||
_remove_label "$ISSUE_NUMBER" "$BUG_REPORT_ID"
|
||||
|
||||
case "$OUTCOME" in
|
||||
reproduced)
|
||||
LABEL_NAME="reproduced"
|
||||
LABEL_COLOR="#0075ca"
|
||||
COMMENT_HEADER="## Reproduce-agent: **Reproduced** :white_check_mark:"
|
||||
|
||||
# Create a backlog issue for the triage/dev agents
|
||||
ROOT_CAUSE=$(grep -m1 "^ROOT_CAUSE=" "/tmp/reproduce-findings-${ISSUE_NUMBER}.md" 2>/dev/null \
|
||||
| sed 's/^ROOT_CAUSE=//' || echo "See findings on issue #${ISSUE_NUMBER}")
|
||||
BACKLOG_BODY="## Summary
|
||||
Bug reproduced from issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}
|
||||
|
||||
Root cause (quick log analysis): ${ROOT_CAUSE}
|
||||
|
||||
## Dependencies
|
||||
- #${ISSUE_NUMBER}
|
||||
|
||||
## Affected files
|
||||
- (see findings on issue #${ISSUE_NUMBER})
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Root cause confirmed and fixed
|
||||
- [ ] Issue #${ISSUE_NUMBER} no longer reproducible"
|
||||
|
||||
log "Creating backlog issue for reproduced bug..."
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/issues" \
|
||||
-d "$(jq -nc \
|
||||
--arg t "fix: $(echo "$ISSUE_TITLE" | sed 's/^bug:/fix:/' | sed 's/^feat:/fix:/')" \
|
||||
--arg b "$BACKLOG_BODY" \
|
||||
'{title:$t, body:$b}')" >/dev/null 2>&1 || \
|
||||
log "WARNING: failed to create backlog issue"
|
||||
;;
|
||||
|
||||
cannot-reproduce)
|
||||
LABEL_NAME="cannot-reproduce"
|
||||
LABEL_COLOR="#e4e669"
|
||||
COMMENT_HEADER="## Reproduce-agent: **Cannot reproduce** :x:"
|
||||
;;
|
||||
|
||||
needs-triage)
|
||||
LABEL_NAME="needs-triage"
|
||||
LABEL_COLOR="#d93f0b"
|
||||
COMMENT_HEADER="## Reproduce-agent: **Needs triage** :mag:"
|
||||
;;
|
||||
esac
|
||||
|
||||
OUTCOME_LABEL_ID=$(_label_id "$LABEL_NAME" "$LABEL_COLOR")
|
||||
_add_label "$ISSUE_NUMBER" "$OUTCOME_LABEL_ID"
|
||||
log "Applied label '${LABEL_NAME}' to issue #${ISSUE_NUMBER}"
|
||||
|
||||
COMMENT_BODY="${COMMENT_HEADER}
|
||||
|
||||
${FINDINGS}${SCREENSHOT_LIST}
|
||||
|
||||
---
|
||||
*Reproduce-agent run at $(date -u '+%Y-%m-%d %H:%M:%S UTC') — project: ${PROJECT_NAME}*"
|
||||
|
||||
_post_comment "$ISSUE_NUMBER" "$COMMENT_BODY"
|
||||
log "Posted findings to issue #${ISSUE_NUMBER}"
|
||||
|
||||
log "Reproduce-agent done. Outcome: ${OUTCOME}"
|
||||
23
formulas/reproduce.toml
Normal file
23
formulas/reproduce.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# formulas/reproduce.toml — Reproduce-agent formula
|
||||
#
|
||||
# Declares the reproduce-agent's runtime parameters.
|
||||
# The dispatcher reads this to configure the sidecar container.
|
||||
#
|
||||
# stack_script: path (relative to PROJECT_REPO_ROOT) of the script used to
|
||||
# restart/rebuild the project stack before reproduction. Omit (or leave
|
||||
# blank) to connect to an existing staging environment instead.
|
||||
#
|
||||
# tools: MCP servers to pass to claude via --mcp-server flags.
|
||||
#
|
||||
# timeout_minutes: hard upper bound on the Claude session.
|
||||
|
||||
name = "reproduce"
|
||||
description = "Navigate the app via Playwright, reproduce a bug-report issue, and do a quick log-based root cause check"
|
||||
version = 1
|
||||
|
||||
# Set stack_script to the restart command for local stacks.
|
||||
# Leave empty ("") to target an existing staging environment.
|
||||
stack_script = ""
|
||||
|
||||
tools = ["playwright"]
|
||||
timeout_minutes = 15
|
||||
|
|
@ -86,9 +86,49 @@ Pre-checks (bash, zero tokens — detect problems before invoking Claude):
|
|||
reproduce" heading, or clear sequence of actions that trigger the bug)
|
||||
c. Issue is not already labeled
|
||||
|
||||
If all criteria match, write an add_label action to the manifest:
|
||||
echo '{"action":"add_label","issue":NNN,"label":"bug-report"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
echo "ACTION: labeled #NNN as bug-report — <reason>" >> "$RESULT_FILE"
|
||||
If all criteria match, enrich the issue body and write the manifest actions:
|
||||
|
||||
Body enrichment (CRITICAL — turns raw reports into actionable investigation briefs):
|
||||
Before writing the add_label action, construct an enriched body by appending
|
||||
these sections to the original issue body:
|
||||
|
||||
a. ``## What was reported``
|
||||
One or two sentence summary of the user's claim. Distill the broken
|
||||
behavior concisely — what the user expected vs. what actually happened.
|
||||
|
||||
b. ``## Known context``
|
||||
What can be inferred from the codebase without running anything:
|
||||
- Which contracts/components/files are involved (use AGENTS.md layout
|
||||
and file paths mentioned in the issue or body)
|
||||
- What the expected behavior should be (from VISION.md, docs, code)
|
||||
- Any recent changes to involved components:
|
||||
git log --oneline -5 -- <paths>
|
||||
- Related issues or prior fixes (cross-reference by number if known)
|
||||
|
||||
c. ``## Reproduction plan``
|
||||
Concrete steps for a reproduce-agent or human. Be specific:
|
||||
- Which environment to use (e.g. "start fresh stack with
|
||||
\`./scripts/dev.sh restart --full\`")
|
||||
- Which transactions or actions to execute (with \`cast\` commands,
|
||||
API calls, or UI navigation steps where applicable)
|
||||
- What state to check after each step (contract reads, API queries,
|
||||
UI observations, log output)
|
||||
|
||||
d. ``## What needs verification``
|
||||
Checkboxes distinguishing known facts from unknowns:
|
||||
- ``- [ ]`` Does the reported behavior actually occur? (reproduce)
|
||||
- ``- [ ]`` Is <component X> behaving as expected? (check state)
|
||||
- ``- [ ]`` Is the data flow correct from <A> to <B>? (trace)
|
||||
Tailor these to the specific bug — three to five items covering the
|
||||
key unknowns a reproduce-agent must resolve.
|
||||
|
||||
e. Construct full new body = original body text + appended sections.
|
||||
Write an edit_body action BEFORE the add_label action:
|
||||
echo '{"action":"edit_body","issue":NNN,"body":"<full new body>"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
|
||||
f. Write the add_label action:
|
||||
echo '{"action":"add_label","issue":NNN,"label":"bug-report"}' >> "$PROJECT_REPO_ROOT/gardener/pending-actions.jsonl"
|
||||
echo "ACTION: labeled #NNN as bug-report — <reason>" >> "$RESULT_FILE"
|
||||
|
||||
Do NOT also add the backlog label — bug-report is a separate triage
|
||||
track that feeds into reproduction automation.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Gardener Agent
|
||||
|
||||
**Role**: Backlog grooming — detect duplicate issues, missing acceptance
|
||||
|
|
|
|||
|
|
@ -60,15 +60,15 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
|||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active gardener
|
||||
acquire_cron_lock "/tmp/gardener-run.lock"
|
||||
check_memory 2000
|
||||
memory_guard 2000
|
||||
|
||||
log "--- Gardener run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_GARDENER_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_GARDENER_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
|
||||
fi
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "gardener" "$FACTORY_ROOT/formulas/run-gardener.toml" || exit 1
|
||||
|
|
@ -127,16 +127,7 @@ ${SCRATCH_INSTRUCTION}
|
|||
${PROMPT_FOOTER}"
|
||||
|
||||
# ── Create worktree ──────────────────────────────────────────────────────
|
||||
cd "$PROJECT_REPO_ROOT"
|
||||
git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
worktree_cleanup "$WORKTREE"
|
||||
git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null
|
||||
|
||||
cleanup() {
|
||||
worktree_cleanup "$WORKTREE"
|
||||
rm -f "$GARDENER_PR_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
formula_worktree_setup "$WORKTREE"
|
||||
|
||||
# ── Post-merge manifest execution ────────────────────────────────────────
|
||||
# Reads gardener/pending-actions.json and executes each action via API.
|
||||
|
|
@ -328,9 +319,9 @@ if [ -n "$PR_NUMBER" ]; then
|
|||
|
||||
if [ "$_PR_WALK_EXIT_REASON" = "merged" ]; then
|
||||
# Post-merge: pull primary, mirror push, execute manifest
|
||||
git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git -C "$PROJECT_REPO_ROOT" pull --ff-only "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
mirror_push
|
||||
_gardener_execute_manifest
|
||||
rm -f "$SCRATCH_FILE"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,22 @@
|
|||
[
|
||||
{
|
||||
"action": "remove_label",
|
||||
"issue": 240,
|
||||
"label": "blocked"
|
||||
"action": "edit_body",
|
||||
"issue": 288,
|
||||
"body": "Flagged by AI reviewer in PR #287.\n\n## Problem\n\n`review/review-pr.sh` fetches the PR head branch using hardcoded `origin` at two locations (lines 134 and 165):\n\n```bash\ngit fetch origin \"$PR_HEAD\"\n```\n\nThis is the same class of bug fixed for cron agents in #278. If the project repo is checked out with a different remote name (e.g. `codeberg`, `forge`), the review agent will silently fail to fetch the PR branch, potentially reviewing a stale or wrong commit.\n\n## Fix\n\nCall `resolve_forge_remote` early in `review-pr.sh` (same pattern as cron agents) and replace hardcoded `origin` with `${FORGE_REMOTE}`.\n\n---\n*Auto-created from AI review*\n\n## Affected files\n- `review/review-pr.sh` (lines ~134, ~165)\n- `lib/mirrors.sh` (for `resolve_forge_remote` reference if needed)\n\n## Acceptance criteria\n- [ ] `resolve_forge_remote` is called early in `review/review-pr.sh` to set `FORGE_REMOTE`\n- [ ] Hardcoded `origin` at both fetch locations replaced with `${FORGE_REMOTE}`\n- [ ] ShellCheck passes on the modified file\n- [ ] Mirrors the same fix pattern used for cron agents in #278\n"
|
||||
},
|
||||
{
|
||||
"action": "add_label",
|
||||
"issue": 240,
|
||||
"issue": 288,
|
||||
"label": "backlog"
|
||||
},
|
||||
{
|
||||
"action": "comment",
|
||||
"issue": 240,
|
||||
"body": "Gardener: PR #242 was closed without merging (implementation was empty). Re-queuing this issue for dev-agent pickup. The fix is well-scoped and blocks #239."
|
||||
"action": "edit_body",
|
||||
"issue": 275,
|
||||
"body": "Flagged by AI reviewer in PR #274.\n\n## Problem\n\nIn `bin/disinto` `setup_forge()`, the admin token was fixed (PR #274) to delete-then-recreate so the sha1 is captured. However the human token fallback at lines 791–797 still uses the old broken pattern:\n\n```sh\nhuman_token=$(curl -sf \\n -u \"${human_user}:${human_pass}\" \\n \"${forge_url}/api/v1/users/${human_user}/tokens\" 2>/dev/null \\n | jq -r '.[0].sha1 // empty') || human_token=\"\"\n```\n\nForge/Forgejo does **not** return `sha1` in token list responses — only at creation time. So on a re-run when `disinto-human-token` already exists, the create call returns 409 (token name collision), the fallback listing returns an empty sha1, and `HUMAN_TOKEN` is silently not saved/updated.\n\n## Fix\n\nApply the same delete-then-recreate pattern used for the admin token in PR #274: look up the token by name, delete it if it exists, then create fresh.\n\n---\n*Auto-created from AI review*\n\n## Affected files\n- `bin/disinto` (lines ~791–797, inside `setup_forge()`)\n\n## Acceptance criteria\n- [ ] Human token creation uses delete-then-recreate pattern (same as admin token in PR #274)\n- [ ] Re-running `disinto init` on an existing box correctly saves `HUMAN_TOKEN` (no silent empty)\n- [ ] No 409 collision on token name re-use\n- [ ] ShellCheck passes on the modified file\n"
|
||||
},
|
||||
{
|
||||
"action": "add_label",
|
||||
"issue": 275,
|
||||
"label": "backlog"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Shared Helpers (`lib/`)
|
||||
|
||||
All agents source `lib/env.sh` as their first action. Additional helpers are
|
||||
|
|
@ -7,16 +7,17 @@ sourced as needed.
|
|||
| File | What it provides | Sourced by |
|
||||
|---|---|---|
|
||||
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
|
||||
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr, supervisor-poll |
|
||||
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote <repo_id> <pipeline_num> <environment>` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs <pipeline_number> [--step <name>]` — reads CI logs from Woodpecker SQLite database via `lib/ci-log-reader.py`; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr, supervisor-poll |
|
||||
| `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) |
|
||||
| `lib/ci-log-reader.py` | Python tool: reads CI logs from Woodpecker SQLite database. `<pipeline_number> [--step <name>]` — returns last 200 lines from failed steps (or specified step). Used by `ci_get_logs()` in ci-helpers.sh. Requires `WOODPECKER_DATA_DIR` (default: /woodpecker-data). | ci-helpers.sh |
|
||||
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror config, etc.). Also exports `FORGE_REPO_OWNER` (the owner component of `FORGE_REPO`, e.g. `disinto-admin` from `disinto-admin/disinto`). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
|
||||
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
|
||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, .profile repo management, prompt assembly, worktree setup). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh |
|
||||
| `lib/formula-session.sh` | `acquire_cron_lock()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven cron agents (lock, .profile repo management, prompt assembly, worktree setup). Memory guard is provided by `memory_guard()` in `lib/env.sh` (not duplicated here). `resolve_agent_identity()` — sets `FORGE_TOKEN`, `AGENT_IDENTITY`, `FORGE_REMOTE` from per-agent token env vars and FORGE_URL remote detection. `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh |
|
||||
| `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
|
||||
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh — called after every successful merge. | dev-poll.sh |
|
||||
| `lib/build-graph.py` | Python tool: parses VISION.md, prerequisites.md (from ops repo), AGENTS.md, formulas/*.toml, evidence/ (from ops repo), and forge issues/labels into a NetworkX DiGraph. Runs structural analyses (orphaned objectives, stale prerequisites, thin evidence, circular deps) and outputs a JSON report. Used by `review-pr.sh` (per-PR changed-file analysis) and `predictor-run.sh` (full-project analysis) to provide structural context to Claude. | review-pr.sh, predictor-run.sh |
|
||||
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh |
|
||||
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
|
||||
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | issue-lifecycle.sh |
|
||||
| `lib/stack-lock.sh` | File-based lock protocol for singleton project stack access. `stack_lock_acquire(holder, project)` — polls until free, breaks stale heartbeats (>10 min old), claims lock. `stack_lock_release(project)` — deletes lock file. `stack_lock_check(project)` — inspect current lock state. `stack_lock_heartbeat(project)` — update heartbeat timestamp (callers must call every 2 min while holding). Lock files at `~/data/locks/<project>-stack.lock`. | docker/edge/dispatcher.sh, reproduce formula |
|
||||
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
|
||||
| `lib/worktree.sh` | Reusable git worktree management: `worktree_create(path, branch, [base_ref])` — create worktree, checkout base, fetch submodules. `worktree_recover(path, branch, [remote])` — detect existing worktree, reuse if on correct branch (sets `_WORKTREE_REUSED`), otherwise clean and recreate. `worktree_cleanup(path)` — `git worktree remove --force`, clear Claude Code project cache (`~/.claude/projects/` matching path). `worktree_cleanup_stale([max_age_hours])` — scan `/tmp` for orphaned worktrees older than threshold, skip preserved and active tmux worktrees, prune. `worktree_preserve(path, reason)` — mark worktree as preserved for debugging (writes `.worktree-preserved` marker, skipped by stale cleanup). | dev-agent.sh, supervisor-run.sh, planner-run.sh, predictor-run.sh, gardener-run.sh |
|
||||
| `lib/pr-lifecycle.sh` | Reusable PR lifecycle library: `pr_create()`, `pr_find_by_branch()`, `pr_poll_ci()`, `pr_poll_review()`, `pr_merge()`, `pr_is_merged()`, `pr_walk_to_merge()`, `build_phase_protocol_prompt()`. Requires `lib/ci-helpers.sh`. | dev-agent.sh (future) |
|
||||
|
|
|
|||
|
|
@ -48,9 +48,17 @@ agent_run() {
|
|||
local run_dir="${worktree_dir:-$(pwd)}"
|
||||
local lock_file="${HOME}/.claude/session.lock"
|
||||
mkdir -p "$(dirname "$lock_file")"
|
||||
local output
|
||||
local output rc
|
||||
log "agent_run: starting (resume=${resume_id:-(new)}, dir=${run_dir})"
|
||||
output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") || true
|
||||
output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") && rc=0 || rc=$?
|
||||
if [ "$rc" -eq 124 ]; then
|
||||
log "agent_run: timeout after ${CLAUDE_TIMEOUT:-7200}s"
|
||||
elif [ "$rc" -ne 0 ]; then
|
||||
log "agent_run: claude exited with code $rc"
|
||||
fi
|
||||
if [ -z "$output" ]; then
|
||||
log "agent_run: empty output (claude may have crashed or failed)"
|
||||
fi
|
||||
|
||||
# Extract and persist session_id
|
||||
local new_sid
|
||||
|
|
@ -68,7 +76,7 @@ agent_run() {
|
|||
|
||||
# Nudge: if the model stopped without pushing, resume with encouragement.
|
||||
# Some models emit end_turn prematurely when confused. A nudge often unsticks them.
|
||||
if [ -n "$_AGENT_SESSION_ID" ]; then
|
||||
if [ -n "$_AGENT_SESSION_ID" ] && [ -n "$output" ]; then
|
||||
local has_changes
|
||||
has_changes=$(cd "$run_dir" && git status --porcelain 2>/dev/null | head -1) || true
|
||||
local has_pushed
|
||||
|
|
@ -78,7 +86,13 @@ agent_run() {
|
|||
# Nudge: there are uncommitted changes
|
||||
local nudge="You stopped but did not push any code. You have uncommitted changes. Commit them and push."
|
||||
log "agent_run: nudging (uncommitted changes)"
|
||||
output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude -p "$nudge" --resume "$_AGENT_SESSION_ID" --output-format json --dangerously-skip-permissions --max-turns 50 ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} 2>>"$LOGFILE") || true
|
||||
local nudge_rc
|
||||
output=$(cd "$run_dir" && flock -w 600 "$lock_file" timeout "${CLAUDE_TIMEOUT:-7200}" claude -p "$nudge" --resume "$_AGENT_SESSION_ID" --output-format json --dangerously-skip-permissions --max-turns 50 ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} 2>>"$LOGFILE") && nudge_rc=0 || nudge_rc=$?
|
||||
if [ "$nudge_rc" -eq 124 ]; then
|
||||
log "agent_run: nudge timeout after ${CLAUDE_TIMEOUT:-7200}s"
|
||||
elif [ "$nudge_rc" -ne 0 ]; then
|
||||
log "agent_run: nudge claude exited with code $nudge_rc"
|
||||
fi
|
||||
new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true
|
||||
if [ -n "$new_sid" ]; then
|
||||
_AGENT_SESSION_ID="$new_sid"
|
||||
|
|
|
|||
|
|
@ -7,27 +7,6 @@ set -euo pipefail
|
|||
# ci_commit_status() / ci_pipeline_number() require: woodpecker_api(), forge_api() (from env.sh)
|
||||
# classify_pipeline_failure() requires: woodpecker_api() (defined in env.sh)
|
||||
|
||||
# ensure_blocked_label_id — look up (or create) the "blocked" label, print its ID.
|
||||
# Caches the result in _BLOCKED_LABEL_ID to avoid repeated API calls.
|
||||
# Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api()
|
||||
ensure_blocked_label_id() {
|
||||
if [ -n "${_BLOCKED_LABEL_ID:-}" ]; then
|
||||
printf '%s' "$_BLOCKED_LABEL_ID"
|
||||
return 0
|
||||
fi
|
||||
_BLOCKED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "blocked") | .id' 2>/dev/null || true)
|
||||
if [ -z "$_BLOCKED_LABEL_ID" ]; then
|
||||
_BLOCKED_LABEL_ID=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}/labels" \
|
||||
-d '{"name":"blocked","color":"#e11d48"}' 2>/dev/null \
|
||||
| jq -r '.id // empty' 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s' "$_BLOCKED_LABEL_ID"
|
||||
}
|
||||
|
||||
# ensure_priority_label — look up (or create) the "priority" label, print its ID.
|
||||
# Caches the result in _PRIORITY_LABEL_ID to avoid repeated API calls.
|
||||
# Requires: FORGE_TOKEN, FORGE_API (from env.sh), forge_api()
|
||||
|
|
|
|||
25
lib/env.sh
25
lib/env.sh
|
|
@ -77,16 +77,11 @@ if [ -n "${PROJECT_TOML:-}" ] && [ -f "$PROJECT_TOML" ]; then
|
|||
source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML"
|
||||
fi
|
||||
|
||||
# Forge token: new FORGE_TOKEN > legacy CODEBERG_TOKEN
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
FORGE_TOKEN="${CODEBERG_TOKEN:-}"
|
||||
fi
|
||||
export FORGE_TOKEN
|
||||
export CODEBERG_TOKEN="${FORGE_TOKEN}" # backwards compat
|
||||
# Forge token
|
||||
export FORGE_TOKEN="${FORGE_TOKEN:-}"
|
||||
|
||||
# Review bot token: FORGE_REVIEW_TOKEN > legacy REVIEW_BOT_TOKEN
|
||||
# Review bot token
|
||||
export FORGE_REVIEW_TOKEN="${FORGE_REVIEW_TOKEN:-${REVIEW_BOT_TOKEN:-}}"
|
||||
export REVIEW_BOT_TOKEN="${FORGE_REVIEW_TOKEN}" # backwards compat
|
||||
|
||||
# Per-agent tokens (#747): each agent gets its own Forgejo identity.
|
||||
# Falls back to FORGE_TOKEN for backwards compat with single-token setups.
|
||||
|
|
@ -97,18 +92,14 @@ export FORGE_SUPERVISOR_TOKEN="${FORGE_SUPERVISOR_TOKEN:-${FORGE_TOKEN}}"
|
|||
export FORGE_PREDICTOR_TOKEN="${FORGE_PREDICTOR_TOKEN:-${FORGE_TOKEN}}"
|
||||
export FORGE_ARCHITECT_TOKEN="${FORGE_ARCHITECT_TOKEN:-${FORGE_TOKEN}}"
|
||||
|
||||
# Bot usernames filter: FORGE_BOT_USERNAMES > legacy CODEBERG_BOT_USERNAMES
|
||||
export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-${CODEBERG_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot}}"
|
||||
export CODEBERG_BOT_USERNAMES="${FORGE_BOT_USERNAMES}" # backwards compat
|
||||
# Bot usernames filter
|
||||
export FORGE_BOT_USERNAMES="${FORGE_BOT_USERNAMES:-dev-bot,review-bot,planner-bot,gardener-bot,vault-bot,supervisor-bot,predictor-bot,architect-bot}"
|
||||
|
||||
# Project config (FORGE_* preferred, CODEBERG_* fallback)
|
||||
export FORGE_REPO="${FORGE_REPO:-${CODEBERG_REPO:-}}"
|
||||
export CODEBERG_REPO="${FORGE_REPO}" # backwards compat
|
||||
# Project config
|
||||
export FORGE_REPO="${FORGE_REPO:-}"
|
||||
export FORGE_URL="${FORGE_URL:-http://localhost:3000}"
|
||||
export FORGE_API="${FORGE_API:-${FORGE_URL}/api/v1/repos/${FORGE_REPO}}"
|
||||
export FORGE_WEB="${FORGE_WEB:-${FORGE_URL}/${FORGE_REPO}}"
|
||||
export CODEBERG_API="${FORGE_API}" # backwards compat
|
||||
export CODEBERG_WEB="${FORGE_WEB}" # backwards compat
|
||||
# tea CLI login name: derived from FORGE_URL (codeberg vs local forgejo)
|
||||
if [ -z "${TEA_LOGIN:-}" ]; then
|
||||
case "${FORGE_URL}" in
|
||||
|
|
@ -209,8 +200,6 @@ forge_api() {
|
|||
-H "Content-Type: application/json" \
|
||||
"${FORGE_API}${path}" "$@"
|
||||
}
|
||||
# Backwards-compat alias
|
||||
codeberg_api() { forge_api "$@"; }
|
||||
|
||||
# Paginate a Forge API GET endpoint and return all items as a merged JSON array.
|
||||
# Usage: forge_api_all /path (no existing query params)
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# file-action-issue.sh — File an action issue for a formula run
|
||||
#
|
||||
# Usage: source this file, then call file_action_issue.
|
||||
# Requires: forge_api() from lib/env.sh, jq, lib/secret-scan.sh
|
||||
#
|
||||
# file_action_issue <formula_name> <title> <body>
|
||||
# Sets FILED_ISSUE_NUM on success.
|
||||
# Returns: 0=created, 1=duplicate exists, 2=label not found, 3=API error, 4=secrets detected
|
||||
|
||||
# Load secret scanner
|
||||
# shellcheck source=secret-scan.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/secret-scan.sh"
|
||||
|
||||
file_action_issue() {
|
||||
local formula_name="$1" title="$2" body="$3"
|
||||
FILED_ISSUE_NUM=""
|
||||
|
||||
# Secret scan: reject issue bodies containing embedded secrets
|
||||
if ! scan_for_secrets "$body"; then
|
||||
echo "file-action-issue: BLOCKED — issue body for '${formula_name}' contains potential secrets. Use env var references instead." >&2
|
||||
return 4
|
||||
fi
|
||||
|
||||
# Dedup: skip if an open action issue for this formula already exists
|
||||
local open_actions
|
||||
open_actions=$(forge_api_all "/issues?state=open&type=issues&labels=action" 2>/dev/null || true)
|
||||
if [ -n "$open_actions" ] && [ "$open_actions" != "null" ]; then
|
||||
local existing
|
||||
existing=$(printf '%s' "$open_actions" | \
|
||||
jq --arg f "$formula_name" '[.[] | select(.title | test($f))] | length' 2>/dev/null || echo 0)
|
||||
if [ "${existing:-0}" -gt 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fetch 'action' label ID
|
||||
local action_label_id
|
||||
action_label_id=$(forge_api GET "/labels" 2>/dev/null | \
|
||||
jq -r '.[] | select(.name == "action") | .id' 2>/dev/null || true)
|
||||
if [ -z "$action_label_id" ]; then
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Create the issue
|
||||
local payload result
|
||||
payload=$(jq -nc \
|
||||
--arg title "$title" \
|
||||
--arg body "$body" \
|
||||
--argjson labels "[$action_label_id]" \
|
||||
'{title: $title, body: $body, labels: $labels}')
|
||||
|
||||
result=$(forge_api POST "/issues" -d "$payload" 2>/dev/null || true)
|
||||
FILED_ISSUE_NUM=$(printf '%s' "$result" | jq -r '.number // empty' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$FILED_ISSUE_NUM" ]; then
|
||||
return 3
|
||||
fi
|
||||
}
|
||||
480
lib/forge-setup.sh
Normal file
480
lib/forge-setup.sh
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# forge-setup.sh — Forgejo provisioning helpers
|
||||
#
|
||||
# Source from bin/disinto: source "${FACTORY_ROOT}/lib/forge-setup.sh"
|
||||
# Requires globals: FACTORY_ROOT (set by env.sh or the caller)
|
||||
# Call _load_init_context before using this library standalone to assert
|
||||
# that FORGE_URL, FACTORY_ROOT, and PRIMARY_BRANCH are all set.
|
||||
|
||||
# Assert required globals are set (call before using this library standalone).
|
||||
_load_init_context() {
|
||||
: "${FORGE_URL:?FORGE_URL must be set}"
|
||||
: "${FACTORY_ROOT:?FACTORY_ROOT must be set}"
|
||||
: "${PRIMARY_BRANCH:?PRIMARY_BRANCH must be set}"
|
||||
}
|
||||
|
||||
# Execute a command in the Forgejo container (for admin operations)
|
||||
_forgejo_exec() {
|
||||
local use_bare="${DISINTO_BARE:-false}"
|
||||
if [ "$use_bare" = true ]; then
|
||||
docker exec -u git disinto-forgejo "$@"
|
||||
else
|
||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T -u git forgejo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Provision or connect to a local Forgejo instance.
|
||||
# Creates admin + bot users, generates API tokens, stores in .env.
|
||||
# When $DISINTO_BARE is set, uses standalone docker run; otherwise uses compose.
|
||||
setup_forge() {
|
||||
local forge_url="$1"
|
||||
local repo_slug="$2"
|
||||
local use_bare="${DISINTO_BARE:-false}"
|
||||
|
||||
echo ""
|
||||
echo "── Forge setup ────────────────────────────────────────"
|
||||
|
||||
# Check if Forgejo is already running
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/version" >/dev/null 2>&1; then
|
||||
echo "Forgejo: ${forge_url} (already running)"
|
||||
else
|
||||
echo "Forgejo not reachable at ${forge_url}"
|
||||
echo "Starting Forgejo via Docker..."
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo "Error: docker not found — needed to provision Forgejo" >&2
|
||||
echo " Install Docker or start Forgejo manually at ${forge_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract port from forge_url
|
||||
local forge_port
|
||||
forge_port=$(printf '%s' "$forge_url" | sed -E 's|.*:([0-9]+)/?$|\1|')
|
||||
forge_port="${forge_port:-3000}"
|
||||
|
||||
if [ "$use_bare" = true ]; then
|
||||
# Bare-metal mode: standalone docker run
|
||||
mkdir -p "${FORGEJO_DATA_DIR}"
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q '^disinto-forgejo$'; then
|
||||
docker start disinto-forgejo >/dev/null 2>&1 || true
|
||||
else
|
||||
docker run -d \
|
||||
--name disinto-forgejo \
|
||||
--restart unless-stopped \
|
||||
-p "${forge_port}:3000" \
|
||||
-p 2222:22 \
|
||||
-v "${FORGEJO_DATA_DIR}:/data" \
|
||||
-e "FORGEJO__database__DB_TYPE=sqlite3" \
|
||||
-e "FORGEJO__server__ROOT_URL=${forge_url}/" \
|
||||
-e "FORGEJO__server__HTTP_PORT=3000" \
|
||||
-e "FORGEJO__service__DISABLE_REGISTRATION=true" \
|
||||
codeberg.org/forgejo/forgejo:11.0
|
||||
fi
|
||||
else
|
||||
# Compose mode: start Forgejo via docker compose
|
||||
docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d forgejo
|
||||
fi
|
||||
|
||||
# Wait for Forgejo to become healthy
|
||||
echo -n "Waiting for Forgejo to start"
|
||||
local retries=0
|
||||
while ! curl -sf --max-time 3 "${forge_url}/api/v1/version" >/dev/null 2>&1; do
|
||||
retries=$((retries + 1))
|
||||
if [ "$retries" -gt 60 ]; then
|
||||
echo ""
|
||||
echo "Error: Forgejo did not become ready within 60s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo " ready"
|
||||
fi
|
||||
|
||||
# Wait for Forgejo database to accept writes (API may be ready before DB is)
|
||||
echo -n "Waiting for Forgejo database"
|
||||
local db_ready=false
|
||||
for _i in $(seq 1 30); do
|
||||
if _forgejo_exec forgejo admin user list >/dev/null 2>&1; then
|
||||
db_ready=true
|
||||
break
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 1
|
||||
done
|
||||
echo ""
|
||||
if [ "$db_ready" != true ]; then
|
||||
echo "Error: Forgejo database not ready after 30s" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create admin user if it doesn't exist
|
||||
local admin_user="disinto-admin"
|
||||
local admin_pass
|
||||
local env_file="${FACTORY_ROOT}/.env"
|
||||
|
||||
# Re-read persisted admin password if available (#158)
|
||||
if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
admin_pass=$(grep '^FORGE_ADMIN_PASS=' "$env_file" | head -1 | cut -d= -f2-)
|
||||
fi
|
||||
# Generate a fresh password only when none was persisted
|
||||
if [ -z "${admin_pass:-}" ]; then
|
||||
admin_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
fi
|
||||
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
|
||||
echo "Creating admin user: ${admin_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--admin \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--email "admin@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create admin user '${admin_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify admin user was actually created
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${admin_user}" >/dev/null 2>&1; then
|
||||
echo "Error: admin user '${admin_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Persist admin password to .env for idempotent re-runs (#158)
|
||||
if grep -q '^FORGE_ADMIN_PASS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^FORGE_ADMIN_PASS=.*|FORGE_ADMIN_PASS=${admin_pass}|" "$env_file"
|
||||
else
|
||||
printf 'FORGE_ADMIN_PASS=%s\n' "$admin_pass" >> "$env_file"
|
||||
fi
|
||||
else
|
||||
echo "Admin user: ${admin_user} (already exists)"
|
||||
# Only reset password if basic auth fails (#158, #267)
|
||||
# Forgejo 11.x may ignore --must-change-password=false, blocking token creation
|
||||
if ! curl -sf --max-time 5 -u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/user" >/dev/null 2>&1; then
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${admin_user}" \
|
||||
--password "${admin_pass}" \
|
||||
--must-change-password=false
|
||||
fi
|
||||
fi
|
||||
# Preserve password for Woodpecker OAuth2 token generation (#779)
|
||||
_FORGE_ADMIN_PASS="$admin_pass"
|
||||
|
||||
# Create human user (disinto-admin) as site admin if it doesn't exist
|
||||
local human_user="disinto-admin"
|
||||
local human_pass
|
||||
human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
echo "Creating human user: ${human_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--admin \
|
||||
--username "${human_user}" \
|
||||
--password "${human_pass}" \
|
||||
--email "admin@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create human user '${human_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${human_user}" \
|
||||
--password "${human_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify human user was actually created
|
||||
if ! curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
echo "Error: human user '${human_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Human user '${human_user}' created as site admin"
|
||||
else
|
||||
echo "Human user: ${human_user} (already exists)"
|
||||
fi
|
||||
|
||||
# Delete existing admin token if present (token sha1 is only returned at creation time)
|
||||
local existing_token_id
|
||||
existing_token_id=$(curl -sf \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id=""
|
||||
if [ -n "$existing_token_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Create admin token (fresh, so sha1 is returned)
|
||||
local admin_token
|
||||
admin_token=$(curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${admin_user}/tokens" \
|
||||
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || admin_token=""
|
||||
|
||||
if [ -z "$admin_token" ]; then
|
||||
echo "Error: failed to obtain admin API token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get or create human user token
|
||||
local human_token
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
|
||||
# Delete existing human token if present (token sha1 is only returned at creation time)
|
||||
local existing_human_token_id
|
||||
existing_human_token_id=$(curl -sf \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id=""
|
||||
if [ -n "$existing_human_token_id" ]; then
|
||||
curl -sf -X DELETE \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Create human token (fresh, so sha1 is returned)
|
||||
human_token=$(curl -sf -X POST \
|
||||
-u "${human_user}:${human_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${human_user}/tokens" \
|
||||
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || human_token=""
|
||||
|
||||
if [ -n "$human_token" ]; then
|
||||
# Store human token in .env
|
||||
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
|
||||
else
|
||||
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
|
||||
fi
|
||||
export HUMAN_TOKEN="$human_token"
|
||||
echo " Human token saved (HUMAN_TOKEN)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create bot users and tokens
|
||||
# Each agent gets its own Forgejo account for identity and audit trail (#747).
|
||||
# Map: bot-username -> env-var-name for the token
|
||||
local -A bot_token_vars=(
|
||||
[dev-bot]="FORGE_TOKEN"
|
||||
[review-bot]="FORGE_REVIEW_TOKEN"
|
||||
[planner-bot]="FORGE_PLANNER_TOKEN"
|
||||
[gardener-bot]="FORGE_GARDENER_TOKEN"
|
||||
[vault-bot]="FORGE_VAULT_TOKEN"
|
||||
[supervisor-bot]="FORGE_SUPERVISOR_TOKEN"
|
||||
[predictor-bot]="FORGE_PREDICTOR_TOKEN"
|
||||
[architect-bot]="FORGE_ARCHITECT_TOKEN"
|
||||
)
|
||||
|
||||
local bot_user bot_pass token token_var
|
||||
|
||||
for bot_user in dev-bot review-bot planner-bot gardener-bot vault-bot supervisor-bot predictor-bot architect-bot; do
|
||||
bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
token_var="${bot_token_vars[$bot_user]}"
|
||||
|
||||
# Check if bot user exists
|
||||
local user_exists=false
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
user_exists=true
|
||||
fi
|
||||
|
||||
if [ "$user_exists" = false ]; then
|
||||
echo "Creating bot user: ${bot_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--email "${bot_user}@disinto.local" \
|
||||
--must-change-password=false 2>&1); then
|
||||
echo "Error: failed to create bot user '${bot_user}':" >&2
|
||||
echo " ${create_output}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Forgejo 11.x ignores --must-change-password=false on create;
|
||||
# explicitly clear the flag so basic-auth token creation works.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false
|
||||
|
||||
# Verify bot user was actually created
|
||||
if ! curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
echo "Error: bot user '${bot_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ${bot_user} user created"
|
||||
else
|
||||
echo " ${bot_user} user exists (resetting password for token generation)"
|
||||
# User exists but may not have a known password.
|
||||
# Use admin API to reset the password so we can generate a new token.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false || {
|
||||
echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Generate token via API (basic auth as the bot user — Forgejo requires
|
||||
# basic auth on POST /users/{username}/tokens, token auth is rejected)
|
||||
# First, try to delete existing tokens to avoid name collision
|
||||
# Use bot user's own Basic Auth (we just set the password above)
|
||||
local existing_token_ids
|
||||
existing_token_ids=$(curl -sf \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids=""
|
||||
|
||||
# Delete any existing tokens for this user
|
||||
if [ -n "$existing_token_ids" ]; then
|
||||
while IFS= read -r tid; do
|
||||
[ -n "$tid" ] && curl -sf -X DELETE \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true
|
||||
done <<< "$existing_token_ids"
|
||||
fi
|
||||
|
||||
token=$(curl -sf -X POST \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" \
|
||||
-d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || token=""
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "Error: failed to create API token for '${bot_user}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Store token in .env under the per-agent variable name
|
||||
if grep -q "^${token_var}=" "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^${token_var}=.*|${token_var}=${token}|" "$env_file"
|
||||
else
|
||||
printf '%s=%s\n' "$token_var" "$token" >> "$env_file"
|
||||
fi
|
||||
export "${token_var}=${token}"
|
||||
echo " ${bot_user} token generated and saved (${token_var})"
|
||||
|
||||
# Backwards-compat aliases for dev-bot and review-bot
|
||||
if [ "$bot_user" = "dev-bot" ]; then
|
||||
export CODEBERG_TOKEN="$token"
|
||||
elif [ "$bot_user" = "review-bot" ]; then
|
||||
export REVIEW_BOT_TOKEN="$token"
|
||||
fi
|
||||
done
|
||||
|
||||
# Store FORGE_URL in .env if not already present
|
||||
if ! grep -q '^FORGE_URL=' "$env_file" 2>/dev/null; then
|
||||
printf 'FORGE_URL=%s\n' "$forge_url" >> "$env_file"
|
||||
fi
|
||||
|
||||
# Create the repo on Forgejo if it doesn't exist
|
||||
local org_name="${repo_slug%%/*}"
|
||||
local repo_name="${repo_slug##*/}"
|
||||
|
||||
# Check if repo already exists
|
||||
if ! curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}" >/dev/null 2>&1; then
|
||||
|
||||
# Try creating org first (ignore if exists)
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs" \
|
||||
-d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true
|
||||
|
||||
# Create repo under org
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs/${org_name}/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
# Fallback: create under the human user namespace using admin endpoint
|
||||
if [ -n "${admin_token:-}" ]; then
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users/${org_name}/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
echo "Error: failed to create repo '${repo_slug}' on Forgejo (admin endpoint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ -n "${HUMAN_TOKEN:-}" ]; then
|
||||
if ! curl -sf -X POST \
|
||||
-H "Authorization: token ${HUMAN_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/user/repos" \
|
||||
-d "{\"name\":\"${repo_name}\",\"auto_init\":false,\"default_branch\":\"main\"}" >/dev/null 2>&1; then
|
||||
echo "Error: failed to create repo '${repo_slug}' on Forgejo (user endpoint)" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: failed to create repo '${repo_slug}' — no admin or human token available" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add all bot users as collaborators with appropriate permissions
|
||||
# dev-bot: write (PR creation via lib/vault.sh)
|
||||
# review-bot: read (PR review)
|
||||
# planner-bot: write (prerequisites.md, memory)
|
||||
# gardener-bot: write (backlog grooming)
|
||||
# vault-bot: write (vault items)
|
||||
# supervisor-bot: read (health monitoring)
|
||||
# predictor-bot: read (pattern detection)
|
||||
# architect-bot: write (sprint PRs)
|
||||
local bot_user bot_perm
|
||||
declare -A bot_permissions=(
|
||||
[dev-bot]="write"
|
||||
[review-bot]="read"
|
||||
[planner-bot]="write"
|
||||
[gardener-bot]="write"
|
||||
[vault-bot]="write"
|
||||
[supervisor-bot]="read"
|
||||
[predictor-bot]="read"
|
||||
[architect-bot]="write"
|
||||
)
|
||||
for bot_user in "${!bot_permissions[@]}"; do
|
||||
bot_perm="${bot_permissions[$bot_user]}"
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}/collaborators/${bot_user}" \
|
||||
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# Add disinto-admin as admin collaborator
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${repo_slug}/collaborators/disinto-admin" \
|
||||
-d '{"permission":"admin"}' >/dev/null 2>&1 || true
|
||||
|
||||
echo "Repo: ${repo_slug} created on Forgejo"
|
||||
else
|
||||
echo "Repo: ${repo_slug} (already exists on Forgejo)"
|
||||
fi
|
||||
|
||||
echo "Forge: ${forge_url} (ready)"
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
#
|
||||
# Functions:
|
||||
# acquire_cron_lock LOCK_FILE — PID lock with stale cleanup
|
||||
# check_memory [MIN_MB] — skip if available RAM too low
|
||||
# load_formula FORMULA_FILE — sets FORMULA_CONTENT
|
||||
# build_context_block FILE [FILE ...] — sets CONTEXT_BLOCK
|
||||
# build_prompt_footer [EXTRA_API_LINES] — sets PROMPT_FOOTER (API ref + env)
|
||||
|
|
@ -51,23 +50,6 @@ acquire_cron_lock() {
|
|||
trap 'rm -f "$_CRON_LOCK_FILE"' EXIT
|
||||
}
|
||||
|
||||
# check_memory [MIN_MB]
|
||||
# Exits 0 (skip) if available memory is below MIN_MB (default 2000).
|
||||
check_memory() {
|
||||
local min_mb="${1:-2000}"
|
||||
# Graceful fallback if free command is not available (procps not installed)
|
||||
if ! command -v free &>/dev/null; then
|
||||
log "run: free not found, skipping memory check"
|
||||
return 0
|
||||
fi
|
||||
local avail_mb
|
||||
avail_mb=$(free -m | awk '/Mem:/{print $7}')
|
||||
if [ "${avail_mb:-0}" -lt "$min_mb" ]; then
|
||||
log "run: skipping — only ${avail_mb}MB available (need ${min_mb})"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Agent identity resolution ────────────────────────────────────────────
|
||||
|
||||
# resolve_agent_identity
|
||||
|
|
@ -91,6 +73,24 @@ resolve_agent_identity() {
|
|||
return 0
|
||||
}
|
||||
|
||||
# ── Forge remote resolution ──────────────────────────────────────────────
|
||||
|
||||
# resolve_forge_remote
|
||||
# Resolves FORGE_REMOTE by matching FORGE_URL hostname against git remotes.
|
||||
# Falls back to "origin" if no match found.
|
||||
# Requires: FORGE_URL, git repo with remotes configured.
|
||||
# Exports: FORGE_REMOTE (always set).
|
||||
resolve_forge_remote() {
|
||||
# Extract hostname from FORGE_URL (e.g., https://codeberg.org/user/repo -> codeberg.org)
|
||||
_forge_host=$(printf '%s' "$FORGE_URL" | sed 's|https\?://||; s|/.*||; s|:.*||')
|
||||
# Find git remote whose push URL matches the forge host
|
||||
FORGE_REMOTE=$(git remote -v | awk -v host="$_forge_host" '$2 ~ host && /\(push\)/ {print $1; exit}')
|
||||
# Fallback to origin if no match found
|
||||
FORGE_REMOTE="${FORGE_REMOTE:-origin}"
|
||||
export FORGE_REMOTE
|
||||
log "forge remote: ${FORGE_REMOTE}"
|
||||
}
|
||||
|
||||
# ── .profile repo management ──────────────────────────────────────────────
|
||||
|
||||
# ensure_profile_repo [AGENT_IDENTITY]
|
||||
|
|
@ -150,7 +150,7 @@ ensure_profile_repo() {
|
|||
# Checks if the agent has a .profile repo by querying Forgejo API.
|
||||
# Returns 0 if repo exists, 1 otherwise.
|
||||
_profile_has_repo() {
|
||||
local agent_identity="${1:-${AGENT_IDENTITY:-}}"
|
||||
local agent_identity="${AGENT_IDENTITY:-}"
|
||||
|
||||
if [ -z "$agent_identity" ]; then
|
||||
if ! resolve_agent_identity; then
|
||||
|
|
@ -186,8 +186,8 @@ _count_undigested_journals() {
|
|||
# Runs a claude -p one-shot to digest undigested journals into lessons-learned.md
|
||||
# Returns 0 on success, 1 on failure.
|
||||
_profile_digest_journals() {
|
||||
local agent_identity="${1:-${AGENT_IDENTITY:-}}"
|
||||
local model="${2:-${CLAUDE_MODEL:-opus}}"
|
||||
local agent_identity="${AGENT_IDENTITY:-}"
|
||||
local model="${CLAUDE_MODEL:-opus}"
|
||||
|
||||
if [ -z "$agent_identity" ]; then
|
||||
if ! resolve_agent_identity; then
|
||||
|
|
@ -711,13 +711,14 @@ build_sdk_prompt_footer() {
|
|||
# Creates an isolated worktree for synchronous formula execution.
|
||||
# Fetches primary branch, cleans stale worktree, creates new one, and
|
||||
# sets an EXIT trap for cleanup.
|
||||
# Requires globals: PROJECT_REPO_ROOT, PRIMARY_BRANCH.
|
||||
# Requires globals: PROJECT_REPO_ROOT, PRIMARY_BRANCH, FORGE_REMOTE.
|
||||
# Ensure resolve_forge_remote() is called before this function.
|
||||
formula_worktree_setup() {
|
||||
local worktree="$1"
|
||||
cd "$PROJECT_REPO_ROOT" || return
|
||||
git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
git fetch "${FORGE_REMOTE}" "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||
worktree_cleanup "$worktree"
|
||||
git worktree add "$worktree" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null
|
||||
git worktree add "$worktree" "${FORGE_REMOTE}/${PRIMARY_BRANCH}" --detach 2>/dev/null
|
||||
# shellcheck disable=SC2064 # expand worktree now, not at trap time
|
||||
trap "worktree_cleanup '$worktree'" EXIT
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ _ilc_log() {
|
|||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Label ID caching — lookup once per name, cache in globals.
|
||||
# Pattern follows ci-helpers.sh (ensure_blocked_label_id).
|
||||
# ---------------------------------------------------------------------------
|
||||
declare -A _ILC_LABEL_IDS
|
||||
_ILC_LABEL_IDS["backlog"]=""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
# PROJECT_CONTAINERS, CHECK_PRS, CHECK_DEV_AGENT,
|
||||
# CHECK_PIPELINE_STALL, CI_STALE_MINUTES,
|
||||
# MIRROR_NAMES, MIRROR_URLS, MIRROR_<NAME> (per configured mirror)
|
||||
# (plus backwards-compat aliases: CODEBERG_REPO, CODEBERG_API, CODEBERG_WEB)
|
||||
#
|
||||
# If no argument given, does nothing (allows poll scripts to work with
|
||||
# plain .env fallback for backwards compatibility).
|
||||
|
|
@ -103,10 +102,6 @@ if [ -n "$FORGE_REPO" ]; then
|
|||
# Extract repo owner (first path segment of owner/repo)
|
||||
export FORGE_REPO_OWNER="${FORGE_REPO%%/*}"
|
||||
fi
|
||||
# Backwards-compat aliases
|
||||
export CODEBERG_REPO="${FORGE_REPO}"
|
||||
export CODEBERG_API="${FORGE_API:-}"
|
||||
export CODEBERG_WEB="${FORGE_WEB:-}"
|
||||
|
||||
# Derive PROJECT_REPO_ROOT if not explicitly set
|
||||
if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then
|
||||
|
|
|
|||
210
lib/profile.sh
210
lib/profile.sh
|
|
@ -1,210 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# profile.sh — Helpers for agent .profile repo management
|
||||
#
|
||||
# Source after lib/env.sh and lib/formula-session.sh:
|
||||
# source "$(dirname "$0")/../lib/env.sh"
|
||||
# source "$(dirname "$0")/lib/formula-session.sh"
|
||||
# source "$(dirname "$0")/lib/profile.sh"
|
||||
#
|
||||
# Required globals: FORGE_TOKEN, FORGE_URL, AGENT_IDENTITY, PROFILE_REPO_PATH
|
||||
#
|
||||
# Functions:
|
||||
# profile_propose_formula NEW_FORMULA CONTENT REASON — create PR to update formula.toml
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Internal log helper
|
||||
_profile_log() {
|
||||
if declare -f log >/dev/null 2>&1; then
|
||||
log "profile: $*"
|
||||
else
|
||||
printf '[%s] profile: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# profile_propose_formula — Propose a formula change via PR
|
||||
#
|
||||
# Creates a branch, writes updated formula.toml, opens a PR, and returns PR number.
|
||||
# Branch is protected (requires admin approval per #87).
|
||||
#
|
||||
# Args:
|
||||
# $1 - NEW_FORMULA_CONTENT: The complete new formula.toml content
|
||||
# $2 - REASON: Human-readable explanation of what changed and why
|
||||
#
|
||||
# Returns:
|
||||
# 0 on success, prints PR number to stdout
|
||||
# 1 on failure
|
||||
#
|
||||
# Example:
|
||||
# source "$(dirname "$0")/../lib/env.sh"
|
||||
# source "$(dirname "$0")/lib/formula-session.sh"
|
||||
# source "$(dirname "$0")/lib/profile.sh"
|
||||
# AGENT_IDENTITY="dev-bot"
|
||||
# ensure_profile_repo "$AGENT_IDENTITY"
|
||||
# profile_propose_formula "$new_formula" "Added new prompt pattern for code review"
|
||||
# -----------------------------------------------------------------------------
|
||||
profile_propose_formula() {
|
||||
local new_formula="$1"
|
||||
local reason="$2"
|
||||
|
||||
if [ -z "${AGENT_IDENTITY:-}" ]; then
|
||||
_profile_log "ERROR: AGENT_IDENTITY not set"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "${PROFILE_REPO_PATH:-}" ]; then
|
||||
_profile_log "ERROR: PROFILE_REPO_PATH not set — ensure_profile_repo not called"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "${FORGE_TOKEN:-}" ]; then
|
||||
_profile_log "ERROR: FORGE_TOKEN not set"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -z "${FORGE_URL:-}" ]; then
|
||||
_profile_log "ERROR: FORGE_URL not set"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Generate short description from reason for branch name
|
||||
local short_desc
|
||||
short_desc=$(printf '%s' "$reason" | \
|
||||
tr '[:upper:]' '[:lower:]' | \
|
||||
sed 's/[^a-z0-9 ]//g' | \
|
||||
sed 's/ */ /g' | \
|
||||
sed 's/^ *//;s/ *$//' | \
|
||||
cut -c1-40 | \
|
||||
tr ' ' '-')
|
||||
|
||||
if [ -z "$short_desc" ]; then
|
||||
short_desc="formula-update"
|
||||
fi
|
||||
|
||||
local branch_name="formula/${short_desc}"
|
||||
local formula_path="${PROFILE_REPO_PATH}/formula.toml"
|
||||
|
||||
_profile_log "Proposing formula change: ${branch_name}"
|
||||
_profile_log "Reason: ${reason}"
|
||||
|
||||
# Ensure we're on main branch and up-to-date
|
||||
_profile_log "Fetching .profile repo"
|
||||
(
|
||||
cd "$PROFILE_REPO_PATH" || return 1
|
||||
|
||||
git fetch origin main --quiet 2>/dev/null || \
|
||||
git fetch origin master --quiet 2>/dev/null || true
|
||||
|
||||
# Reset to main/master
|
||||
if git checkout main --quiet 2>/dev/null; then
|
||||
git pull --ff-only origin main --quiet 2>/dev/null || true
|
||||
elif git checkout master --quiet 2>/dev/null; then
|
||||
git pull --ff-only origin master --quiet 2>/dev/null || true
|
||||
else
|
||||
_profile_log "ERROR: Failed to checkout main/master branch"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create and checkout new branch
|
||||
git checkout -b "$branch_name" 2>/dev/null || {
|
||||
_profile_log "Branch ${branch_name} may already exist"
|
||||
git checkout "$branch_name" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# Write formula.toml
|
||||
printf '%s' "$new_formula" > "$formula_path"
|
||||
|
||||
# Commit the change
|
||||
git config user.name "${AGENT_IDENTITY}" || true
|
||||
git config user.email "${AGENT_IDENTITY}@users.noreply.codeberg.org" || true
|
||||
|
||||
git add "$formula_path"
|
||||
git commit -m "formula: ${reason}" --no-verify || {
|
||||
_profile_log "No changes to commit (formula unchanged)"
|
||||
# Check if branch has any commits
|
||||
if git rev-parse HEAD >/dev/null 2>&1; then
|
||||
: # branch has commits, continue
|
||||
else
|
||||
_profile_log "ERROR: Failed to create commit"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Push branch
|
||||
local remote="${FORGE_REMOTE:-origin}"
|
||||
git push --set-upstream "$remote" "$branch_name" --quiet 2>/dev/null || {
|
||||
_profile_log "ERROR: Failed to push branch"
|
||||
return 1
|
||||
}
|
||||
|
||||
_profile_log "Branch pushed: ${branch_name}"
|
||||
|
||||
# Create PR
|
||||
local forge_url="${FORGE_URL%/}"
|
||||
local api_url="${forge_url}/api/v1/repos/${AGENT_IDENTITY}/.profile"
|
||||
local primary_branch="main"
|
||||
|
||||
# Check if main or master is the primary branch
|
||||
if ! curl -sf -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/git/branches/main" 2>/dev/null | grep -q "200"; then
|
||||
primary_branch="master"
|
||||
fi
|
||||
|
||||
local pr_title="formula: ${reason}"
|
||||
local pr_body="# Formula Update
|
||||
|
||||
**Reason:** ${reason}
|
||||
|
||||
---
|
||||
*This PR was auto-generated by ${AGENT_IDENTITY}.*
|
||||
"
|
||||
|
||||
local pr_response http_code
|
||||
local pr_json
|
||||
pr_json=$(jq -n \
|
||||
--arg t "$pr_title" \
|
||||
--arg b "$pr_body" \
|
||||
--arg h "$branch_name" \
|
||||
--arg base "$primary_branch" \
|
||||
'{title:$t, body:$b, head:$h, base:$base}') || {
|
||||
_profile_log "ERROR: Failed to build PR JSON"
|
||||
return 1
|
||||
}
|
||||
|
||||
pr_response=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${api_url}/pulls" \
|
||||
-d "$pr_json" || true)
|
||||
|
||||
http_code=$(printf '%s\n' "$pr_response" | tail -1)
|
||||
pr_response=$(printf '%s\n' "$pr_response" | sed '$d')
|
||||
|
||||
if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then
|
||||
local pr_num
|
||||
pr_num=$(printf '%s' "$pr_response" | jq -r '.number')
|
||||
_profile_log "PR created: #${pr_num}"
|
||||
printf '%s' "$pr_num"
|
||||
return 0
|
||||
else
|
||||
# Check if PR already exists (409 conflict)
|
||||
if [ "$http_code" = "409" ]; then
|
||||
local existing_pr
|
||||
existing_pr=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${api_url}/pulls?state=open&head=${AGENT_IDENTITY}:formula/${short_desc}" 2>/dev/null | \
|
||||
jq -r '.[0].number // empty') || true
|
||||
if [ -n "$existing_pr" ]; then
|
||||
_profile_log "PR already exists: #${existing_pr}"
|
||||
printf '%s' "$existing_pr"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
_profile_log "ERROR: Failed to create PR (HTTP ${http_code})"
|
||||
return 1
|
||||
fi
|
||||
)
|
||||
|
||||
return $?
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Planner Agent
|
||||
|
||||
**Role**: Strategic planning using a Prerequisite Tree (Theory of Constraints),
|
||||
|
|
|
|||
|
|
@ -48,15 +48,15 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
|||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active planner
|
||||
acquire_cron_lock "/tmp/planner-run.lock"
|
||||
check_memory 2000
|
||||
memory_guard 2000
|
||||
|
||||
log "--- Planner run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PLANNER_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PLANNER_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
|
||||
fi
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "planner" "$FACTORY_ROOT/formulas/run-planner.toml" || exit 1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Predictor Agent
|
||||
|
||||
**Role**: Abstract adversary (the "goblin"). Runs a 2-step formula
|
||||
|
|
|
|||
|
|
@ -49,15 +49,15 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
|||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active predictor
|
||||
acquire_cron_lock "/tmp/predictor-run.lock"
|
||||
check_memory 2000
|
||||
memory_guard 2000
|
||||
|
||||
log "--- Predictor run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_PREDICTOR_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_PREDICTOR_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
|
||||
fi
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Load formula + context ───────────────────────────────────────────────
|
||||
load_formula_or_profile "predictor" "$FACTORY_ROOT/formulas/run-predictor.toml" || exit 1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Review Agent
|
||||
|
||||
**Role**: AI-powered PR review — post structured findings and formal
|
||||
|
|
|
|||
|
|
@ -58,13 +58,15 @@ if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 10
|
|||
mv "$LOGFILE" "$LOGFILE.old"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# RESOLVE FORGE REMOTE FOR GIT OPERATIONS
|
||||
# =============================================================================
|
||||
resolve_forge_remote
|
||||
|
||||
# =============================================================================
|
||||
# RESOLVE AGENT IDENTITY FOR .PROFILE REPO
|
||||
# =============================================================================
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
|
||||
fi
|
||||
resolve_agent_identity || true
|
||||
|
||||
# =============================================================================
|
||||
# MEMORY GUARD
|
||||
|
|
@ -131,7 +133,7 @@ PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \
|
|||
if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then
|
||||
PREV_BODY=$(printf '%s' "$PREV_REV" | jq -r '.body')
|
||||
PREV_SHA=$(printf '%s' "$PREV_BODY" | grep -oP '<!-- reviewed: \K[a-f0-9]+' | head -1)
|
||||
cd "${PROJECT_REPO_ROOT}"; git fetch origin "$PR_HEAD" 2>/dev/null || true
|
||||
cd "${PROJECT_REPO_ROOT}"; git fetch "${FORGE_REMOTE}" "$PR_HEAD" 2>/dev/null || true
|
||||
INCR=$(git diff "${PREV_SHA}..${PR_SHA}" 2>/dev/null | head -c "$MAX_DIFF") || true
|
||||
if [ -n "$INCR" ]; then
|
||||
IS_RE_REVIEW=true; log "re-review: previous at ${PREV_SHA:0:7}"
|
||||
|
|
@ -162,7 +164,7 @@ DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes
|
|||
# WORKTREE SETUP
|
||||
# =============================================================================
|
||||
cd "${PROJECT_REPO_ROOT}"
|
||||
git fetch origin "$PR_HEAD" 2>/dev/null || true
|
||||
git fetch "${FORGE_REMOTE}" "$PR_HEAD" 2>/dev/null || true
|
||||
|
||||
if [ -d "$WORKTREE" ]; then
|
||||
cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<!-- last-reviewed: a8f13e1ac305540b73fd6c05a722b65d2ab94de2 -->
|
||||
<!-- last-reviewed: 8d321681213a455ed01eefc13ccbd9af7daae453 -->
|
||||
# Supervisor Agent
|
||||
|
||||
**Role**: Health monitoring and auto-remediation, executed as a formula-driven
|
||||
|
|
|
|||
|
|
@ -51,18 +51,18 @@ log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
|||
# ── Guards ────────────────────────────────────────────────────────────────
|
||||
check_active supervisor
|
||||
acquire_cron_lock "/tmp/supervisor-run.lock"
|
||||
check_memory 2000
|
||||
memory_guard 2000
|
||||
|
||||
log "--- Supervisor run start ---"
|
||||
|
||||
# ── Resolve forge remote for git operations ─────────────────────────────
|
||||
resolve_forge_remote
|
||||
|
||||
# ── Housekeeping: clean up stale crashed worktrees (>24h) ────────────────
|
||||
cleanup_stale_crashed_worktrees 24
|
||||
|
||||
# ── Resolve agent identity for .profile repo ────────────────────────────
|
||||
if [ -z "${AGENT_IDENTITY:-}" ] && [ -n "${FORGE_SUPERVISOR_TOKEN:-}" ]; then
|
||||
AGENT_IDENTITY=$(curl -sf -H "Authorization: token ${FORGE_SUPERVISOR_TOKEN}" \
|
||||
"${FORGE_URL:-http://localhost:3000}/api/v1/user" 2>/dev/null | jq -r '.login // empty' 2>/dev/null || true)
|
||||
fi
|
||||
resolve_agent_identity || true
|
||||
|
||||
# ── Collect pre-flight metrics ────────────────────────────────────────────
|
||||
log "Running preflight.sh"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue