suite-py 1.48.0__tar.gz → 1.49.1__tar.gz

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.
Files changed (46) hide show
  1. {suite_py-1.48.0 → suite_py-1.49.1}/PKG-INFO +2 -2
  2. {suite_py-1.48.0 → suite_py-1.49.1}/pyproject.toml +2 -2
  3. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/__version__.py +1 -1
  4. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/cli.py +27 -6
  5. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/context.py +7 -3
  6. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/create_branch.py +48 -24
  7. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/release.py +105 -16
  8. {suite_py-1.48.0 → suite_py-1.49.1}/LICENSE-APACHE +0 -0
  9. {suite_py-1.48.0 → suite_py-1.49.1}/LICENSE-MIT +0 -0
  10. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/__init__.py +0 -0
  11. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/__init__.py +0 -0
  12. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/ask_review.py +0 -0
  13. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/bump.py +0 -0
  14. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/check.py +0 -0
  15. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/common.py +0 -0
  16. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/estimate_cone.py +0 -0
  17. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/login.py +0 -0
  18. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/merge_pr.py +0 -0
  19. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/open_pr.py +0 -0
  20. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/project_lock.py +0 -0
  21. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/set_token.py +0 -0
  22. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/commands/status.py +0 -0
  23. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/__init__.py +0 -0
  24. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/config.py +0 -0
  25. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/__init__.py +0 -0
  26. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/aws_handler.py +0 -0
  27. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/captainhook_handler.py +0 -0
  28. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/changelog_handler.py +0 -0
  29. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/frequent_reviewers_handler.py +0 -0
  30. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/git_handler.py +0 -0
  31. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/github_handler.py +0 -0
  32. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/metrics_handler.py +0 -0
  33. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/okta_handler.py +0 -0
  34. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/pre_commit_handler.py +0 -0
  35. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/prompt_utils.py +0 -0
  36. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/version_handler.py +0 -0
  37. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/handler/youtrack_handler.py +0 -0
  38. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/logger.py +0 -0
  39. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/metrics.py +0 -0
  40. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/oauth.py +0 -0
  41. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/requests/__init__.py +0 -0
  42. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/requests/auth.py +0 -0
  43. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/requests/session.py +0 -0
  44. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/symbol.py +0 -0
  45. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/lib/tokens.py +0 -0
  46. {suite_py-1.48.0 → suite_py-1.49.1}/suite_py/templates/login.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: suite-py
3
- Version: 1.48.0
3
+ Version: 1.49.1
4
4
  Summary:
5
5
  Author: larrywax, EugenioLaghi, michelangelomo
6
6
  Author-email: devops@prima.it
@@ -28,7 +28,7 @@ Requires-Dist: pytest (>=7.0.0)
28
28
  Requires-Dist: python-dateutil (>=2.8.2)
29
29
  Requires-Dist: requests (>=2.26.0)
30
30
  Requires-Dist: requests-toolbelt (>=0.9.1)
31
- Requires-Dist: rich (==14.1.0)
31
+ Requires-Dist: rich (==14.2.0)
32
32
  Requires-Dist: semver (>=3.0.4,<4.0.0)
33
33
  Requires-Dist: termcolor (>=1.1.0)
34
34
  Requires-Dist: truststore (>=0.7,<0.11) ; python_version >= "3.10"
@@ -2,7 +2,7 @@
2
2
  authors = ["larrywax, EugenioLaghi, michelangelomo <devops@prima.it>"]
3
3
  description = ""
4
4
  name = "suite-py"
5
- version = "1.48.0"
5
+ version = "1.49.1"
6
6
 
7
7
  [tool.poetry.dependencies]
8
8
  Click = ">=7.0"
@@ -23,7 +23,7 @@ python = ">=3.8,<4.0"
23
23
  python-dateutil = ">=2.8.2"
24
24
  requests = ">=2.26.0"
25
25
  requests-toolbelt = ">=0.9.1"
26
- rich = "==14.1.0"
26
+ rich = "==14.2.0"
27
27
  semver = "^3.0.4"
28
28
  termcolor = ">=1.1.0"
29
29
  truststore = {version = ">=0.7,<0.11", python = ">=3.10"}
@@ -1,2 +1,2 @@
1
1
  # -*- encoding: utf-8 -*-
2
- __version__ = "1.48.0"
2
+ __version__ = "1.49.1"
@@ -47,8 +47,10 @@ 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
49
  from suite_py.lib.handler.captainhook_handler import CaptainHook
50
+ from suite_py.lib.handler.git_handler import GitHandler
50
51
  from suite_py.lib.handler.okta_handler import Okta
51
52
  from suite_py.lib.handler.pre_commit_handler import PreCommit
53
+ from suite_py.lib.handler.youtrack_handler import YoutrackHandler
52
54
  from suite_py.lib.tokens import Tokens
53
55
 
54
56
  # pylint: enable=wrong-import-position
@@ -176,17 +178,21 @@ def main(ctx, project, timeout, verbose):
176
178
  if timeout:
177
179
  config.user["captainhook_timeout"] = timeout
178
180
 
179
- project = os.path.basename(project)
180
181
  tokens = Tokens()
181
182
  okta = Okta(config, tokens)
182
183
  captainhook = CaptainHook(config, okta, tokens)
184
+ project = os.path.basename(project)
185
+ git_handler = GitHandler(project, config)
186
+ youtrack_handler = YoutrackHandler(config, tokens)
183
187
 
184
188
  ctx.obj = Context(
189
+ captainhook=captainhook,
190
+ config=config,
191
+ git_handler=git_handler,
192
+ okta=okta,
185
193
  project=project,
186
194
  tokens=tokens,
187
- okta=okta,
188
- config=config,
189
- captainhook=captainhook,
195
+ youtrack_handler=youtrack_handler,
190
196
  )
191
197
 
192
198
  ctx.obj.call(metrics.setup)
@@ -233,12 +239,27 @@ def bump(obj: Context, project: Optional[str] = None, version: Optional[str] = N
233
239
  is_flag=True,
234
240
  help="Stash uncommitted changes before creating the branch and reapply them afterward",
235
241
  )
242
+ @click.option(
243
+ "--parent-branch",
244
+ type=click.STRING,
245
+ help="Parent branch to create the new branch from",
246
+ )
247
+ @click.option(
248
+ "--branch-name",
249
+ type=click.STRING,
250
+ help="Branch name template. Supports {card_id}, {type}, {summary} placeholders (ex. '{card_id}/{type}/{summary}')",
251
+ )
236
252
  @click.pass_obj
237
253
  @catch_exceptions
238
- def cli_create_branch(obj, card, autostash):
254
+ def cli_create_branch(obj, card, autostash, parent_branch, branch_name):
239
255
  from suite_py.commands.create_branch import CreateBranch
240
256
 
241
- obj.call(CreateBranch, card=card, autostash=autostash).run()
257
+ obj.call(CreateBranch).run(
258
+ card_id=card,
259
+ autostash=autostash,
260
+ parent_branch=parent_branch,
261
+ branch_name=branch_name,
262
+ )
242
263
 
243
264
 
244
265
  @main.command("lock", help="Lock project on staging or prod")
@@ -3,17 +3,21 @@ from inspect import signature
3
3
 
4
4
  from suite_py.lib.config import Config
5
5
  from suite_py.lib.handler.captainhook_handler import CaptainHook
6
+ from suite_py.lib.handler.git_handler import GitHandler
6
7
  from suite_py.lib.handler.okta_handler import Okta
8
+ from suite_py.lib.handler.youtrack_handler import YoutrackHandler
7
9
  from suite_py.lib.tokens import Tokens
8
10
 
9
11
 
10
12
  @dataclasses.dataclass
11
13
  class Context:
12
- project: str
13
- config: Config
14
14
  captainhook: CaptainHook
15
- tokens: Tokens
15
+ config: Config
16
+ git_handler: GitHandler
16
17
  okta: Okta
18
+ project: str
19
+ tokens: Tokens
20
+ youtrack_handler: YoutrackHandler
17
21
 
18
22
  # Call the function to_call with kwargs, injecting fields from self as default arguments
19
23
  def call(self, to_call, **kwargs):
@@ -11,25 +11,37 @@ from suite_py.lib.handler.youtrack_handler import YoutrackHandler
11
11
 
12
12
 
13
13
  class CreateBranch:
14
- def __init__(self, project, card, config, tokens, autostash=False):
15
- self._project = project
16
- self._card = card
14
+ def __init__(
15
+ self,
16
+ config,
17
+ git_handler: GitHandler,
18
+ youtrack_handler: YoutrackHandler,
19
+ ):
17
20
  self._config = config
18
- self._youtrack = YoutrackHandler(config, tokens)
19
- self._git = GitHandler(project, config)
20
- self._autostash = autostash
21
+ self._git_handler = git_handler
22
+ self._youtrack = youtrack_handler
21
23
 
22
24
  @metrics.command("create-branch")
23
- def run(self):
24
- if not self._git.is_detached() and self._git.is_dirty() and not self._autostash:
25
+ def run(
26
+ self,
27
+ autostash=False,
28
+ branch_name=None,
29
+ card_id=None,
30
+ parent_branch=None,
31
+ ):
32
+ if (
33
+ not self._git_handler.is_detached()
34
+ and self._git_handler.is_dirty()
35
+ and not autostash
36
+ ):
25
37
  # Default behaviour is to pull when not detached.
26
38
  # Can't do that with uncommitted changes.
27
39
  logger.error("You have some uncommitted changes, I can't continue")
28
40
  sys.exit(-1)
29
41
 
30
42
  try:
31
- if self._card:
32
- issue = self._youtrack.get_issue(self._card)
43
+ if card_id:
44
+ issue = self._youtrack.get_issue(card_id)
33
45
  else:
34
46
  issue = self._youtrack.get_issue(self._ask_card())
35
47
  except Exception:
@@ -38,7 +50,7 @@ class CreateBranch:
38
50
  )
39
51
  sys.exit(-1)
40
52
 
41
- self._checkout_branch(issue, self._autostash)
53
+ self._checkout_branch(issue, autostash, parent_branch, branch_name)
42
54
 
43
55
  user = self._youtrack.get_current_user()
44
56
  self._youtrack.assign_to(issue["id"], user["login"])
@@ -95,33 +107,45 @@ class CreateBranch:
95
107
  )
96
108
  return []
97
109
 
98
- def _checkout_branch(self, issue, autostash=False):
110
+ def _checkout_branch(
111
+ self, issue, autostash=False, parent_branch=None, branch_name_template=None
112
+ ):
99
113
  default_parent_branch_name = self._config.user.get(
100
- "default_parent_branch", self._git.current_branch_name()
114
+ "default_parent_branch", self._git_handler.current_branch_name()
101
115
  )
102
116
 
103
- parent_branch_name = prompt_utils.ask_questions_input(
104
- "Insert initial branch: ", default_parent_branch_name
117
+ parent_branch_name = parent_branch or prompt_utils.ask_questions_input(
118
+ "Enter parent branch: ", default_parent_branch_name
105
119
  )
106
120
 
107
121
  full_branch_name = ""
108
122
  branch_name = _normalize_git_ref_segment(issue["summary"])
109
123
  branch_type = _normalize_git_ref_segment(issue["Type"])
110
124
 
111
- while True:
112
- branch_name = str(
113
- prompt_utils.ask_questions_input("Enter branch name: ", branch_name)
125
+ if branch_name_template:
126
+ # Use the template and replace placeholders
127
+ full_branch_name = branch_name_template.format(
128
+ card_id=issue["idReadable"], type=branch_type, summary=branch_name
114
129
  )
115
130
 
116
- full_branch_name = f"{issue['idReadable']}/{branch_type}/{branch_name}"
117
- if is_branch_name_valid(full_branch_name):
118
- break
131
+ if not is_branch_name_valid(full_branch_name):
132
+ logger.error(f"Invalid branch name from template: {full_branch_name}")
133
+ sys.exit(-1)
134
+ else:
135
+ while True:
136
+ branch_name = str(
137
+ prompt_utils.ask_questions_input("Enter branch name: ", branch_name)
138
+ )
119
139
 
120
- logger.error(f"Invalid branch name: {full_branch_name}. Try again?")
140
+ full_branch_name = f"{issue['idReadable']}/{branch_type}/{branch_name}"
141
+ if is_branch_name_valid(full_branch_name):
142
+ break
121
143
 
122
- self._git.checkout(parent_branch_name, autostash=autostash)
144
+ logger.error(f"Invalid branch name: {full_branch_name}. Try again?")
123
145
 
124
- self._git.checkout(full_branch_name, new=True, autostash=autostash)
146
+ self._git_handler.checkout(parent_branch_name, autostash=autostash)
147
+
148
+ self._git_handler.checkout(full_branch_name, new=True, autostash=autostash)
125
149
 
126
150
 
127
151
  # Normalize a string into a valid segment(ie. the part of the branch name between the /)
@@ -1,6 +1,7 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  import re
3
3
  import sys
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
5
 
5
6
  from suite_py.commands import common
6
7
  from suite_py.lib import logger, metrics
@@ -71,7 +72,6 @@ class Release:
71
72
  sha = self._resolve_target_sha(commits)
72
73
  self._create_release(new_version, message, sha)
73
74
 
74
- # ---- helpers (extracted to reduce complexity of _create) ----
75
75
  def _gather_commits_and_version(self, latest):
76
76
  """Return (commits, new_version, base_message)."""
77
77
  if latest == "":
@@ -79,6 +79,11 @@ class Release:
79
79
 
80
80
  logger.info(f"The current release is {latest}")
81
81
  commits = self._github.get_commits_since_release(self._repo, latest)
82
+ if not commits:
83
+ logger.warning(
84
+ "No commits found after the latest release tag; nothing to release."
85
+ )
86
+ sys.exit(0)
82
87
  # If user did not pass a target commit, ask interactively which commit to release.
83
88
  # Only perform interactive selection if user explicitly requested it
84
89
  if self._interactive:
@@ -88,7 +93,7 @@ class Release:
88
93
  message = self._build_commits_message(commits)
89
94
  logger.info(f"\nCommits list:\n{message}\n")
90
95
  if not prompt_utils.ask_confirm("Do you want to continue?"):
91
- sys.exit() # Preserve previous early return semantics
96
+ sys.exit()
92
97
  new_version = self._version.select_new_version(latest, allow_prerelease=True)
93
98
  return commits, new_version, message
94
99
 
@@ -106,7 +111,7 @@ class Release:
106
111
  try:
107
112
  resolved_commit = self._repo.get_commit(self._commit)
108
113
  target_sha = resolved_commit.sha
109
- except Exception: # pragma: no cover
114
+ except Exception:
110
115
  logger.error(
111
116
  f"The provided commit '{self._commit}' was not found in the repository."
112
117
  )
@@ -125,6 +130,8 @@ class Release:
125
130
  "The specified commit is not part of the unreleased commits (i.e., not after the latest release)."
126
131
  )
127
132
  sys.exit(-1)
133
+ # We want ONLY the selected commit and the older commits (towards the previous release),
134
+ # excluding any *newer* commits that appear before it in the list.
128
135
  return commits[target_index:]
129
136
 
130
137
  def _select_commit_if_needed(self, commits):
@@ -137,22 +144,104 @@ class Release:
137
144
  """
138
145
  if self._commit or not commits:
139
146
  return
140
- if len(commits) == 1:
141
- # Only one unreleased commit; implicitly choose it.
142
- self._commit = commits[0].sha
143
- return
144
- choices = [
145
- {
146
- "name": f"{c.sha[:8]} | {c.commit.message.splitlines()[0]}",
147
+
148
+ choices = []
149
+
150
+ def build_choice(c, icon):
151
+ summary = c.commit.message.splitlines()[0]
152
+ author = c.commit.author.name
153
+ return {
154
+ "name": f"{icon} {c.sha[:8]} | {summary} by {author}",
147
155
  "value": c.sha,
148
156
  }
149
- for c in commits
150
- ]
157
+
158
+ with ThreadPoolExecutor(max_workers=min(8, len(commits))) as executor:
159
+ future_map = {
160
+ executor.submit(self._get_commit_status_icon, c.sha): c for c in commits
161
+ }
162
+
163
+ icon_results = {}
164
+ for future in as_completed(future_map):
165
+ c = future_map[future]
166
+ try:
167
+ icon_results[c.sha] = future.result()
168
+ except Exception:
169
+ icon_results[c.sha] = "?"
170
+ for c in commits:
171
+ choices.append(build_choice(c, icon_results.get(c.sha, "?")))
172
+
173
+ subtitle = "(only one available)" if len(choices) == 1 else "(newest first):"
151
174
  selected = prompt_utils.ask_choices(
152
- "Select commit to release (newest first):", choices
175
+ f"Select commit to release {subtitle}", choices
153
176
  )
154
177
  self._commit = selected
155
178
 
179
+ def _get_commit_status_icon(self, sha):
180
+ """Return an icon representing the simplified CI status for a commit.
181
+
182
+ Final exposed statuses (icons):
183
+ success (✅), failure (❌), in_progress (🏗️), cancelled (🚫), unknown (❓)
184
+ """
185
+ check_runs = self._safe_get_check_runs(sha)
186
+ if not check_runs:
187
+ return self._icon_for_state("unknown")
188
+ state = self._classify_from_check_runs(check_runs)
189
+ return self._icon_for_state(state)
190
+
191
+ def _safe_get_check_runs(self, sha):
192
+ try:
193
+ commit = self._repo.get_commit(sha)
194
+ return list(commit.get_check_runs())
195
+ except Exception:
196
+ return []
197
+
198
+ def _classify_from_check_runs(self, check_runs):
199
+ statuses = {r.status for r in check_runs if getattr(r, "status", None)}
200
+ conclusions = {
201
+ r.conclusion for r in check_runs if getattr(r, "conclusion", None)
202
+ }
203
+
204
+ if self._any_in_progress(statuses):
205
+ return "in_progress"
206
+ if self._any_failure(conclusions):
207
+ return "failure"
208
+ if "cancelled" in conclusions:
209
+ return "cancelled"
210
+ if self._is_success(conclusions):
211
+ return "success"
212
+ return "unknown"
213
+
214
+ @staticmethod
215
+ def _any_in_progress(statuses):
216
+ return any(
217
+ s in {"in_progress", "queued", "waiting", "pending"} for s in statuses
218
+ )
219
+
220
+ @staticmethod
221
+ def _any_failure(conclusions):
222
+ return any(
223
+ c in {"failure", "timed_out", "action_required", "stale"}
224
+ for c in conclusions
225
+ )
226
+
227
+ @staticmethod
228
+ def _is_success(conclusions):
229
+ return bool(conclusions) and (
230
+ all(c == "success" for c in conclusions)
231
+ or ("success" in conclusions and not Release._any_failure(conclusions))
232
+ )
233
+
234
+ @staticmethod
235
+ def _icon_for_state(state):
236
+ icon_mapping = {
237
+ "success": "✅",
238
+ "failure": "❌",
239
+ "in_progress": "🏗️",
240
+ "cancelled": "🚫",
241
+ "unknown": "❓",
242
+ }
243
+ return icon_mapping.get(state, "❓")
244
+
156
245
  @staticmethod
157
246
  def _build_commits_message(commits):
158
247
  return "\n".join(
@@ -179,7 +268,7 @@ class Release:
179
268
  return commits[0].commit.sha if commits else ""
180
269
  try:
181
270
  sha = self._repo.get_commit(self._commit).sha
182
- except Exception: # pragma: no cover
271
+ except Exception:
183
272
  logger.error(
184
273
  f"Unable to resolve commit '{self._commit}' during release creation."
185
274
  )
@@ -189,13 +278,13 @@ class Release:
189
278
 
190
279
  def _validate_target_commit_not_tagged(self, sha):
191
280
  try:
192
- for t in self._repo.get_tags(): # network call
281
+ for t in self._repo.get_tags():
193
282
  if t.commit.sha == sha:
194
283
  logger.error(
195
284
  f"The commit {sha[:7]} is already tagged with {t.name}. Aborting."
196
285
  )
197
286
  sys.exit(-1)
198
- except Exception: # pragma: no cover
287
+ except Exception:
199
288
  logger.warning("Could not verify if commit already tagged; continuing.")
200
289
 
201
290
  def _create_release(self, new_version, message, commit):
File without changes
File without changes