zt-devops-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zt_devops_cli-0.1.0/PKG-INFO +74 -0
- zt_devops_cli-0.1.0/README.md +63 -0
- zt_devops_cli-0.1.0/pyproject.toml +22 -0
- zt_devops_cli-0.1.0/setup.cfg +4 -0
- zt_devops_cli-0.1.0/src/devops_cli/__init__.py +0 -0
- zt_devops_cli-0.1.0/src/devops_cli/api.py +103 -0
- zt_devops_cli-0.1.0/src/devops_cli/auth.py +161 -0
- zt_devops_cli-0.1.0/src/devops_cli/cli.py +317 -0
- zt_devops_cli-0.1.0/src/devops_cli/config.py +38 -0
- zt_devops_cli-0.1.0/src/zt_devops_cli.egg-info/PKG-INFO +74 -0
- zt_devops_cli-0.1.0/src/zt_devops_cli.egg-info/SOURCES.txt +13 -0
- zt_devops_cli-0.1.0/src/zt_devops_cli.egg-info/dependency_links.txt +1 -0
- zt_devops_cli-0.1.0/src/zt_devops_cli.egg-info/entry_points.txt +2 -0
- zt_devops_cli-0.1.0/src/zt_devops_cli.egg-info/requires.txt +4 -0
- zt_devops_cli-0.1.0/src/zt_devops_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zt-devops-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DevOps 平台迭代管理 CLI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: playwright>=1.40.0
|
|
8
|
+
Requires-Dist: requests>=2.31.0
|
|
9
|
+
Requires-Dist: click>=8.1.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
|
|
12
|
+
# DevOps CLI
|
|
13
|
+
|
|
14
|
+
蓝鲸 DevOps 平台迭代管理命令行工具。
|
|
15
|
+
|
|
16
|
+
## 打包
|
|
17
|
+
```bash
|
|
18
|
+
python -m pip install --upgrade build twine
|
|
19
|
+
python -m build
|
|
20
|
+
twine upload dist/*
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 使用
|
|
29
|
+
|
|
30
|
+
### 登陆
|
|
31
|
+
|
|
32
|
+
首次运行时会自动打开浏览器,请登录蓝鲸 DevOps。登录成功后 cookie 会缓存到本地。
|
|
33
|
+
```bash
|
|
34
|
+
devops login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 项目列表
|
|
38
|
+
```bash
|
|
39
|
+
devops project list
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 迭代列表
|
|
43
|
+
```bash
|
|
44
|
+
devops sprint list --project k64352
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 创建迭代
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
devops sprint create --project k64352 \
|
|
51
|
+
--title "迭代名称" \
|
|
52
|
+
--start-date 2026-04-04 \
|
|
53
|
+
--end-date 2026-05-30 \
|
|
54
|
+
--purpose "迭代目标"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 启用迭代
|
|
58
|
+
```bash
|
|
59
|
+
devops sprint start --project k64352 --sprint-id <id>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 删除迭代
|
|
63
|
+
```bash
|
|
64
|
+
devops sprint delete --project k64352 --sprint-id <id>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 删除迭代
|
|
68
|
+
```bash
|
|
69
|
+
devops sprint done --project k64352 --sprint-id <id>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 配置
|
|
73
|
+
- cookie 缓存:`~/.devops-cli/cookies.json`
|
|
74
|
+
- 配置文件:`~/.devops-cli/config.yaml`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# DevOps CLI
|
|
2
|
+
|
|
3
|
+
蓝鲸 DevOps 平台迭代管理命令行工具。
|
|
4
|
+
|
|
5
|
+
## 打包
|
|
6
|
+
```bash
|
|
7
|
+
python -m pip install --upgrade build twine
|
|
8
|
+
python -m build
|
|
9
|
+
twine upload dist/*
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 使用
|
|
18
|
+
|
|
19
|
+
### 登陆
|
|
20
|
+
|
|
21
|
+
首次运行时会自动打开浏览器,请登录蓝鲸 DevOps。登录成功后 cookie 会缓存到本地。
|
|
22
|
+
```bash
|
|
23
|
+
devops login
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 项目列表
|
|
27
|
+
```bash
|
|
28
|
+
devops project list
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 迭代列表
|
|
32
|
+
```bash
|
|
33
|
+
devops sprint list --project k64352
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 创建迭代
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
devops sprint create --project k64352 \
|
|
40
|
+
--title "迭代名称" \
|
|
41
|
+
--start-date 2026-04-04 \
|
|
42
|
+
--end-date 2026-05-30 \
|
|
43
|
+
--purpose "迭代目标"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 启用迭代
|
|
47
|
+
```bash
|
|
48
|
+
devops sprint start --project k64352 --sprint-id <id>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 删除迭代
|
|
52
|
+
```bash
|
|
53
|
+
devops sprint delete --project k64352 --sprint-id <id>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 删除迭代
|
|
57
|
+
```bash
|
|
58
|
+
devops sprint done --project k64352 --sprint-id <id>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 配置
|
|
62
|
+
- cookie 缓存:`~/.devops-cli/cookies.json`
|
|
63
|
+
- 配置文件:`~/.devops-cli/config.yaml`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zt-devops-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "DevOps 平台迭代管理 CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"playwright>=1.40.0",
|
|
13
|
+
"requests>=2.31.0",
|
|
14
|
+
"click>=8.1.0",
|
|
15
|
+
"pyyaml>=6.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
devops-cli = "devops_cli.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""API 请求封装"""
|
|
2
|
+
import json as json_mod
|
|
3
|
+
import requests
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .auth import get_auth_cookies, get_auth_headers
|
|
7
|
+
from .config import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DevOpsAPI:
|
|
11
|
+
"""DevOps API 客户端"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, project_id: str):
|
|
14
|
+
self.project_id = project_id
|
|
15
|
+
self.base_url = config.BASE_URL
|
|
16
|
+
|
|
17
|
+
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
18
|
+
"""发送 API 请求"""
|
|
19
|
+
cookies = get_auth_cookies()
|
|
20
|
+
headers = get_auth_headers(cookies, self.project_id)
|
|
21
|
+
|
|
22
|
+
url = f"{self.base_url}/{path}"
|
|
23
|
+
|
|
24
|
+
# 处理 data 参数
|
|
25
|
+
if "data" in kwargs:
|
|
26
|
+
kwargs["json"] = kwargs.pop("data")
|
|
27
|
+
|
|
28
|
+
#############################################
|
|
29
|
+
# # 打印 curl 命令
|
|
30
|
+
# curl_parts = [f"curl -X {method}"]
|
|
31
|
+
# for k, v in headers.items():
|
|
32
|
+
# curl_parts.append(f"-H '{k}: {v}'")
|
|
33
|
+
# if "json" in kwargs:
|
|
34
|
+
# curl_parts.append(f"-d '{json_mod.dumps(kwargs['json'], ensure_ascii=False)}'")
|
|
35
|
+
# curl_parts.append(f"'{url}'")
|
|
36
|
+
# print(" ".join(curl_parts))
|
|
37
|
+
#############################################
|
|
38
|
+
|
|
39
|
+
response = requests.request(
|
|
40
|
+
method=method,
|
|
41
|
+
url=url,
|
|
42
|
+
headers=headers,
|
|
43
|
+
cookies=cookies,
|
|
44
|
+
**kwargs
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if response.status_code >= 400:
|
|
48
|
+
raise Exception(f"API 错误: {response.status_code} {response.text}")
|
|
49
|
+
|
|
50
|
+
return response.json()
|
|
51
|
+
|
|
52
|
+
# ========== 迭代操作 ==========
|
|
53
|
+
|
|
54
|
+
def create_sprint(
|
|
55
|
+
self,
|
|
56
|
+
title: str,
|
|
57
|
+
start_date: str,
|
|
58
|
+
end_date: str,
|
|
59
|
+
purpose: str = "",
|
|
60
|
+
test_start_date: Optional[str] = None,
|
|
61
|
+
test_end_date: Optional[str] = None,
|
|
62
|
+
development_pic: str = "",
|
|
63
|
+
test_pic: str = "",
|
|
64
|
+
acceptance_pic: str = "",
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""创建迭代"""
|
|
67
|
+
data = {
|
|
68
|
+
"title": title,
|
|
69
|
+
"startDate": start_date,
|
|
70
|
+
"endDate": end_date,
|
|
71
|
+
"purpose": purpose,
|
|
72
|
+
"daterange": [f"{start_date}T16:00:00.000Z", f"{end_date}T16:00:00.000Z"],
|
|
73
|
+
"reviewTime": "",
|
|
74
|
+
"testStartDate": test_start_date or start_date,
|
|
75
|
+
"testEndDate": test_end_date or end_date,
|
|
76
|
+
"developmentPic": development_pic,
|
|
77
|
+
"testPic": test_pic,
|
|
78
|
+
"acceptancePic": acceptance_pic,
|
|
79
|
+
"excludeTime": "",
|
|
80
|
+
"state": "ACTIVE",
|
|
81
|
+
}
|
|
82
|
+
return self._request("POST", f"/ms/vteam/api/user/issue_sprint/{self.project_id}", data=data)
|
|
83
|
+
|
|
84
|
+
def start_sprint(self, sprint_data: dict) -> dict:
|
|
85
|
+
"""启用迭代"""
|
|
86
|
+
return self._request("PUT", f"/ms/vteam/api/user/issue_sprint/{self.project_id}/start", data=sprint_data)
|
|
87
|
+
|
|
88
|
+
def delete_sprint(self, sprint_id: str) -> dict:
|
|
89
|
+
"""删除迭代"""
|
|
90
|
+
return self._request("DELETE", f"/ms/vteam/api/user/issue_sprint/{self.project_id}/{sprint_id}")
|
|
91
|
+
|
|
92
|
+
def done_sprint(self, sprint_id: str) -> dict:
|
|
93
|
+
"""完成迭代"""
|
|
94
|
+
return self._request("PUT", f"/ms/vteam/api/user/issue_sprint/{self.project_id}/complete/{sprint_id}")
|
|
95
|
+
|
|
96
|
+
def list_sprints(self) -> dict:
|
|
97
|
+
"""查询迭代列表"""
|
|
98
|
+
return self._request("GET", f"/ms/vteam/api/user/issue_sprint/{self.project_id}/flat?num=1&count=1&size=20&content=&start_time=&end_time=&state=ACTIVE%2CNOT_STARTED&sort_field=CREATED_TIME&sort_rule=DESC")
|
|
99
|
+
|
|
100
|
+
def list_projects(self) -> dict:
|
|
101
|
+
"""查询项目列表"""
|
|
102
|
+
return self._request("GET", f"/ms/projectmanager/api/user/project/cw/selectByType?showTree=false&haveUser=false")
|
|
103
|
+
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""认证模块 - 通过 Playwright 获取浏览器 cookie"""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from playwright.sync_api import sync_playwright
|
|
6
|
+
|
|
7
|
+
from .config import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# DevOps 域名
|
|
11
|
+
DEVOPS_DOMAIN = ".ztn.cn"
|
|
12
|
+
|
|
13
|
+
# SSO 登录页面
|
|
14
|
+
LOGIN_URL = "https://sso.ztn.cn/v2/cas/login?service=https%3A%2F%2Fpaas.ztn.cn%2Flogin%2F%3Fc_url%3Dhttps%3A%2F%2Fdevops.ztn.cn%2Fconsole%2F&locale=zh_CN"
|
|
15
|
+
|
|
16
|
+
def save_cookies(cookies: dict):
|
|
17
|
+
"""保存 cookie 到缓存文件"""
|
|
18
|
+
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
with open(config.COOKIE_FILE, "w") as f:
|
|
20
|
+
json.dump(cookies, f)
|
|
21
|
+
|
|
22
|
+
def load_cookies() -> dict:
|
|
23
|
+
"""从缓存文件加载 cookie"""
|
|
24
|
+
if config.COOKIE_FILE.exists():
|
|
25
|
+
with open(config.COOKIE_FILE) as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
def get_auth_headers(cookies: dict, project_id: str) -> dict:
|
|
30
|
+
"""从 cookie 构建认证请求头"""
|
|
31
|
+
return {
|
|
32
|
+
"Accept": "application/json, text/plain, */*",
|
|
33
|
+
"Accept-Language": "zh-CN",
|
|
34
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
35
|
+
"Origin": "https://devops.ztn.cn",
|
|
36
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36",
|
|
37
|
+
"X-DEVOPS-TENANT-ID": cookies.get("X-DEVOPS-TENANT-ID", "ZTN"),
|
|
38
|
+
"bk_token": cookies.get("bk_token", ""),
|
|
39
|
+
"X-DEVOPS-TOKEN": cookies.get("X-DEVOPS-TOKEN", ""),
|
|
40
|
+
"X-DEVOPS-PROJECT-ID": project_id,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def get_auth_cookies() -> dict:
|
|
44
|
+
"""获取认证 cookie,优先使用缓存,失效则重新获取"""
|
|
45
|
+
cookies = load_cookies()
|
|
46
|
+
|
|
47
|
+
# 检查必要的 cookie 是否存在
|
|
48
|
+
required = ["bk_token", "X-DEVOPS-TOKEN"]
|
|
49
|
+
if all(cookies.get(k) for k in required):
|
|
50
|
+
return cookies
|
|
51
|
+
|
|
52
|
+
# 缓存失效,重新获取
|
|
53
|
+
cookies = get_cookies_from_browser()
|
|
54
|
+
save_cookies(cookies)
|
|
55
|
+
|
|
56
|
+
print(f"已获取新的 cookie: {cookies}")
|
|
57
|
+
return cookies
|
|
58
|
+
|
|
59
|
+
def open_login_page(page) -> bool:
|
|
60
|
+
"""自动登录 - 尝试读取环境变量自动填写账号密码"""
|
|
61
|
+
username = os.environ.get("DEVOPS_USERNAME")
|
|
62
|
+
password = os.environ.get("DEVOPS_PASSWORD")
|
|
63
|
+
|
|
64
|
+
if not username or not password:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
print(f"检测到环境变量,自动登录中...")
|
|
68
|
+
|
|
69
|
+
# 等待页面加载
|
|
70
|
+
page.wait_for_selector("#username")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
# 填写用户名
|
|
74
|
+
page.fill("#username", username)
|
|
75
|
+
print(f"已填写用户名")
|
|
76
|
+
|
|
77
|
+
# 填写密码 - 使用 #password2
|
|
78
|
+
page.fill("#password2", password)
|
|
79
|
+
print(f"已填写密码")
|
|
80
|
+
|
|
81
|
+
# 点击登录按钮
|
|
82
|
+
page.click(".login_in")
|
|
83
|
+
print("已点击登录按钮")
|
|
84
|
+
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
print(f"自动登录失败: {e}")
|
|
89
|
+
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def get_cookies_from_browser() -> dict:
|
|
93
|
+
"""通过 Playwright 从浏览器获取 cookie"""
|
|
94
|
+
print("正在打开浏览器,请登录...")
|
|
95
|
+
|
|
96
|
+
with sync_playwright() as p:
|
|
97
|
+
browser = p.chromium.launch(headless=False)
|
|
98
|
+
context = browser.new_context()
|
|
99
|
+
page = context.new_page()
|
|
100
|
+
|
|
101
|
+
# 导航到 SSO 登录页
|
|
102
|
+
page.goto(LOGIN_URL)
|
|
103
|
+
|
|
104
|
+
# 尝试自动登录
|
|
105
|
+
if not open_login_page(page):
|
|
106
|
+
print("自动登陆失败. 请在浏览器中完成登录,登录成功后会自动继续...")
|
|
107
|
+
|
|
108
|
+
print("登陆中.检测关键 cookie 出现")
|
|
109
|
+
page.wait_for_function(
|
|
110
|
+
f"""() => {{
|
|
111
|
+
const cookies = document.cookie;
|
|
112
|
+
return cookies.includes('X-DEVOPS-TOKEN');
|
|
113
|
+
}}""",
|
|
114
|
+
timeout=300000 # 5分钟超时
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
print("登陆成功. 已获取关键 cookie")
|
|
118
|
+
|
|
119
|
+
# 获取所有 cookie
|
|
120
|
+
cookies = context.cookies()
|
|
121
|
+
|
|
122
|
+
# 提取需要的 cookie
|
|
123
|
+
cookie_dict = {}
|
|
124
|
+
for cookie in cookies:
|
|
125
|
+
if cookie["domain"] == DEVOPS_DOMAIN or DEVOPS_DOMAIN in cookie["domain"]:
|
|
126
|
+
cookie_dict[cookie["name"]] = cookie["value"]
|
|
127
|
+
|
|
128
|
+
# 从 cookie 获取 X-DEVOPS-PROJECT-ID
|
|
129
|
+
project_id = page.evaluate("""() => {
|
|
130
|
+
const name = 'X-DEVOPS-PROJECT-ID=';
|
|
131
|
+
const decodedCookie = decodeURIComponent(document.cookie);
|
|
132
|
+
const ca = decodedCookie.split(';');
|
|
133
|
+
for (let i = 0; i < ca.length; i++) {
|
|
134
|
+
let c = ca[i];
|
|
135
|
+
while (c.charAt(0) == ' ') {
|
|
136
|
+
c = c.substring(1);
|
|
137
|
+
}
|
|
138
|
+
if (c.indexOf(name) == 0) {
|
|
139
|
+
return c.substring(name.length, c.length);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return '';
|
|
143
|
+
}""")
|
|
144
|
+
|
|
145
|
+
if project_id:
|
|
146
|
+
cookie_dict["X-DEVOPS-PROJECT-ID"] = project_id
|
|
147
|
+
|
|
148
|
+
browser.close()
|
|
149
|
+
|
|
150
|
+
return cookie_dict
|
|
151
|
+
|
|
152
|
+
def login() -> dict:
|
|
153
|
+
"""手动登录 - 强制打开浏览器重新获取 cookie"""
|
|
154
|
+
print("正在打开浏览器,请重新登录...")
|
|
155
|
+
cookies = get_cookies_from_browser()
|
|
156
|
+
save_cookies(cookies)
|
|
157
|
+
print("登录成功,cookie 已缓存!")
|
|
158
|
+
return cookies
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
print(login())
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""CLI 入口"""
|
|
2
|
+
import click
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from .api import DevOpsAPI
|
|
6
|
+
from .auth import login
|
|
7
|
+
from .config import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _normalize_cell(value):
|
|
11
|
+
if value is None:
|
|
12
|
+
return "-"
|
|
13
|
+
return str(value)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _truncate_text(text, width):
|
|
17
|
+
if width <= 0:
|
|
18
|
+
return ""
|
|
19
|
+
if len(text) <= width:
|
|
20
|
+
return text
|
|
21
|
+
if width <= 1:
|
|
22
|
+
return text[:width]
|
|
23
|
+
return text[: width - 1] + "…"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_table(headers, rows, max_width=120, no_truncate_headers=None):
|
|
27
|
+
if not rows:
|
|
28
|
+
click.echo("暂无数据")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
terminal_width = shutil.get_terminal_size((max_width, 20)).columns
|
|
32
|
+
table_max_width = min(max_width, terminal_width)
|
|
33
|
+
|
|
34
|
+
no_truncate_headers = set(no_truncate_headers or [])
|
|
35
|
+
|
|
36
|
+
normalized_rows = []
|
|
37
|
+
for row in rows:
|
|
38
|
+
normalized = [_normalize_cell(cell) for cell in row[: len(headers)]]
|
|
39
|
+
if len(normalized) < len(headers):
|
|
40
|
+
normalized.extend(["-"] * (len(headers) - len(normalized)))
|
|
41
|
+
normalized_rows.append(normalized)
|
|
42
|
+
|
|
43
|
+
widths = [len(h) for h in headers]
|
|
44
|
+
for row in normalized_rows:
|
|
45
|
+
for idx, cell in enumerate(row):
|
|
46
|
+
widths[idx] = max(widths[idx], len(cell))
|
|
47
|
+
|
|
48
|
+
# 边框和分隔占用: 左右边框 + 每列两侧空格 + 列分隔符
|
|
49
|
+
overhead = 3 * len(headers) + 1
|
|
50
|
+
available = max(20, table_max_width - overhead)
|
|
51
|
+
min_col_width = 10
|
|
52
|
+
widths = [max(min_col_width, w) for w in widths]
|
|
53
|
+
|
|
54
|
+
total = sum(widths)
|
|
55
|
+
if total > available:
|
|
56
|
+
fixed_idx = {i for i, h in enumerate(headers) if h in no_truncate_headers}
|
|
57
|
+
shrinkable_idx = [i for i in range(len(headers)) if i not in fixed_idx]
|
|
58
|
+
|
|
59
|
+
if shrinkable_idx:
|
|
60
|
+
while sum(widths) > available:
|
|
61
|
+
idx = max(shrinkable_idx, key=lambda i: widths[i])
|
|
62
|
+
if widths[idx] > min_col_width:
|
|
63
|
+
widths[idx] -= 1
|
|
64
|
+
else:
|
|
65
|
+
if all(widths[i] <= min_col_width for i in shrinkable_idx):
|
|
66
|
+
break
|
|
67
|
+
# 若仍超出可用宽度,保留不截断列完整内容,允许终端自动换行/横向滚动查看
|
|
68
|
+
|
|
69
|
+
def format_row(cells):
|
|
70
|
+
parts = []
|
|
71
|
+
for i, cell in enumerate(cells):
|
|
72
|
+
text = cell if headers[i] in no_truncate_headers else _truncate_text(cell, widths[i])
|
|
73
|
+
parts.append(f" {text.ljust(widths[i])} ")
|
|
74
|
+
return "|" + "|".join(parts) + "|"
|
|
75
|
+
|
|
76
|
+
border = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
|
|
77
|
+
click.echo(border)
|
|
78
|
+
click.echo(format_row(headers))
|
|
79
|
+
click.echo(border)
|
|
80
|
+
for row in normalized_rows:
|
|
81
|
+
click.echo(format_row(row))
|
|
82
|
+
click.echo(border)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@click.group()
|
|
86
|
+
@click.option("--project", "-p", default=None, help="项目 ID")
|
|
87
|
+
@click.option(
|
|
88
|
+
"--output",
|
|
89
|
+
"-o",
|
|
90
|
+
"output_format",
|
|
91
|
+
type=click.Choice(["human", "json"], case_sensitive=False),
|
|
92
|
+
default="json",
|
|
93
|
+
show_default=True,
|
|
94
|
+
help="输出格式: human(表格) / json",
|
|
95
|
+
)
|
|
96
|
+
@click.pass_context
|
|
97
|
+
def cli(ctx, project, output_format):
|
|
98
|
+
"""DevOps 平台迭代管理工具"""
|
|
99
|
+
ctx.ensure_object(dict)
|
|
100
|
+
ctx.obj["project"] = project or config.default_project
|
|
101
|
+
ctx.obj["output_format"] = output_format.lower()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.command("login")
|
|
105
|
+
def cmd_login():
|
|
106
|
+
"""登录 - 打开浏览器重新获取 cookie"""
|
|
107
|
+
login()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@cli.group()
|
|
111
|
+
def sprint():
|
|
112
|
+
"""迭代管理命令组"""
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
@cli.group()
|
|
116
|
+
def project():
|
|
117
|
+
"""项目管理命令组"""
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
@sprint.command("create")
|
|
121
|
+
@click.option("--project", "-p", default=None, help="项目 ID")
|
|
122
|
+
@click.option("--title", "-t", required=True, help="迭代名称")
|
|
123
|
+
@click.option("--start-date", "-s", required=True, help="开始日期 (YYYY-MM-DD)")
|
|
124
|
+
@click.option("--end-date", "-e", required=True, help="结束日期 (YYYY-MM-DD)")
|
|
125
|
+
@click.option("--purpose", help="迭代目标")
|
|
126
|
+
@click.option("--test-start", help="测试开始日期")
|
|
127
|
+
@click.option("--test-end", help="测试结束日期")
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def create_sprint(ctx, project, title, start_date, end_date, purpose, test_start, test_end):
|
|
130
|
+
"""创建迭代"""
|
|
131
|
+
project_id = project or ctx.obj["project"]
|
|
132
|
+
if not project_id:
|
|
133
|
+
click.echo("错误: 请通过 -p 指定项目 ID 或在配置中设置 default_project")
|
|
134
|
+
raise click.Abort()
|
|
135
|
+
api = DevOpsAPI(project_id)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = api.create_sprint(
|
|
139
|
+
title=title,
|
|
140
|
+
start_date=start_date,
|
|
141
|
+
end_date=end_date,
|
|
142
|
+
purpose=purpose or "",
|
|
143
|
+
test_start_date=test_start,
|
|
144
|
+
test_end_date=test_end,
|
|
145
|
+
)
|
|
146
|
+
click.echo(f"创建成功! 迭代 ID: {result.get('id')}")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
click.echo(f"错误: {e}", err=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@sprint.command("start")
|
|
152
|
+
@click.option("--project", "-p", default=None, help="项目 ID")
|
|
153
|
+
@click.option("--sprint-id", "-i", required=True, help="迭代 ID")
|
|
154
|
+
@click.pass_context
|
|
155
|
+
def start_sprint(ctx, project, sprint_id):
|
|
156
|
+
"""启用迭代"""
|
|
157
|
+
project_id = project or ctx.obj["project"]
|
|
158
|
+
if not project_id:
|
|
159
|
+
click.echo("错误: 请通过 -p 指定项目 ID 或在配置中设置 default_project")
|
|
160
|
+
raise click.Abort()
|
|
161
|
+
api = DevOpsAPI(project_id)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
sprints = api.list_sprints()
|
|
165
|
+
sprint_data = None
|
|
166
|
+
for s in sprints.get("data").get("content"):
|
|
167
|
+
if s.get("id") == sprint_id:
|
|
168
|
+
sprint_data = s
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if not sprint_data:
|
|
172
|
+
click.echo(f"未找到迭代: {sprint_id}", err=True)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
result = api.start_sprint(sprint_data)
|
|
176
|
+
click.echo(f"启用成功!")
|
|
177
|
+
except Exception as e:
|
|
178
|
+
click.echo(f"错误: {e}", err=True)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@sprint.command("delete")
|
|
182
|
+
@click.option("--project", "-p", default=None, help="项目 ID")
|
|
183
|
+
@click.option("--sprint-id", "-i", required=True, help="迭代 ID")
|
|
184
|
+
@click.pass_context
|
|
185
|
+
def delete_sprint(ctx, project, sprint_id):
|
|
186
|
+
"""删除迭代"""
|
|
187
|
+
project_id = project or ctx.obj["project"]
|
|
188
|
+
if not project_id:
|
|
189
|
+
click.echo("错误: 请通过 -p 指定项目 ID 或在配置中设置 default_project")
|
|
190
|
+
raise click.Abort()
|
|
191
|
+
api = DevOpsAPI(project_id)
|
|
192
|
+
|
|
193
|
+
if not click.confirm(f"确认删除迭代 {sprint_id}?"):
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
api.delete_sprint(sprint_id)
|
|
198
|
+
click.echo(f"删除成功!")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
click.echo(f"错误: {e}", err=True)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@sprint.command("done")
|
|
204
|
+
@click.option("--project", "-p", default=None, help="项目 ID")
|
|
205
|
+
@click.option("--sprint-id", "-i", required=True, help="迭代 ID")
|
|
206
|
+
@click.pass_context
|
|
207
|
+
def done_sprint(ctx, project, sprint_id):
|
|
208
|
+
""" 完成迭代"""
|
|
209
|
+
project_id = project or ctx.obj["project"]
|
|
210
|
+
if not project_id:
|
|
211
|
+
click.echo("错误: 请通过 -p 指定项目 ID 或在配置中设置 default_project")
|
|
212
|
+
raise click.Abort()
|
|
213
|
+
api = DevOpsAPI(project_id)
|
|
214
|
+
|
|
215
|
+
if not click.confirm(f"确认完成迭代 {sprint_id}?"):
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
api.done_sprint(sprint_id)
|
|
220
|
+
click.echo(f"完成成功!")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
click.echo(f"错误: {e}", err=True)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@sprint.command("list")
|
|
227
|
+
@click.option("--project", "-p", default=None, help="项目 ID")
|
|
228
|
+
@click.pass_context
|
|
229
|
+
def list_sprint(ctx, project):
|
|
230
|
+
|
|
231
|
+
project_id = project or ctx.obj["project"]
|
|
232
|
+
if not project_id:
|
|
233
|
+
click.echo("错误: 请通过 -p 指定项目 ID 或在配置中设置 default_project")
|
|
234
|
+
raise click.Abort()
|
|
235
|
+
api = DevOpsAPI(project_id)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
sprints = api.list_sprints()
|
|
240
|
+
sprint_list = sprints.get("data", {}).get("content", [])
|
|
241
|
+
if ctx.obj.get("output_format") == "json":
|
|
242
|
+
click.echo(
|
|
243
|
+
json.dumps(
|
|
244
|
+
sprint_list,
|
|
245
|
+
ensure_ascii=False,
|
|
246
|
+
indent=2,
|
|
247
|
+
default=str,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
render_table(
|
|
252
|
+
headers=["迭代名称", "迭代ID", "开始时间", "结束时间", "迭代状态", "提测时间", "测试开始时间", "测试完成时间","验收完成", "负责人"],
|
|
253
|
+
rows=[
|
|
254
|
+
[
|
|
255
|
+
s.get("title"),
|
|
256
|
+
s.get("id"),
|
|
257
|
+
s.get("startDate"),
|
|
258
|
+
s.get("endDate"),
|
|
259
|
+
s.get("state"),
|
|
260
|
+
s.get("testStartDate"),
|
|
261
|
+
s.get("smokeEndDate"),
|
|
262
|
+
s.get("testEndDate"),
|
|
263
|
+
s.get("acceptanceEndDate"),
|
|
264
|
+
s.get("developmentPic"),
|
|
265
|
+
s.get("testPic"),
|
|
266
|
+
s.get("acceptancePic")
|
|
267
|
+
]
|
|
268
|
+
for s in sprint_list
|
|
269
|
+
],
|
|
270
|
+
no_truncate_headers=["迭代ID","迭代名称"],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
click.echo(f"错误: {e}", err=True)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@project.command("list")
|
|
279
|
+
@click.pass_context
|
|
280
|
+
def list_project(ctx):
|
|
281
|
+
|
|
282
|
+
api = DevOpsAPI("")
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
projects = api.list_projects()
|
|
286
|
+
project_list = projects.get("data", [])
|
|
287
|
+
if ctx.obj.get("output_format") == "json":
|
|
288
|
+
click.echo(
|
|
289
|
+
json.dumps(
|
|
290
|
+
project_list,
|
|
291
|
+
ensure_ascii=False,
|
|
292
|
+
indent=2,
|
|
293
|
+
default=str,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return
|
|
297
|
+
render_table(
|
|
298
|
+
headers=["项目代码", "项目名称", "部门名称"],
|
|
299
|
+
rows=[
|
|
300
|
+
[
|
|
301
|
+
s.get("projectCode"),
|
|
302
|
+
s.get("projectName"),
|
|
303
|
+
s.get("deptName"),
|
|
304
|
+
]
|
|
305
|
+
for s in project_list
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
click.echo(f"错误: {e}", err=True)
|
|
311
|
+
|
|
312
|
+
def main():
|
|
313
|
+
cli()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""配置管理模块"""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Config:
|
|
10
|
+
"""配置类"""
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = Path.home() / ".devops-cli"
|
|
13
|
+
COOKIE_FILE = CONFIG_DIR / "cookies.json"
|
|
14
|
+
CONFIG_FILE = CONFIG_DIR / "config.yaml"
|
|
15
|
+
|
|
16
|
+
# API 配置
|
|
17
|
+
BASE_URL = "https://devops.ztn.cn"
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self._config = self._load_config()
|
|
21
|
+
|
|
22
|
+
def _load_config(self) -> dict:
|
|
23
|
+
"""加载配置文件"""
|
|
24
|
+
if self.CONFIG_FILE.exists():
|
|
25
|
+
with open(self.CONFIG_FILE) as f:
|
|
26
|
+
return yaml.safe_load(f) or {}
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def default_project(self) -> Optional[str]:
|
|
31
|
+
return self._config.get("default_project")
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def browser_channel(self) -> str:
|
|
35
|
+
return self._config.get("browser_channel", "chromium")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
config = Config()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zt-devops-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DevOps 平台迭代管理 CLI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: playwright>=1.40.0
|
|
8
|
+
Requires-Dist: requests>=2.31.0
|
|
9
|
+
Requires-Dist: click>=8.1.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
|
|
12
|
+
# DevOps CLI
|
|
13
|
+
|
|
14
|
+
蓝鲸 DevOps 平台迭代管理命令行工具。
|
|
15
|
+
|
|
16
|
+
## 打包
|
|
17
|
+
```bash
|
|
18
|
+
python -m pip install --upgrade build twine
|
|
19
|
+
python -m build
|
|
20
|
+
twine upload dist/*
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
```bash
|
|
25
|
+
pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 使用
|
|
29
|
+
|
|
30
|
+
### 登陆
|
|
31
|
+
|
|
32
|
+
首次运行时会自动打开浏览器,请登录蓝鲸 DevOps。登录成功后 cookie 会缓存到本地。
|
|
33
|
+
```bash
|
|
34
|
+
devops login
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 项目列表
|
|
38
|
+
```bash
|
|
39
|
+
devops project list
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 迭代列表
|
|
43
|
+
```bash
|
|
44
|
+
devops sprint list --project k64352
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 创建迭代
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
devops sprint create --project k64352 \
|
|
51
|
+
--title "迭代名称" \
|
|
52
|
+
--start-date 2026-04-04 \
|
|
53
|
+
--end-date 2026-05-30 \
|
|
54
|
+
--purpose "迭代目标"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 启用迭代
|
|
58
|
+
```bash
|
|
59
|
+
devops sprint start --project k64352 --sprint-id <id>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 删除迭代
|
|
63
|
+
```bash
|
|
64
|
+
devops sprint delete --project k64352 --sprint-id <id>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 删除迭代
|
|
68
|
+
```bash
|
|
69
|
+
devops sprint done --project k64352 --sprint-id <id>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 配置
|
|
73
|
+
- cookie 缓存:`~/.devops-cli/cookies.json`
|
|
74
|
+
- 配置文件:`~/.devops-cli/config.yaml`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/devops_cli/__init__.py
|
|
4
|
+
src/devops_cli/api.py
|
|
5
|
+
src/devops_cli/auth.py
|
|
6
|
+
src/devops_cli/cli.py
|
|
7
|
+
src/devops_cli/config.py
|
|
8
|
+
src/zt_devops_cli.egg-info/PKG-INFO
|
|
9
|
+
src/zt_devops_cli.egg-info/SOURCES.txt
|
|
10
|
+
src/zt_devops_cli.egg-info/dependency_links.txt
|
|
11
|
+
src/zt_devops_cli.egg-info/entry_points.txt
|
|
12
|
+
src/zt_devops_cli.egg-info/requires.txt
|
|
13
|
+
src/zt_devops_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
devops_cli
|