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