initial: gitea-bot — mochi 全站 issue/PR 自动处理

systemd --user unit, polls famzheng.me/gitea every 60s, dispatches
`claude --dangerously-skip-permissions -p ...` per new activity.
sqlite state, log to ~/.local/state/gitea-bot/bot.log.
This commit is contained in:
Fam Zheng
2026-05-05 10:14:03 +01:00
commit f83ebf5854
5 changed files with 456 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""gitea-bot — mochi 全站 issue/PR 自动处理。
轮询 famzheng.me/gitea 所有 repo 的 open issue/PR
每条"最新活动不是 mochi 自己"的条目,spawn 一个
`claude --dangerously-skip-permissions -p <prompt>`
让 claude 用 mochi token 直接调 gitea API + git push 来回复 / 提 PR。
"""
import json
import os
import shlex
import sqlite3
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
CONFIG = {
"base_url": "https://famzheng.me/gitea",
"username": "mochi",
"token_file": Path.home() / ".gitea-mochi-token",
"state_dir": Path.home() / ".local/state/gitea-bot",
"poll_interval_sec": 60,
"claude_bin": "/home/fam/.local/bin/claude",
"claude_timeout_sec": 1200,
}
def log(msg: str) -> None:
ts = datetime.now().isoformat(timespec="seconds")
print(f"[{ts}] {msg}", flush=True)
def load_token() -> str:
return CONFIG["token_file"].read_text().strip()
def gitea_get(path: str, token: str, **params) -> object:
url = f"{CONFIG['base_url']}/api/v1{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(
url,
headers={
"Authorization": f"token {token}",
"Accept": "application/json",
"User-Agent": "gitea-bot/mochi",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def list_all_repos(token: str) -> list[dict]:
repos: list[dict] = []
page = 1
while True:
batch = gitea_get("/repos/search", token, limit=50, page=page)
items = batch.get("data", []) if isinstance(batch, dict) else []
if not items:
break
repos.extend(items)
if len(items) < 50:
break
page += 1
return repos
def list_open_issues(owner: str, repo: str, token: str, type_: str) -> list[dict]:
"""type_ in {'issues', 'pulls'}"""
items: list[dict] = []
page = 1
while True:
batch = gitea_get(
f"/repos/{owner}/{repo}/issues",
token,
state="open",
type=type_,
page=page,
limit=50,
)
if not isinstance(batch, list) or not batch:
break
items.extend(batch)
if len(batch) < 50:
break
page += 1
return items
def list_comments(owner: str, repo: str, number: int, token: str) -> list[dict]:
out = gitea_get(f"/repos/{owner}/{repo}/issues/{number}/comments", token)
return out if isinstance(out, list) else []
def init_state(db_path: Path) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS handled (
key TEXT PRIMARY KEY,
handled_at TEXT NOT NULL,
last_signal TEXT NOT NULL
)
"""
)
conn.commit()
return conn
def get_handled_signal(conn: sqlite3.Connection, key: str) -> str | None:
row = conn.execute(
"SELECT last_signal FROM handled WHERE key = ?", (key,)
).fetchone()
return row[0] if row else None
def set_handled(conn: sqlite3.Connection, key: str, signal: str) -> None:
conn.execute(
"""
INSERT INTO handled(key, handled_at, last_signal) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
handled_at = excluded.handled_at,
last_signal = excluded.last_signal
""",
(key, datetime.now(timezone.utc).isoformat(), signal),
)
conn.commit()
def latest_signal(issue: dict, comments: list[dict]) -> tuple[str, str]:
"""Return (time, author_login) for the latest activity on this issue."""
cands: list[tuple[str, str]] = [
(issue["updated_at"], issue["user"]["login"]),
]
for c in comments:
cands.append((c["updated_at"], c["user"]["login"]))
cands.sort(key=lambda x: x[0])
return cands[-1]
def build_prompt(repo: dict, issue: dict, is_pr: bool, comments: list[dict]) -> str:
typ = "pull request" if is_pr else "issue"
full = repo["full_name"]
labels = ", ".join(l["name"] for l in issue.get("labels") or []) or "(none)"
body = (issue.get("body") or "(empty)").strip()
if len(body) > 8000:
body = body[:8000] + "\n…(truncated)"
comments_tail = comments[-10:]
if comments_tail:
comments_str = "\n\n".join(
f"### @{c['user']['login']} at {c['created_at']}\n{(c.get('body') or '').strip()}"
for c in comments_tail
)
else:
comments_str = "(no comments yet)"
pr_note = ""
if is_pr:
pr_note = (
f"\nThis is a PULL REQUEST. You can fetch the diff at "
f"`GET /repos/{full}/pulls/{issue['number']}.diff` (raw text). "
f"You may post review comments via the issues comments endpoint, "
f"or full reviews via `POST /repos/{full}/pulls/{issue['number']}/reviews`.\n"
)
return f"""You are **mochi** (麻薯), the autonomous Gitea bot running site-wide on
{CONFIG['base_url']}. You are invoked once per "new activity" on an issue or PR
and must finish your work in this single run.
# Identity & access
- Gitea username: `mochi` (you have admin on this instance).
- Auth token: env var `MOCHI_TOKEN`. Send it as `Authorization: token $MOCHI_TOKEN`.
- API base: `{CONFIG['base_url']}/api/v1`
- SSH is disabled — git over HTTPS only. To clone/push:
`git clone https://mochi:$MOCHI_TOKEN@famzheng.me/gitea/<owner>/<repo>.git`
- Configure git author when committing:
`git -c user.name=mochi -c user.email=mochi@famzheng.me commit ...`
- Work in `/tmp/gitea-bot-<rand>/`. Clean up when done.
- You have full shell access (--dangerously-skip-permissions). Be careful but decisive.
# Voice
- Smart, concise senior-engineer tone. No customer-service fluff.
- Reply in the **same language** as the issue body (中文 or English — detect it).
- Do NOT use bracketed RP actions like `(笑)` `(歪头)` `(撒娇)`. Express tone with words.
- Sign every comment you POST with this footer line on its own line:
`🤖 mochi bot · automated · {CONFIG['base_url']}`
# Target {typ}
- Repo: `{full}` (default branch: `{repo.get('default_branch', 'main')}`)
- Issue/PR #: {issue['number']}
- Title: {issue['title']}
- Author: @{issue['user']['login']}
- URL: {issue['html_url']}
- Labels: {labels}
- State: {issue['state']}
- Created: {issue['created_at']} · Updated: {issue['updated_at']}
## Body
{body}
## Recent comments (oldest → newest, up to 10)
{comments_str}
{pr_note}
# What to do
1. Re-read the issue/PR. If the **latest** comment is from `mochi` and there's no
new question/instruction after it, exit silently — do not double-post.
2. Classify and act:
- **Question / discussion / unclear ask** → post **one** helpful comment
(≤ 8 lines). Ask clarifying questions if needed. Do not open a PR.
- **Feature request** (clear, scoped, small enough that you can implement it
in one PR):
a. `git clone` the repo to a temp dir.
b. `git switch -c mochi/issue-{issue['number']}`
c. Implement the change. If the repo has obvious build/test commands
(Makefile, package.json scripts, cargo, etc.), run them and ensure they pass.
d. Commit with a clear message; author `mochi <mochi@famzheng.me>`.
e. Push the branch.
f. Open a PR via `POST /repos/{full}/pulls` with base = default branch,
head = `mochi/issue-{issue['number']}`, title like
`feat: <summary> (closes #{issue['number']})`, body referencing the issue.
g. Post **one** short comment on the issue linking the PR.
If the request is too large, ambiguous, or risky → instead post a comment
with a proposed plan and ask for confirmation. Don't push.
- **Bug report** → if the fix is obvious from reading the code, follow the
same flow as feature request with title `fix: <summary> (closes #{issue['number']})`.
Otherwise post a diagnostic comment asking for repro steps / logs.
- **Pull request** → if the PR author is `mochi`, exit. Otherwise fetch the
diff and post **one** brief review comment (correctness/style/obvious bugs).
Don't push commits to other people's PRs.
3. Be conservative. When unsure, comment instead of pushing code.
Begin now. When done, exit.
"""
def dispatch(
repo: dict, issue: dict, is_pr: bool, comments: list[dict], token: str
) -> None:
prompt = build_prompt(repo, issue, is_pr, comments)
env = os.environ.copy()
env["MOCHI_TOKEN"] = token
# ensure claude can find git, curl, node (claude wraps node), etc.
env["PATH"] = (
"/home/fam/.local/bin:/home/fam/.cargo/bin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
)
env["HOME"] = "/home/fam"
full = repo["full_name"]
log(
f"dispatch {full}#{issue['number']} "
f"({'PR' if is_pr else 'issue'}): {issue['title']!r}"
)
cmd = [CONFIG["claude_bin"], "--dangerously-skip-permissions", "-p", prompt]
try:
r = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=CONFIG["claude_timeout_sec"],
cwd="/tmp",
)
log(f" → exit={r.returncode}, stdout={len(r.stdout)}B, stderr={len(r.stderr)}B")
if r.stdout.strip():
tail = r.stdout[-1500:]
log(f" stdout tail:\n{tail}")
if r.stderr.strip():
tail = r.stderr[-1500:]
log(f" stderr tail:\n{tail}")
except subprocess.TimeoutExpired:
log(f" → TIMEOUT after {CONFIG['claude_timeout_sec']}s")
except Exception as e:
log(f" → dispatch error: {e!r}")
def poll_once(conn: sqlite3.Connection, token: str) -> None:
repos = list_all_repos(token)
log(f"polling {len(repos)} repos")
for repo in repos:
if repo.get("archived"):
continue
owner = repo["owner"]["username"] if "username" in repo["owner"] else repo["owner"]["login"]
name = repo["name"]
full = repo["full_name"]
for type_ in ("issues", "pulls"):
try:
items = list_open_issues(owner, name, token, type_)
except urllib.error.HTTPError as e:
if e.code in (403, 404):
continue
log(f" ! list {full} {type_}: HTTP {e.code}")
continue
except Exception as e:
log(f" ! list {full} {type_}: {e!r}")
continue
for issue in items:
is_pr = "pull_request" in issue and issue["pull_request"] is not None
if type_ == "issues" and is_pr:
continue
if type_ == "pulls" and not is_pr:
continue
num = issue["number"]
try:
comments = list_comments(owner, name, num, token)
except Exception as e:
log(f" ! comments {full}#{num}: {e!r}")
comments = []
latest_time, latest_user = latest_signal(issue, comments)
key = f"{full}#{num}"
if latest_user == CONFIG["username"]:
# last activity is mochi's own — record & skip
set_handled(conn, key, latest_time)
continue
if get_handled_signal(conn, key) == latest_time:
continue
dispatch(repo, issue, is_pr, comments, token)
set_handled(conn, key, latest_time)
def main() -> None:
state_dir = CONFIG["state_dir"]
state_dir.mkdir(parents=True, exist_ok=True)
conn = init_state(state_dir / "state.db")
log(
f"gitea-bot up; base={CONFIG['base_url']} "
f"interval={CONFIG['poll_interval_sec']}s "
f"claude={CONFIG['claude_bin']}"
)
while True:
try:
token = load_token()
poll_once(conn, token)
except Exception as e:
log(f"poll error: {e!r}")
time.sleep(CONFIG["poll_interval_sec"])
if __name__ == "__main__":
main()