feat: Gitea 项目治理工具箱(工单/标签/里程碑/人员/看板)
- gitea_ops.py: API CLI(组织/成员/工单/标签/里程碑/仓库),纯标准库 - board_ops.py: 看板网页 CLI(Playwright),处理 Gitea 1.26 看板无 API 限制 - SKILL.md: QwenPaw skill 文档(命令手册 + 血泪教训 + 失败模式) - README.md: 项目说明
This commit is contained in:
@@ -1,3 +1,34 @@
|
||||
# GiteaProjectOps
|
||||
# GiteaProjectOps — Gitea 项目治理自动化工具箱
|
||||
|
||||
Gitea 项目治理自动化:工单/标签/里程碑/人员/看板操作脚本与 Skill(gitea_ops.py API + board_ops.py 网页看板)
|
||||
对 Gitea 组织/仓库做项目治理操作的完整工具箱:**工单(issue)、标签(label)、里程碑(milestone)、人员(组织成员)、组织/仓库信息、看板(project board)**。
|
||||
|
||||
## 组件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `SKILL.md` | QwenPaw 可复用 Skill(`/gitea-project-manage`),含完整命令手册与踩坑记录 |
|
||||
| `scripts/gitea_ops.py` | API CLI:工单/标签/里程碑/成员/组织/仓库(Python 标准库,零依赖) |
|
||||
| `scripts/board_ops.py` | 看板网页 CLI:新建/改名/删除看板、列管理、工单进看板、卡片拖拽(Playwright) |
|
||||
|
||||
## 为什么拆两个脚本
|
||||
|
||||
Gitea 1.26 的看板(Projects)**没有 REST API**(swagger 300 个端点无 project 相关),
|
||||
工单/标签/里程碑有 API,但看板必须走浏览器 DOM 操作。详见 `SKILL.md`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# API 操作(token 默认读 /app/working.secret/gitea_token)
|
||||
python3 scripts/gitea_ops.py org members
|
||||
python3 scripts/gitea_ops.py issue list PineSoundServer --state open
|
||||
|
||||
# 看板操作(需 playwright + Gitea 账号)
|
||||
pip install playwright && playwright install chromium
|
||||
GITEA_USER=<用户名> GITEA_PASS=<密码> python3 scripts/board_ops.py boards PineSoundServer
|
||||
```
|
||||
|
||||
## 环境
|
||||
|
||||
- Gitea:内网 `http://192.168.1.3:10082`(API 前缀 `/api/v1`)· 外网 `code.infoepoch.cn`
|
||||
- 组织:`PineSound`
|
||||
- 详见 `SKILL.md` 的环境与凭据章节。
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
description: 'Use this skill when the user needs to manage Gitea projects/orgs: listing
|
||||
or creating issues (获取/创建工单), milestones (查看/创建/管理里程碑), labels, getting org members/people
|
||||
(获取人员), repo info/topics, or creating and managing kanban boards (创建和管理看板). Triggers
|
||||
on PineSound repo governance, Gitea API scripts, board/project operations, or "查工单/建工单/看板/里程碑".
|
||||
Provides ready-to-run scripts gitea_ops.py (API) and board_ops.py (Playwright for
|
||||
boards, which have no REST API in Gitea 1.26).'
|
||||
name: gitea-project-manage
|
||||
---
|
||||
|
||||
# Gitea 项目管理操作(gitea-project-manage)
|
||||
|
||||
## 概述
|
||||
|
||||
对 Gitea 组织/仓库做项目治理操作的完整工具箱:**工单(issue)、标签(label)、里程碑(milestone)、人员(组织成员)、组织/仓库信息、看板(project board)**。
|
||||
|
||||
一次写好的两个脚本,避免每次重复手写 curl/网页操作:
|
||||
|
||||
| 脚本 | 定位 | 依赖 |
|
||||
|---|---|---|
|
||||
| `scripts/gitea_ops.py` | API 操作:工单/标签/里程碑/成员/组织/仓库(**看板除外**) | Python 3.10+ 标准库,零第三方依赖 |
|
||||
| `scripts/board_ops.py` | **看板网页操作**:新建/改名/删除看板、列管理、工单进看板、卡片拖拽、完成列关单 | playwright + chromium |
|
||||
|
||||
> ⚠️ **为什么拆两个脚本**:Gitea 1.26 的看板(Projects)**没有 REST API**(swagger 里 300 个端点完全没有 project 相关),工单/标签/里程碑有 API 但看板必须走浏览器 DOM。这是版本事实,不是配置问题。
|
||||
|
||||
## 环境与凭据
|
||||
|
||||
| 条目 | 内容 |
|
||||
|---|---|
|
||||
| Gitea Web | 内网 `http://192.168.1.3:10082`(API 前缀 `/api/v1`)· 外网 `code.infoepoch.cn` |
|
||||
| 组织 | `PineSound`(可 `--org` 覆盖) |
|
||||
| API Token | `/app/working.secret/gitea_token`(chmod 600,管理员权限,**不提交 git、不出现在文档**) |
|
||||
| 网页登录 | Gitea 1.26 登录页**无 CSRF**,直接表单提交;凭据用 `GITEA_USER` / `GITEA_PASS` 环境变量传入(**不写明文进脚本/文档**) |
|
||||
| SSH push | `ssh -i ~/.ssh/id_ed25519_gitea -p 2222 git@192.168.1.3`(Gitea 用户 Luci,可认证) |
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# gitea_ops.py:token 自动从 /app/working.secret/gitea_token 读取,直接跑
|
||||
python3 scripts/gitea_ops.py org members
|
||||
python3 scripts/gitea_ops.py issue list PineSoundServer --state open
|
||||
|
||||
# board_ops.py:首次需 playwright 环境
|
||||
pip install playwright && playwright install chromium
|
||||
GITEA_USER=Pine GITEA_PASS='<密码>' python3 scripts/board_ops.py boards PineSoundServer
|
||||
# 登录态 cookie 缓存到 /tmp/gitea_board_state.json,之后同目录复用;密码只从环境变量读
|
||||
```
|
||||
|
||||
## gitea_ops.py(API 操作)
|
||||
|
||||
全局参数:`--org <组织>`(默认 PineSound)、`--json`(原始 JSON,**必须放在子命令前**,如 `gitea_ops.py --json issue get ...`)。
|
||||
|
||||
### 组织与人员
|
||||
|
||||
```bash
|
||||
python3 gitea_ops.py config # 当前配置
|
||||
python3 gitea_ops.py org repos # 组织仓库列表(名称/公私/描述)
|
||||
python3 gitea_ops.py org members # 组织成员(人员),含邮箱
|
||||
```
|
||||
|
||||
### 工单(issue)
|
||||
|
||||
```bash
|
||||
python3 gitea_ops.py issue list <repo> [--state open|closed|all] [--milestone 标题] [--label 标签名] [--assignee 用户名] [--limit N]
|
||||
python3 gitea_ops.py issue get <repo> <number>
|
||||
python3 gitea_ops.py issue create <repo> --title "标题" [--body] [--labels "标签1,标签2"] [--milestone "Beta-V1.1"] [--assignee "YangYi,Luci"] [--deadline 2026-08-31]
|
||||
python3 gitea_ops.py issue edit <repo> <number> [--title] [--body] [--labels "标签1" | ""] [--assignee "用户" | ""] [--milestone 标题|none] [--state open|closed] [--deadline YYYY-MM-DD|none]
|
||||
python3 gitea_ops.py issue close <repo> <number> [--comment "原因"] # 同理 reopen
|
||||
python3 gitea_ops.py issue comment <repo> <number> --body "评论内容"
|
||||
python3 gitea_ops.py issue delete <repo> <number> --yes # 危险,不可恢复
|
||||
```
|
||||
|
||||
关键事实(实测):
|
||||
- `--labels` / `--milestone` / `--assignee` 都支持**名称**(自动解析 id),不必查 id。
|
||||
- **换标签必须走 `PUT /issues/{index}/labels`**:`PATCH /issues/{index}` 里的 `labels` 字段**不生效**(实测 201 但标签不变),gitea_ops.py 内部已处理。
|
||||
- 工单列表自动过滤 PR(issue 对象带 `"pull_request": null` 键,直接判断会把 PR 也算进来)。
|
||||
- 关闭后工单仍在里程碑统计内,`--comment` 会顺带加关闭说明(团队习惯:为什么关)。
|
||||
|
||||
### 里程碑(milestone)
|
||||
|
||||
```bash
|
||||
python3 gitea_ops.py milestone list <repo> [--state all|open|closed]
|
||||
python3 gitea_ops.py milestone create <repo> --title "Beta-V1.1" [--description] [--deadline 2026-08-31]
|
||||
python3 gitea_ops.py milestone edit <repo> <id> [--title] [--description] [--deadline] [--state]
|
||||
python3 gitea_ops.py milestone delete <repo> <id> --yes
|
||||
```
|
||||
|
||||
### 标签(label)
|
||||
|
||||
默认仓库级;加 `--org-level` 变组织级(组织级标签所有仓库共享)。
|
||||
|
||||
```bash
|
||||
python3 gitea_ops.py label list <repo> # 仓库级
|
||||
python3 gitea_ops.py label list --org-level # 组织级
|
||||
python3 gitea_ops.py label create <repo> --name "🐛 缺陷(Bug)" --color "#d73a4a" [--description]
|
||||
python3 gitea_ops.py label edit <repo> <id> [--name] [--color] [--description]
|
||||
python3 gitea_ops.py label delete <repo> <id> --yes
|
||||
```
|
||||
|
||||
### 仓库信息
|
||||
|
||||
```bash
|
||||
python3 gitea_ops.py repo info <repo> # 描述/topics/头像/默认分支
|
||||
python3 gitea_ops.py repo topics <repo> --topics backend,api,go
|
||||
python3 gitea_ops.py repo avatar <repo> /path/logo.png
|
||||
```
|
||||
|
||||
## board_ops.py(看板网页操作)
|
||||
|
||||
Gitea 1.26 看板无 API,用 Playwright 驱动真实浏览器。**依赖**:`pip install playwright && playwright install chromium`(浏览器在 `/usr/bin/chromium`)。
|
||||
|
||||
**登录**:`GITEA_USER` / `GITEA_PASS` 环境变量(或 `--user/--pass`)。登录态存 `/tmp/gitea_board_state.json` + profile 目录 `/tmp/gitea_board_profile`,同目录复跑不再要密码(换机器/清 /tmp 后重新登录)。调试可见模式:`BOARD_HEADED=1`。
|
||||
|
||||
```bash
|
||||
python3 board_ops.py boards <repo> # 列出所有看板 + 每列(id/名/卡片数)
|
||||
python3 board_ops.py create <repo> --name "开发看板" # 新建(基础看板模板,4 列)
|
||||
python3 board_ops.py rename <repo> --board 3 --name "新名字"
|
||||
python3 board_ops.py delete <repo> --board 3 --yes
|
||||
python3 board_ops.py add-column <repo> --board 3 --name "🚧 测试列"
|
||||
python3 board_ops.py rename-column <repo> --board 3 --column 10 --name "新列名" # column 支持 id 或当前名
|
||||
python3 board_ops.py add-issue <repo> --issue 8 --board 3 # 工单进看板(进"默认列"= 星标列)
|
||||
python3 board_ops.py move <repo> --issue 8 --board 3 --to "🛠 开发中" # 卡片拖拽(自动两步法)
|
||||
python3 board_ops.py done-close <repo> --board 3 --done "✅ 完成" # 完成列所有工单批量关闭(内部调 gitea_ops.py)
|
||||
```
|
||||
|
||||
## 关键经验(血泪教训,勿改)
|
||||
|
||||
1. **工单进看板必须 DOM click**:`fetch/requests` 模拟 `POST /issues/projects`、`/issues/milestone` 返回 `{"ok":true}` 但**实际不生效**(Gitea 无 CSRF meta、cookie httpOnly、API 不暴露 project 字段)。必须打开工单详情页 → 点侧栏「项目」下拉 → 点项目项。
|
||||
2. **拖拽用两步法**:跨多列一次性拖拽不稳定,先拖到中间列再拖到目标列(脚本已自动)。
|
||||
3. **JS 派发的事件无效**(`isTrusted=false`),必须 Playwright/浏览器真实鼠标点击与拖拽。
|
||||
4. **卡片默认 `draggable=false`**,进看板页后先刷新一次再拖(脚本已处理)。
|
||||
5. **列选择器用 `.project-column[data-id="X"]`**;`#board_X .ui.cards` 是后代选择器永远匹配不到(`#board_X` 本身就是 `.ui.cards`)。
|
||||
6. **登录页按钮没有 `type=submit`**,用 `button.ui.primary.button` 定位。
|
||||
7. **`wait_for_load_state("networkidle")` 会超时**(Gitea 有长轮询),一律用 `"load"` + 固定等待。
|
||||
8. **移到「✅ 完成」列 = 顺手关闭工单**(治理惯例,根治"干完忘关");`done-close` 实现批量。
|
||||
9. 标签体系(PineSound 标准):中文名 + 括号英文,如 `🐛 缺陷(Bug)`;打标签规则:新功能类🟠高、维护优化类🟡中、bug 加🐛缺陷。
|
||||
|
||||
## 典型工作流(新需求从提出到上板)
|
||||
|
||||
```bash
|
||||
# 1. 建工单(带标签/里程碑/指派)
|
||||
python3 gitea_ops.py issue create PineSoundServer --title "音频向量化接口" \
|
||||
--labels "✨ 新功能(Feature),🟠 高(High),🧠 AI算法(AI Algorithm)" \
|
||||
--milestone "Beta-V1.1" --assignee YangYi
|
||||
# 2. 进看板(默认列=待办)
|
||||
python3 board_ops.py add-issue PineSoundServer --issue <N> --board 3
|
||||
# 3. 开发中
|
||||
python3 board_ops.py move PineSoundServer --issue <N> --board 3 --to "🛠 开发中"
|
||||
# 4. dev 测试 / 合并 main
|
||||
python3 board_ops.py move PineSoundServer --issue <N> --board 3 --to "🧪 dev 测试"
|
||||
python3 board_ops.py move PineSoundServer --issue <N> --board 3 --to "🚀 已合并 main"
|
||||
# 5. 完成 → 拖到 ✅ 完成 + 关单(两步可以合并:done-close)
|
||||
python3 board_ops.py move PineSoundServer --issue <N> --board 3 --to "✅ 完成"
|
||||
python3 gitea_ops.py issue close PineSoundServer <N> --comment "合入 main,关闭"
|
||||
```
|
||||
|
||||
## 失败模式与恢复
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `issue edit --labels` 后标签没变 | PATCH 的 labels 字段不生效 | 已修复为 PUT /labels;确认在最新脚本 |
|
||||
| `board_ops.py` 登录报 Timeout | 按钮选择器/networkidle | 更新到最新脚本;手动用浏览器验证登录页结构 |
|
||||
| 拖拽后卡片没动 | 卡片 draggable=false 或一次跨太多列 | 脚本已刷新+两步法;仍失败可开 `BOARD_HEADED=1` 人工看 |
|
||||
| add-issue 找不到项目下拉 | 工单详情页结构变化(Gitea 升级) | 打开工单页找 `.ui.dropdown.full-width` 里 strong 文本 ==「项目」 |
|
||||
| 404 on org repos | BASE 少了 `/api/v1` | `GITEA_BASE` 必须带 `/api/v1` |
|
||||
| 看板列 id 与名称 | 列 id 不稳定(不同看板不同) | 一律用 `boards` 子命令先查 id,或用列名(脚本按名匹配) |
|
||||
|
||||
## 维护说明
|
||||
|
||||
- 脚本自带 `python3 -m py_compile` 可快速验证语法。
|
||||
- 若 Gitea 升级导致 DOM 变化,用 Playwright/浏览器 DevTools 重新抓取选择器(参考:列 `.project-column[data-id]`、列名 `.project-column-title-text`、列菜单 `.project-column-header .ui.dropdown.tw-p-1`、创建列表单 `#project-column-title-input` + `.project-column-button-save`、看板删除 `button[data-url*="/projects/{id}/delete"]`、确认按钮 `.ui.primary.ok.button`)。
|
||||
- 经验文档 `docs/gitea-project-governance.md` 记录了 PineSound 四仓库治理现状(里程碑/看板列/标签全量清单),动治理前先读。
|
||||
Executable
+423
@@ -0,0 +1,423 @@
|
||||
#!/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()
|
||||
Executable
+462
@@ -0,0 +1,462 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
gitea_ops.py — Gitea 项目管理 CLI(PineSound 组织专用,也可用于任意 org/repo)
|
||||
|
||||
封装 Gitea 1.26 API 的常用项目管理操作:
|
||||
组织 / 仓库 / 人员 / 工单 / 标签 / 里程碑 / 仓库信息(描述、topics、头像)
|
||||
|
||||
注意:Gitea 1.26 的「看板(Projects)」没有 REST API,必须走网页操作,
|
||||
请使用同目录的 board_ops.py(Playwright)或 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}/labels(Replace)才能替换标签。
|
||||
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()
|
||||
Reference in New Issue
Block a user