Files
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

424 lines
19 KiB
Python
Executable File
Raw Permalink 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 -*-
"""
board_ops.py — Gitea 1.26 看板(Projects)网页操作 CLI
Gitea 1.26 的看板没有 REST API(工单/里程碑/标签有,看板没有),
所有看板操作(创建/改名/列管理/卡片拖拽/工单进看板)都必须走浏览器 DOM。
本脚本用 Playwright 驱动真实浏览器完成这些操作。
依赖(首次使用):
pip install playwright && playwright install chromium
登录:
- 方式1(推荐):环境变量 GITEA_USER / GITEA_PASSGitea 登录页无 CSRF,直接表单提交)
- 方式2--user / --pass 参数
登录态保存在 --state-file(默认 /tmp/gitea_board_state.json),之后复用 cookie。
用法示例:
python3 board_ops.py boards PineSoundServer # 列出仓库所有看板+列
python3 board_ops.py create PineSoundServer --name "开发看板" # 新建(基础看板模板,4 列)
python3 board_ops.py rename PineSoundServer --board 3 --name "新名" # 看板改名
python3 board_ops.py delete PineSoundServer --board 3 --yes # 删除看板
python3 board_ops.py add-column PineSoundServer --board 3 --name "新列"
python3 board_ops.py rename-column PineSoundServer --board 3 --column 10 --name "改名列"
python3 board_ops.py add-issue PineSoundServer --issue 8 --board 3 # 工单进看板(进默认列)
python3 board_ops.py move PineSoundServer --issue 8 --board 3 --to "🛠 开发中" # 卡片拖拽(两步法)
python3 board_ops.py done-close PineSoundServer --board 3 --done "✅ 完成" # 完成列工单批量关闭(调 gitea_ops.py
关键经验(血泪教训,勿改):
1. 工单进看板:必须 DOM clickfetch/requests 模拟 POST /issues/projects 返回 ok 但实际不生效)
2. 拖拽:跨多列一次性拖拽不稳定,用两步法(先中间列再目标列)
3. JS 派发的事件 isTrusted=false 无效,必须 Playwright 真实鼠标/点击
4. 卡片默认 draggable=false,进入看板页后先刷新一次即可拖拽
5. 列选择器用 .project-column[data-id="X"],不要用 #board_X(那是列容器本身,后代选择器永远匹配不到)
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
BASE = os.environ.get("GITEA_BASE", "http://192.168.1.3:10082")
ORG = os.environ.get("GITEA_ORG", "PineSound")
STATE_FILE = "/tmp/gitea_board_state.json"
def log(msg):
print(msg, file=sys.stderr, flush=True)
def new_page():
from playwright.sync_api import sync_playwright
p = sync_playwright().start()
ctx = p.chromium.launch_persistent_context(
user_data_dir="/tmp/gitea_board_profile",
headless=not os.environ.get("BOARD_HEADED"),
executable_path="/usr/bin/chromium",
viewport={"width": 1600, "height": 1000},
)
# 复用登录态
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE) as f:
ctx.add_cookies(json.load(f))
except Exception:
pass
page = ctx.pages[0] if ctx.pages else ctx.new_page()
return p, ctx, page
def ensure_login(page, user, password):
page.goto(f"{BASE}/user/login", timeout=30000)
page.wait_for_load_state("load")
# 已登录则跳过(登录页会跳走)
if "/user/login" not in page.url:
return
log(f"登录 {user} ...")
page.fill('input[name="user_name"]', user)
page.fill('input[name="password"]', password)
# Gitea 1.26 登录按钮没有 type=submit(实测),用文本/class 定位
page.click('form button.ui.primary.button, form button:has-text("Sign In"), form button:has-text("登 录"), form button:has-text("登录")')
page.wait_for_load_state("load")
page.wait_for_timeout(1000)
if "/user/login" in page.url:
raise RuntimeError("登录失败:请检查 GITEA_USER / GITEA_PASS")
def save_state(ctx):
try:
with open(STATE_FILE, "w") as f:
json.dump(ctx.cookies(), f)
except Exception:
pass
def open_project(page, repo, board_id=None):
url = f"{BASE}/{ORG}/{repo}/projects"
if board_id:
url += f"/{board_id}"
page.goto(url, timeout=30000)
page.wait_for_load_state("load")
page.wait_for_timeout(800)
return page
def board_columns(page):
"""从当前看板页解析 (col_id, 列名, 卡片数)。"""
return page.evaluate("""() => Array.from(document.querySelectorAll('.project-column')).map(c => ({
id: c.getAttribute('data-id'),
name: (c.querySelector('.project-column-title-text')?.textContent || '').trim(),
cards: c.querySelectorAll('.issue-card').length,
card_issues: Array.from(c.querySelectorAll('.issue-card a[href*="/issues/"]')).map(a => {
const m = a.getAttribute('href').match(/issues\\/(\\d+)/); return m ? Number(m[1]) : null;
}).filter(Boolean)
}))""")
def find_col_id(columns, ref):
"""按 id 或列名找列 id。"""
for c in columns:
if str(c["id"]) == str(ref) or c["name"] == ref:
return str(c["id"]), c["name"]
raise RuntimeError(f"列不存在: {ref}(现有列: {[c['name'] for c in columns]}")
# ---------- 子命令 ----------
def cmd_boards(args, page):
page.goto(f"{BASE}/{ORG}/{args.repo}/projects", timeout=30000)
page.wait_for_load_state("load")
boards = page.evaluate("""() => Array.from(document.querySelectorAll('a[href*="/projects/"]'))
.map(a => { const m = a.getAttribute('href').match(/projects\\/(\\d+)\\s*$/); return m ? {id: m[1], name: a.textContent.trim()} : null; })
.filter(Boolean)""")
if not boards:
# 兜底:项目列表页通常有 .board-list 等结构
html = page.content()
boards = re.findall(r'/projects/(\d+)"[^>]*>([^<]+)<', html)
boards = [{"id": i, "name": n.strip()} for i, n in boards]
print(f"== {ORG}/{args.repo} 看板 ==")
for b in boards:
print(f" #{b['id']} {b['name']}")
page.goto(f"{BASE}/{ORG}/{args.repo}/projects/{b['id']}", timeout=30000)
page.wait_for_load_state("load")
page.wait_for_timeout(600)
for c in board_columns(page):
print(f" [列 {c['id']}] {c['name']} ({c['cards']} 卡片)")
def _list_board_ids(page, repo):
"""从项目列表页抓 (id, name) 列表。"""
page.goto(f"{BASE}/{ORG}/{repo}/projects", timeout=30000)
page.wait_for_load_state("load")
page.wait_for_timeout(800)
boards = page.evaluate("""() => Array.from(document.querySelectorAll('a[href*="/projects/"]'))
.map(a => { const m = a.getAttribute('href').match(/projects\\/(\\d+)\\s*$/); return m ? {id: m[1], name: a.textContent.trim()} : null; })
.filter(Boolean)""")
return boards
def cmd_create(args, page):
before = {b["id"] for b in _list_board_ids(page, args.repo)}
page.goto(f"{BASE}/{ORG}/{args.repo}/projects/new", timeout=30000)
page.wait_for_load_state("load")
page.fill('form[action*="/projects/new"] input[name="title"]', args.name)
# 模板下拉:默认选"基础看板"value=14 列 Backlog/To Do/In Progress/Done
page.click('form[action*="/projects/new"] .ui.selection.dropdown')
page.wait_for_timeout(500)
page.click('form[action*="/projects/new"] .menu .item[data-value="1"]')
page.wait_for_timeout(300)
page.click('form[action*="/projects/new"] button:has-text("创建项目")')
page.wait_for_load_state("load")
page.wait_for_timeout(2000)
after = {b["id"] for b in _list_board_ids(page, args.repo)}
new_ids = after - before
if new_ids:
new_id = sorted(new_ids, key=int)[0]
print(f"created board #{new_id}: {args.name}")
return new_id
print("看板创建结果未知(URL: " + page.url + "),请到网页确认")
return None
def cmd_rename(args, page):
open_project(page, args.repo, args.board)
page.goto(f"{BASE}/{ORG}/{args.repo}/projects/{args.board}/edit", timeout=30000)
page.wait_for_load_state("load")
page.fill('form[action*="/projects/edit"] input[name="title"], form input[name="title"]', args.name)
page.click('form button:has-text("保存"), form button:has-text("更新"), form button[type="submit"]')
page.wait_for_load_state("load")
page.wait_for_timeout(1000)
print(f"board #{args.board} 已改名为: {args.name}")
def cmd_delete(args, page):
open_project(page, args.repo, args.board)
# 删除按钮是 link-actiondata-url=.../projects/{id}/delete
page.click(f'button[data-url*="/projects/{args.board}/delete"]')
page.wait_for_timeout(800)
# 确认 modal:点 ok 按钮(实测 class 为 .ui.primary.ok.button
page.click('.ui.modal .ui.primary.ok.button, .ui.active.modal .ui.primary.ok.button')
page.wait_for_load_state("load")
page.wait_for_timeout(1500)
print(f"board #{args.board} 已删除")
def cmd_add_column(args, page):
open_project(page, args.repo, args.board)
page.click('button.show-project-column-modal-edit[data-modal-header="创建列"]')
page.wait_for_timeout(600)
page.fill("#project-column-title-input", args.name)
page.click(".project-column-button-save")
page.wait_for_timeout(1200)
cols = board_columns(page)
print(f"新列已添加: {args.name}")
print("当前列:", [c["name"] for c in cols])
def cmd_rename_column(args, page):
open_project(page, args.repo, args.board)
cols = board_columns(page)
col_id, _ = find_col_id(cols, args.column)
# 打开列菜单 → 点"编辑列"
page.click(f'.project-column[data-id="{col_id}"] .project-column-header .ui.dropdown.tw-p-1')
page.wait_for_timeout(500)
page.click(f'.project-column[data-id="{col_id}"] a.show-project-column-modal-edit')
page.wait_for_timeout(600)
page.fill("#project-column-title-input", args.name)
page.click(".project-column-button-save")
page.wait_for_timeout(1200)
print(f"{col_id} 已改名为: {args.name}")
def cmd_add_issue(args, page):
"""工单进看板:工单详情页「项目」下拉 → DOM click 项目项(fetch 模拟无效!)。"""
page.goto(f"{BASE}/{ORG}/{args.repo}/issues/{args.issue}", timeout=30000)
page.wait_for_load_state("load")
# 找「项目」dropdown(侧栏有 标签/里程碑/项目/指派人 四个 full-width dropdown
project_dd = page.evaluate("""() => {
const dds = Array.from(document.querySelectorAll('.ui.dropdown.full-width'));
const pd = dds.find(d => d.querySelector('strong')?.textContent === '项目');
return pd ? pd.outerHTML.slice(0, 200) : null;
}""")
if not project_dd:
raise RuntimeError("未找到「项目」下拉(工单详情页结构变化?)")
page.click('.ui.dropdown.full-width:has(strong:text("项目"))')
page.wait_for_timeout(800)
# 菜单项 a.item[data-value="{board_id}"];若工单已在其它项目,先点"清除项目"
item = page.query_selector(f'.ui.dropdown.full-width a.item[data-value="{args.board}"]')
if not item:
clear = page.query_selector('.ui.dropdown.full-width .item.clear-selection')
if clear:
log("工单已在其它项目,先清除")
clear.click()
page.wait_for_timeout(500)
page.click('.ui.dropdown.full-width:has(strong:text("项目"))')
page.wait_for_timeout(500)
item = page.query_selector(f'.ui.dropdown.full-width a.item[data-value="{args.board}"]')
if not item:
raise RuntimeError(f"项目 #{args.board} 不在下拉里")
item.click()
page.wait_for_timeout(1200)
print(f"工单 #{args.issue} 已加入看板 #{args.board}(默认列)")
def drag_card(page, src_sel, dst_sel):
"""Playwright 真实鼠标拖拽(Gitea 用 HTML5 drag,必须 mouse 模拟)。"""
src = page.query_selector(src_sel)
dst = page.query_selector(dst_sel)
if not src or not dst:
raise RuntimeError(f"拖拽元素缺失: {src_sel} / {dst_sel}")
src_box = src.bounding_box()
dst_box = dst.bounding_box()
sx, sy = src_box["x"] + src_box["width"] / 2, src_box["y"] + src_box["height"] / 2
dx, dy = dst_box["x"] + dst_box["width"] / 2, dst_box["y"] + 40
page.mouse.move(sx, sy)
page.mouse.down()
page.wait_for_timeout(300)
# 分步移动(模拟真实拖拽路径)
page.mouse.move((sx + dx) / 2, (sy + dy) / 2, steps=8)
page.mouse.move(dx, dy, steps=12)
page.wait_for_timeout(500)
page.mouse.up()
page.wait_for_timeout(1200)
def cmd_move(args, page):
open_project(page, args.repo, args.board)
page.wait_for_timeout(500)
# 刷新一次确保卡片 draggable=true
page.reload()
page.wait_for_load_state("load")
page.wait_for_timeout(800)
cols = board_columns(page)
to_id, to_name = find_col_id(cols, args.to)
# 源卡片可能在任意列
cards = page.evaluate(f"""() => Array.from(document.querySelectorAll('.issue-card'))
.filter(c => (c.querySelector('a[href$="/issues/{args.issue}"]') || c.querySelector('a[href*="/issues/{args.issue}"]')))
.map(c => c.closest('.project-column')?.getAttribute('data-id'))""")
if not cards:
# 允许没有斜杠的通用匹配
cards = page.evaluate(f"""() => Array.from(document.querySelectorAll('.issue-card'))
.filter(c => c.textContent.includes('#{args.issue} '))
.map(c => c.closest('.project-column')?.getAttribute('data-id'))""")
if not cards:
raise RuntimeError(f"看板 #{args.board} 上找不到工单 #{args.issue} 的卡片")
from_id = cards[0]
if from_id == to_id:
print(f"工单 #{args.issue} 已在目标列 {to_name}")
return
src_sel = f'.project-column[data-id="{from_id}"] .issue-card:has(a[href$="/issues/{args.issue}"])'
# 两步法:先到中间列再目标列(跨多列一次拖拽不稳定)
all_ids = [c["id"] for c in cols]
try:
fi = all_ids.index(from_id); ti = all_ids.index(to_id)
except ValueError:
fi, ti = 0, len(all_ids) - 1
if abs(ti - fi) > 1:
mid = all_ids[(fi + ti) // 2]
mid_name = next(c["name"] for c in cols if c["id"] == mid)
log(f"两步拖拽: {from_id}{mid}({mid_name}) → {to_id}({to_name})")
drag_card(page, src_sel, f'.project-column[data-id="{mid}"]')
page.wait_for_timeout(800)
src_sel = f'.project-column[data-id="{mid}"] .issue-card:has(a[href$="/issues/{args.issue}"])'
log(f"拖拽: → {to_id}({to_name})")
drag_card(page, src_sel, f'.project-column[data-id="{to_id}"]')
# 验证
page.reload()
page.wait_for_load_state("load")
page.wait_for_timeout(800)
cols2 = board_columns(page)
target = next(c for c in cols2 if c["id"] == to_id)
ok = args.issue in target["card_issues"]
print(f"工单 #{args.issue} 移动至「{to_name}{'✓ 成功' if ok else '✗ 未生效(请检查,可能需要手动拖拽)'}")
return 0 if ok else 1
def cmd_done_close(args, page):
"""把指定列(默认 ✅ 完成)的所有卡片对应工单关闭(联动惯例:移到完成列=关闭工单)。"""
open_project(page, args.repo, args.board)
cols = board_columns(page)
col_id, col_name = find_col_id(cols, args.done)
col = next(c for c in cols if c["id"] == col_id)
issues = col["card_issues"]
if not issues:
print(f"{col_name}」列没有卡片")
return
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gitea_ops.py")
for n in issues:
log(f"关闭工单 #{n} ...")
subprocess.run([sys.executable, script, "issue", "close", args.repo, str(n)],
check=False)
print(f"已关闭 {len(issues)} 个工单: {issues}")
# ---------- main ----------
def build_parser():
p = argparse.ArgumentParser(prog="board_ops.py", description="Gitea 1.26 看板网页操作 CLIPlaywright")
p.add_argument("--org", default=ORG)
p.add_argument("--base", default=BASE)
p.add_argument("--user", default=os.environ.get("GITEA_USER", ""), help="Gitea 用户名(或环境变量 GITEA_USER)")
p.add_argument("--pass", dest="password", default=os.environ.get("GITEA_PASS", ""), help="Gitea 密码(或环境变量 GITEA_PASS")
p.add_argument("--state-file", default=STATE_FILE)
sub = p.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("boards", help="列出仓库所有看板+列")
b.add_argument("repo"); b.set_defaults(func=cmd_boards)
c = sub.add_parser("create", help="新建看板(基础看板模板)")
c.add_argument("repo"); c.add_argument("--name", required=True); c.set_defaults(func=cmd_create)
r = sub.add_parser("rename", help="看板改名")
r.add_argument("repo"); r.add_argument("--board", required=True); r.add_argument("--name", required=True)
r.set_defaults(func=cmd_rename)
d = sub.add_parser("delete", help="删除看板")
d.add_argument("repo"); d.add_argument("--board", required=True); d.add_argument("--yes", action="store_true")
d.set_defaults(func=cmd_delete)
ac = sub.add_parser("add-column", help="新建列")
ac.add_argument("repo"); ac.add_argument("--board", required=True); ac.add_argument("--name", required=True)
ac.set_defaults(func=cmd_add_column)
rc = sub.add_parser("rename-column", help="列改名")
rc.add_argument("repo"); rc.add_argument("--board", required=True); rc.add_argument("--column", required=True)
rc.add_argument("--name", required=True)
rc.set_defaults(func=cmd_rename_column)
ai = sub.add_parser("add-issue", help="工单进看板(默认列)")
ai.add_argument("repo"); ai.add_argument("--issue", required=True, type=int); ai.add_argument("--board", required=True)
ai.set_defaults(func=cmd_add_issue)
mv = sub.add_parser("move", help="卡片拖拽到指定列(两步法)")
mv.add_argument("repo"); mv.add_argument("--issue", required=True, type=int); mv.add_argument("--board", required=True)
mv.add_argument("--to", required=True, help="目标列名或列 id")
mv.set_defaults(func=cmd_move)
dc = sub.add_parser("done-close", help="关闭某列(默认完成列)所有工单")
dc.add_argument("repo"); dc.add_argument("--board", required=True); dc.add_argument("--done", default="✅ 完成")
dc.set_defaults(func=cmd_done_close)
return p
def main():
args = build_parser().parse_args()
if not (args.user and args.password):
print("错误:需要登录凭据。设置环境变量 GITEA_USER/GITEA_PASS 或传 --user/--pass", file=sys.stderr)
sys.exit(1)
p, ctx, page = new_page()
try:
ensure_login(page, args.user, args.password)
save_state(ctx)
rc = args.func(args, page) or 0
save_state(ctx)
sys.exit(rc)
finally:
ctx.close()
p.stop()
if __name__ == "__main__":
main()