suite-py 1.49.1__py3-none-any.whl → 1.52.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.49.1"
2
+ __version__ = "1.52.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,
@@ -233,21 +237,21 @@ def bump(obj: Context, project: Optional[str] = None, version: Optional[str] = N
233
237
  @main.command(
234
238
  "create-branch", help="Create local branch and set the YouTrack card in progress"
235
239
  )
236
- @click.option("--card", type=click.STRING, help="YouTrack card number (ex. PRIMA-123)")
237
240
  @click.option(
238
241
  "--autostash",
239
242
  is_flag=True,
240
243
  help="Stash uncommitted changes before creating the branch and reapply them afterward",
241
244
  )
242
245
  @click.option(
243
- "--parent-branch",
246
+ "--branch-name",
244
247
  type=click.STRING,
245
- help="Parent branch to create the new branch from",
248
+ help="Branch name template. Supports {card_id}, {type}, {summary} placeholders (ex. '{card_id}/{type}/{summary}')",
246
249
  )
250
+ @click.option("--card", type=click.STRING, help="YouTrack card ID (ex. PRIMA-4423)")
247
251
  @click.option(
248
- "--branch-name",
252
+ "--parent-branch",
249
253
  type=click.STRING,
250
- help="Branch name template. Supports {card_id}, {type}, {summary} placeholders (ex. '{card_id}/{type}/{summary}')",
254
+ help="Parent branch to create the new branch from",
251
255
  )
252
256
  @click.pass_obj
253
257
  @catch_exceptions
@@ -255,10 +259,10 @@ def cli_create_branch(obj, card, autostash, parent_branch, branch_name):
255
259
  from suite_py.commands.create_branch import CreateBranch
256
260
 
257
261
  obj.call(CreateBranch).run(
258
- card_id=card,
259
262
  autostash=autostash,
260
- parent_branch=parent_branch,
261
263
  branch_name=branch_name,
264
+ card_id=card,
265
+ parent_branch=parent_branch,
262
266
  )
263
267
 
264
268
 
@@ -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
@@ -1,7 +1,6 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  import re
3
3
  import sys
4
-
5
4
  import requests
6
5
 
7
6
  from suite_py.lib import logger, metrics
@@ -19,7 +18,7 @@ class CreateBranch:
19
18
  ):
20
19
  self._config = config
21
20
  self._git_handler = git_handler
22
- self._youtrack = youtrack_handler
21
+ self._youtrack_handler = youtrack_handler
23
22
 
24
23
  @metrics.command("create-branch")
25
24
  def run(
@@ -41,22 +40,22 @@ class CreateBranch:
41
40
 
42
41
  try:
43
42
  if card_id:
44
- issue = self._youtrack.get_issue(card_id)
43
+ issue = self._youtrack_handler.get_issue(card_id)
45
44
  else:
46
- issue = self._youtrack.get_issue(self._ask_card())
47
- except Exception:
45
+ issue = self._youtrack_handler.get_issue(self._ask_card_id())
46
+ except Exception as e:
48
47
  logger.error(
49
- "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}"
50
49
  )
51
50
  sys.exit(-1)
52
51
 
53
52
  self._checkout_branch(issue, autostash, parent_branch, branch_name)
54
53
 
55
- user = self._youtrack.get_current_user()
56
- self._youtrack.assign_to(issue["id"], user["login"])
54
+ user = self._youtrack_handler.get_current_user()
55
+ self._youtrack_handler.assign_to(issue["id"], user["login"])
57
56
 
58
57
  try:
59
- self._youtrack.update_state(
58
+ self._youtrack_handler.update_state(
60
59
  issue["id"], self._config.youtrack["picked_state"]
61
60
  )
62
61
  except requests.exceptions.HTTPError:
@@ -87,7 +86,7 @@ class CreateBranch:
87
86
  "Insert the YouTrack issue number:", self._config.user["default_slug"]
88
87
  )
89
88
 
90
- def _ask_card(self):
89
+ def _ask_card_id(self):
91
90
  suggestions = self._get_card_suggestions()
92
91
  user_choice = (
93
92
  self._select_card(suggestions)
@@ -98,8 +97,9 @@ class CreateBranch:
98
97
 
99
98
  def _get_card_suggestions(self):
100
99
  try:
101
- return self._youtrack.search_issues(
102
- self._config.user["card_suggest_query"], 5
100
+ return self._youtrack_handler.search_issues(
101
+ self._config.user["card_suggest_query"],
102
+ self._config.user["card_suggestions_limit"],
103
103
  )
104
104
  except Exception:
105
105
  logger.warning(
@@ -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...")
@@ -1,5 +1,6 @@
1
1
  # -*- encoding: utf-8 -*-
2
2
  import sys
3
+ from string import Template
3
4
 
4
5
  from github import GithubException
5
6
 
@@ -77,7 +78,10 @@ class OpenPR:
77
78
 
78
79
  default_title = self._youtrack.get_issue(youtrack_id)["summary"]
79
80
  title_without_prefix = _ask_for_title(default_title)
80
- title = f"[{youtrack_id}]: {title_without_prefix}"
81
+
82
+ title = Template(self._config.user["pr_title_template"]).substitute(
83
+ card_id=youtrack_id, title=title_without_prefix
84
+ )
81
85
  else:
82
86
  logger.warning(
83
87
  f"Creating pull request on the {self._project} project for the {self._branch_name} branch NOT linked to YouTrack card"
@@ -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(
@@ -228,7 +280,11 @@ class Release:
228
280
  def _is_success(conclusions):
229
281
  return bool(conclusions) and (
230
282
  all(c == "success" for c in conclusions)
231
- or ("success" in conclusions and not Release._any_failure(conclusions))
283
+ or (
284
+ "success" in conclusions
285
+ and not Release._any_failure(conclusions)
286
+ and "cancelled" not in conclusions
287
+ )
232
288
  )
233
289
 
234
290
  @staticmethod
suite_py/lib/config.py CHANGED
@@ -36,11 +36,15 @@ class Config:
36
36
  conf["user"].setdefault("default_slug", "PRIMA-XXX")
37
37
  default_search = f"in:{conf['user']['default_slug'].split('-')[0]} #{{To Do}}"
38
38
  conf["user"].setdefault("card_suggest_query", default_search)
39
+ conf["user"].setdefault("card_suggestions_limit", 5)
39
40
  # This is in seconds
40
41
  conf["user"].setdefault("captainhook_timeout", 30)
41
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")
42
45
  conf["user"].setdefault("use_commits_in_pr_body", False)
43
46
  conf["user"].setdefault("frequent_reviewers_max_number", 5)
47
+ conf["user"].setdefault("pr_title_template", "[$card_id]: $title")
44
48
 
45
49
  conf["youtrack"].setdefault("add_reviewers_tags", True)
46
50
  conf["youtrack"].setdefault("default_issue_type", "Task")
@@ -0,0 +1,82 @@
1
+ # -*- encoding: utf-8 -*-
2
+ import requests
3
+
4
+ from suite_py.__version__ import __version__
5
+ from suite_py.lib.handler.github_handler import GithubHandler
6
+ from suite_py.lib.handler.okta_handler import Okta
7
+
8
+
9
+ class BackstageResponseError(Exception): ...
10
+
11
+
12
+ class Backstage:
13
+ _okta: Okta
14
+ _github: GithubHandler
15
+
16
+ def __init__(self, config, okta: Okta, tokens=None):
17
+ backstage_url = config.user["backstage_url"]
18
+ self._baseurl = f"{backstage_url}/api/entity-locking"
19
+ self._timeout = config.user["backstage_timeout"]
20
+ self._okta = okta
21
+
22
+ if tokens is not None:
23
+ self._github = GithubHandler(tokens)
24
+
25
+ def lock_project(self, project, env):
26
+ return self.send_request(
27
+ "post",
28
+ "/projects/manage-lock",
29
+ json={
30
+ "project": project,
31
+ "status": "locked",
32
+ "user": self._get_user(),
33
+ "environment": env,
34
+ },
35
+ )
36
+
37
+ def unlock_project(self, project, env):
38
+ return self.send_request(
39
+ "post",
40
+ "/projects/manage-lock",
41
+ json={
42
+ "project": project,
43
+ "status": "unlocked",
44
+ "user": self._get_user(),
45
+ "environment": env,
46
+ },
47
+ )
48
+
49
+ def status(self, project, env):
50
+ return self.send_request(
51
+ "get", f"/projects/check?project={project}&environment={env}"
52
+ )
53
+
54
+ def check(self):
55
+ response = self.send_request("get", "/ping", timeout=(2, self._timeout))
56
+ assert response.text == "pong"
57
+
58
+ def send_request(self, method, endpoint, data=None, json=None, timeout=None):
59
+ response = requests.request(
60
+ method,
61
+ f"{self._baseurl}{endpoint}",
62
+ headers=self._headers(),
63
+ data=data,
64
+ json=json,
65
+ timeout=(timeout or self._timeout),
66
+ )
67
+
68
+ if response.status_code == 200:
69
+ return response
70
+
71
+ raise BackstageResponseError(
72
+ f"Got unexpected status code from Backstage: {response.status_code}"
73
+ )
74
+
75
+ def _get_user(self):
76
+ return self._github.get_user().login
77
+
78
+ def _headers(self):
79
+ return {
80
+ "User-Agent": f"suite-py/{__version__}",
81
+ "Authorization": f"Bearer {self._okta.get_id_token()}",
82
+ }
@@ -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.49.1
3
+ Version: 1.52.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=VeDjrisUW4YqNSxqHjb9wWGHToZW1js3I9jRqyYdTYI,49
3
- suite_py/cli.py,sha256=IV73nNqBwLKLAfucLxyIne_V7uBQT6OBY3Z2VryQt2M,12582
2
+ suite_py/__version__.py,sha256=aH4waNa4t_y13ssQMfeCqJjhsWfkkKeGmRRt8xfdQBo,49
3
+ suite_py/cli.py,sha256=FP9p8j3RDxcAd6vYJObysC7I3CsIwmRH2wf3sw4krQQ,12778
4
4
  suite_py/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  suite_py/commands/ask_review.py,sha256=yN__Ac-fiZBPShjRDhyCCQZGfVlQE16KozoJk4UtiNw,3788
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=eilFIsCkMjU51maeJlMMMMBpnM6rlUauXIFA-92j7no,5277
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
14
- suite_py/commands/open_pr.py,sha256=djaF2OsYbQo0YLTEbNRTYYFzsNT0sl7VRxqtxSX1PKc,7150
15
- suite_py/commands/project_lock.py,sha256=b7OkGysue_Sl13VIT7B5CTBppCvrB_Q6iC0IJRBSHp8,1909
16
- suite_py/commands/release.py,sha256=eg9RoCBj5NVfaxQW7eHF4TvPj_eherJ-ovokU7Pt4k0,14659
13
+ suite_py/commands/merge_pr.py,sha256=QkuT1iQJ05yxQvvToFO8M739H9oevonq-OlR_AO7uR8,5371
14
+ suite_py/commands/open_pr.py,sha256=yys08FIHBbajmKTT6p_JWHu6Y9y2JKI43MKXQ-iIQ-A,7275
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=52YV0K0GK1WRbeM70IDXkpG8oZD1Zvxipn1b-XekwNA,3907
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=CGeuhlmNMUNka0-1nIxNDYw_teoey9ScWYArKzT-1J8,2370
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.49.1.dist-info/METADATA,sha256=9VplRO35M4_maBHOmHZCRRNR4Us5JQOWLidhFq9jvZY,1250
44
- suite_py-1.49.1.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
45
- suite_py-1.49.1.dist-info/entry_points.txt,sha256=dVKLC-9Infy-dHJT_MkK6LcDjOgBCJ8lfPkURJhBjxE,46
46
- suite_py-1.49.1.dist-info/RECORD,,
44
+ suite_py-1.52.0.dist-info/METADATA,sha256=I-0R3K5j-1kejZ9jgu1n3bw_uM31h6sjP0HehU6IzSs,1188
45
+ suite_py-1.52.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
46
+ suite_py-1.52.0.dist-info/entry_points.txt,sha256=dVKLC-9Infy-dHJT_MkK6LcDjOgBCJ8lfPkURJhBjxE,46
47
+ suite_py-1.52.0.dist-info/RECORD,,