f83ebf5854
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.
360 lines
12 KiB
Python
360 lines
12 KiB
Python
#!/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()
|