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 CHANGED
@@ -1,2 +1,2 @@
1
1
  # -*- encoding: utf-8 -*-
2
- __version__ = "1.50.0"
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,
@@ -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, captainhook, config, tokens):
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._captainhook = captainhook
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._captainhook.get_users_list().json()
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):
@@ -8,18 +8,22 @@ from suite_py.lib.symbol import CHECKMARK, CROSSMARK
8
8
 
9
9
 
10
10
  class Check:
11
- def __init__(self, captainhook, config, tokens):
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 != "":
@@ -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. Check that the issue number is correct"
48
+ f"There was a problem retrieving the issue `{card_id}` from YouTrack. {e}"
49
49
  )
50
50
  sys.exit(-1)
51
51
 
@@ -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__(self, project, captainhook: CaptainHook, config, tokens):
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)
@@ -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
- statuses = {r.status for r in check_runs if getattr(r, "status", None)}
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 check_runs if getattr(r, "conclusion", None)
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
- self._client = Github(tokens.github)
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
@@ -5,7 +5,7 @@ from suite_py.lib import logger, oauth
5
5
  from suite_py.lib.config import Config
6
6
  from suite_py.lib.tokens import Tokens
7
7
 
8
- _SCOPE = "openid offline_access"
8
+ _SCOPE = "openid email offline_access"
9
9
 
10
10
 
11
11
  class OktaError(Exception):
@@ -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
- type_field = [x for x in issue["customFields"] if x["name"] == "Type"][0]
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
- if type_field["value"]["name"] in WORKABLE_CARD_TYPES:
216
- return type_field["value"]["name"]
235
+ if len(issue["parent"]["issues"]) == 0:
236
+ return self._default_issue_type
217
237
 
218
- if len(issue["parent"]["issues"]) == 0:
219
- return self._default_issue_type
238
+ parent = issue["parent"]["issues"][0]
239
+ # recursively get parent issue's type
240
+ return self.get_issue(parent["id"])["Type"]
220
241
 
221
- parent = issue["parent"]["issues"][0]
222
- # recursively get parent issue's type
223
- return self.get_issue(parent["id"])["Type"]
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.50.0
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.8,<4.0
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.17.84)
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 (>=2.14.5,<4.0.0)
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=38W7jlGbYwxo07w_vLhpzv02ReQdpK1kDEMKK2DCpHA,49
3
- suite_py/cli.py,sha256=wTV-d7Yq52JjkugxVCGCHM9Qsb3vKDv3VnTIJNJ162c,12579
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=yN__Ac-fiZBPShjRDhyCCQZGfVlQE16KozoJk4UtiNw,3788
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=jCX59g6DgTA55yD_75mgcqJ5zCjRwl_eIRGDeUFjUWY,3316
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=NAMZjJtTIi8xKIhdWEYNKwz0f4wUubW2eAj08gxRRbw,1267
10
- suite_py/commands/create_branch.py,sha256=HuNA5DR-9d-yupBCljxYasElwxwlP3XI_UyairnfurU,5397
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=fXIE8mT9MjvvpqE-uVdXGBVFGhn0eQzcBxNr-N8SyAY,5171
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=b7OkGysue_Sl13VIT7B5CTBppCvrB_Q6iC0IJRBSHp8,1909
16
- suite_py/commands/release.py,sha256=_pH1YxrK-8M2vi7Bed0hGn53esTHQDQYKyquPDZ8RlI,14756
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=x4tJxm7IjyEkBSybD8hxYfi365qbaIFsMaoBw6yJb6I,4043
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=xnBATLOTnOLpiYE29WwUrtDr7hxusfId9a1KbfK1OyA,2952
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=UiRcBDmFkMFi9H7Me1QaruC8yPI5fFbnLGzOf3kfxG0,2805
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=_CwzeFhj7kMYb61CN8IYF0IE55Zrj6vZT38SUZLA5B8,10192
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.50.0.dist-info/METADATA,sha256=E2McUnkrL3Q3OC1hP4rrm2h8NMBdWqPKTUuKdmFZya8,1250
44
- suite_py-1.50.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
45
- suite_py-1.50.0.dist-info/entry_points.txt,sha256=dVKLC-9Infy-dHJT_MkK6LcDjOgBCJ8lfPkURJhBjxE,46
46
- suite_py-1.50.0.dist-info/RECORD,,
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,,