From f83ebf585488b22d5aca544eb0e4e4b8d1adfd95 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Tue, 5 May 2026 10:14:03 +0100 Subject: [PATCH] =?UTF-8?q?initial:=20gitea-bot=20=E2=80=94=20mochi=20?= =?UTF-8?q?=E5=85=A8=E7=AB=99=20issue/PR=20=E8=87=AA=E5=8A=A8=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 5 + Makefile | 34 +++++ README.md | 40 ++++++ bot.py | 359 ++++++++++++++++++++++++++++++++++++++++++++++ gitea-bot.service | 18 +++ 5 files changed, 456 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 bot.py create mode 100644 gitea-bot.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..198f89d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +*.log +state.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9e3514b --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: install uninstall restart status logs test-once + +UNIT_DIR := $(HOME)/.config/systemd/user +STATE_DIR := $(HOME)/.local/state/gitea-bot + +install: + @test -f $(HOME)/.gitea-mochi-token || (echo "missing $(HOME)/.gitea-mochi-token"; exit 1) + @test -x /home/fam/.local/bin/claude || (echo "missing claude bin"; exit 1) + mkdir -p $(UNIT_DIR) $(STATE_DIR) + install -m 644 gitea-bot.service $(UNIT_DIR)/gitea-bot.service + systemctl --user daemon-reload + systemctl --user enable --now gitea-bot + @sleep 1 + systemctl --user status gitea-bot --no-pager -l | head -25 + +uninstall: + -systemctl --user disable --now gitea-bot + rm -f $(UNIT_DIR)/gitea-bot.service + systemctl --user daemon-reload + +restart: + systemctl --user restart gitea-bot + systemctl --user status gitea-bot --no-pager -l | head -10 + +status: + systemctl --user status gitea-bot --no-pager -l | head -25 + +logs: + tail -n 200 -f $(STATE_DIR)/bot.log + +test-once: + @# 单次轮询:reset state.db 让所有未处理 issue 都被 dispatch(慎用) + MOCHI_TOKEN=$$(cat $(HOME)/.gitea-mochi-token) python3 -c \ + "import bot; bot.poll_once(bot.init_state(bot.CONFIG['state_dir']/'state.db'), open('$(HOME)/.gitea-mochi-token').read().strip())" diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9856aa --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# gitea-bot + +麻薯(mochi)的全站 issue/PR 自动处理 bot——轮询 `famzheng.me/gitea` 所有 repo +的 open issue 和 PR,遇到"最新一条活动不是 mochi 自己"就 spawn +`claude --dangerously-skip-permissions -p `,让 claude 自己用 mochi +token 调 gitea API + git push 完成回复 / 提 PR。 + +## 部署 + +跑在 famzheng.me 节点本机,systemd `--user` unit。 + +```sh +make install # 装到 ~/.config/systemd/user/,enable + start +make logs # tail -f 日志 +make restart # 改完 bot.py 重启 +make uninstall +``` + +依赖: +- `~/.gitea-mochi-token` (mode 600,scopes=all) +- `/home/fam/.local/bin/claude` +- python3 (stdlib only) + +## 行为 + +`bot.py` 主循环每 60s: +1. `GET /api/v1/repos/search` 拉所有 repo(mochi 是 admin 全部可见) +2. 对每个 repo,list open issues + open PRs +3. 看每条最新活动的作者:是 `mochi` 就跳过;其他人就 dispatch +4. dispatch = 把 repo / issue / 最近 10 条 comment 拼进 prompt,spawn claude +5. claude 自己分类 → 留 comment / 提 PR / review;同语言回复,带 footer 签名 + +state 在 `~/.local/state/gitea-bot/state.db`(sqlite,`(repo#num) → last_signal_time`)。 +日志在 `~/.local/state/gitea-bot/bot.log`(systemd append)。 + +## 文件 + +- `bot.py` — 主循环 +- `gitea-bot.service` — systemd --user unit(PATH 含 `~/.local/bin`、`~/.cargo/bin`) +- `Makefile` — install / restart / logs diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..cf2c157 --- /dev/null +++ b/bot.py @@ -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 `, +让 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//.git` +- Configure git author when committing: + `git -c user.name=mochi -c user.email=mochi@famzheng.me commit ...` +- Work in `/tmp/gitea-bot-/`. 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 `. + 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: (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: (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() diff --git a/gitea-bot.service b/gitea-bot.service new file mode 100644 index 0000000..db79715 --- /dev/null +++ b/gitea-bot.service @@ -0,0 +1,18 @@ +[Unit] +Description=gitea-bot — mochi 全站 issue/PR 自动处理 +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /home/fam/src/gitea-bot/bot.py +Restart=on-failure +RestartSec=15 +# claude / git / curl / node 等都在 PATH 里 +Environment=PATH=/home/fam/.local/bin:/home/fam/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=HOME=/home/fam +StandardOutput=append:/home/fam/.local/state/gitea-bot/bot.log +StandardError=append:/home/fam/.local/state/gitea-bot/bot.log + +[Install] +WantedBy=default.target