-
Notifications
You must be signed in to change notification settings - Fork 4
/
issue.py
executable file
·303 lines (244 loc) · 9.82 KB
/
issue.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
#!/usr/bin/env python
from __future__ import annotations
import configparser
import random
from dataclasses import dataclass
import requests
from jira import JIRA, Issue
from requests.models import Response
# issue を取得する QUERY
QUERY = """project = {project} AND status in (Open, "In Progress", Reopened)
AND {due} ORDER BY due ASC, component ASC"""
# プロジェクト名とコンポーネント、チャンネルの一覧
PROJECTS = {
"ISSHA": [
# (コンポーネント, チャンネル)
("一般社団法人", "#committee"),
("Python Boot Camp", "#pycamp"),
("Pycamp Caravan", "#pycamp-caravan"),
("PyCon JP TV", "#pyconjptv準備室"),
],
"TAT": [
("0. 全体", "#2024"),
("1. 会場", "#2024-t-venue"),
("2. 参加者管理", "#2024-t-attendee"),
("3. プログラム", "#2024-t-program"),
("4. 広報", "#2024-t-pr"),
("5. スポンサー", "#2024-t-sponsor"),
("6. 会計", "#2024-t-account"),
],
}
# プロジェクトのメインチャンネル
PROJECT_CHANNEL = {"ISSHA": "#committee", "TAT": "#2024"}
# JIRA サーバー
SERVER = "https://pyconjp.atlassian.net"
# Slack API
SLACK_API = "https://slack.com/api/"
# ロボット顔文字
FACES = ("┗┫ ̄皿 ̄┣┛", "┗┃ ̄□ ̄;┃┓ ", "┏┫ ̄皿 ̄┣┛", "┗┃・ ■ ・┃┛", "┗┫=皿[+]┣┛")
@dataclass
class IssueInfo:
"""JIRAの1課題分の情報を保持するクラス"""
key: str # 課題のキー(ISSHA-XXXX等)
url: str # 課題のURL
summary: str
created: str # 作成日時
updated: str # 更新日時
duedate: str # 期日
priority: str # 優先度
status: str # 状態
components: list
name: str = "" # 担当者名
slack: str = "" # slackの名前
def issue_to_issue_info(issue: Issue, users: dict[str, str]) -> IssueInfo:
"""
issue から必要な値を取り出して、IssueInfo形式にして返す
"""
# コンポーネント名のリストを作成
components = [component["name"] for component in issue.raw["fields"]["components"]]
issue_info = IssueInfo(
key=issue.raw["key"],
url=issue.permalink(),
summary=issue.raw["fields"]["summary"],
created=issue.raw["fields"]["created"],
updated=issue.raw["fields"]["updated"],
duedate=issue.raw["fields"]["duedate"],
priority=issue.raw["fields"]["priority"]["name"],
status=issue.raw["fields"]["status"]["name"],
components=components,
)
# 担当者が存在する場合はnameに名前を設定する
assignee = issue.raw["fields"]["assignee"]
if assignee is not None:
issue_info.name = assignee.get("displayName")
# nameがSlackに存在したら、Slackのreal_nameを設定する
if issue_info.name.lower() in users:
issue_info.slack = users[issue_info.name.lower()]
return issue_info
def get_issue_infos(jira: JIRA, query: str, users: dict[str, str]) -> list[IssueInfo]:
"""
JIRAから指定されたqueryに合致するissueの一覧を返す
"""
issues = jira.search_issues(query)
issue_infos = [issue_to_issue_info(issue, users) for issue in issues]
return issue_infos
def get_expired_issues(
jira: JIRA, project: str, users: dict[str, str]
) -> tuple[list[IssueInfo], list[IssueInfo]]:
"""
JIRAから期限切れ、もうすぐ期限切れのissueの一覧をかえす
"""
# 期限切れ
expired_query = QUERY.format(project=project, due='due <= "0"')
expired = get_issue_infos(jira, expired_query, users)
# もうすぐ期限切れ
soon_query = QUERY.format(project=project, due='due > "0" AND due <= 7d')
soon = get_issue_infos(jira, soon_query, users)
return expired, soon
def get_issue_infos_by_component(
issue_infos: list[IssueInfo], component: str | tuple[str]
) -> list[IssueInfo]:
"""
指定されたコンポーネント(複数の場合もある)に関連づいたissue_infoを返す
"""
# コンポーネントを set に変換する
if isinstance(component, str):
component_set = {component}
else:
component_set = set(component)
result = []
for issue_info in issue_infos:
# 関連するコンポーネントが存在するissueを抜き出す
if component_set & set(issue_info.components):
result.append(issue_info)
return result
def get_users_from_slack(token: str) -> dict[str, str]:
"""
Slack上のUserListを取得
API: https://api.slack.com/methods/users.list
"""
url = SLACK_API + "users.list"
payload = {"token": token}
response = requests.get(url, payload)
users_list = response.json()
# real_nameをキー、slackのnameを値にした辞書を作成する
members = users_list["members"]
users = {m["profile"].get("real_name").lower(): m["name"] for m in members}
return users
def formatted_issue_info(issue_info: IssueInfo) -> str:
"""
1件のissue_infoを文字列にして返す
"""
issue_text = f"- {issue_info.duedate} <{issue_info.url}|{issue_info.key}>: "
issue_text += f"{issue_info.summary}"
if issue_info.slack:
issue_text += f" (@{issue_info.slack})"
elif issue_info.name:
issue_text += f" ({issue_info.name})"
else:
issue_text += " (*担当者未設定*)"
return issue_text
def create_issue_message(title: str, issue_infos: list[IssueInfo]) -> str:
"""
チケットの一覧をメッセージを作成する
"""
# 通知用のテキストを生成
text = f"{title}ハ *{len(issue_infos)}件* デス{random.choice(FACES)}\n"
text += (
"> JIRAの氏名(<https://id.atlassian.com/manage-profile|"
"プロファイルとその公開範囲>)と"
"SlackのFull nameを同一にするとメンションされるので"
"おすすめ(大文字小文字は無視)\n"
)
for issue_info in issue_infos:
text += formatted_issue_info(issue_info) + "\n"
return text
def send_message_to_slack(
title: str, text: str, channel: str, token: str, debug: bool
) -> Response:
"""
メッセージを Slack に送信
"""
url = SLACK_API + "chat.postMessage"
payload = {
"token": token,
"channel": channel,
"username": "JIRA bot",
"icon_emoji": ":jirabot:",
"fallback": title,
"text": text,
"mrkdwn": True,
"link_names": 1,
}
# debugモードの場合は slack-test に投げる
if debug:
payload["channel"] = "slack-test"
r = requests.post(url, payload)
return r
def main(username: str, password: str, token: str, debug: bool) -> None:
"""
期限切れ、もうすぐ期限切れのチケットの一覧を取得してSlackで通知する
"""
# JIRA に接続
options = {"server": SERVER}
jira = JIRA(options=options, basic_auth=(username, password))
# Slack から UserListを取得
users = get_users_from_slack(token)
# 対象となるJIRAプロジェクト: コンポーネントの一覧
for project, components in PROJECTS.items():
# 期限切れ(expired)、もうすぐ期限切れ(soon)のチケット一覧を取得
pj_expired, pj_soon = get_expired_issues(jira, project, users)
# プロジェクトごとのチケット状況をまとめる
summaries = []
# issueをコンポーネントごとに分ける
for component, channel in components:
expired = get_issue_infos_by_component(pj_expired, component)
soon = get_issue_infos_by_component(pj_soon, component)
if isinstance(component, tuple):
component = "、".join(component)
header = f"*{project}/{component}* の"
if expired:
# 期限切れチケットのメッセージを送信
title = header + "「期限切れチケット」"
text = create_issue_message(title, expired)
send_message_to_slack(title, text, channel, token, debug)
if soon:
# もうすぐ期限切れチケットのメッセージを送信
title = header + "「もうすぐ期限切れチケット」"
text = create_issue_message(title, soon)
send_message_to_slack(title, text, channel, token, debug)
# チケット状況を保存
summaries.append(
{
"component": component,
"channel": channel,
"expired": len(expired),
"soon": len(soon),
}
)
# プロジェクト全体の状況をまとめる
title = "チケット状況"
text = f"*{project}* ノチケット状況デス\n"
for summary in summaries:
# 残りの件数によって天気マークを付ける
summary["icon"] = ":sunny:"
if isinstance(summary["expired"], int):
if summary["expired"] >= 10:
summary["icon"] = ":umbrella:"
elif summary["expired"] >= 5:
summary["icon"] = ":cloud:"
text += (
"{icon} *{component}* ({channel}) 期限切れ *{expired}* "
"もうすぐ期限切れ *{soon}*\n".format(**summary)
)
channel = PROJECT_CHANNEL[project]
send_message_to_slack(title, text, channel, token, debug)
if __name__ == "__main__":
# config.ini からパラメーターを取得
config = configparser.ConfigParser()
config.read("config.ini")
username = config["DEFAULT"]["username"]
password = config["DEFAULT"]["password"]
token = config["DEFAULT"]["token"]
debug = config["DEFAULT"].getboolean("debug")
main(username, password, token, debug)