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.
|
major/breaking ones to Matrix.
|
||||||
- **Matrix**: bot `@maintainer-bot:semprini.me` DMs `@paul:semprini.me`. Escalations
|
- **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.
|
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` |
|
- **Autonomy** (`config.json` → `autonomy`): `full` (default) | `fix-only` |
|
||||||
`notify`.
|
`notify`.
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ Claude Code, then relays/acts on the agent's JSON result.
|
|||||||
| `maintainer.py` | The daemon (stdlib only). |
|
| `maintainer.py` | The daemon (stdlib only). |
|
||||||
| `config.example.json` | Template — copy to `config.json`. |
|
| `config.example.json` | Template — copy to `config.json`. |
|
||||||
| `register-matrix-bot.sh` | Creates `@maintainer-bot` account + DM room; writes token to config. |
|
| `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. |
|
| `install.sh` | Syntax-check, install unit, enable + start. |
|
||||||
| `semprini-maintainer.service` | systemd unit (runs as `paul`). |
|
| `semprini-maintainer.service` | systemd unit (runs as `paul`). |
|
||||||
| `docs/architecture.md` | Architecture decision record. |
|
| `docs/architecture.md` | Architecture decision record. |
|
||||||
|
|||||||
@@ -108,12 +108,35 @@ cp config.example.json config.json
|
|||||||
./register-matrix-bot.sh
|
./register-matrix-bot.sh
|
||||||
# then, one-time: accept the room invite in Element as @paul:semprini.me
|
# 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
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`config.json` and `.bot-secrets` hold the bot token/password and are gitignored.
|
`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
|
## Operate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -134,6 +157,7 @@ python3 maintainer.py --config config.json --once replies
|
|||||||
| `maintainer.py` | The daemon. Stdlib only. |
|
| `maintainer.py` | The daemon. Stdlib only. |
|
||||||
| `config.example.json` | Template — copy to `config.json`. |
|
| `config.example.json` | Template — copy to `config.json`. |
|
||||||
| `register-matrix-bot.sh` | Creates the `@maintainer` account + room, writes token to config. |
|
| `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`). |
|
| `semprini-maintainer.service` | systemd unit (runs as `paul`). |
|
||||||
| `install.sh` | Syntax-check, install, enable, start. |
|
| `install.sh` | Syntax-check, install, enable, start. |
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,16 @@
|
|||||||
|
|
||||||
"state_file": "/home/paul/.local/state/semprini-maintainer/state.json",
|
"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": {
|
"matrix": {
|
||||||
"homeserver": "https://matrix.semprini.me",
|
"homeserver": "https://matrix.semprini.me",
|
||||||
"user_id": "@maintainer-bot:semprini.me",
|
"user_id": "@maintainer-bot:semprini.me",
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ feeds the agent state and relays its results.
|
|||||||
`fix-only` or `notify`.
|
`fix-only` or `notify`.
|
||||||
- **Matrix transport**: dedicated bot `@maintainer:semprini.me` (created via the
|
- **Matrix transport**: dedicated bot `@maintainer:semprini.me` (created via the
|
||||||
Synapse registration shared secret) in a private room with `@paul:semprini.me`.
|
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
|
## Cadence
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ case "$TOKEN" in
|
|||||||
""|FILLED_BY_*) echo "ERROR: Matrix not provisioned. Run register-matrix-bot.sh first."; exit 1;;
|
""|FILLED_BY_*) echo "ERROR: Matrix not provisioned. Run register-matrix-bot.sh first."; exit 1;;
|
||||||
esac
|
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"
|
echo "→ Syntax-checking maintainer.py"
|
||||||
python3 -m py_compile "$DIR/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.
|
# Send a quiet "all healthy" heartbeat to Matrix at most this often.
|
||||||
"heartbeat_interval_seconds": 86400, # daily
|
"heartbeat_interval_seconds": 86400, # daily
|
||||||
"state_file": "/home/paul/.local/state/semprini-maintainer/state.json",
|
"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": {
|
"matrix": {
|
||||||
"homeserver": "https://matrix.semprini.me",
|
"homeserver": "https://matrix.semprini.me",
|
||||||
"user_id": "",
|
"user_id": "",
|
||||||
@@ -90,8 +103,8 @@ def load_config(path):
|
|||||||
with open(path) as fh:
|
with open(path) as fh:
|
||||||
user = json.load(fh)
|
user = json.load(fh)
|
||||||
for k, v in user.items():
|
for k, v in user.items():
|
||||||
if k == "matrix" and isinstance(v, dict):
|
if k in ("matrix", "gitea") and isinstance(v, dict):
|
||||||
cfg["matrix"].update(v)
|
cfg[k].update(v)
|
||||||
else:
|
else:
|
||||||
cfg[k] = v
|
cfg[k] = v
|
||||||
return cfg
|
return cfg
|
||||||
@@ -375,6 +388,81 @@ status meanings:
|
|||||||
""".strip()
|
""".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):
|
def run_agent(cfg, task):
|
||||||
"""Invoke the canonical stack-support agent headless and return its output."""
|
"""Invoke the canonical stack-support agent headless and return its output."""
|
||||||
agent_path = os.path.join(cfg["repo_dir"], cfg["agent_file"])
|
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"
|
"--- BEGIN agents/stack-support.agent.md ---\n"
|
||||||
f"{agent_md}\n"
|
f"{agent_md}\n"
|
||||||
"--- END agents/stack-support.agent.md ---\n\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 = [
|
cmd = [
|
||||||
cfg["claude_bin"], "-p", task,
|
cfg["claude_bin"], "-p", task,
|
||||||
@@ -416,6 +507,7 @@ def run_agent(cfg, task):
|
|||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
cmd, cwd=cfg["repo_dir"], capture_output=True, text=True,
|
cmd, cwd=cfg["repo_dir"], capture_output=True, text=True,
|
||||||
timeout=cfg["agent_timeout_seconds"],
|
timeout=cfg["agent_timeout_seconds"],
|
||||||
|
env={**os.environ, **git_env},
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
log(f"WARN: agent exited {proc.returncode}: {proc.stderr.strip()[:500]}")
|
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