pytbox 0.0.1__py3-none-any.whl → 0.3.1__py3-none-any.whl
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.
Potentially problematic release.
This version of pytbox might be problematic. Click here for more details.
- pytbox/alert/alert_handler.py +139 -0
- pytbox/alert/ping.py +24 -0
- pytbox/alicloud/sls.py +9 -14
- pytbox/base.py +121 -0
- pytbox/categraf/build_config.py +143 -0
- pytbox/categraf/instances.toml +39 -0
- pytbox/categraf/jinja2/__init__.py +6 -0
- pytbox/categraf/jinja2/input.cpu/cpu.toml.j2 +5 -0
- pytbox/categraf/jinja2/input.disk/disk.toml.j2 +11 -0
- pytbox/categraf/jinja2/input.diskio/diskio.toml.j2 +6 -0
- pytbox/categraf/jinja2/input.dns_query/dns_query.toml.j2 +12 -0
- pytbox/categraf/jinja2/input.http_response/http_response.toml.j2 +9 -0
- pytbox/categraf/jinja2/input.mem/mem.toml.j2 +5 -0
- pytbox/categraf/jinja2/input.net/net.toml.j2 +11 -0
- pytbox/categraf/jinja2/input.net_response/net_response.toml.j2 +9 -0
- pytbox/categraf/jinja2/input.ping/ping.toml.j2 +11 -0
- pytbox/categraf/jinja2/input.prometheus/prometheus.toml.j2 +12 -0
- pytbox/categraf/jinja2/input.snmp/cisco_interface.toml.j2 +96 -0
- pytbox/categraf/jinja2/input.snmp/cisco_system.toml.j2 +41 -0
- pytbox/categraf/jinja2/input.snmp/h3c_interface.toml.j2 +96 -0
- pytbox/categraf/jinja2/input.snmp/h3c_system.toml.j2 +41 -0
- pytbox/categraf/jinja2/input.snmp/huawei_interface.toml.j2 +96 -0
- pytbox/categraf/jinja2/input.snmp/huawei_system.toml.j2 +41 -0
- pytbox/categraf/jinja2/input.snmp/ruijie_interface.toml.j2 +96 -0
- pytbox/categraf/jinja2/input.snmp/ruijie_system.toml.j2 +41 -0
- pytbox/categraf/jinja2/input.vsphere/vsphere.toml.j2 +211 -0
- pytbox/cli/__init__.py +7 -0
- pytbox/cli/categraf/__init__.py +7 -0
- pytbox/cli/categraf/commands.py +55 -0
- pytbox/cli/commands/vm.py +22 -0
- pytbox/cli/common/__init__.py +6 -0
- pytbox/cli/common/options.py +42 -0
- pytbox/cli/common/utils.py +269 -0
- pytbox/cli/formatters/__init__.py +7 -0
- pytbox/cli/formatters/output.py +155 -0
- pytbox/cli/main.py +24 -0
- pytbox/cli.py +9 -0
- pytbox/database/mongo.py +99 -0
- pytbox/database/victoriametrics.py +404 -0
- pytbox/dida365.py +11 -17
- pytbox/excel.py +64 -0
- pytbox/feishu/endpoints.py +12 -9
- pytbox/{logger.py → log/logger.py} +78 -30
- pytbox/{victorialog.py → log/victorialog.py} +2 -2
- pytbox/mail/alimail.py +142 -0
- pytbox/mail/client.py +171 -0
- pytbox/mail/mail_detail.py +30 -0
- pytbox/mingdao.py +164 -0
- pytbox/network/meraki.py +537 -0
- pytbox/notion.py +731 -0
- pytbox/pyjira.py +612 -0
- pytbox/utils/cronjob.py +79 -0
- pytbox/utils/env.py +2 -2
- pytbox/utils/load_config.py +132 -0
- pytbox/utils/load_vm_devfile.py +45 -0
- pytbox/utils/response.py +1 -1
- pytbox/utils/richutils.py +31 -0
- pytbox/utils/timeutils.py +479 -14
- pytbox/vmware.py +120 -0
- pytbox/win/ad.py +30 -0
- {pytbox-0.0.1.dist-info → pytbox-0.3.1.dist-info}/METADATA +13 -3
- pytbox-0.3.1.dist-info/RECORD +72 -0
- pytbox-0.3.1.dist-info/entry_points.txt +2 -0
- pytbox/common/base.py +0 -0
- pytbox/victoriametrics.py +0 -37
- pytbox-0.0.1.dist-info/RECORD +0 -21
- {pytbox-0.0.1.dist-info → pytbox-0.3.1.dist-info}/WHEEL +0 -0
- {pytbox-0.0.1.dist-info → pytbox-0.3.1.dist-info}/top_level.txt +0 -0
pytbox/pyjira.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Literal, Optional, List
|
|
5
|
+
|
|
6
|
+
from requests.auth import HTTPBasicAuth
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .utils.response import ReturnResponse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PyJira:
|
|
13
|
+
"""JIRA API客户端类
|
|
14
|
+
|
|
15
|
+
提供JIRA操作的封装方法,支持任务创建、更新、查询等功能。
|
|
16
|
+
建议使用OAuth 2.0认证以提高安全性。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self,
|
|
20
|
+
base_url: str=None,
|
|
21
|
+
proxies: dict=None,
|
|
22
|
+
token: str=None,
|
|
23
|
+
username: str=None,
|
|
24
|
+
password: str=None,
|
|
25
|
+
timeout: int=10
|
|
26
|
+
) -> None:
|
|
27
|
+
'''
|
|
28
|
+
_summary_
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
base_url (str, optional): _description_. Defaults to None.
|
|
32
|
+
proxies (dict, optional): _description_. Defaults to None.
|
|
33
|
+
token (str, optional): _description_. Defaults to None.
|
|
34
|
+
username (str, optional): _description_. Defaults to None.
|
|
35
|
+
password (str, optional): _description_. Defaults to None.
|
|
36
|
+
'''
|
|
37
|
+
self.base_url = base_url
|
|
38
|
+
self.rest_version = 3
|
|
39
|
+
self.proxies = None
|
|
40
|
+
|
|
41
|
+
if 'atlassian.net' in self.base_url:
|
|
42
|
+
self.deploy_type = 'cloud'
|
|
43
|
+
self.rest_version = 3
|
|
44
|
+
else:
|
|
45
|
+
self.deploy_type = 'datacenter'
|
|
46
|
+
self.rest_version = 2
|
|
47
|
+
|
|
48
|
+
if self.deploy_type == 'cloud':
|
|
49
|
+
self.headers = {
|
|
50
|
+
"Accept": "application/json",
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"Authorization": f"Basic {token}"
|
|
53
|
+
}
|
|
54
|
+
else:
|
|
55
|
+
self.headers = {
|
|
56
|
+
"Accept": "application/json",
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"Authorization": f"Bearer {token}"
|
|
59
|
+
}
|
|
60
|
+
self.auth = HTTPBasicAuth(username, password)
|
|
61
|
+
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
# 使用 requests.Session 统一管理连接与 headers(不改变现有硬编码与参数传递逻辑)
|
|
64
|
+
self.session = requests.Session()
|
|
65
|
+
self.session.headers.update(self.headers)
|
|
66
|
+
|
|
67
|
+
def get_project_boards(self, project_key: Literal['TEST'] = 'TEST') -> list:
|
|
68
|
+
"""获取指定项目的看板列表
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
project_key: 项目key,例如 'OPS'
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
list: 看板列表
|
|
75
|
+
"""
|
|
76
|
+
boards = self.jira.boards(projectKeyOrID=project_key)
|
|
77
|
+
for board in boards:
|
|
78
|
+
print(board.name)
|
|
79
|
+
return boards
|
|
80
|
+
|
|
81
|
+
def issue_get(self, issue_id_or_key: str, account_id: bool = False) -> ReturnResponse:
|
|
82
|
+
"""获取指定JIRA任务
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
issue_key: 任务key,例如 'TEST-50'
|
|
86
|
+
account_id: 是否返回账户ID,默认为False
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
dict: 任务信息或账户ID
|
|
90
|
+
"""
|
|
91
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_id_or_key}"
|
|
92
|
+
print(url)
|
|
93
|
+
r = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
94
|
+
|
|
95
|
+
print(r.text)
|
|
96
|
+
|
|
97
|
+
# 移除所有 key 以 customfield_ 开头且后面跟数字的字段
|
|
98
|
+
fields = r.json()['fields']
|
|
99
|
+
keys_to_remove = [k for k in fields if k.startswith('customfield_') and k[12:].isdigit()]
|
|
100
|
+
for k in keys_to_remove:
|
|
101
|
+
fields.pop(k)
|
|
102
|
+
|
|
103
|
+
if r.status_code == 200:
|
|
104
|
+
return ReturnResponse(code=0, msg=f'获取 issue [{issue_id_or_key}] 成功', data=fields)
|
|
105
|
+
else:
|
|
106
|
+
return ReturnResponse(code=1, msg=f'获取 issue [{issue_id_or_key}] 失败')
|
|
107
|
+
|
|
108
|
+
def issue_get_by_key(self, issue_id_or_key:str='', get_key: Literal['assignee', 'summary', 'description', 'status']=None):
|
|
109
|
+
'''
|
|
110
|
+
_summary_
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
issue_id_or_key (str, optional): _description_. Defaults to ''.
|
|
114
|
+
get_key: status 就是 transitions
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: _description_
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
_type_: _description_
|
|
121
|
+
'''
|
|
122
|
+
r = self.issue_get(issue_id_or_key=issue_id_or_key)
|
|
123
|
+
if r.code == 0:
|
|
124
|
+
if get_key:
|
|
125
|
+
# print(get_key)
|
|
126
|
+
# print(r.data['assignee'])
|
|
127
|
+
try:
|
|
128
|
+
return r.data[get_key]
|
|
129
|
+
except KeyError as e:
|
|
130
|
+
return r.data
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f"{issue_id_or_key} 的 {get_key} 未查找到")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def issue_create(self,
|
|
136
|
+
project_id: int = 10000,
|
|
137
|
+
summary: Optional[str] = None,
|
|
138
|
+
description: str = None,
|
|
139
|
+
description_adf: bool=True,
|
|
140
|
+
issue_type: Literal['任务'] = '任务',
|
|
141
|
+
priority: Literal['Highest', 'High', 'Medium', 'Low', 'Lowest'] = 'Medium',
|
|
142
|
+
reporter: dict=None,
|
|
143
|
+
assignee: dict={},
|
|
144
|
+
parent_key: Optional[str] = None) -> ReturnResponse:
|
|
145
|
+
"""创建一个新的JIRA任务
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
project_id: 项目ID,默认为 10000
|
|
149
|
+
summary: 任务标题
|
|
150
|
+
description: 任务描述,默认为空字符串,支持普通文本,会自动转换为ADF格式
|
|
151
|
+
issue_type: 任务类型,默认为 'Task'
|
|
152
|
+
priority: 优先级,默认为 'Medium'
|
|
153
|
+
parent_key: 父任务key,如果提供则创建子任务,默认为None
|
|
154
|
+
reporter(dict): { "name": reporter}, : {"id" : xx}
|
|
155
|
+
assignee: {"accountId": "xxx"}
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Response: 创建结果,包含任务信息或错误信息
|
|
159
|
+
"""
|
|
160
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue"
|
|
161
|
+
data = {
|
|
162
|
+
"fields": {
|
|
163
|
+
'project': { "id": project_id},
|
|
164
|
+
'summary': summary,
|
|
165
|
+
'issuetype': { "name": issue_type},
|
|
166
|
+
'priority': { "name": priority},
|
|
167
|
+
# 'reporter': reporter
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if assignee:
|
|
171
|
+
data['fields']['assignee'] = assignee
|
|
172
|
+
if description:
|
|
173
|
+
# 将普通文本转换为ADF格式
|
|
174
|
+
if description_adf:
|
|
175
|
+
data['fields']['description'] = self.text_to_adf(description)
|
|
176
|
+
else:
|
|
177
|
+
data['fields']['description'] = description
|
|
178
|
+
|
|
179
|
+
if parent_key:
|
|
180
|
+
data['fields']['parent'] = {"key": parent_key}
|
|
181
|
+
|
|
182
|
+
if reporter:
|
|
183
|
+
if isinstance(reporter, str):
|
|
184
|
+
data['fields']['reporter'] = {"name": reporter}
|
|
185
|
+
else:
|
|
186
|
+
data['fields']['reporter'] = reporter
|
|
187
|
+
|
|
188
|
+
r = self.session.post(
|
|
189
|
+
url,
|
|
190
|
+
headers=self.headers,
|
|
191
|
+
json=data,
|
|
192
|
+
timeout=self.timeout,
|
|
193
|
+
proxies=self.proxies
|
|
194
|
+
)
|
|
195
|
+
if r.status_code == 201:
|
|
196
|
+
return ReturnResponse(code=0, msg=f'创建 issue [{summary}] 成功', data=r.json())
|
|
197
|
+
else:
|
|
198
|
+
return ReturnResponse(code=1, msg=f'创建 issue [{summary}] 失败, {r.text}')
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def issue_update(self,
|
|
202
|
+
issue_key: str,
|
|
203
|
+
summary: Optional[str] = None,
|
|
204
|
+
description: str = None,
|
|
205
|
+
issue_type: str = None,
|
|
206
|
+
priority: Literal['Blocker', 'Critical', 'Major', 'Minor'] = None,
|
|
207
|
+
labels: Optional[list[str]] = None,
|
|
208
|
+
parent_key: str=None) -> dict:
|
|
209
|
+
"""更新JIRA任务
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
issue_key: 任务key,例如 'TEST-50'
|
|
213
|
+
summary: 任务标题,默认为None
|
|
214
|
+
description: 任务描述,默认为空字符串,支持普通文本,会自动转换为ADF格式
|
|
215
|
+
issue_type: 任务类型,默认为 'Task'
|
|
216
|
+
priority: 优先级,默认为 'Minor'
|
|
217
|
+
assignee: 分配人,默认为 'MingMing Hou'
|
|
218
|
+
status: 任务状态,默认为None
|
|
219
|
+
labels: 标签列表,默认为None
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
dict: 更新后的任务信息或错误信息
|
|
223
|
+
"""
|
|
224
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_key}"
|
|
225
|
+
data = { "fields": {} }
|
|
226
|
+
if summary:
|
|
227
|
+
data['fields']['summary'] = summary
|
|
228
|
+
if description is not None:
|
|
229
|
+
description = self.format_description(description)
|
|
230
|
+
# 将普通文本转换为ADF格式
|
|
231
|
+
data['fields']['description'] = self.text_to_adf(description)
|
|
232
|
+
if issue_type:
|
|
233
|
+
data['fields']['issuetype'] = issue_type
|
|
234
|
+
if priority:
|
|
235
|
+
data['fields']['priority'] = {"name": priority}
|
|
236
|
+
if labels:
|
|
237
|
+
data['fields']['labels'] = labels
|
|
238
|
+
|
|
239
|
+
if parent_key:
|
|
240
|
+
data['fields']['parent'] = {"key": parent_key}
|
|
241
|
+
|
|
242
|
+
# print(data)
|
|
243
|
+
r = self.session.put(url, headers=self.headers, json=data, timeout=self.timeout)
|
|
244
|
+
if r.status_code == 204:
|
|
245
|
+
return ReturnResponse(code=0, msg=f'更新 issue [{issue_key}] 成功', data=r.text)
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
error_data = r.json()
|
|
249
|
+
error_message = f'更新 issue [{issue_key}] 失败, status: {r.status_code}, 错误: {error_data}'
|
|
250
|
+
except:
|
|
251
|
+
error_message = f'更新 issue [{issue_key}] 失败, status: {r.status_code}, 响应: {r.text}'
|
|
252
|
+
return ReturnResponse(code=1, msg=error_message, data=r.text)
|
|
253
|
+
|
|
254
|
+
def issue_assign(self, issue_id_or_key: str='', name: str=None, display_name: str=None, account_id: str=None) -> ReturnResponse:
|
|
255
|
+
'''
|
|
256
|
+
_summary_
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
issue_id_or_key (str, optional): _description_. Defaults to ''.
|
|
260
|
+
name (str, optional): _description_. Defaults to None.
|
|
261
|
+
display_name (str, optional): _description_. Defaults to None.
|
|
262
|
+
account_id (str, optional): _description_. 侯明明: 612dbc1d1dbcd90069240013 包柿林: 712020:66dfa4a9-383e-4aa2-abdd-521adfccf967
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
ReturnResponse: _description_
|
|
266
|
+
'''
|
|
267
|
+
update = False
|
|
268
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_id_or_key}/assignee"
|
|
269
|
+
data = {}
|
|
270
|
+
if name:
|
|
271
|
+
data["name"] = name
|
|
272
|
+
assignee_message = name
|
|
273
|
+
update = True
|
|
274
|
+
|
|
275
|
+
if display_name:
|
|
276
|
+
data["displayName"] = display_name
|
|
277
|
+
assignee_message = display_name
|
|
278
|
+
update = True
|
|
279
|
+
|
|
280
|
+
if account_id:
|
|
281
|
+
account_info = self.issue_get_by_key(issue_id_or_key=issue_id_or_key, get_key='assignee')
|
|
282
|
+
if account_info is None:
|
|
283
|
+
update = True
|
|
284
|
+
else:
|
|
285
|
+
current_account_id = account_info['accountId']
|
|
286
|
+
if current_account_id != account_id:
|
|
287
|
+
update = True
|
|
288
|
+
|
|
289
|
+
data["accountId"] = account_id
|
|
290
|
+
assignee_message = account_id
|
|
291
|
+
|
|
292
|
+
if update:
|
|
293
|
+
r = self.session.put(url, headers=self.headers, json=data, timeout=self.timeout)
|
|
294
|
+
if r.status_code == 204:
|
|
295
|
+
return ReturnResponse(code=0, msg=f'更新 issue [{issue_id_or_key}] 的分配人成功, 经办人被更新为 {assignee_message}', data=r.text)
|
|
296
|
+
return ReturnResponse(code=1, msg=f'更新 issue [{issue_id_or_key}] 的分配人失败, status code: {r.status_code}, 报错: {r.text}')
|
|
297
|
+
return ReturnResponse(code=0, msg=f'issue [{issue_id_or_key}] 的分配人已经为 {assignee_message}, 不需要更新')
|
|
298
|
+
|
|
299
|
+
def issue_comment_add(self, issue_key: str, comment: str) -> dict:
|
|
300
|
+
"""添加JIRA任务评论
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
issue_key: 任务key,例如 'TEST-50'
|
|
304
|
+
comment: 评论内容
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
dict: 评论信息
|
|
308
|
+
"""
|
|
309
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_key}/comment/"
|
|
310
|
+
comment_adf = self.text_to_adf(text=self.format_description(comment))
|
|
311
|
+
r = self.session.post(url, headers=self.headers, json={"body": comment_adf}, timeout=self.timeout)
|
|
312
|
+
if r.status_code == 201:
|
|
313
|
+
return ReturnResponse(code=0, msg=f'添加评论 [{comment}] 成功', data=r.json())
|
|
314
|
+
else:
|
|
315
|
+
return ReturnResponse(code=1, msg=f'添加评论 [{comment}] 失败, 返回值: {r.text}', data=r.json())
|
|
316
|
+
|
|
317
|
+
def issue_search(self, jql: str, max_results: int = 1000, fields: Optional[List[str]] = None) -> ReturnResponse:
|
|
318
|
+
"""使用JQL搜索JIRA任务(支持自动分页获取所有结果)
|
|
319
|
+
https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-rest-api-3-search-jql-get
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
jql: JQL查询字符串
|
|
323
|
+
max_results: 最大返回结果数,默认1000。会自动分页获取
|
|
324
|
+
fields: 需要返回的字段列表,默认返回所有字段
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
ReturnResponse: 包含任务列表的响应数据
|
|
328
|
+
"""
|
|
329
|
+
url = f"{self.base_url}/rest/api/3/search/jql"
|
|
330
|
+
|
|
331
|
+
# Jira API 单次请求最多返回100条,需要分页
|
|
332
|
+
page_size = 100
|
|
333
|
+
all_issues = []
|
|
334
|
+
seen_keys = set() # 用于去重
|
|
335
|
+
next_page_token = None
|
|
336
|
+
page_count = 0
|
|
337
|
+
|
|
338
|
+
while len(all_issues) < max_results:
|
|
339
|
+
page_count += 1
|
|
340
|
+
|
|
341
|
+
# 构建查询参数
|
|
342
|
+
params = {
|
|
343
|
+
"jql": jql,
|
|
344
|
+
"maxResults": page_size
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# 如果有下一页的 token,添加到参数中(按文档使用 nextPageToken 参数名)
|
|
348
|
+
if next_page_token:
|
|
349
|
+
params["nextPageToken"] = next_page_token
|
|
350
|
+
|
|
351
|
+
# 如果指定了字段,添加到查询参数中
|
|
352
|
+
if isinstance(fields, list):
|
|
353
|
+
params["fields"] = ",".join(fields)
|
|
354
|
+
else:
|
|
355
|
+
params["fields"] = fields
|
|
356
|
+
|
|
357
|
+
r = self.session.get(url, headers=self.headers, params=params, timeout=self.timeout)
|
|
358
|
+
|
|
359
|
+
if r.status_code != 200:
|
|
360
|
+
return ReturnResponse(code=1, msg=f'获取 issue 失败, status code: {r.status_code}, 报错: {r.text}')
|
|
361
|
+
|
|
362
|
+
data = r.json()
|
|
363
|
+
issues = data.get('issues', [])
|
|
364
|
+
|
|
365
|
+
if not issues:
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
# 去重:只添加未见过的 issue
|
|
369
|
+
new_issues_count = 0
|
|
370
|
+
for issue in issues:
|
|
371
|
+
issue_key = issue.get('key')
|
|
372
|
+
if issue_key and issue_key not in seen_keys:
|
|
373
|
+
all_issues.append(issue)
|
|
374
|
+
seen_keys.add(issue_key)
|
|
375
|
+
new_issues_count += 1
|
|
376
|
+
|
|
377
|
+
# 检查是否是最后一页
|
|
378
|
+
is_last = data.get('isLast', True)
|
|
379
|
+
next_page_token = data.get('nextPageToken')
|
|
380
|
+
|
|
381
|
+
# 如果是最后一页或没有新数据,退出循环
|
|
382
|
+
if is_last or new_issues_count == 0:
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
# 如果获取的数据超过 max_results,截断到指定数量
|
|
386
|
+
if len(all_issues) > max_results:
|
|
387
|
+
all_issues = all_issues[:max_results]
|
|
388
|
+
|
|
389
|
+
# 返回合并后的结果
|
|
390
|
+
result_data = {
|
|
391
|
+
'issues': all_issues,
|
|
392
|
+
'total': len(all_issues),
|
|
393
|
+
'startAt': 0
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return ReturnResponse(code=0, msg=f'成功获取 {len(all_issues)} 个唯一 issue(共请求 {page_count} 页)', data=result_data)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def get_boards(self) -> ReturnResponse:
|
|
400
|
+
"""获取所有看板信息并打印
|
|
401
|
+
|
|
402
|
+
使用REST API获取看板信息,主要用于调试和查看。
|
|
403
|
+
"""
|
|
404
|
+
url = f"{self.base_url}/rest/agile/1.0/board"
|
|
405
|
+
response = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
406
|
+
response.raise_for_status()
|
|
407
|
+
return ReturnResponse(code=0, msg='', data=response.json())
|
|
408
|
+
|
|
409
|
+
def get_issue_fields(self) -> ReturnResponse:
|
|
410
|
+
url = f"{self.base_url}/rest/api/3/field"
|
|
411
|
+
response = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
412
|
+
response.raise_for_status()
|
|
413
|
+
return ReturnResponse(code=0, msg='', data=response.json())
|
|
414
|
+
|
|
415
|
+
def get_project(self, project_id_or_key: Literal['TEST']='TEST') -> ReturnResponse:
|
|
416
|
+
if self.deploy_type == 'cloud':
|
|
417
|
+
url = f"{self.base_url}/rest/api/3/project/{project_id_or_key}"
|
|
418
|
+
else:
|
|
419
|
+
url = f"{self.base_url}/rest/api/2/project"
|
|
420
|
+
|
|
421
|
+
response = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
422
|
+
if response.status_code == 401:
|
|
423
|
+
return ReturnResponse(code=1, msg=response.text)
|
|
424
|
+
else:
|
|
425
|
+
return ReturnResponse(code=0, msg='', data=response.json())
|
|
426
|
+
|
|
427
|
+
# def get_issue(self, issue_id_or_key: str) -> ReturnResponse:
|
|
428
|
+
# url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_id_or_key}"
|
|
429
|
+
# response = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
430
|
+
# if response.status_code == 200:
|
|
431
|
+
# return ReturnResponse(code=0, msg=f'根据输入的 {issue_id_or_key} 成功获取到 issue', data=response.json())
|
|
432
|
+
# else:
|
|
433
|
+
# return ReturnResponse(code=1, msg=response.text)
|
|
434
|
+
|
|
435
|
+
def get_metadata_for_project_issue_types(self, project_id_or_key: Literal['TEST', 'MO', '10000']='TEST'):
|
|
436
|
+
url = f"{self.base_url}/rest/api/2/issue/createmeta/{project_id_or_key}/issuetypes"
|
|
437
|
+
response = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
438
|
+
if response.status_code == 200:
|
|
439
|
+
return ReturnResponse(code=0, msg='', data=response.json())
|
|
440
|
+
else:
|
|
441
|
+
return ReturnResponse(code=1, msg=f"{response.status_code}: {response.text}")
|
|
442
|
+
|
|
443
|
+
def get_metadata_for_issue_type_used_for_create_issue(self, project_id_or_key: Literal['TEST', 'MO']='TEST', issue_type_id: Literal['10000']='10000'):
|
|
444
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/createmeta/{project_id_or_key}/issuetypes/{issue_type_id}"
|
|
445
|
+
r = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
446
|
+
return r
|
|
447
|
+
|
|
448
|
+
def get_user(self, username: str=None, key: str=None):
|
|
449
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/user"
|
|
450
|
+
r = self.session.get(url, headers=self.headers, params={'username': username}, timeout=self.timeout)
|
|
451
|
+
return r
|
|
452
|
+
|
|
453
|
+
def get_issue_property(self, issue_id_or_key):
|
|
454
|
+
url = f"{self.base_url}/rest/api/2/issue/{issue_id_or_key}/properties"
|
|
455
|
+
r = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
456
|
+
return r
|
|
457
|
+
|
|
458
|
+
def find_user(self, query: str) -> ReturnResponse:
|
|
459
|
+
'''
|
|
460
|
+
查找用户
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
query (str): 建议输入用户的邮箱
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
_type_:
|
|
467
|
+
'''
|
|
468
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/user/search"
|
|
469
|
+
r = self.session.get(url, headers=self.headers, params={'query': query}, timeout=self.timeout)
|
|
470
|
+
if r.status_code == 200:
|
|
471
|
+
return ReturnResponse(code=0, msg=f'查找到 {len(r.json())} 个用户', data=r.json()[0])
|
|
472
|
+
else:
|
|
473
|
+
return ReturnResponse(code=1, msg=f'获取用户失败, status code: {r.status_code}, 报错: {r.text}')
|
|
474
|
+
|
|
475
|
+
def get_issue_transitions(self, issue_id_or_key) -> ReturnResponse:
|
|
476
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_id_or_key}/transitions"
|
|
477
|
+
r = self.session.get(url, headers=self.headers, timeout=self.timeout)
|
|
478
|
+
if r.status_code == 200:
|
|
479
|
+
return ReturnResponse(code=0, msg='', data=r.json())
|
|
480
|
+
else:
|
|
481
|
+
return ReturnResponse(code=1, msg=f'获取 issue [{issue_id_or_key}] 的 transitions 失败, status code: {r.status_code}, 报错: {r.text}')
|
|
482
|
+
|
|
483
|
+
def get_issue_transitions_by_name(self, issue_id_or_key, name) -> ReturnResponse:
|
|
484
|
+
r = self.get_issue_transitions(issue_id_or_key=issue_id_or_key)
|
|
485
|
+
for transition in r.data['transitions']:
|
|
486
|
+
if transition['name'] == name:
|
|
487
|
+
return ReturnResponse(code=0, msg=f'获取 issue [{issue_id_or_key}] 的 transitions 成功, 返回值: {transition["id"]}', data=transition['id'])
|
|
488
|
+
raise ValueError(f'获取 issue [{issue_id_or_key}] 的 transitions 失败, 没有找到状态为 {name} 的 transition')
|
|
489
|
+
|
|
490
|
+
def issue_transition(self, issue_id_or_key, transition_name) -> ReturnResponse:
|
|
491
|
+
update = False
|
|
492
|
+
status = self.issue_get_by_key(issue_id_or_key=issue_id_or_key, get_key='status')
|
|
493
|
+
if status:
|
|
494
|
+
if transition_name != status['name']:
|
|
495
|
+
update = True
|
|
496
|
+
if update:
|
|
497
|
+
url = f"{self.base_url}/rest/api/{self.rest_version}/issue/{issue_id_or_key}/transitions"
|
|
498
|
+
data = {
|
|
499
|
+
"transition": {
|
|
500
|
+
"id": self.get_issue_transitions_by_name(issue_id_or_key=issue_id_or_key, name=transition_name).data
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
r = self.session.post(url, headers=self.headers, json=data, timeout=self.timeout)
|
|
504
|
+
if r.status_code == 204:
|
|
505
|
+
return ReturnResponse(code=0, msg=f'更新 issue [{issue_id_or_key}] 的状态成功, 状态被更新为 {transition_name}', data=r.text)
|
|
506
|
+
else:
|
|
507
|
+
return ReturnResponse(code=1, msg=f'更新 issue [{issue_id_or_key}] 的状态失败, status code: {r.status_code}, 报错: {r.text}')
|
|
508
|
+
else:
|
|
509
|
+
return ReturnResponse(code=0, msg=f'issue [{issue_id_or_key}] 的状态已经为 {status["name"]}, 不需要更新')
|
|
510
|
+
|
|
511
|
+
def format_description(self, description):
|
|
512
|
+
'''
|
|
513
|
+
格式化描述
|
|
514
|
+
'''
|
|
515
|
+
if not description:
|
|
516
|
+
return ""
|
|
517
|
+
|
|
518
|
+
new_description = description
|
|
519
|
+
|
|
520
|
+
# 内容清理正则表达式
|
|
521
|
+
CONTENT_CLEANUP_PATTERNS = [
|
|
522
|
+
r'\*\*工作历时\*\*: \d+小时',
|
|
523
|
+
r'\*\*工作历时\*\*:.*小时',
|
|
524
|
+
r'!\[image\]\(.*?\)',
|
|
525
|
+
r'!\[file\]\(.*?\)',
|
|
526
|
+
r'\[Jira Link\]\(.*?\)',
|
|
527
|
+
r'bear://x-callback-url/open-note\?id=.*'
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
for pattern in CONTENT_CLEANUP_PATTERNS:
|
|
531
|
+
new_description = re.sub(pattern, '', new_description)
|
|
532
|
+
return new_description.strip()
|
|
533
|
+
|
|
534
|
+
def text_to_adf(self, text: str) -> dict:
|
|
535
|
+
"""
|
|
536
|
+
将普通文本转换为Atlassian Document Format (ADF)格式。
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
text: 要转换的文本内容
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
dict: ADF格式的文档对象
|
|
543
|
+
"""
|
|
544
|
+
if not text:
|
|
545
|
+
return {
|
|
546
|
+
"type": "doc",
|
|
547
|
+
"version": 1,
|
|
548
|
+
"content": [
|
|
549
|
+
{
|
|
550
|
+
"type": "paragraph",
|
|
551
|
+
"content": [
|
|
552
|
+
{
|
|
553
|
+
"type": "text",
|
|
554
|
+
"text": ""
|
|
555
|
+
}
|
|
556
|
+
]
|
|
557
|
+
}
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
# 将文本按行分割
|
|
562
|
+
lines = text.split('\n')
|
|
563
|
+
content = []
|
|
564
|
+
|
|
565
|
+
for line in lines:
|
|
566
|
+
if line.strip(): # 非空行
|
|
567
|
+
paragraph_content = [
|
|
568
|
+
{
|
|
569
|
+
"type": "text",
|
|
570
|
+
"text": line
|
|
571
|
+
}
|
|
572
|
+
]
|
|
573
|
+
content.append({
|
|
574
|
+
"type": "paragraph",
|
|
575
|
+
"content": paragraph_content
|
|
576
|
+
})
|
|
577
|
+
else: # 空行,添加换行符
|
|
578
|
+
if content: # 如果前面有内容,添加换行
|
|
579
|
+
content.append({
|
|
580
|
+
"type": "paragraph",
|
|
581
|
+
"content": [
|
|
582
|
+
{
|
|
583
|
+
"type": "text",
|
|
584
|
+
"text": ""
|
|
585
|
+
}
|
|
586
|
+
]
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
# 如果没有内容,至少添加一个空段落
|
|
590
|
+
if not content:
|
|
591
|
+
content = [
|
|
592
|
+
{
|
|
593
|
+
"type": "paragraph",
|
|
594
|
+
"content": [
|
|
595
|
+
{
|
|
596
|
+
"type": "text",
|
|
597
|
+
"text": ""
|
|
598
|
+
}
|
|
599
|
+
]
|
|
600
|
+
}
|
|
601
|
+
]
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
"type": "doc",
|
|
605
|
+
"version": 1,
|
|
606
|
+
"content": content
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
if __name__ == '__main__':
|
|
611
|
+
pass
|
|
612
|
+
|
pytbox/utils/cronjob.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from ..database.victoriametrics import VictoriaMetrics
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cronjob_counter(vm: VictoriaMetrics=None, log: str=None, app_type: Literal['']='', app='', comment=None, schedule_interval=None, schedule_cron=None):
|
|
10
|
+
"""计算函数运行时间的装饰器,支持记录到 VictoriaMetrics
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
app_type: 应用类型 ('alert', 'meraki', 'other')
|
|
14
|
+
app: 应用名称
|
|
15
|
+
comment: 备注信息
|
|
16
|
+
schedule_interval: 定时任务间隔(如 '1m', '5m')
|
|
17
|
+
schedule_cron: cron 表达式(如 '0 */5 * * *')
|
|
18
|
+
"""
|
|
19
|
+
def decorator(func):
|
|
20
|
+
@wraps(func)
|
|
21
|
+
def wrapper(*args, **kwargs):
|
|
22
|
+
start_time = time.time()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
result = func(*args, **kwargs)
|
|
26
|
+
end_time = time.time()
|
|
27
|
+
elapsed_time = end_time - start_time
|
|
28
|
+
|
|
29
|
+
# 记录任务成功完成状态
|
|
30
|
+
vm.insert_cronjob_run_status(
|
|
31
|
+
app_type=app_type,
|
|
32
|
+
app=app,
|
|
33
|
+
status_code=1, # 0 表示成功完成
|
|
34
|
+
comment=f"成功完成: {comment}" if comment else "任务成功完成",
|
|
35
|
+
schedule_interval=schedule_interval,
|
|
36
|
+
schedule_cron=schedule_cron
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 记录执行耗时
|
|
40
|
+
vm.insert_cronjob_duration_seconds(
|
|
41
|
+
app_type=app_type,
|
|
42
|
+
app=app,
|
|
43
|
+
duration_seconds=elapsed_time,
|
|
44
|
+
comment=comment,
|
|
45
|
+
schedule_interval=schedule_interval,
|
|
46
|
+
schedule_cron=schedule_cron
|
|
47
|
+
)
|
|
48
|
+
log.info(f"{app} 任务成功完成, 耗时 {elapsed_time:.2f} 秒")
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
except Exception as e:
|
|
53
|
+
end_time = time.time()
|
|
54
|
+
elapsed_time = end_time - start_time
|
|
55
|
+
|
|
56
|
+
# 记录任务失败状态
|
|
57
|
+
error_comment = f"执行出错: {str(e)}" if not comment else f"{comment} (出错: {str(e)})"
|
|
58
|
+
vm.insert_cronjob_run_status(
|
|
59
|
+
app_type=app_type,
|
|
60
|
+
app=app,
|
|
61
|
+
status_code=0, # 1 表示失败
|
|
62
|
+
comment=error_comment,
|
|
63
|
+
schedule_interval=schedule_interval,
|
|
64
|
+
schedule_cron=schedule_cron
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 即使出错也记录耗时
|
|
68
|
+
vm.insert_cronjob_duration_seconds(
|
|
69
|
+
app_type=app_type,
|
|
70
|
+
app=app,
|
|
71
|
+
duration_seconds=elapsed_time,
|
|
72
|
+
comment=error_comment,
|
|
73
|
+
schedule_interval=schedule_interval,
|
|
74
|
+
schedule_cron=schedule_cron
|
|
75
|
+
)
|
|
76
|
+
log.error(f"任务失败: {error_comment}")
|
|
77
|
+
raise
|
|
78
|
+
return wrapper
|
|
79
|
+
return decorator
|