suite-py 1.50.0__py3-none-any.whl → 1.53.0__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.
- suite_py/__version__.py +1 -1
- suite_py/cli.py +5 -1
- suite_py/commands/ask_review.py +4 -6
- suite_py/commands/check.py +12 -1
- suite_py/commands/context.py +4 -0
- suite_py/commands/create_branch.py +2 -2
- suite_py/commands/merge_pr.py +6 -1
- suite_py/commands/project_lock.py +6 -1
- suite_py/commands/release.py +54 -2
- suite_py/lib/config.py +2 -0
- suite_py/lib/handler/backstage_handler.py +167 -0
- suite_py/lib/handler/github_handler.py +173 -2
- suite_py/lib/handler/okta_handler.py +1 -1
- suite_py/lib/handler/youtrack_handler.py +45 -9
- {suite_py-1.50.0.dist-info → suite_py-1.53.0.dist-info}/METADATA +4 -5
- {suite_py-1.50.0.dist-info → suite_py-1.53.0.dist-info}/RECORD +18 -17
- {suite_py-1.50.0.dist-info → suite_py-1.53.0.dist-info}/WHEEL +0 -0
- {suite_py-1.50.0.dist-info → suite_py-1.53.0.dist-info}/entry_points.txt +0 -0
suite_py/__version__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.53.0"
|
suite_py/cli.py
CHANGED
|
@@ -46,6 +46,7 @@ from suite_py.lib import logger, metrics
|
|
|
46
46
|
from suite_py.lib.config import Config
|
|
47
47
|
from suite_py.lib.handler import git_handler as git
|
|
48
48
|
from suite_py.lib.handler import prompt_utils
|
|
49
|
+
from suite_py.lib.handler.backstage_handler import Backstage
|
|
49
50
|
from suite_py.lib.handler.captainhook_handler import CaptainHook
|
|
50
51
|
from suite_py.lib.handler.git_handler import GitHandler
|
|
51
52
|
from suite_py.lib.handler.okta_handler import Okta
|
|
@@ -132,7 +133,7 @@ def catch_exceptions(func):
|
|
|
132
133
|
@click.option(
|
|
133
134
|
"--timeout",
|
|
134
135
|
type=click.INT,
|
|
135
|
-
help="Timeout in seconds for Captainhook operations",
|
|
136
|
+
help="Timeout in seconds for Captainhook/Backstage operations",
|
|
136
137
|
)
|
|
137
138
|
@click.option("-v", "--verbose", count=True)
|
|
138
139
|
@click.pass_context
|
|
@@ -177,16 +178,19 @@ def main(ctx, project, timeout, verbose):
|
|
|
177
178
|
ctx.exit()
|
|
178
179
|
if timeout:
|
|
179
180
|
config.user["captainhook_timeout"] = timeout
|
|
181
|
+
config.user["backstage_timeout"] = timeout
|
|
180
182
|
|
|
181
183
|
tokens = Tokens()
|
|
182
184
|
okta = Okta(config, tokens)
|
|
183
185
|
captainhook = CaptainHook(config, okta, tokens)
|
|
186
|
+
backstage = Backstage(config, okta, tokens)
|
|
184
187
|
project = os.path.basename(project)
|
|
185
188
|
git_handler = GitHandler(project, config)
|
|
186
189
|
youtrack_handler = YoutrackHandler(config, tokens)
|
|
187
190
|
|
|
188
191
|
ctx.obj = Context(
|
|
189
192
|
captainhook=captainhook,
|
|
193
|
+
backstage=backstage,
|
|
190
194
|
config=config,
|
|
191
195
|
git_handler=git_handler,
|
|
192
196
|
okta=okta,
|
suite_py/commands/ask_review.py
CHANGED
|
@@ -10,11 +10,11 @@ from suite_py.lib.handler.youtrack_handler import YoutrackHandler
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class AskReview:
|
|
13
|
-
def __init__(self, project,
|
|
13
|
+
def __init__(self, project, backstage, config, tokens):
|
|
14
14
|
self._project = project
|
|
15
15
|
self._config = config
|
|
16
16
|
self._youtrack = YoutrackHandler(config, tokens)
|
|
17
|
-
self.
|
|
17
|
+
self._backstage = backstage
|
|
18
18
|
self._git = GitHandler(project, config)
|
|
19
19
|
self._github = GithubHandler(tokens)
|
|
20
20
|
self._frequent_reviewers = FrequentReviewersHandler(config)
|
|
@@ -32,13 +32,11 @@ class AskReview:
|
|
|
32
32
|
|
|
33
33
|
def _maybe_get_users_list(self):
|
|
34
34
|
try:
|
|
35
|
-
users = self.
|
|
35
|
+
users = self._backstage.get_users_list()
|
|
36
36
|
self._config.put_cache("users", users)
|
|
37
37
|
return users
|
|
38
38
|
except Exception:
|
|
39
|
-
logger.warning(
|
|
40
|
-
"Can't get users list from Captainhook. Using cached version."
|
|
41
|
-
)
|
|
39
|
+
logger.warning("Can't get users list from Backstage. Using cached version.")
|
|
42
40
|
return self._config.get_cache("users")
|
|
43
41
|
|
|
44
42
|
def _get_pr(self):
|
suite_py/commands/check.py
CHANGED
|
@@ -8,18 +8,22 @@ from suite_py.lib.symbol import CHECKMARK, CROSSMARK
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class Check:
|
|
11
|
-
|
|
11
|
+
# delete this after `captainhook` has been removed
|
|
12
|
+
# pylint: disable=too-many-instance-attributes
|
|
13
|
+
def __init__(self, captainhook, backstage, config, tokens):
|
|
12
14
|
self.config = config
|
|
13
15
|
self._invalid_or_missing_tokens = []
|
|
14
16
|
self._tokens = tokens
|
|
15
17
|
self._youtrack = YoutrackHandler(config, tokens)
|
|
16
18
|
self._github = GithubHandler(tokens)
|
|
17
19
|
self._captainhook = captainhook
|
|
20
|
+
self._backstage = backstage
|
|
18
21
|
|
|
19
22
|
self._checks = [
|
|
20
23
|
("Github", self._check_github),
|
|
21
24
|
("Youtrack", self._check_youtrack),
|
|
22
25
|
("CaptainHook", self._check_captainhook),
|
|
26
|
+
("Backstage", self._check_backstage),
|
|
23
27
|
("AWS", self._check_aws),
|
|
24
28
|
]
|
|
25
29
|
|
|
@@ -69,6 +73,13 @@ class Check:
|
|
|
69
73
|
except Exception:
|
|
70
74
|
return "unreachable"
|
|
71
75
|
|
|
76
|
+
def _check_backstage(self):
|
|
77
|
+
try:
|
|
78
|
+
self._backstage.check()
|
|
79
|
+
return "ok"
|
|
80
|
+
except Exception:
|
|
81
|
+
return "unreachable"
|
|
82
|
+
|
|
72
83
|
def _check_aws(self):
|
|
73
84
|
try:
|
|
74
85
|
if self.config.aws != "":
|
suite_py/commands/context.py
CHANGED
|
@@ -2,6 +2,7 @@ import dataclasses
|
|
|
2
2
|
from inspect import signature
|
|
3
3
|
|
|
4
4
|
from suite_py.lib.config import Config
|
|
5
|
+
from suite_py.lib.handler.backstage_handler import Backstage
|
|
5
6
|
from suite_py.lib.handler.captainhook_handler import CaptainHook
|
|
6
7
|
from suite_py.lib.handler.git_handler import GitHandler
|
|
7
8
|
from suite_py.lib.handler.okta_handler import Okta
|
|
@@ -9,9 +10,12 @@ from suite_py.lib.handler.youtrack_handler import YoutrackHandler
|
|
|
9
10
|
from suite_py.lib.tokens import Tokens
|
|
10
11
|
|
|
11
12
|
|
|
13
|
+
# delete this after `captainhook` has been removed
|
|
14
|
+
# pylint: disable=too-many-instance-attributes
|
|
12
15
|
@dataclasses.dataclass
|
|
13
16
|
class Context:
|
|
14
17
|
captainhook: CaptainHook
|
|
18
|
+
backstage: Backstage
|
|
15
19
|
config: Config
|
|
16
20
|
git_handler: GitHandler
|
|
17
21
|
okta: Okta
|
|
@@ -43,9 +43,9 @@ class CreateBranch:
|
|
|
43
43
|
issue = self._youtrack_handler.get_issue(card_id)
|
|
44
44
|
else:
|
|
45
45
|
issue = self._youtrack_handler.get_issue(self._ask_card_id())
|
|
46
|
-
except Exception:
|
|
46
|
+
except Exception as e:
|
|
47
47
|
logger.error(
|
|
48
|
-
"There was a problem retrieving the issue from YouTrack.
|
|
48
|
+
f"There was a problem retrieving the issue `{card_id}` from YouTrack. {e}"
|
|
49
49
|
)
|
|
50
50
|
sys.exit(-1)
|
|
51
51
|
|
suite_py/commands/merge_pr.py
CHANGED
|
@@ -6,6 +6,7 @@ from halo import Halo
|
|
|
6
6
|
from suite_py.lib import logger, metrics
|
|
7
7
|
from suite_py.lib.handler import git_handler as git
|
|
8
8
|
from suite_py.lib.handler import prompt_utils
|
|
9
|
+
from suite_py.lib.handler.backstage_handler import Backstage
|
|
9
10
|
from suite_py.lib.handler.captainhook_handler import CaptainHook
|
|
10
11
|
from suite_py.lib.handler.git_handler import GitHandler
|
|
11
12
|
from suite_py.lib.handler.github_handler import GithubHandler
|
|
@@ -14,11 +15,14 @@ from suite_py.lib.symbol import CHECKMARK, CROSSMARK
|
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class MergePR:
|
|
17
|
-
def __init__(
|
|
18
|
+
def __init__(
|
|
19
|
+
self, project, captainhook: CaptainHook, backstage: Backstage, config, tokens
|
|
20
|
+
):
|
|
18
21
|
self._project = project
|
|
19
22
|
self._config = config
|
|
20
23
|
self._youtrack = YoutrackHandler(config, tokens)
|
|
21
24
|
self._captainhook = captainhook
|
|
25
|
+
self._backstage = backstage
|
|
22
26
|
self._git = GitHandler(project, config)
|
|
23
27
|
self._github = GithubHandler(tokens)
|
|
24
28
|
|
|
@@ -63,6 +67,7 @@ class MergePR:
|
|
|
63
67
|
default=False,
|
|
64
68
|
):
|
|
65
69
|
self._captainhook.lock_project(self._project, "staging")
|
|
70
|
+
self._backstage.lock_project(self._project, "staging")
|
|
66
71
|
|
|
67
72
|
if youtrack_id:
|
|
68
73
|
logger.info("Updating card status on YouTrack...")
|
|
@@ -7,11 +7,12 @@ from suite_py.lib import logger, metrics
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class ProjectLock:
|
|
10
|
-
def __init__(self, project, env, action, captainhook):
|
|
10
|
+
def __init__(self, project, env, action, captainhook, backstage):
|
|
11
11
|
self._project = project
|
|
12
12
|
self._env = _parse_env(env)
|
|
13
13
|
self._action = action
|
|
14
14
|
self._captainhook = captainhook
|
|
15
|
+
self._backstage = backstage
|
|
15
16
|
|
|
16
17
|
@metrics.command("manage-project-lock")
|
|
17
18
|
def run(self):
|
|
@@ -25,6 +26,8 @@ class ProjectLock:
|
|
|
25
26
|
"Captainhook request timed out. Try with suite-py --timeout=60 lock-project lock"
|
|
26
27
|
)
|
|
27
28
|
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
self._backstage.lock_project(self._project, self._env)
|
|
28
31
|
elif self._action == "unlock":
|
|
29
32
|
try:
|
|
30
33
|
req = self._captainhook.unlock_project(self._project, self._env)
|
|
@@ -37,6 +40,8 @@ class ProjectLock:
|
|
|
37
40
|
"Captainhook request timed out. Try with suite-py --timeout=60 lock-project lock"
|
|
38
41
|
)
|
|
39
42
|
sys.exit(1)
|
|
43
|
+
|
|
44
|
+
self._backstage.unlock_project(self._project, self._env)
|
|
40
45
|
else:
|
|
41
46
|
logger.warning("I'm confused. Make the correct choice.")
|
|
42
47
|
sys.exit(-1)
|
suite_py/commands/release.py
CHANGED
|
@@ -42,6 +42,8 @@ class Release:
|
|
|
42
42
|
self._repo = self._github.get_repo(project)
|
|
43
43
|
self._git = GitHandler(project, config)
|
|
44
44
|
self._version = VersionHandler(self._repo, self._git, self._github)
|
|
45
|
+
# Cache for workflow events {check_run_database_id: event_type}
|
|
46
|
+
self.workflow_events = {}
|
|
45
47
|
|
|
46
48
|
@metrics.command("release")
|
|
47
49
|
def run(self):
|
|
@@ -145,6 +147,12 @@ class Release:
|
|
|
145
147
|
if self._commit or not commits:
|
|
146
148
|
return
|
|
147
149
|
|
|
150
|
+
# Batch fetch workflow events for all commits upfront for efficiency
|
|
151
|
+
shas = [c.sha for c in commits]
|
|
152
|
+
self.workflow_events = self._github.get_workflow_events_for_commits(
|
|
153
|
+
self._repo, shas
|
|
154
|
+
)
|
|
155
|
+
|
|
148
156
|
choices = []
|
|
149
157
|
|
|
150
158
|
def build_choice(c, icon):
|
|
@@ -196,9 +204,20 @@ class Release:
|
|
|
196
204
|
return []
|
|
197
205
|
|
|
198
206
|
def _classify_from_check_runs(self, check_runs):
|
|
199
|
-
|
|
207
|
+
"""Classify commit status from check runs, filtering by event type.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
check_runs: List of check run objects from GitHub API
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
str: One of "in_progress", "failure", "cancelled", "success", "unknown"
|
|
214
|
+
"""
|
|
215
|
+
# Filter check runs by event type if workflow events are available
|
|
216
|
+
filtered_runs = self._filter_check_runs_by_event(check_runs)
|
|
217
|
+
|
|
218
|
+
statuses = {r.status for r in filtered_runs if getattr(r, "status", None)}
|
|
200
219
|
conclusions = {
|
|
201
|
-
r.conclusion for r in
|
|
220
|
+
r.conclusion for r in filtered_runs if getattr(r, "conclusion", None)
|
|
202
221
|
}
|
|
203
222
|
|
|
204
223
|
if self._any_in_progress(statuses):
|
|
@@ -211,6 +230,39 @@ class Release:
|
|
|
211
230
|
return "success"
|
|
212
231
|
return "unknown"
|
|
213
232
|
|
|
233
|
+
def _filter_check_runs_by_event(self, check_runs):
|
|
234
|
+
"""Filter check runs to include only push events and unknown events.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
check_runs: List of check run objects from GitHub API
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
list: Filtered list of check runs
|
|
241
|
+
"""
|
|
242
|
+
# If no workflow events cached, return all check runs (fallback)
|
|
243
|
+
if not self.workflow_events:
|
|
244
|
+
return check_runs
|
|
245
|
+
|
|
246
|
+
filtered = []
|
|
247
|
+
|
|
248
|
+
for check_run in check_runs:
|
|
249
|
+
# Get the database ID for this check run
|
|
250
|
+
database_id = getattr(check_run, "id", None)
|
|
251
|
+
if not database_id:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Look up the event type for this check run
|
|
255
|
+
event_type = self.workflow_events.get(database_id, "unknown")
|
|
256
|
+
|
|
257
|
+
# Include check runs with "push" or "unknown" event types
|
|
258
|
+
# "unknown" covers manual checks and status checks without workflows
|
|
259
|
+
if event_type in ("push", "unknown"):
|
|
260
|
+
filtered.append(check_run)
|
|
261
|
+
|
|
262
|
+
# If filtering resulted in no check runs, fall back to all check runs
|
|
263
|
+
# This handles edge cases where GraphQL data might be incomplete
|
|
264
|
+
return filtered if filtered else check_runs
|
|
265
|
+
|
|
214
266
|
@staticmethod
|
|
215
267
|
def _any_in_progress(statuses):
|
|
216
268
|
return any(
|
suite_py/lib/config.py
CHANGED
|
@@ -40,6 +40,8 @@ class Config:
|
|
|
40
40
|
# This is in seconds
|
|
41
41
|
conf["user"].setdefault("captainhook_timeout", 30)
|
|
42
42
|
conf["user"].setdefault("captainhook_url", "https://captainhook.prima.it")
|
|
43
|
+
conf["user"].setdefault("backstage_timeout", 30)
|
|
44
|
+
conf["user"].setdefault("backstage_url", "https://backstage.helloprima.com")
|
|
43
45
|
conf["user"].setdefault("use_commits_in_pr_body", False)
|
|
44
46
|
conf["user"].setdefault("frequent_reviewers_max_number", 5)
|
|
45
47
|
conf["user"].setdefault("pr_title_template", "[$card_id]: $title")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
|
2
|
+
import ssl
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from suite_py.__version__ import __version__
|
|
7
|
+
from suite_py.lib.handler.github_handler import GithubHandler
|
|
8
|
+
from suite_py.lib.handler.okta_handler import Okta
|
|
9
|
+
from suite_py.lib import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackstageError(Exception): ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Backstage:
|
|
16
|
+
_okta: Okta
|
|
17
|
+
_github: GithubHandler
|
|
18
|
+
|
|
19
|
+
def __init__(self, config, okta: Okta, tokens=None):
|
|
20
|
+
self._backstage_url = config.user["backstage_url"]
|
|
21
|
+
self._timeout = config.user["backstage_timeout"]
|
|
22
|
+
self._backstage_identity_token = None
|
|
23
|
+
|
|
24
|
+
# Under mise cacert are loaded from ~/.local/share/mise
|
|
25
|
+
# so we need to force system defaults to make warp happy
|
|
26
|
+
self._verify = ssl.get_default_verify_paths().capath
|
|
27
|
+
self._okta = okta
|
|
28
|
+
|
|
29
|
+
if tokens is not None:
|
|
30
|
+
self._github = GithubHandler(tokens)
|
|
31
|
+
|
|
32
|
+
def lock_project(self, project, env):
|
|
33
|
+
return self._request(
|
|
34
|
+
method="post",
|
|
35
|
+
url=self._entity_locking_endpoint("/projects/manage-lock"),
|
|
36
|
+
headers=self._okta_headers(),
|
|
37
|
+
raise_for_status=True,
|
|
38
|
+
json={
|
|
39
|
+
"project": project,
|
|
40
|
+
"status": "locked",
|
|
41
|
+
"user": self._get_user(),
|
|
42
|
+
"environment": env,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def unlock_project(self, project, env):
|
|
47
|
+
return self._request(
|
|
48
|
+
method="post",
|
|
49
|
+
url=self._entity_locking_endpoint("/projects/manage-lock"),
|
|
50
|
+
headers=self._okta_headers(),
|
|
51
|
+
raise_for_status=True,
|
|
52
|
+
json={
|
|
53
|
+
"project": project,
|
|
54
|
+
"status": "unlocked",
|
|
55
|
+
"user": self._get_user(),
|
|
56
|
+
"environment": env,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def status(self, project, env):
|
|
61
|
+
return self._request(
|
|
62
|
+
method="get",
|
|
63
|
+
url=self._entity_locking_endpoint(
|
|
64
|
+
f"/projects/check?project={project}&environment={env}"
|
|
65
|
+
),
|
|
66
|
+
raise_for_status=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def check(self):
|
|
70
|
+
response = self._request(
|
|
71
|
+
method="get",
|
|
72
|
+
url=self._entity_locking_endpoint("/ping"),
|
|
73
|
+
headers=self._okta_headers(),
|
|
74
|
+
timeout=(2, self._timeout),
|
|
75
|
+
raise_for_status=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert response.text == "pong"
|
|
79
|
+
|
|
80
|
+
def get_users_list(self):
|
|
81
|
+
response = self._fetch_users()
|
|
82
|
+
|
|
83
|
+
if response.status_code == 401:
|
|
84
|
+
self._refresh_backstage_identity_token()
|
|
85
|
+
response = self._fetch_users()
|
|
86
|
+
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
|
|
89
|
+
return [
|
|
90
|
+
{"name": u.pop("displayName"), **u}
|
|
91
|
+
for u in (item["spec"]["profile"] for item in response.json()["items"])
|
|
92
|
+
if "github" in u and "youtrack" in u and "displayName" in u
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def _request(self, method, url, **kwargs):
|
|
96
|
+
timeout = kwargs.pop("timeout", self._timeout)
|
|
97
|
+
verify = kwargs.pop("verify", self._verify)
|
|
98
|
+
allow_redirects = kwargs.pop("allow_redirects", False)
|
|
99
|
+
raise_for_status = kwargs.pop("raise_for_status", False)
|
|
100
|
+
|
|
101
|
+
response = requests.request(
|
|
102
|
+
method=method,
|
|
103
|
+
url=url,
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
verify=verify,
|
|
106
|
+
allow_redirects=allow_redirects,
|
|
107
|
+
**kwargs,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
redirect = (
|
|
111
|
+
urlparse(response.headers["Location"])
|
|
112
|
+
if response.status_code == 302
|
|
113
|
+
else None
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if redirect is not None and redirect.netloc == "prima.cloudflareaccess.com":
|
|
117
|
+
logger.error(
|
|
118
|
+
"It looks like Cloudflare WARP is not active, please enable it and try again"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
raise BackstageError("Cloudflare Access authentication redirect")
|
|
122
|
+
|
|
123
|
+
if raise_for_status:
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
|
|
126
|
+
return response
|
|
127
|
+
|
|
128
|
+
def _entity_locking_endpoint(self, path):
|
|
129
|
+
return f"{self._backstage_url}{path}"
|
|
130
|
+
|
|
131
|
+
def _get_user(self):
|
|
132
|
+
return self._github.get_user().login
|
|
133
|
+
|
|
134
|
+
def _okta_headers(self):
|
|
135
|
+
return {
|
|
136
|
+
"User-Agent": f"suite-py/{__version__}",
|
|
137
|
+
"Authorization": f"Bearer {self._okta.get_id_token()}",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def _backstage_headers(self):
|
|
141
|
+
if self._backstage_identity_token is None:
|
|
142
|
+
self._refresh_backstage_identity_token()
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"User-Agent": f"suite-py/{__version__}",
|
|
146
|
+
"Authorization": f"Bearer {self._backstage_identity_token}",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _fetch_users(self):
|
|
150
|
+
fields = ",".join(
|
|
151
|
+
f"spec.profile.{f}" for f in ("displayName", "youtrack", "slack", "github")
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return self._request(
|
|
155
|
+
method="GET",
|
|
156
|
+
url=f"{self._backstage_url}/api/catalog/entities/by-query?filter=kind=user&fields={fields}&limit=500",
|
|
157
|
+
headers=self._backstage_headers(),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _refresh_backstage_identity_token(self):
|
|
161
|
+
response = self._request(
|
|
162
|
+
method="GET",
|
|
163
|
+
url=f"{self._backstage_url}/api/auth/cfaccess/refresh",
|
|
164
|
+
raise_for_status=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
self._backstage_identity_token = response.json()["backstageIdentity"]["token"]
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
-
from github import Github
|
|
2
|
+
from github import Auth, Github
|
|
3
3
|
from github.GitRelease import GitRelease # pylint: disable=unused-import
|
|
4
4
|
from github.Repository import Repository # pylint: disable=unused-import
|
|
5
5
|
|
|
6
|
+
from suite_py.lib import logger
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
class GithubHandler:
|
|
10
|
+
# pylint: disable=too-many-public-methods
|
|
8
11
|
_organization = "primait"
|
|
9
12
|
|
|
10
13
|
def __init__(self, tokens):
|
|
11
|
-
|
|
14
|
+
auth = Auth.Token(tokens.github)
|
|
15
|
+
self._client = Github(auth=auth)
|
|
12
16
|
|
|
13
17
|
def get_repo(self, repo_name):
|
|
14
18
|
return self._client.get_repo(f"{self._organization}/{repo_name}")
|
|
@@ -90,3 +94,170 @@ class GithubHandler:
|
|
|
90
94
|
repo = self.get_repo(repo)
|
|
91
95
|
content = repo.get_contents(file, ref)
|
|
92
96
|
return content.decoded_content
|
|
97
|
+
|
|
98
|
+
def _execute_graphql(self, query, variables):
|
|
99
|
+
"""
|
|
100
|
+
Execute a GraphQL query against GitHub's GraphQL API.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
query: The GraphQL query string
|
|
104
|
+
variables: Dictionary of variables for the query (can be None)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
dict: The parsed JSON response, or None if an error occurs
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
requester = self._client.requester
|
|
111
|
+
response = requester.graphql_query(query, variables)
|
|
112
|
+
return response
|
|
113
|
+
except Exception as error:
|
|
114
|
+
logger.error(
|
|
115
|
+
"GraphQL query failed: %s. Variables: %s",
|
|
116
|
+
str(error),
|
|
117
|
+
variables,
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def get_workflow_events_for_commits(self, repo, shas):
|
|
122
|
+
"""Fetch workflow events for a list of commit SHAs using GraphQL.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
repo: Repository object from PyGithub
|
|
126
|
+
shas: List of commit SHA strings
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
dict: Mapping of {check_run_database_id: event_type}
|
|
130
|
+
Returns empty dict if GraphQL query fails or response contains errors.
|
|
131
|
+
This allows callers to continue with unfiltered check runs as fallback.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If response parsing fails (raised by _parse_workflow_events_response)
|
|
135
|
+
"""
|
|
136
|
+
if not shas:
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
# Build GraphQL query using variables for commit SHAs (not string interpolation)
|
|
140
|
+
# The GitHub GraphQL API only accepts single oid per object() call
|
|
141
|
+
commit_fragments = []
|
|
142
|
+
variables = {
|
|
143
|
+
"owner": repo.owner.login,
|
|
144
|
+
"repo": repo.name,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Add each SHA as a variable and build corresponding query fragment
|
|
148
|
+
for idx, sha in enumerate(shas):
|
|
149
|
+
sha_var = f"sha{idx}"
|
|
150
|
+
variables[sha_var] = sha
|
|
151
|
+
|
|
152
|
+
commit_fragments.append(
|
|
153
|
+
f"""
|
|
154
|
+
commit{idx}: object(oid: ${sha_var}) {{
|
|
155
|
+
... on Commit {{
|
|
156
|
+
oid
|
|
157
|
+
checkSuites(first: 100) {{
|
|
158
|
+
nodes {{
|
|
159
|
+
workflowRun {{
|
|
160
|
+
event
|
|
161
|
+
}}
|
|
162
|
+
checkRuns(first: 100) {{
|
|
163
|
+
nodes {{
|
|
164
|
+
databaseId
|
|
165
|
+
name
|
|
166
|
+
}}
|
|
167
|
+
}}
|
|
168
|
+
}}
|
|
169
|
+
}}
|
|
170
|
+
}}
|
|
171
|
+
}}
|
|
172
|
+
"""
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Build query signature with all SHA variables
|
|
176
|
+
sha_params = ", ".join([f"$sha{i}: GitObjectID!" for i in range(len(shas))])
|
|
177
|
+
commit_fragments_str = "\n".join(commit_fragments)
|
|
178
|
+
query = f"""
|
|
179
|
+
query($owner: String!, $repo: String!, {sha_params}) {{
|
|
180
|
+
repository(owner: $owner, name: $repo) {{
|
|
181
|
+
{commit_fragments_str}
|
|
182
|
+
}}
|
|
183
|
+
}}
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
response = self._execute_graphql(query, variables)
|
|
187
|
+
|
|
188
|
+
if not response or "errors" in response or "data" not in response:
|
|
189
|
+
if response and "errors" in response:
|
|
190
|
+
logger.error(
|
|
191
|
+
"GraphQL errors for workflow events (repo=%s/%s, commits=%d): %s",
|
|
192
|
+
repo.owner.login,
|
|
193
|
+
repo.name,
|
|
194
|
+
len(shas),
|
|
195
|
+
response["errors"],
|
|
196
|
+
)
|
|
197
|
+
elif not response:
|
|
198
|
+
logger.warning(
|
|
199
|
+
"No GraphQL response for workflow events (repo=%s/%s, commits=%d)",
|
|
200
|
+
repo.owner.login,
|
|
201
|
+
repo.name,
|
|
202
|
+
len(shas),
|
|
203
|
+
)
|
|
204
|
+
return {}
|
|
205
|
+
|
|
206
|
+
return self._parse_workflow_events_response(response, shas)
|
|
207
|
+
|
|
208
|
+
def _parse_workflow_events_response(self, response, shas):
|
|
209
|
+
"""Parse GraphQL response to extract workflow events for commits.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
response: GraphQL response dict from GitHub API
|
|
213
|
+
shas: List of commit SHA strings (used for indexing aliased queries)
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
dict: Mapping of {check_run_database_id: event_type}
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: If response structure is invalid or unexpected
|
|
220
|
+
"""
|
|
221
|
+
result = {}
|
|
222
|
+
try:
|
|
223
|
+
repository = response.get("data", {}).get("repository", {})
|
|
224
|
+
|
|
225
|
+
# Iterate through the aliased commit responses
|
|
226
|
+
for idx in range(len(shas)):
|
|
227
|
+
commit_alias = f"commit{idx}"
|
|
228
|
+
commit_data = repository.get(commit_alias)
|
|
229
|
+
|
|
230
|
+
if not commit_data:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
check_suites = commit_data.get("checkSuites", {}).get("nodes", [])
|
|
234
|
+
for suite in check_suites:
|
|
235
|
+
if not suite:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Handle case where workflowRun is None (manual checks, status checks)
|
|
239
|
+
workflow_run = suite.get("workflowRun")
|
|
240
|
+
event_type = (
|
|
241
|
+
workflow_run.get("event") if workflow_run else "unknown"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
check_runs = suite.get("checkRuns", {}).get("nodes", [])
|
|
245
|
+
for check_run in check_runs:
|
|
246
|
+
if not check_run:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
database_id = check_run.get("databaseId")
|
|
250
|
+
if database_id:
|
|
251
|
+
result[database_id] = event_type
|
|
252
|
+
|
|
253
|
+
except (AttributeError, KeyError, TypeError) as error:
|
|
254
|
+
# Log and raise if there's any issue parsing the response
|
|
255
|
+
logger.error(
|
|
256
|
+
"Failed to parse workflow events response: %s. Response structure may be unexpected.",
|
|
257
|
+
str(error),
|
|
258
|
+
)
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"Failed to parse GraphQL workflow events response: {error}"
|
|
261
|
+
) from error
|
|
262
|
+
|
|
263
|
+
return result
|
|
@@ -19,6 +19,7 @@ WORKABLE_CARD_TYPES = [
|
|
|
19
19
|
"Rework",
|
|
20
20
|
"Analysis",
|
|
21
21
|
"Sec Issue",
|
|
22
|
+
"Task",
|
|
22
23
|
"UXUI",
|
|
23
24
|
]
|
|
24
25
|
|
|
@@ -53,10 +54,12 @@ class YoutrackHandler:
|
|
|
53
54
|
"customFields(name,value(name))",
|
|
54
55
|
]
|
|
55
56
|
params = {"fields": ",".join(_fields)}
|
|
57
|
+
|
|
56
58
|
issue = self._client.get(f"issues/{issue_id}", params=params).json()
|
|
57
|
-
issue["Type"] = self._get_issue_type_name(issue)
|
|
58
59
|
logger.debug(f"YouTrack Issue json {issue}")
|
|
59
60
|
|
|
61
|
+
issue["Type"] = self._get_issue_type_name(issue)
|
|
62
|
+
|
|
60
63
|
return issue
|
|
61
64
|
|
|
62
65
|
def search_issues(self, search, amount=5):
|
|
@@ -210,17 +213,50 @@ class YoutrackHandler:
|
|
|
210
213
|
return re.sub(REGEX, f"[\\1]({self._issue_url}\\1)", text)
|
|
211
214
|
|
|
212
215
|
def _get_issue_type_name(self, issue):
|
|
213
|
-
|
|
216
|
+
try:
|
|
217
|
+
type_field = [x for x in issue["customFields"] if x["name"] == "Type"][0]
|
|
218
|
+
|
|
219
|
+
if isinstance(type_field, list):
|
|
220
|
+
logger.debug(
|
|
221
|
+
"Type field is a list. Defaulting to first value!",
|
|
222
|
+
extra={"card_id": issue["idReadable"], "type_field": type_field},
|
|
223
|
+
)
|
|
224
|
+
type_field = type_field[0]
|
|
225
|
+
|
|
226
|
+
if type_field["value"]["name"] in WORKABLE_CARD_TYPES:
|
|
227
|
+
return type_field["value"]["name"]
|
|
228
|
+
|
|
229
|
+
logger.debug(
|
|
230
|
+
"Type field %s is not workable",
|
|
231
|
+
type_field["value"]["name"],
|
|
232
|
+
extra={"card_id": issue["idReadable"]},
|
|
233
|
+
)
|
|
214
234
|
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
if len(issue["parent"]["issues"]) == 0:
|
|
236
|
+
return self._default_issue_type
|
|
217
237
|
|
|
218
|
-
|
|
219
|
-
|
|
238
|
+
parent = issue["parent"]["issues"][0]
|
|
239
|
+
# recursively get parent issue's type
|
|
240
|
+
return self.get_issue(parent["id"])["Type"]
|
|
220
241
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
242
|
+
except Exception:
|
|
243
|
+
logger.warning(
|
|
244
|
+
"""
|
|
245
|
+
Failed to get the issue type from youtrack card: %s. Defaulting to %s.
|
|
246
|
+
This is likely caused by either a misconfiguration of your YouTrack board or a suite-py bug. Ask the DevEx team for help.
|
|
247
|
+
""",
|
|
248
|
+
issue["idReadable"],
|
|
249
|
+
self._default_issue_type,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Log exception in debug mode
|
|
253
|
+
logger.debug(
|
|
254
|
+
"Failed to get issue type from youtrack card %s",
|
|
255
|
+
issue["idReadable"],
|
|
256
|
+
exc_info=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return self._default_issue_type
|
|
224
260
|
|
|
225
261
|
def get_issue_descendants(self, issue_id):
|
|
226
262
|
_fields = [
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: suite-py
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.53.0
|
|
4
4
|
Summary:
|
|
5
5
|
Author: larrywax, EugenioLaghi, michelangelomo
|
|
6
6
|
Author-email: devops@prima.it
|
|
7
|
-
Requires-Python: >=3.
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
10
9
|
Classifier: Programming Language :: Python :: 3.9
|
|
11
10
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -16,14 +15,14 @@ Requires-Dist: InquirerPy (>=0.2.0)
|
|
|
16
15
|
Requires-Dist: PyGithub (>=1.57)
|
|
17
16
|
Requires-Dist: PyYaml (>=5.4)
|
|
18
17
|
Requires-Dist: autoupgrade-prima (>=0.6)
|
|
19
|
-
Requires-Dist: boto3 (>=1.
|
|
18
|
+
Requires-Dist: boto3 (>=1.40)
|
|
20
19
|
Requires-Dist: cement (>=3.0)
|
|
21
20
|
Requires-Dist: colorama (>=0.4.3)
|
|
22
21
|
Requires-Dist: halo (>=0.0.28)
|
|
23
22
|
Requires-Dist: inquirer (>=3.1.4,<4.0.0)
|
|
24
23
|
Requires-Dist: itsdangerous (==2.2.0)
|
|
25
24
|
Requires-Dist: keyring (>=23.9.1,<26.0.0)
|
|
26
|
-
Requires-Dist: pylint (
|
|
25
|
+
Requires-Dist: pylint (<5.0.0)
|
|
27
26
|
Requires-Dist: pytest (>=7.0.0)
|
|
28
27
|
Requires-Dist: python-dateutil (>=2.8.2)
|
|
29
28
|
Requires-Dist: requests (>=2.26.0)
|
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
suite_py/__init__.py,sha256=REmi3D0X2G1ZWnYpKs8Ffm3NIj-Hw6dMuvz2b9NW344,142
|
|
2
|
-
suite_py/__version__.py,sha256=
|
|
3
|
-
suite_py/cli.py,sha256=
|
|
2
|
+
suite_py/__version__.py,sha256=_JRpDUEypCD4eQVzAs5zctzA42WPoq46aNflAFHe77g,49
|
|
3
|
+
suite_py/cli.py,sha256=FP9p8j3RDxcAd6vYJObysC7I3CsIwmRH2wf3sw4krQQ,12778
|
|
4
4
|
suite_py/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
suite_py/commands/ask_review.py,sha256=
|
|
5
|
+
suite_py/commands/ask_review.py,sha256=YptUKyac2eckhemv0FtQlJFoTAxS0IXAYkgokmKoxg8,3741
|
|
6
6
|
suite_py/commands/bump.py,sha256=oFZU1hPfD11ujFC5G7wFyQOf2alY3xp2SO1h1ldjf3s,5406
|
|
7
|
-
suite_py/commands/check.py,sha256=
|
|
7
|
+
suite_py/commands/check.py,sha256=bRAiuGbafQV33dCZQQSNZ1zBYsecIX3E19iAQKbY86I,3684
|
|
8
8
|
suite_py/commands/common.py,sha256=aWCEvO3hqdheuMUmZcHuc9EGZPQTk7VkzkHJk283MxQ,566
|
|
9
|
-
suite_py/commands/context.py,sha256=
|
|
10
|
-
suite_py/commands/create_branch.py,sha256=
|
|
9
|
+
suite_py/commands/context.py,sha256=ymd7EbGavyIcYbOKJipe75CoZyZYzZCsydjAciSeBb4,1451
|
|
10
|
+
suite_py/commands/create_branch.py,sha256=oPthmoOhRvDMBgyJOcvHijRu-8YXvM8fMDLbCUl2S3w,5380
|
|
11
11
|
suite_py/commands/estimate_cone.py,sha256=_RekBWzPlzInlZRpSIeKUVkx-A8Phx0IEYVouTbN7z4,3411
|
|
12
12
|
suite_py/commands/login.py,sha256=A59e1HsbN7Ocv2L_2H0Eb7MZK7AzLkLb72QxBthnIqU,258
|
|
13
|
-
suite_py/commands/merge_pr.py,sha256=
|
|
13
|
+
suite_py/commands/merge_pr.py,sha256=QkuT1iQJ05yxQvvToFO8M739H9oevonq-OlR_AO7uR8,5371
|
|
14
14
|
suite_py/commands/open_pr.py,sha256=yys08FIHBbajmKTT6p_JWHu6Y9y2JKI43MKXQ-iIQ-A,7275
|
|
15
|
-
suite_py/commands/project_lock.py,sha256=
|
|
16
|
-
suite_py/commands/release.py,sha256=
|
|
15
|
+
suite_py/commands/project_lock.py,sha256=x1xFfLN5_T_c8tAQiHC6Rb2x1WSbzb_uGRII737oTqA,2094
|
|
16
|
+
suite_py/commands/release.py,sha256=6-gQ6N2qITOghD1RNWf9A6LBfmkAiQ3dYG1cYyRtQ1I,16756
|
|
17
17
|
suite_py/commands/set_token.py,sha256=fehIqKjKhE-BJGFhgkPTo3Ntr0MvpgLd6EC5yjKuRs8,1508
|
|
18
18
|
suite_py/commands/status.py,sha256=0JUK53_d1-U3WNS742JD2QTiGmCGZONo3jJx8WR7q70,1122
|
|
19
19
|
suite_py/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
-
suite_py/lib/config.py,sha256=
|
|
20
|
+
suite_py/lib/config.py,sha256=YwimlbFJZh-K9Xrnmmw84SWy-GdfkFWSfnGl_wW5sII,4185
|
|
21
21
|
suite_py/lib/handler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
22
|
suite_py/lib/handler/aws_handler.py,sha256=dRvRDicikfRbuFtCLPbevaX-yC-fO4LwXFdyqLPJ8OI,8815
|
|
23
|
+
suite_py/lib/handler/backstage_handler.py,sha256=pzgOwG_2sKMmrg8T8rrJkgHKxhxBXMa0lAVHoHGfqsY,5212
|
|
23
24
|
suite_py/lib/handler/captainhook_handler.py,sha256=R30_Vvh2ck7fM5fwbpm3UV_FtlQr2xnx6RJpkG1Gn14,2983
|
|
24
25
|
suite_py/lib/handler/changelog_handler.py,sha256=-ppnRl3smBA_ys8tPqXmytS4eyntlwfawC2fiXFcwlw,4818
|
|
25
26
|
suite_py/lib/handler/frequent_reviewers_handler.py,sha256=EIJX5FEMWzrxuXS9A17hu1vfxgJSOHSBX_ahCEZ2FVA,2185
|
|
26
27
|
suite_py/lib/handler/git_handler.py,sha256=8UVttqo65CTg5LCvOBTDXb_sNxLKUrpevCGiohp42Is,13099
|
|
27
|
-
suite_py/lib/handler/github_handler.py,sha256=
|
|
28
|
+
suite_py/lib/handler/github_handler.py,sha256=KWPu3HxqRNALZZLNWDyY6OKGdFofLS6sfa82NC-_JJg,8967
|
|
28
29
|
suite_py/lib/handler/metrics_handler.py,sha256=-Tp62pFIiYsBkDga0nQG3lWU-gxH68wEjIIIJeU1jHk,3159
|
|
29
|
-
suite_py/lib/handler/okta_handler.py,sha256
|
|
30
|
+
suite_py/lib/handler/okta_handler.py,sha256=-xSlxOD_ic4_isewdm3DwUTZlUC7aCCYzefHhl9u1UA,2811
|
|
30
31
|
suite_py/lib/handler/pre_commit_handler.py,sha256=D2C3b07naOf7wq-OOrrlwWsk5Rk5Zu49-_sNTevDG8I,4300
|
|
31
32
|
suite_py/lib/handler/prompt_utils.py,sha256=vgk1O7h-iYEAZv1sXtMh8xIgH1djI398rzxRIgZWZcg,2474
|
|
32
33
|
suite_py/lib/handler/version_handler.py,sha256=DXTx4yCAbFVC6CdMqPJ-LiN5YM-dT2zklG8POyKTP5A,6774
|
|
33
|
-
suite_py/lib/handler/youtrack_handler.py,sha256=
|
|
34
|
+
suite_py/lib/handler/youtrack_handler.py,sha256=QeCcAslOG50N4lMyKkD-K6pNmD_Z4gSiT0eF9Jo7n38,11377
|
|
34
35
|
suite_py/lib/logger.py,sha256=SsJ1MpA3Kkt_4VTscfmsnlYZRZdzWwkxfWfVWVgsADI,1181
|
|
35
36
|
suite_py/lib/metrics.py,sha256=urTBVzIc1Ys6OHPOO32fLPPRcQU45tzM3XMJ7mUl5dc,1629
|
|
36
37
|
suite_py/lib/oauth.py,sha256=tE3MgCnYhW6ZIbkyeUohFTOUvnBsrJXfHN8XsAVQNXk,5077
|
|
@@ -40,7 +41,7 @@ suite_py/lib/requests/session.py,sha256=P32H3cWnCWunu91WIj2iDM5U3HzaBglg60VN_C9J
|
|
|
40
41
|
suite_py/lib/symbol.py,sha256=z3QYBuNIwD3qQ3zF-cLOomIr_-C3bO_u5UIDAHMiyTo,60
|
|
41
42
|
suite_py/lib/tokens.py,sha256=4DbsHDFLIxs40t3mRw_ZyhmejZQ0Bht7iAL8dTCTQd4,5458
|
|
42
43
|
suite_py/templates/login.html,sha256=fJLls2SB84oZTSrxTdA5q1PqfvIHcCD4fhVWfyco7Ig,861
|
|
43
|
-
suite_py-1.
|
|
44
|
-
suite_py-1.
|
|
45
|
-
suite_py-1.
|
|
46
|
-
suite_py-1.
|
|
44
|
+
suite_py-1.53.0.dist-info/METADATA,sha256=UBcjPtdZV_Oqp15XtmIfBsp1XMcAoKUH76cmrbCnzXI,1188
|
|
45
|
+
suite_py-1.53.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
46
|
+
suite_py-1.53.0.dist-info/entry_points.txt,sha256=dVKLC-9Infy-dHJT_MkK6LcDjOgBCJ8lfPkURJhBjxE,46
|
|
47
|
+
suite_py-1.53.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|