From 00195ee6d58fa535d756819dc58d135fef6af7e1 Mon Sep 17 00:00:00 2001 From: Semprini Date: Mon, 29 Jun 2026 20:46:33 +1200 Subject: [PATCH] Add Gitea integration for agent to push branches and open pull requests - Implemented `register-gitea-bot.sh` to provision Gitea access token. - Updated `maintainer.py` to support Gitea API for push and PR operations. - Modified `install.sh` to warn if Gitea token is not provisioned. - Enhanced documentation in `README.md`, `CLAUDE.md`, and `architecture.md` to reflect new Gitea functionality. - Added Gitea configuration to `config.example.json`. --- CLAUDE.md | 8 ++++ README.md | 26 +++++++++++- config.example.json | 10 +++++ docs/architecture.md | 12 ++++++ install.sh | 7 ++++ maintainer.py | 98 +++++++++++++++++++++++++++++++++++++++++-- register-gitea-bot.sh | 98 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 4 deletions(-) create mode 100755 register-gitea-bot.sh diff --git a/CLAUDE.md b/CLAUDE.md index 02eb7cf..ac930bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,13 @@ Claude Code, then relays/acts on the agent's JSON result. major/breaking ones to Matrix. - **Matrix**: bot `@maintainer-bot:semprini.me` DMs `@paul:semprini.me`. Escalations are numbered questions; the reply is fed back to the agent to carry out. +- **Git/PRs**: when the agent changes tracked files it branches, pushes, and opens + a **pull request** on git.semprini.me (Gitea) as the `claude-code` user (never + commits to `main`); live remediation still happens immediately and directly. + The daemon injects claude-code's git identity + credential and the Gitea API + env into the headless agent (see `agent_git_env` / `GIT_PR_POLICY` in + `maintainer.py`); token provisioned by `register-gitea-bot.sh`. Empty + `gitea.token` disables the workflow. - **Autonomy** (`config.json` → `autonomy`): `full` (default) | `fix-only` | `notify`. @@ -60,6 +67,7 @@ Claude Code, then relays/acts on the agent's JSON result. | `maintainer.py` | The daemon (stdlib only). | | `config.example.json` | Template — copy to `config.json`. | | `register-matrix-bot.sh` | Creates `@maintainer-bot` account + DM room; writes token to config. | +| `register-gitea-bot.sh` | Mints the `claude-code` Gitea token (push + PRs); writes it to config. | | `install.sh` | Syntax-check, install unit, enable + start. | | `semprini-maintainer.service` | systemd unit (runs as `paul`). | | `docs/architecture.md` | Architecture decision record. | diff --git a/README.md b/README.md index 0a7e636..59dae11 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,35 @@ cp config.example.json config.json ./register-matrix-bot.sh # then, one-time: accept the room invite in Element as @paul:semprini.me -# 3. Install + start the service +# 3. Provision the Gitea token (lets the agent push + open PRs as claude-code) +./register-gitea-bot.sh + +# 4. Install + start the service ./install.sh ``` `config.json` and `.bot-secrets` hold the bot token/password and are gitignored. +### Pushing code & pull requests + +When the agent changes tracked files in a repo (compose files, configs, scripts, +docs) it does **not** commit to `main` — it branches, commits, pushes, and opens +a **pull request** on `git.semprini.me` (Gitea) for the operator to review, then +reports the PR URL to Matrix. Live remediation that must restore service now +(e.g. `docker compose up`) still happens immediately; only the git change is +gated behind the PR. + +It authenticates as the **`claude-code`** user (an OIDC account in +auth.semprini.me that is already a push-capable collaborator). `register-gitea-bot.sh` +mints a Gitea personal access token via the gitea admin CLI inside the +`user-gitea` container — git.semprini.me has password login and HTTP basic auth +disabled, but a PAT still works for git-over-HTTPS and the API, so no SSH +exposure or core-stack change is needed. The daemon hands the headless agent +claude-code's git identity + credential (via `GIT_CONFIG_*`, scoped to +git.semprini.me) and the Gitea API coordinates (`$GITEA_TOKEN`, `$GITEA_API`, +`$GITEA_REPO`) so it can open the PR itself. Leaving `gitea.token` empty disables +the workflow — the agent then edits/redeploys live without committing. + ## Operate ```bash @@ -134,6 +157,7 @@ python3 maintainer.py --config config.json --once replies | `maintainer.py` | The daemon. Stdlib only. | | `config.example.json` | Template — copy to `config.json`. | | `register-matrix-bot.sh` | Creates the `@maintainer` account + room, writes token to config. | +| `register-gitea-bot.sh` | Mints the `claude-code` Gitea token for push + PRs, writes it to config. | | `semprini-maintainer.service` | systemd unit (runs as `paul`). | | `install.sh` | Syntax-check, install, enable, start. | diff --git a/config.example.json b/config.example.json index c38b4b0..5eb8cb8 100644 --- a/config.example.json +++ b/config.example.json @@ -20,6 +20,16 @@ "state_file": "/home/paul/.local/state/semprini-maintainer/state.json", + "gitea": { + "api_base": "https://git.semprini.me/api/v1", + "web_base": "https://git.semprini.me", + "user": "claude-code", + "repo": "paul/semprini-core", + "token": "FILLED_BY_register-gitea-bot.sh", + "git_author_name": "Claude Code", + "git_author_email": "claude-code@semprini.me" + }, + "matrix": { "homeserver": "https://matrix.semprini.me", "user_id": "@maintainer-bot:semprini.me", diff --git a/docs/architecture.md b/docs/architecture.md index b8a2920..3e57499 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,18 @@ feeds the agent state and relays its results. `fix-only` or `notify`. - **Matrix transport**: dedicated bot `@maintainer:semprini.me` (created via the Synapse registration shared secret) in a private room with `@paul:semprini.me`. +- **Code changes go through pull requests.** When the agent edits tracked files + it branches, pushes, and opens a PR on Gitea (`git.semprini.me`) as the + `claude-code` user rather than committing to `main`, giving the operator a + review gate. Live remediation (restart/redeploy) is *not* gated — only the git + change is. claude-code is an OIDC-linked, push-capable collaborator; since + git.semprini.me disables password login + HTTP basic auth, `register-gitea-bot.sh` + mints a personal access token via the gitea admin CLI inside the `user-gitea` + container (mirroring the Matrix bot's shared-secret pattern). A PAT still works + for git-over-HTTPS and the API, so no SSH exposure or core-stack change is + required. The daemon injects the bot's git identity + credential (`GIT_CONFIG_*`, + scoped to the Gitea host so it overrides the repo's own `user.*`) and the API + coordinates into the headless agent; the agent opens the PR itself. ## Cadence diff --git a/install.sh b/install.sh index 95e0482..49cefd0 100755 --- a/install.sh +++ b/install.sh @@ -27,6 +27,13 @@ case "$TOKEN" in ""|FILLED_BY_*) echo "ERROR: Matrix not provisioned. Run register-matrix-bot.sh first."; exit 1;; esac +# Gitea (push/PR capability) is optional — warn but don't block if unprovisioned. +GTOK="$(python3 -c "import json;print(json.load(open('$DIR/config.json')).get('gitea',{}).get('token',''))")" +case "$GTOK" in + ""|FILLED_BY_*) echo "WARN: Gitea not provisioned — the agent will not push or open PRs."; + echo " Run register-gitea-bot.sh to enable it.";; +esac + echo "→ Syntax-checking maintainer.py" python3 -m py_compile "$DIR/maintainer.py" diff --git a/maintainer.py b/maintainer.py index 65ebf3e..41e6345 100755 --- a/maintainer.py +++ b/maintainer.py @@ -74,6 +74,19 @@ DEFAULTS = { # Send a quiet "all healthy" heartbeat to Matrix at most this often. "heartbeat_interval_seconds": 86400, # daily "state_file": "/home/paul/.local/state/semprini-maintainer/state.json", + # Gitea (git.semprini.me) — lets the agent push branches and open pull + # requests as the `claude-code` user. Token is filled by + # register-gitea-bot.sh; leaving it empty disables the git/PR workflow (the + # agent then just edits/redeploys live without committing). + "gitea": { + "api_base": "https://git.semprini.me/api/v1", + "web_base": "https://git.semprini.me", + "user": "claude-code", + "repo": "paul/semprini-core", + "token": "", + "git_author_name": "Claude Code", + "git_author_email": "claude-code@semprini.me", + }, "matrix": { "homeserver": "https://matrix.semprini.me", "user_id": "", @@ -90,8 +103,8 @@ def load_config(path): with open(path) as fh: user = json.load(fh) for k, v in user.items(): - if k == "matrix" and isinstance(v, dict): - cfg["matrix"].update(v) + if k in ("matrix", "gitea") and isinstance(v, dict): + cfg[k].update(v) else: cfg[k] = v return cfg @@ -375,6 +388,81 @@ status meanings: """.strip() +def agent_git_env(cfg): + """Environment that lets the headless agent push + open PRs as claude-code. + + git.semprini.me (Gitea) has password login and HTTP basic auth disabled, but + still accepts a personal access token as the git-over-HTTPS password and via + the API `Authorization: token` header. We materialise a 0600 credential store + and inject, at highest precedence (GIT_CONFIG_*, which overrides the repo's + own user.* config), the bot identity + a credential helper scoped to the + Gitea host. The Gitea API coordinates (token, repo, base URLs) are exported so + the agent can open the pull request itself. Returns {} when no token is + provisioned, so the daemon still runs without the git/PR capability. + """ + gt = cfg.get("gitea", {}) + token = gt.get("token", "") + if not token or token.startswith("FILLED_BY_"): + return {} + + state_dir = os.path.dirname(cfg["state_file"]) + os.makedirs(state_dir, exist_ok=True) + cred_file = os.path.join(state_dir, "git-credentials") + + web = gt.get("web_base", "https://git.semprini.me").rstrip("/") + parts = urllib.parse.urlsplit(web) + user = gt.get("user", "claude-code") + line = (f"{parts.scheme}://{urllib.parse.quote(user)}:" + f"{urllib.parse.quote(token)}@{parts.netloc}\n") + fd = os.open(cred_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(line) + + return { + "GIT_CONFIG_COUNT": "4", + "GIT_CONFIG_KEY_0": "user.name", + "GIT_CONFIG_VALUE_0": gt.get("git_author_name", "Claude Code"), + "GIT_CONFIG_KEY_1": "user.email", + "GIT_CONFIG_VALUE_1": gt.get("git_author_email", f"{user}@semprini.me"), + "GIT_CONFIG_KEY_2": f"credential.{web}.helper", + "GIT_CONFIG_VALUE_2": f"store --file={cred_file}", + "GIT_CONFIG_KEY_3": f"credential.{web}.username", + "GIT_CONFIG_VALUE_3": user, + "GITEA_API": gt.get("api_base", web + "/api/v1"), + "GITEA_WEB": web, + "GITEA_REPO": gt.get("repo", ""), + "GITEA_USER": user, + "GITEA_TOKEN": token, + } + + +GIT_PR_POLICY = """ +VERSION CONTROL — the working tree under repo_dir (and the other project repos) +is a live Gitea checkout. When you change tracked files (compose files, env +templates, configs, scripts, docs), do NOT commit to main. Instead: branch, +commit, push, and open a pull request for the operator to review. Git is +preconfigured for you — commits are authored as the claude-code bot and pushes +to git.semprini.me authenticate automatically — so use ordinary git commands: + + git checkout -b maint/ + git add -A && git commit -m "" + git push -u origin maint/ + +Then open the PR via the Gitea API (token + coordinates are in the environment): + + curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" \\ + -H 'Content-Type: application/json' \\ + "$GITEA_API/repos/$GITEA_REPO/pulls" \\ + -d '{"head":"","base":"main","title":"","body":"<body>"}' + +For a change in a different repo, target that repo's owner/name in the URL (the +bot must be a collaborator there; escalate if it is not). Put the resulting PR +URL in your summary. Live remediation that must restore service now (docker +compose up/restart, etc.) still happens immediately and directly — only the git +change is gated behind the PR, not the recovery. +""".strip() + + def run_agent(cfg, task): """Invoke the canonical stack-support agent headless and return its output.""" agent_path = os.path.join(cfg["repo_dir"], cfg["agent_file"]) @@ -397,8 +485,11 @@ def run_agent(cfg, task): "--- BEGIN agents/stack-support.agent.md ---\n" f"{agent_md}\n" "--- END agents/stack-support.agent.md ---\n\n" - f"{RESULT_PROTOCOL}" ) + git_env = agent_git_env(cfg) + if git_env: + system += GIT_PR_POLICY + "\n\n" + system += RESULT_PROTOCOL cmd = [ cfg["claude_bin"], "-p", task, @@ -416,6 +507,7 @@ def run_agent(cfg, task): proc = subprocess.run( cmd, cwd=cfg["repo_dir"], capture_output=True, text=True, timeout=cfg["agent_timeout_seconds"], + env={**os.environ, **git_env}, ) if proc.returncode != 0: log(f"WARN: agent exited {proc.returncode}: {proc.stderr.strip()[:500]}") diff --git a/register-gitea-bot.sh b/register-gitea-bot.sh new file mode 100755 index 0000000..ea214bf --- /dev/null +++ b/register-gitea-bot.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# register-gitea-bot.sh — provision the Gitea access token the maintenance agent +# uses to push branches and open pull requests as the `claude-code` user. +# +# git.semprini.me (Gitea) authenticates humans via Keycloak OIDC and has the +# password sign-in form AND HTTP basic auth disabled. `claude-code` is an +# OIDC-linked account that is already a push-capable collaborator on the repo; it +# has no password, so — exactly like the Matrix bot's shared-secret registration +# — we mint a standalone personal access token via the Gitea admin CLI inside the +# running container and store it in config.json. +# +# git-over-HTTPS still accepts a personal access token as the password even with +# basic auth disabled, and the API accepts it via the `Authorization: token` +# header — so no SSH exposure or core-stack change is required. +# +# Idempotent: reuses the token already in config.json when it still validates; +# otherwise mints a fresh one (Gitea reveals a token only at creation time, so a +# lost token means a new one). Old tokens can be revoked in the Gitea UI. +# +# Requirements: run on the core-stack host with the gitea container running. +# +# Usage: +# ./register-gitea-bot.sh # uses config.json next to this script +# CONFIG=/path/config.json ./register-gitea-bot.sh +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="${CONFIG:-$DIR/config.json}" +GITEA_CONTAINER="${GITEA_CONTAINER:-user-gitea}" +# write:repository -> push + open PRs; read:user -> token self-validation. +TOKEN_SCOPES="${TOKEN_SCOPES:-write:repository,read:user}" + +[ -f "$CONFIG" ] || { + echo "ERROR: $CONFIG not found. Copy config.example.json to config.json first." + exit 1; } + +# Read a python expression against the parsed config (c), with safe defaults so a +# config.json that predates the gitea block still works. +cfg_get() { python3 -c "import json; c=json.load(open('$CONFIG')); print($1)"; } + +API_BASE="$(cfg_get "c.get('gitea',{}).get('api_base','https://git.semprini.me/api/v1')")" +GUSER="$(cfg_get "c.get('gitea',{}).get('user','claude-code')")" +REPO="$(cfg_get "c.get('gitea',{}).get('repo','paul/semprini-core')")" + +echo "→ Gitea API : $API_BASE" +echo "→ User : $GUSER" +echo "→ Repo : $REPO" + +token_valid() { + local t="$1" + [ -n "$t" ] || return 1 + case "$t" in FILLED_BY_*) return 1;; esac + local login + login="$(curl -s -m 15 -H "Authorization: token $t" "$API_BASE/user" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("login",""))' \ + 2>/dev/null || true)" + [ "$login" = "$GUSER" ] +} + +# 1. Reuse the stored token if it still validates; otherwise mint a fresh one. +STORED="$(cfg_get "c.get('gitea',{}).get('token','')")" +if token_valid "$STORED"; then + echo "→ Existing token in config.json is valid; reusing it." + TOKEN="$STORED" +else + NAME="semprini-maintainer-$(date +%s)" + echo "→ Minting a new access token ($NAME; scopes: $TOKEN_SCOPES)…" + TOKEN="$(docker exec -u git "$GITEA_CONTAINER" \ + gitea admin user generate-access-token \ + -u "$GUSER" -t "$NAME" --scopes "$TOKEN_SCOPES" --raw 2>&1 \ + | tr -d '[:space:]')" + echo "$TOKEN" | grep -qE '^[0-9a-f]{40}$' || { + echo "ERROR: failed to mint token: $TOKEN"; exit 1; } +fi + +# 2. Verify push access to the target repo (warn only — token is still usable). +PUSH="$(curl -s -m 15 -H "Authorization: token $TOKEN" "$API_BASE/repos/$REPO" \ + | python3 -c 'import sys,json; print("yes" if json.load(sys.stdin).get("permissions",{}).get("push") else "no")' \ + 2>/dev/null || echo no)" +if [ "$PUSH" = "yes" ]; then + echo "→ $GUSER has push access to $REPO ✓" +else + echo "WARN: $GUSER cannot push to $REPO. Add it as a collaborator with Write" + echo " permission in Gitea, or pushes/PRs will be rejected." +fi + +# 3. Write the token back into config.json (creating the gitea block if absent). +python3 - "$CONFIG" "$TOKEN" <<'PY' +import json, sys +cfg_path, token = sys.argv[1], sys.argv[2] +cfg = json.load(open(cfg_path)) +cfg.setdefault("gitea", {})["token"] = token +json.dump(cfg, open(cfg_path, "w"), indent=2) +print(f"→ Wrote gitea.token to {cfg_path}") +PY + +echo +echo "✓ Done. The maintenance agent can now push branches and open PRs as $GUSER."