Files
GiteaProjectOps/scripts/gitea_ops.py
T
Luci fb63d134e5 feat: Gitea 项目治理工具箱(工单/标签/里程碑/人员/看板)
- gitea_ops.py: API CLI(组织/成员/工单/标签/里程碑/仓库),纯标准库
- board_ops.py: 看板网页 CLI(Playwright),处理 Gitea 1.26 看板无 API 限制
- SKILL.md: QwenPaw skill 文档(命令手册 + 血泪教训 + 失败模式)
- README.md: 项目说明
2026-08-01 17:19:07 +00:00

463 lines
21 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
gitea_ops.py — Gitea 项目管理 CLIPineSound 组织专用,也可用于任意 org/repo)
封装 Gitea 1.26 API 的常用项目管理操作:
组织 / 仓库 / 人员 / 工单 / 标签 / 里程碑 / 仓库信息(描述、topics、头像)
注意:Gitea 1.26 的「看板(Projects)」没有 REST API,必须走网页操作,
请使用同目录的 board_ops.pyPlaywright)或 SKILL.md 中的网页操作指引。
环境变量:
GITEA_BASE Gitea 地址(默认 http://192.168.1.3:10082
GITEA_TOKEN API token(默认读取 /app/working.secret/gitea_token
GITEA_ORG 默认组织(默认 PineSound
用法示例:
python3 gitea_ops.py org repos
python3 gitea_ops.py org members
python3 gitea_ops.py issue list PineSoundServer --state open
python3 gitea_ops.py issue create PineSoundServer --title "测试" --body "内容" --labels "🐛 缺陷(Bug,🟠 高(High" --milestone "Beta-V1.1" --assignee YangYi
python3 gitea_ops.py issue close PineSoundServer 8
python3 gitea_ops.py issue delete PineSoundServer 99 --yes
python3 gitea_ops.py milestone list PineSoundServer
python3 gitea_ops.py label list PineSoundServer
python3 gitea_ops.py repo topics PineSoundServer --topics backend,go,api
python3 gitea_ops.py avatar PineSoundServer /tmp/avatar.png
所有子命令都可用 --json 输出原始 JSON(便于管道处理)。
"""
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("GITEA_BASE", "http://192.168.1.3:10082/api/v1")
TOKEN = os.environ.get("GITEA_TOKEN", "")
if not TOKEN:
_tf = "/app/working.secret/gitea_token"
if os.path.exists(_tf):
TOKEN = open(_tf).read().strip()
ORG = os.environ.get("GITEA_ORG", "PineSound")
def api(method, path, body=None, params=None, raw=False):
"""调用 Gitea API。params 会拼到 query string。返回 (status, parsed)。"""
url = BASE + path
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method)
req.add_header("Authorization", "token " + TOKEN)
if body is not None:
req.add_header("Content-Type", "application/json")
data = json.dumps(body).encode()
else:
data = None
try:
with urllib.request.urlopen(req, data=data, timeout=30) as r:
content = r.read().decode()
return r.status, content if raw else (json.loads(content) if content else None)
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:500]
except Exception as e:
return -1, str(e)
# ---------- 输出 ----------
def out(obj, as_json=False):
if as_json:
print(json.dumps(obj, ensure_ascii=False, indent=1))
elif isinstance(obj, list):
for o in obj:
print(json.dumps(o, ensure_ascii=False))
elif isinstance(obj, dict):
print(json.dumps(obj, ensure_ascii=False, indent=1))
else:
print(obj)
def resolve_milestone_id(repo, ref):
"""milestone 参数支持 id 或标题,解析为 id。"""
if isinstance(ref, int):
return ref
if str(ref).isdigit():
return int(ref)
st, data = api("GET", f"/repos/{ORG}/{repo}/milestones", params={"state": "all"})
if st != 200:
return None
for m in data:
if m.get("title") == ref:
return m.get("id")
return None
def resolve_label_ids(repo, names):
"""labels 参数支持逗号分隔的标签名,解析为 id 列表。"""
if not names:
return []
st, data = api("GET", f"/repos/{ORG}/{repo}/labels", params={"limit": 100})
if st != 200:
return []
by_name = {l.get("name"): l.get("id") for l in data}
ids = []
for n in names:
n = n.strip()
if n in by_name:
ids.append(by_name[n])
else:
print(f" [warn] 标签不存在: {n}", file=sys.stderr)
return ids
# ---------- 子命令 ----------
def cmd_config(args):
out({"base": BASE, "web_base": BASE.replace("/api/v1", ""), "org": ORG, "token_set": bool(TOKEN), "token_tail": TOKEN[-6:] if TOKEN else ""}, args.json)
def cmd_org_repos(args):
st, data = api("GET", f"/orgs/{args.org}/repos", params={"limit": 100})
if st != 200:
out({"error": st, "msg": data}, args.json); return
if args.json:
out(data, True)
return
for r in sorted(data, key=lambda x: x["name"]):
print(f"{r['name']:28s} {'私有' if r.get('private') else '公开'} stars={r.get('stars_count',0)} {r.get('description') or ''}")
def cmd_org_members(args):
st, data = api("GET", f"/orgs/{args.org}/members")
if st != 200:
out({"error": st, "msg": data}, args.json); return
if args.json:
out(data, True)
return
for u in data:
print(f"{u.get('login'):20s} {u.get('full_name') or ''} {u.get('email') or ''}")
def cmd_issue_list(args):
params = {"limit": args.limit, "state": args.state}
if args.milestone:
mid = resolve_milestone_id(args.repo, args.milestone)
if mid is None:
out({"error": "milestone not found: " + args.milestone}, args.json); return
params["milestones"] = mid
if args.label:
lids = resolve_label_ids(args.repo, [args.label])
if lids:
params["labels"] = ",".join(str(i) for i in lids)
if args.assignee:
params["assignee"] = args.assignee
st, data = api("GET", f"/repos/{ORG}/{args.repo}/issues", params=params)
if st != 200:
out({"error": st, "msg": data}, args.json); return
# 过滤 PR
data = [i for i in data if i.get("pull_request") is None]
if args.json:
out(data, True)
return
for i in data:
labels = ",".join(l["name"] for l in (i.get("labels") or []))
ms = (i.get("milestone") or {}).get("title", "")
assignees = ",".join(a["login"] for a in (i.get("assignees") or []))
print(f"#{i['number']:>4} [{i['state']:>6}] {i['title'][:50]} {ms} [{labels}] @{assignees}")
def cmd_issue_get(args):
st, data = api("GET", f"/repos/{ORG}/{args.repo}/issues/{args.number}")
out({"error": st, "msg": data} if st != 200 else data, args.json or st != 200)
def cmd_issue_create(args):
body = {"title": args.title, "body": args.body or ""}
if args.assignee:
body["assignees"] = [a.strip() for a in args.assignee.split(",")]
if args.labels:
body["labels"] = resolve_label_ids(args.repo, args.labels.split(","))
if args.milestone:
mid = resolve_milestone_id(args.repo, args.milestone)
if mid is None:
out({"error": "milestone not found: " + args.milestone}, args.json); return
body["milestone"] = mid
if args.deadline:
body["due_date"] = args.deadline + "T23:59:59+08:00"
st, data = api("POST", f"/repos/{ORG}/{args.repo}/issues", body=body)
if st != 201:
out({"error": st, "msg": data}, args.json); return
print(f"created #{data.get('number')}: {data.get('html_url')}" if not args.json else json.dumps(data, ensure_ascii=False, indent=1))
def _ok(st):
return 200 <= st < 300
def cmd_issue_edit(args):
body = {}
if args.title: body["title"] = args.title
if args.body is not None: body["body"] = args.body
if args.state: body["state"] = args.state
if args.labels is not None:
# Gitea 1.26 的 PATCH /issues/{index} 的 labels 字段不生效(实测!),
# 必须用 PUT /issues/{index}/labelsReplace)才能替换标签。
lid_list = resolve_label_ids(args.repo, args.labels.split(",")) if args.labels else []
api("PUT", f"/repos/{ORG}/{args.repo}/issues/{args.number}/labels", body={"labels": lid_list})
if args.assignee is not None:
body["assignees"] = [a.strip() for a in args.assignee.split(",")] if args.assignee else []
body["clear_assignees"] = not bool(args.assignee)
if args.milestone is not None:
if args.milestone == "none":
body["unset_milestone"] = True
else:
mid = resolve_milestone_id(args.repo, args.milestone)
if mid is None:
out({"error": "milestone not found: " + args.milestone}, args.json); return
body["milestone"] = mid
if args.deadline is not None:
if args.deadline == "none":
body["unset_due_date"] = True
else:
body["due_date"] = args.deadline + "T23:59:59+08:00"
st, data = api("PATCH", f"/repos/{ORG}/{args.repo}/issues/{args.number}", body=body)
if not _ok(st):
out({"error": st, "msg": data}, args.json or True)
else:
out({"ok": True, "number": args.number, "state": data.get("state"), "title": data.get("title"), "labels": [l.get("name") for l in (data.get("labels") or [])]}, args.json or False)
def cmd_issue_state(args, state):
st, data = api("PATCH", f"/repos/{ORG}/{args.repo}/issues/{args.number}", body={"state": state})
if args.comment:
api("POST", f"/repos/{ORG}/{args.repo}/issues/{args.number}/comments", body={"body": args.comment})
out({"error": st, "msg": data} if not _ok(st) else {"ok": True, "number": args.number, "state": state}, args.json or (not _ok(st)))
def cmd_issue_comment(args):
st, data = api("POST", f"/repos/{ORG}/{args.repo}/issues/{args.number}/comments", body={"body": args.body})
out({"error": st, "msg": data} if not _ok(st) else {"ok": True, "id": data.get("id")} if isinstance(data, dict) else data, args.json)
def cmd_issue_delete(args):
if not args.yes:
print(f"危险操作!删除工单 #{args.number} 不可恢复。请加 --yes 确认。"); return
st, data = api("DELETE", f"/repos/{ORG}/{args.repo}/issues/{args.number}")
print({"error": st, "msg": data} if not _ok(st) else {"ok": True, "deleted": args.number})
def cmd_milestone_list(args):
st, data = api("GET", f"/repos/{ORG}/{args.repo}/milestones", params={"state": args.state})
if st != 200:
out({"error": st, "msg": data}, args.json); return
if args.json:
out(data, True); return
for m in sorted(data, key=lambda x: x["id"]):
d = (m.get("due_on") or "")[:10]
print(f"#{m['id']:>4} [{m.get('state') or m.get('status'):>6}] {m.get('title')} 截止 {d} 完成 {m.get('closed_issues', 0)}/{m.get('open_issues', 0) + m.get('closed_issues', 0)}")
def cmd_milestone_create(args):
body = {"title": args.title, "description": args.description or ""}
if args.deadline:
body["due_on"] = args.deadline + "T23:59:59+08:00"
st, data = api("POST", f"/repos/{ORG}/{args.repo}/milestones", body=body)
out({"error": st, "msg": data} if not _ok(st) else {"ok": True, "id": data.get("id"), "title": data.get("title")}, args.json or (not _ok(st)))
def cmd_milestone_edit(args):
body = {}
if args.title: body["title"] = args.title
if args.description is not None: body["description"] = args.description
if args.deadline is not None: body["due_on"] = args.deadline + "T23:59:59+08:00"
if args.state: body["state"] = args.state
st, data = api("PATCH", f"/repos/{ORG}/{args.repo}/milestones/{args.id}", body=body)
out({"error": st, "msg": data} if not _ok(st) else {"ok": True, "id": args.id}, args.json or (not _ok(st)))
def cmd_milestone_delete(args):
if not args.yes:
print("危险操作!删除里程碑不可恢复。请加 --yes 确认。"); return
st, data = api("DELETE", f"/repos/{ORG}/{args.repo}/milestones/{args.id}")
print({"error": st, "msg": data} if not _ok(st) else {"ok": True, "deleted": args.id})
def cmd_label_list(args):
if args.org_level:
st, data = api("GET", f"/orgs/{args.org}/labels", params={"limit": 100})
title = f"组织级标签 {args.org}"
else:
st, data = api("GET", f"/repos/{ORG}/{args.repo}/labels", params={"limit": 100})
title = f"仓库标签 {args.repo}"
if st != 200:
out({"error": st, "msg": data}, args.json); return
if args.json:
out(data, True); return
print(f"== {title} ==")
for l in sorted(data, key=lambda x: x["id"]):
print(f"#{l['id']:>4} {l['name']} ({l['color']}) {l.get('description') or ''}")
def cmd_label_create(args):
if args.org_level:
st, data = api("POST", f"/orgs/{args.org}/labels", body={"name": args.name, "color": args.color.lstrip('#'), "description": args.description or ""})
else:
st, data = api("POST", f"/repos/{ORG}/{args.repo}/labels", body={"name": args.name, "color": args.color.lstrip('#'), "description": args.description or ""})
out({"error": st, "msg": data} if not _ok(st) else {"ok": True, "id": data.get("id"), "name": data.get("name")}, args.json or (not _ok(st)))
def cmd_label_edit(args):
body = {}
if args.name: body["name"] = args.name
if args.color: body["color"] = args.color.lstrip('#')
if args.description is not None: body["description"] = args.description
if args.org_level:
st, data = api("PATCH", f"/orgs/{args.org}/labels/{args.id}", body=body)
else:
st, data = api("PATCH", f"/repos/{ORG}/{args.repo}/labels/{args.id}", body=body)
out({"error": st, "msg": data} if not _ok(st) else {"ok": True, "id": args.id}, args.json or (not _ok(st)))
def cmd_label_delete(args):
if not args.yes:
print("危险操作!删除标签不可恢复。请加 --yes 确认。"); return
if args.org_level:
st, data = api("DELETE", f"/orgs/{args.org}/labels/{args.id}")
else:
st, data = api("DELETE", f"/repos/{ORG}/{args.repo}/labels/{args.id}")
print({"error": st, "msg": data} if not _ok(st) else {"ok": True, "deleted": args.id})
def cmd_repo_info(args):
st, data = api("GET", f"/repos/{ORG}/{args.repo}")
out({"error": st, "msg": data} if st != 200 else {
"name": data.get("name"), "description": data.get("description"),
"topics": data.get("topics"), "website": data.get("website"),
"private": data.get("private"), "default_branch": data.get("default_branch"),
"avatar_url": data.get("avatar_url"), "stars": data.get("stars_count"),
}, args.json or st != 200)
def cmd_repo_topics(args):
topics = [t.strip() for t in args.topics.split(",") if t.strip()]
st, data = api("PUT", f"/repos/{ORG}/{args.repo}/topics", body={"topics": topics})
print({"error": st, "msg": data} if st not in (200, 204) else {"ok": True, "topics": topics})
def cmd_avatar(args):
with open(args.image, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
st, data = api("POST", f"/repos/{ORG}/{args.repo}/avatar", body={"image": b64})
print({"error": st, "msg": data} if st not in (200, 204) else {"ok": True, "avatar_updated": args.repo})
def cmd_board_help(args):
print("""看板(Projects)在 Gitea 1.26 没有 REST API,必须网页操作。
请使用同目录的 board_ops.py(基于 Playwright 的网页自动化 CLI):
python3 board_ops.py boards PineSoundServer
python3 board_ops.py create PineSoundServer --name "开发看板"
python3 board_ops.py add-issue PineSoundServer --issue 8 --board 3
python3 board_ops.py move PineSoundServer --issue 8 --board 3 --to "🛠 开发中"
详细用法见 SKILL.md 或 python3 board_ops.py -h""")
# ---------- main ----------
def build_parser():
p = argparse.ArgumentParser(prog="gitea_ops.py", description="Gitea 项目管理 CLI")
p.add_argument("--org", default=ORG, help=f"组织名(默认 {ORG}")
p.add_argument("--json", action="store_true", help="输出原始 JSON")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("config", help="显示当前配置").set_defaults(func=cmd_config)
pr = sub.add_parser("org", help="组织操作")
orgsub = pr.add_subparsers(dest="org_cmd", required=True)
orgsub.add_parser("repos", help="组织仓库列表").set_defaults(func=cmd_org_repos)
orgsub.add_parser("members", help="组织成员(人员)列表").set_defaults(func=cmd_org_members)
pi = sub.add_parser("issue", help="工单操作")
isub = pi.add_subparsers(dest="issue_cmd", required=True)
li = isub.add_parser("list", help="工单列表")
li.add_argument("repo"); li.add_argument("--state", default="open", choices=["open", "closed", "all"])
li.add_argument("--limit", type=int, default=100); li.add_argument("--milestone"); li.add_argument("--label"); li.add_argument("--assignee")
li.set_defaults(func=cmd_issue_list)
gi = isub.add_parser("get", help="工单详情")
gi.add_argument("repo"); gi.add_argument("number", type=int)
gi.set_defaults(func=cmd_issue_get)
ci = isub.add_parser("create", help="创建工单")
ci.add_argument("repo"); ci.add_argument("--title", required=True); ci.add_argument("--body")
ci.add_argument("--assignee"); ci.add_argument("--labels"); ci.add_argument("--milestone"); ci.add_argument("--deadline")
ci.set_defaults(func=cmd_issue_create)
ei = isub.add_parser("edit", help="编辑工单")
ei.add_argument("repo"); ei.add_argument("number", type=int)
ei.add_argument("--title"); ei.add_argument("--body"); ei.add_argument("--state", choices=["open", "closed"])
ei.add_argument("--assignee", help="逗号分隔用户名,传空串清空"); ei.add_argument("--labels", help="逗号分隔标签名,传空串清空")
ei.add_argument("--milestone", help="id/标题,或 none 清空"); ei.add_argument("--deadline", help="YYYY-MM-DD,或 none 清空")
ei.set_defaults(func=cmd_issue_edit)
for name, st in (("close", "closed"), ("reopen", "open")):
x = isub.add_parser(name)
x.add_argument("repo"); x.add_argument("number", type=int); x.add_argument("--comment")
x.set_defaults(func=lambda a, _st=st: cmd_issue_state(a, _st))
cm = isub.add_parser("comment")
cm.add_argument("repo"); cm.add_argument("number", type=int); cm.add_argument("--body", required=True)
cm.set_defaults(func=cmd_issue_comment)
di = isub.add_parser("delete")
di.add_argument("repo"); di.add_argument("number", type=int); di.add_argument("--yes", action="store_true")
di.set_defaults(func=cmd_issue_delete)
pm = sub.add_parser("milestone", help="里程碑操作")
msub = pm.add_subparsers(dest="milestone_cmd", required=True)
lm = msub.add_parser("list"); lm.add_argument("repo"); lm.add_argument("--state", default="all")
lm.set_defaults(func=cmd_milestone_list)
crm = msub.add_parser("create"); crm.add_argument("repo"); crm.add_argument("--title", required=True)
crm.add_argument("--description"); crm.add_argument("--deadline")
crm.set_defaults(func=cmd_milestone_create)
em = msub.add_parser("edit"); em.add_argument("repo"); em.add_argument("id", type=int)
em.add_argument("--title"); em.add_argument("--description"); em.add_argument("--deadline"); em.add_argument("--state")
em.set_defaults(func=cmd_milestone_edit)
dm = msub.add_parser("delete"); dm.add_argument("repo"); dm.add_argument("id", type=int)
dm.add_argument("--yes", action="store_true")
dm.set_defaults(func=cmd_milestone_delete)
pl = sub.add_parser("label", help="标签操作(默认仓库级,--org-level 为组织级)")
lsub = pl.add_subparsers(dest="label_cmd", required=True)
ll = lsub.add_parser("list"); ll.add_argument("repo", nargs="?", default=""); ll.add_argument("--org-level", action="store_true")
ll.set_defaults(func=cmd_label_list)
cl = lsub.add_parser("create"); cl.add_argument("repo", nargs="?", default="")
cl.add_argument("--name", required=True); cl.add_argument("--color", required=True, help="如 #ff0000"); cl.add_argument("--description")
cl.add_argument("--org-level", action="store_true")
cl.set_defaults(func=cmd_label_create)
el = lsub.add_parser("edit"); el.add_argument("repo", nargs="?", default=""); el.add_argument("id", type=int)
el.add_argument("--name"); el.add_argument("--color"); el.add_argument("--description"); el.add_argument("--org-level", action="store_true")
el.set_defaults(func=cmd_label_edit)
dl = lsub.add_parser("delete"); dl.add_argument("repo", nargs="?", default=""); dl.add_argument("id", type=int)
dl.add_argument("--yes", action="store_true"); dl.add_argument("--org-level", action="store_true")
dl.set_defaults(func=cmd_label_delete)
pr2 = sub.add_parser("repo", help="仓库信息操作")
rsub = pr2.add_subparsers(dest="repo_cmd", required=True)
ir = rsub.add_parser("info"); ir.add_argument("repo"); ir.set_defaults(func=cmd_repo_info)
tr = rsub.add_parser("topics"); tr.add_argument("repo"); tr.add_argument("--topics", required=True, help="逗号分隔")
tr.set_defaults(func=cmd_repo_topics)
ar = rsub.add_parser("avatar"); ar.add_argument("repo"); ar.add_argument("image", help="PNG 图片路径")
ar.set_defaults(func=cmd_avatar)
sub.add_parser("board", help="看板说明(看板无 API,用 board_ops.py").set_defaults(func=cmd_board_help)
return p
def main():
args = build_parser().parse_args()
if not TOKEN:
print("错误:未找到 API token。请设置 GITEA_TOKEN 环境变量或写入 /app/working.secret/gitea_token", file=sys.stderr)
sys.exit(1)
args.func(args)
if __name__ == "__main__":
main()