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`.
This commit is contained in:
2026-06-29 20:46:33 +12:00
parent 9df86ead20
commit 00195ee6d5
7 changed files with 255 additions and 4 deletions
+8
View File
@@ -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. |
+25 -1
View File
@@ -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. |
+10
View File
@@ -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",
+12
View File
@@ -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
+7
View File
@@ -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"
+95 -3
View File
@@ -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/<short-topic>
git add -A && git commit -m "<what and why>"
git push -u origin maint/<short-topic>
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":"<branch>","base":"main","title":"<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]}")
+98
View File
@@ -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."