feat: extract reforge engine into a standalone consumable flake

The forgejo-sandbox / reforge harness, lifted out of the machine config
into a host-agnostic, generic engine anyone can consume with Nix.

Two layers:
- engine (this repo) — nixosModules.reforge stands up the sandbox forge,
  provisions role accounts + tokens, enforces branch protection, and puts
  the reforge-* CLI + forgejo-mcp on PATH. Carries no project specifics.
- run config — per-project manifest/charter/agenda/issues an adopter fills
  in; scaffold one with the `reforge` flake template.

Portability fixes vs the in-config version:
- forgejo-mcp resolved from $REFORGE_MCP_BIN or PATH, never a named host
  (kills the nixosConfigurations.omni hardcode).
- all instance data + paths parameterized via REFORGE_* env, baked into the
  reforge-scripts wrappers from module options (configDir, agentsDir,
  refsDir, org, port, tokenOwner, ...).
- option namespace neutral (reforge.* not omni.packs.*); settings policies
  carry no absolute /etc/nixos paths.
- role briefs + orchestrator playbook genericized: all project specifics
  point at the charter; refs corpus optional.

Validated: nix flake check (eval) + builds of forgejo-mcp, reforge-scripts,
and a module-eval check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-18 22:50:49 +02:00
commit df0fd9a9ba
32 changed files with 2698 additions and 0 deletions

View file

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Reforge lifecycle step 3 (docs/reforge.md): file the run agenda as issues
# in the relevant sandbox repos. The issue rows live in
# $REFORGE_CONFIG_DIR/issues.tsv (one per line):
#
# repo|title|body
#
# ('#' and blank lines are ignored; body is single-line markdown.) Keep
# this file as the machine-readable twin of your agenda.md — titles should
# carry an agenda item id (e.g. "[A1] ...") for traceability.
#
# Idempotent: an issue whose exact title already exists in the repo (any
# state) is skipped, so re-running converges.
#
# reforge-kickoff
set -euo pipefail
FORGE_URL=${REFORGE_FORGE_URL:-http://localhost:3030}
ORG=${REFORGE_ORG:-sandbox-team}
ADMIN_USER=${REFORGE_ADMIN_USER:-sandbox-admin}
TOKENS_DIR=${REFORGE_TOKENS_DIR:-/var/lib/forgejo-sandbox/tokens}
CONFIG_DIR=${REFORGE_CONFIG_DIR:?set REFORGE_CONFIG_DIR to your run config dir}
TOKEN_FILE=${REFORGE_ADMIN_TOKEN_FILE:-$TOKENS_DIR/${ADMIN_USER}.token}
API="$FORGE_URL/api/v1"
TOKEN=$(cat "$TOKEN_FILE")
ISSUES_FILE="$CONFIG_DIR/issues.tsv"
[ -r "$ISSUES_FILE" ] || { echo "kickoff: no readable issues file at $ISSUES_FILE" >&2; exit 1; }
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
RESP="$WORK/resp"
api() { # method path [json-body] -> echoes HTTP code, body in $RESP
local method=$1 path=$2 data=${3:-}
local args=(
-sS -o "$RESP" -w '%{http_code}' -X "$method"
-H "Authorization: token $TOKEN"
-H 'Content-Type: application/json'
)
if [ -n "$data" ]; then args+=(--data "$data"); fi
curl "${args[@]}" "$API$path"
}
filed=0 skipped=0 failed=0
mapfile -t ROWS < <(grep -Ev '^[[:space:]]*(#|$)' "$ISSUES_FILE")
current_repo=
for row in "${ROWS[@]}"; do
IFS='|' read -r repo title body <<<"$row"
# one issue-list fetch per repo (titles for the idempotency check)
if [ "$repo" != "$current_repo" ]; then
current_repo=$repo
if [ "$(api GET "/repos/$ORG/$repo/issues?state=all&type=issues&limit=50")" = 200 ]; then
jq -r '.[].title' <"$RESP" >"$WORK/titles" || : >"$WORK/titles"
else
: >"$WORK/titles"
fi
fi
if grep -qxF "$title" "$WORK/titles"; then
skipped=$((skipped + 1))
continue
fi
payload=$(jq -n --arg t "$title" --arg b "$body" '{title: $t, body: $b}')
code=$(api POST "/repos/$ORG/$repo/issues" "$payload")
if [ "$code" = 201 ]; then
echo " filed $repo: $title"
filed=$((filed + 1))
else
echo " FAILED $repo: $title (HTTP $code)" >&2
cat "$RESP" >&2
failed=$((failed + 1))
fi
done
echo
echo "kickoff: $filed filed, $skipped already present, $failed failed."
[ "$failed" = 0 ]