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:
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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]}")
|
||||
|
||||
Executable
+98
@@ -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."
|
||||
Reference in New Issue
Block a user