fb63d134e5
- gitea_ops.py: API CLI(组织/成员/工单/标签/里程碑/仓库),纯标准库 - board_ops.py: 看板网页 CLI(Playwright),处理 Gitea 1.26 看板无 API 限制 - SKILL.md: QwenPaw skill 文档(命令手册 + 血泪教训 + 失败模式) - README.md: 项目说明
424 lines
19 KiB
Python
Executable File
424 lines
19 KiB
Python
Executable File
#!/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_PASS(Gitea 登录页无 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 click(fetch/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=1,4 列 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-action,data-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 看板网页操作 CLI(Playwright)")
|
||
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()
|