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
+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]}")