suite-py 1.47.2__tar.gz → 1.49.0__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 (47) hide show
  1. {suite_py-1.47.2 → suite_py-1.49.0}/PKG-INFO +2 -2
  2. {suite_py-1.47.2 → suite_py-1.49.0}/pyproject.toml +2 -2
  3. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/__version__.py +1 -1
  4. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/cli.py +51 -8
  5. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/context.py +7 -3
  6. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/create_branch.py +48 -24
  7. suite_py-1.49.0/suite_py/commands/release.py +407 -0
  8. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/pre_commit_handler.py +10 -3
  9. suite_py-1.47.2/suite_py/commands/release.py +0 -206
  10. {suite_py-1.47.2 → suite_py-1.49.0}/LICENSE-APACHE +0 -0
  11. {suite_py-1.47.2 → suite_py-1.49.0}/LICENSE-MIT +0 -0
  12. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/__init__.py +0 -0
  13. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/__init__.py +0 -0
  14. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/ask_review.py +0 -0
  15. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/bump.py +0 -0
  16. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/check.py +0 -0
  17. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/common.py +0 -0
  18. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/estimate_cone.py +0 -0
  19. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/login.py +0 -0
  20. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/merge_pr.py +0 -0
  21. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/open_pr.py +0 -0
  22. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/project_lock.py +0 -0
  23. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/set_token.py +0 -0
  24. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/commands/status.py +0 -0
  25. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/__init__.py +0 -0
  26. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/config.py +0 -0
  27. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/__init__.py +0 -0
  28. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/aws_handler.py +0 -0
  29. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/captainhook_handler.py +0 -0
  30. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/changelog_handler.py +0 -0
  31. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/frequent_reviewers_handler.py +0 -0
  32. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/git_handler.py +0 -0
  33. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/github_handler.py +0 -0
  34. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/metrics_handler.py +0 -0
  35. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/okta_handler.py +0 -0
  36. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/prompt_utils.py +0 -0
  37. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/version_handler.py +0 -0
  38. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/handler/youtrack_handler.py +0 -0
  39. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/logger.py +0 -0
  40. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/metrics.py +0 -0
  41. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/oauth.py +0 -0
  42. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/requests/__init__.py +0 -0
  43. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/requests/auth.py +0 -0
  44. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/requests/session.py +0 -0
  45. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/symbol.py +0 -0
  46. {suite_py-1.47.2 → suite_py-1.49.0}/suite_py/lib/tokens.py +0 -0
  47. {suite_py-1.47.2 → suite_py-1.49.0}/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.47.2
3
+ Version: 1.49.0
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.47.2"
5
+ version = "1.49.0"
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.47.2"
2
+ __version__ = "1.49.0"
@@ -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")
@@ -326,12 +347,34 @@ def release():
326
347
  @release.command(
327
348
  "create", help="Create a github release (and deploy it if GHA are used)"
328
349
  )
350
+ @click.argument(
351
+ "commit",
352
+ required=False,
353
+ type=str,
354
+ metavar="[COMMIT]",
355
+ )
356
+ @click.option(
357
+ "--interactive",
358
+ "-i",
359
+ is_flag=True,
360
+ help="Interactively choose which unreleased commit to release (ignored if COMMIT is provided)",
361
+ )
329
362
  @click.pass_obj
330
363
  @catch_exceptions
331
- def cli_release_create(obj):
364
+ def cli_release_create(obj, commit, interactive): # type: ignore[override]
365
+ """Create a release.
366
+
367
+ Optionally pass a COMMIT (full or short SHA) to create the release at that
368
+ specific commit instead of the latest commit (HEAD). Example:
369
+
370
+ suite-py release create 644a699
371
+
372
+ If you don't provide a COMMIT you can pass --interactive / -i to select
373
+ one among the unreleased commits (those after the last tag).
374
+ """
332
375
  from suite_py.commands.release import Release
333
376
 
334
- obj.call(Release, action="create").run()
377
+ obj.call(Release, action="create", commit=commit, interactive=interactive).run()
335
378
 
336
379
 
337
380
  @main.command("status", help="Current status of a project")
@@ -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 /)
@@ -0,0 +1,407 @@
1
+ # -*- coding: utf-8 -*-
2
+ import re
3
+ import sys
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+
6
+ from suite_py.commands import common
7
+ from suite_py.lib import logger, metrics
8
+ from suite_py.lib.handler import git_handler as git
9
+ from suite_py.lib.handler import prompt_utils
10
+ from suite_py.lib.handler.changelog_handler import ChangelogHandler
11
+ from suite_py.lib.handler.git_handler import GitHandler
12
+ from suite_py.lib.handler.github_handler import GithubHandler
13
+ from suite_py.lib.handler.version_handler import DEFAULT_VERSION, VersionHandler
14
+ from suite_py.lib.handler.youtrack_handler import YoutrackHandler
15
+
16
+
17
+ class Release:
18
+ # pylint: disable=too-many-instance-attributes
19
+ # pylint: disable=too-many-positional-arguments
20
+ def __init__(
21
+ self,
22
+ action,
23
+ project,
24
+ captainhook,
25
+ config,
26
+ tokens,
27
+ commit=None,
28
+ interactive=False,
29
+ ):
30
+ self._action = action
31
+ self._project = project
32
+ self._config = config
33
+ self._tokens = tokens
34
+ # Optional commit (sha or short sha) to release instead of HEAD
35
+ self._commit = commit
36
+ # Whether to prompt the user to choose commit among unreleased ones
37
+ self._interactive = interactive
38
+ self._changelog_handler = ChangelogHandler()
39
+ self._youtrack = YoutrackHandler(config, tokens)
40
+ self._captainhook = captainhook
41
+ self._github = GithubHandler(tokens)
42
+ self._repo = self._github.get_repo(project)
43
+ self._git = GitHandler(project, config)
44
+ self._version = VersionHandler(self._repo, self._git, self._github)
45
+
46
+ @metrics.command("release")
47
+ def run(self):
48
+ self._stop_if_prod_locked()
49
+ self._git.fetch()
50
+
51
+ if self._action == "create":
52
+ self._create()
53
+
54
+ def _stop_if_prod_locked(self):
55
+ request = self._captainhook.status(self._project, "production")
56
+ if request.status_code != 200:
57
+ logger.error("Unable to determine lock status on master.")
58
+ sys.exit(-1)
59
+
60
+ request_object = request.json()
61
+ if request_object["locked"]:
62
+ logger.error(
63
+ f"The project is locked in production by {request_object['by']}. Unable to continue."
64
+ )
65
+ sys.exit(-1)
66
+
67
+ def _create(self):
68
+ latest = self._version.get_latest_version()
69
+ commits, new_version, message = self._gather_commits_and_version(latest)
70
+ message = self._augment_message_with_changelog(new_version, message)
71
+ message = common.ask_for_release_description(message)
72
+ sha = self._resolve_target_sha(commits)
73
+ self._create_release(new_version, message, sha)
74
+
75
+ def _gather_commits_and_version(self, latest):
76
+ """Return (commits, new_version, base_message)."""
77
+ if latest == "":
78
+ return self._first_release_flow()
79
+
80
+ logger.info(f"The current release is {latest}")
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)
87
+ # If user did not pass a target commit, ask interactively which commit to release.
88
+ # Only perform interactive selection if user explicitly requested it
89
+ if self._interactive:
90
+ self._select_commit_if_needed(commits)
91
+ commits = self._maybe_trim_commits(commits)
92
+ _check_migrations_deploy(commits)
93
+ message = self._build_commits_message(commits)
94
+ logger.info(f"\nCommits list:\n{message}\n")
95
+ if not prompt_utils.ask_confirm("Do you want to continue?"):
96
+ sys.exit()
97
+ new_version = self._version.select_new_version(latest, allow_prerelease=True)
98
+ return commits, new_version, message
99
+
100
+ def _first_release_flow(self):
101
+ logger.warning(f"No tags found, I'm about to push the tag {DEFAULT_VERSION}")
102
+ if not prompt_utils.ask_confirm(
103
+ "Are you sure you want to continue?", default=False
104
+ ):
105
+ sys.exit()
106
+ return [], DEFAULT_VERSION, f"First release with tag {DEFAULT_VERSION}"
107
+
108
+ def _maybe_trim_commits(self, commits):
109
+ if not self._commit:
110
+ return commits
111
+ try:
112
+ resolved_commit = self._repo.get_commit(self._commit)
113
+ target_sha = resolved_commit.sha
114
+ except Exception:
115
+ logger.error(
116
+ f"The provided commit '{self._commit}' was not found in the repository."
117
+ )
118
+ sys.exit(-1)
119
+ target_index = None
120
+ for idx, c in enumerate(commits):
121
+ if (
122
+ c.sha == target_sha
123
+ or getattr(c, "commit", None)
124
+ and c.commit.sha == target_sha
125
+ ):
126
+ target_index = idx
127
+ break
128
+ if target_index is None:
129
+ logger.error(
130
+ "The specified commit is not part of the unreleased commits (i.e., not after the latest release)."
131
+ )
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.
135
+ return commits[target_index:]
136
+
137
+ def _select_commit_if_needed(self, commits):
138
+ """Interactively ask the user which commit to release if none was provided.
139
+
140
+ Presents the unreleased commits (newest first) allowing the user to pick a
141
+ target commit. The chosen commit's SHA (full) is stored in `self._commit` so
142
+ that downstream logic (_maybe_trim_commits / _resolve_target_sha) works
143
+ unchanged.
144
+ """
145
+ if self._commit or not commits:
146
+ return
147
+
148
+ choices = []
149
+
150
+ def build_choice(c, icon):
151
+ summary = c.commit.message.splitlines()[0]
152
+ return {
153
+ "name": f"{icon} {c.sha[:8]} | {summary}",
154
+ "value": c.sha,
155
+ }
156
+
157
+ with ThreadPoolExecutor(max_workers=min(8, len(commits))) as executor:
158
+ future_map = {
159
+ executor.submit(self._get_commit_status_icon, c.sha): c for c in commits
160
+ }
161
+
162
+ icon_results = {}
163
+ for future in as_completed(future_map):
164
+ c = future_map[future]
165
+ try:
166
+ icon_results[c.sha] = future.result()
167
+ except Exception:
168
+ icon_results[c.sha] = "?"
169
+ for c in commits:
170
+ choices.append(build_choice(c, icon_results.get(c.sha, "?")))
171
+
172
+ subtitle = "(only one available)" if len(choices) == 1 else "(newest first):"
173
+ selected = prompt_utils.ask_choices(
174
+ f"Select commit to release {subtitle}", choices
175
+ )
176
+ self._commit = selected
177
+
178
+ def _get_commit_status_icon(self, sha):
179
+ """Return an icon representing the simplified CI status for a commit.
180
+
181
+ Final exposed statuses (icons):
182
+ success (✅), failure (❌), in_progress (🏗️), cancelled (🚫), unknown (❓)
183
+
184
+ Everything else is folded into one of those buckets:
185
+ pending / queued / waiting -> in_progress
186
+ skipped / neutral -> unknown (unless at least one success present)
187
+ Preference order for determining state:
188
+ 1. Check runs API
189
+ 2. Combined status API
190
+ 3. Fallback unknown
191
+ """
192
+ check_runs = self._safe_get_check_runs(sha)
193
+ state = self._classify_from_check_runs(check_runs) if check_runs else None
194
+ if state is None:
195
+ state = self._classify_from_combined_status(sha)
196
+ return self._icon_for_state(state)
197
+
198
+ def _safe_get_check_runs(self, sha):
199
+ try:
200
+ commit = self._repo.get_commit(sha)
201
+ return list(commit.get_check_runs())
202
+ except Exception:
203
+ return []
204
+
205
+ def _classify_from_check_runs(self, check_runs):
206
+ statuses = {r.status for r in check_runs if getattr(r, "status", None)}
207
+ conclusions = {
208
+ r.conclusion for r in check_runs if getattr(r, "conclusion", None)
209
+ }
210
+
211
+ if self._any_in_progress(statuses):
212
+ return "in_progress"
213
+ if self._any_failure(conclusions):
214
+ return "failure"
215
+ if "cancelled" in conclusions:
216
+ return "cancelled"
217
+ if self._is_success(conclusions):
218
+ return "success"
219
+ return "unknown"
220
+
221
+ @staticmethod
222
+ def _any_in_progress(statuses):
223
+ return any(
224
+ s in {"in_progress", "queued", "waiting", "pending"} for s in statuses
225
+ )
226
+
227
+ @staticmethod
228
+ def _any_failure(conclusions):
229
+ return any(
230
+ c in {"failure", "timed_out", "action_required", "stale"}
231
+ for c in conclusions
232
+ )
233
+
234
+ @staticmethod
235
+ def _is_success(conclusions):
236
+ return bool(conclusions) and (
237
+ all(c == "success" for c in conclusions)
238
+ or ("success" in conclusions and not Release._any_failure(conclusions))
239
+ )
240
+
241
+ def _classify_from_combined_status(self, sha):
242
+ try:
243
+ status = self._github.get_build_status(self._project, sha)
244
+ combined = (status.state or "unknown").lower()
245
+ except Exception:
246
+ combined = "unknown"
247
+ if combined in {"pending", "queued", "waiting", "in_progress"}:
248
+ return "in_progress"
249
+ if combined == "success":
250
+ return "success"
251
+ if combined == "failure":
252
+ return "failure"
253
+ if combined == "cancelled":
254
+ return "cancelled"
255
+ return "unknown"
256
+
257
+ @staticmethod
258
+ def _icon_for_state(state):
259
+ icon_mapping = {
260
+ "success": "✅",
261
+ "failure": "❌",
262
+ "in_progress": "🏗️",
263
+ "cancelled": "🚫",
264
+ "unknown": "❓",
265
+ }
266
+ return icon_mapping.get(state, "❓")
267
+
268
+ @staticmethod
269
+ def _build_commits_message(commits):
270
+ return "\n".join(
271
+ [
272
+ "* " + c.commit.message.splitlines()[0] + " by " + c.commit.author.name
273
+ for c in commits
274
+ ]
275
+ )
276
+
277
+ def _augment_message_with_changelog(self, new_version, message):
278
+ if not self._changelog_handler.changelog_exists():
279
+ return message
280
+ latest_tag, latest_entry = self._changelog_handler.get_latest_entry_with_tag()
281
+ if latest_tag != new_version:
282
+ if not prompt_utils.ask_confirm(
283
+ "You didn't update your changelog, are you sure you want to proceed?"
284
+ ):
285
+ sys.exit()
286
+ return message
287
+ return f"{latest_entry}\n\n# Commits\n\n{message}"
288
+
289
+ def _resolve_target_sha(self, commits):
290
+ if not self._commit:
291
+ return commits[0].commit.sha if commits else ""
292
+ try:
293
+ sha = self._repo.get_commit(self._commit).sha
294
+ except Exception:
295
+ logger.error(
296
+ f"Unable to resolve commit '{self._commit}' during release creation."
297
+ )
298
+ sys.exit(-1)
299
+ self._validate_target_commit_not_tagged(sha)
300
+ return sha
301
+
302
+ def _validate_target_commit_not_tagged(self, sha):
303
+ try:
304
+ for t in self._repo.get_tags():
305
+ if t.commit.sha == sha:
306
+ logger.error(
307
+ f"The commit {sha[:7]} is already tagged with {t.name}. Aborting."
308
+ )
309
+ sys.exit(-1)
310
+ except Exception:
311
+ logger.warning("Could not verify if commit already tagged; continuing.")
312
+
313
+ def _create_release(self, new_version, message, commit):
314
+ new_release = self._repo.create_git_release(
315
+ new_version,
316
+ new_version,
317
+ self._youtrack.replace_card_names_with_md_links(message),
318
+ target_commitish=commit,
319
+ )
320
+ if new_release:
321
+ logger.info(f"The release has been created! Link: {new_release.html_url}")
322
+
323
+ def _manage_youtrack_card(self, version, countries):
324
+ release_state = self._config.youtrack["release_state"]
325
+
326
+ release_body = self._repo.get_release(version).body
327
+
328
+ issue_ids = self._youtrack.get_ids_from_release_body(release_body)
329
+
330
+ if len(issue_ids) > 0:
331
+ update_youtrack_state = prompt_utils.ask_confirm(
332
+ f"Do you want to move the associated cards to {release_state} state?",
333
+ default=False,
334
+ )
335
+
336
+ for issue_id in issue_ids:
337
+ try:
338
+ self._youtrack.comment(
339
+ issue_id,
340
+ f"Deploy in production of {self._project} in countries {countries} done with the release {version}",
341
+ )
342
+ if update_youtrack_state:
343
+ self._youtrack.update_state(issue_id, release_state)
344
+ logger.info(f"{issue_id} moved to {release_state}")
345
+ except Exception:
346
+ logger.warning(
347
+ f"An error occurred while moving the card {issue_id} to {release_state}"
348
+ )
349
+ repos_status = self._get_repos_status_from_issue(issue_id)
350
+ if all(r["deployed"] for r in repos_status.values()):
351
+ try:
352
+ self._youtrack.update_deployed_field(issue_id)
353
+ logger.info("Custom field Deployed updated on YouTrack")
354
+ except Exception:
355
+ logger.warning(
356
+ "An error occurred while updating the custom field Deployed"
357
+ )
358
+
359
+ def _get_repos_status_from_issue(self, issue_id):
360
+ regex_pr = r"^PR .* -> https:\/\/github\.com\/primait\/(.*)\/pull\/([0-9]*)$"
361
+ regex_deploy = r"^Deploy in production of (.*) in countries"
362
+ comments = self._youtrack.get_comments(issue_id)
363
+ repos_status = {}
364
+
365
+ for c in comments:
366
+ m = re.match(regex_pr, c["text"])
367
+ if m:
368
+ project = m.group(1)
369
+ pr_number = int(m.group(2))
370
+ repos_status[project] = {}
371
+ repos_status[project]["pr"] = pr_number
372
+ repos_status[project]["deployed"] = False
373
+ m = re.match(regex_deploy, c["text"])
374
+ if m:
375
+ project = m.group(1)
376
+ try:
377
+ repos_status[project]["deployed"] = True
378
+ except Exception:
379
+ pass
380
+ return repos_status
381
+
382
+ def _tags_drifted(self, versions):
383
+ for country, version in versions.items():
384
+ for c, v in versions.items():
385
+ if country == c:
386
+ continue
387
+ if version is None or v is None or version.compare(v) != 0:
388
+ return True
389
+ return False
390
+
391
+
392
+ def _check_migrations_deploy(commits):
393
+ if not commits:
394
+ logger.error("ERROR: no commit found")
395
+ sys.exit(-1)
396
+ elif len(commits) == 1:
397
+ files_changed = git.files_changed_between_commits("--raw", f"{commits[0].sha}~")
398
+ else:
399
+ files_changed = git.files_changed_between_commits(
400
+ f"{commits[-1].sha}~", commits[0].sha
401
+ )
402
+ if git.migrations_found(files_changed):
403
+ logger.warning("WARNING: migrations detected in the code")
404
+ if not prompt_utils.ask_confirm(
405
+ "Are you sure you want to continue?", default=False
406
+ ):
407
+ sys.exit()
@@ -51,10 +51,17 @@ to disable it globally
51
51
  * is there a `.git/hooks/pre-commit` shell script that contains keyword "gitleaks"
52
52
  * is there a `.husky/pre-commit` shell script that contains keyword "security-hooks" (primait/security-hooks repo)
53
53
  """
54
- path = os.path.join(self._git.hooks_path(), "pre-commit")
55
- keywords = ["gitleaks", "security-hooks"]
54
+ checks = [
55
+ (os.path.join(self._git.hooks_path(), "pre-commit"), "gitleaks"),
56
+ (
57
+ os.path.join(self._git.get_path(), ".husky", "pre-commit"),
58
+ "security-hooks",
59
+ ),
60
+ ]
56
61
 
57
- return any(self._script_contains_keyword(path, keyword) for keyword in keywords)
62
+ return any(
63
+ self._script_contains_keyword(path, keyword) for path, keyword in checks
64
+ )
58
65
 
59
66
  def _is_pre_commit_py_hook_setup(self):
60
67
  """
@@ -1,206 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- import re
3
- import sys
4
-
5
- from suite_py.commands import common
6
- from suite_py.lib import logger, metrics
7
- from suite_py.lib.handler import git_handler as git
8
- from suite_py.lib.handler import prompt_utils
9
- from suite_py.lib.handler.changelog_handler import ChangelogHandler
10
- from suite_py.lib.handler.git_handler import GitHandler
11
- from suite_py.lib.handler.github_handler import GithubHandler
12
- from suite_py.lib.handler.version_handler import DEFAULT_VERSION, VersionHandler
13
- from suite_py.lib.handler.youtrack_handler import YoutrackHandler
14
-
15
-
16
- class Release:
17
- # pylint: disable=too-many-instance-attributes
18
- # pylint: disable=too-many-positional-arguments
19
- def __init__(self, action, project, captainhook, config, tokens):
20
- self._action = action
21
- self._project = project
22
- self._config = config
23
- self._tokens = tokens
24
- self._changelog_handler = ChangelogHandler()
25
- self._youtrack = YoutrackHandler(config, tokens)
26
- self._captainhook = captainhook
27
- self._github = GithubHandler(tokens)
28
- self._repo = self._github.get_repo(project)
29
- self._git = GitHandler(project, config)
30
- self._version = VersionHandler(self._repo, self._git, self._github)
31
-
32
- @metrics.command("release")
33
- def run(self):
34
- self._stop_if_prod_locked()
35
- self._git.fetch()
36
-
37
- if self._action == "create":
38
- self._create()
39
-
40
- def _stop_if_prod_locked(self):
41
- request = self._captainhook.status(self._project, "production")
42
- if request.status_code != 200:
43
- logger.error("Unable to determine lock status on master.")
44
- sys.exit(-1)
45
-
46
- request_object = request.json()
47
- if request_object["locked"]:
48
- logger.error(
49
- f"The project is locked in production by {request_object['by']}. Unable to continue."
50
- )
51
- sys.exit(-1)
52
-
53
- def _create(self):
54
- latest = self._version.get_latest_version()
55
-
56
- commits = []
57
- if latest != "":
58
- logger.info(f"The current release is {latest}")
59
- commits = self._github.get_commits_since_release(self._repo, latest)
60
-
61
- _check_migrations_deploy(commits)
62
-
63
- message = "\n".join(
64
- [
65
- "* "
66
- + c.commit.message.splitlines()[0]
67
- + " by "
68
- + c.commit.author.name
69
- for c in commits
70
- ]
71
- )
72
-
73
- logger.info(f"\nCommits list:\n{message}\n")
74
-
75
- if not prompt_utils.ask_confirm("Do you want to continue?"):
76
- return
77
-
78
- new_version = self._version.select_new_version(
79
- latest, allow_prerelease=True
80
- )
81
-
82
- else:
83
- # Se non viene trovata la release e non ci sono tag, viene saltato il check delle migrations e l'update delle card su youtrack
84
- logger.warning(
85
- f"No tags found, I'm about to push the tag {DEFAULT_VERSION}"
86
- )
87
- if not prompt_utils.ask_confirm(
88
- "Are you sure you want to continue?", default=False
89
- ):
90
- sys.exit()
91
- new_version = DEFAULT_VERSION
92
- message = f"First release with tag {new_version}"
93
-
94
- if self._changelog_handler.changelog_exists():
95
- (
96
- latest_tag,
97
- latest_entry,
98
- ) = self._changelog_handler.get_latest_entry_with_tag()
99
-
100
- if latest_tag != new_version:
101
- if not prompt_utils.ask_confirm(
102
- "You didn't update your changelog, are you sure you want to proceed?"
103
- ):
104
- sys.exit()
105
- else:
106
- message = f"{latest_entry}\n\n# Commits\n\n{message}"
107
-
108
- message = common.ask_for_release_description(message)
109
- sha = commits[0].commit.sha if len(commits) > 0 else ""
110
- self._create_release(new_version, message, sha)
111
-
112
- def _create_release(self, new_version, message, commit):
113
- new_release = self._repo.create_git_release(
114
- new_version,
115
- new_version,
116
- self._youtrack.replace_card_names_with_md_links(message),
117
- target_commitish=commit,
118
- )
119
- if new_release:
120
- logger.info(f"The release has been created! Link: {new_release.html_url}")
121
-
122
- def _manage_youtrack_card(self, version, countries):
123
- release_state = self._config.youtrack["release_state"]
124
-
125
- release_body = self._repo.get_release(version).body
126
-
127
- issue_ids = self._youtrack.get_ids_from_release_body(release_body)
128
-
129
- if len(issue_ids) > 0:
130
- update_youtrack_state = prompt_utils.ask_confirm(
131
- f"Do you want to move the associated cards to {release_state} state?",
132
- default=False,
133
- )
134
-
135
- for issue_id in issue_ids:
136
- try:
137
- self._youtrack.comment(
138
- issue_id,
139
- f"Deploy in production of {self._project} in countries {countries} done with the release {version}",
140
- )
141
- if update_youtrack_state:
142
- self._youtrack.update_state(issue_id, release_state)
143
- logger.info(f"{issue_id} moved to {release_state}")
144
- except Exception:
145
- logger.warning(
146
- f"An error occurred while moving the card {issue_id} to {release_state}"
147
- )
148
- repos_status = self._get_repos_status_from_issue(issue_id)
149
- if all(r["deployed"] for r in repos_status.values()):
150
- try:
151
- self._youtrack.update_deployed_field(issue_id)
152
- logger.info("Custom field Deployed updated on YouTrack")
153
- except Exception:
154
- logger.warning(
155
- "An error occurred while updating the custom field Deployed"
156
- )
157
-
158
- def _get_repos_status_from_issue(self, issue_id):
159
- regex_pr = r"^PR .* -> https:\/\/github\.com\/primait\/(.*)\/pull\/([0-9]*)$"
160
- regex_deploy = r"^Deploy in production of (.*) in countries"
161
- comments = self._youtrack.get_comments(issue_id)
162
- repos_status = {}
163
-
164
- for c in comments:
165
- m = re.match(regex_pr, c["text"])
166
- if m:
167
- project = m.group(1)
168
- pr_number = int(m.group(2))
169
- repos_status[project] = {}
170
- repos_status[project]["pr"] = pr_number
171
- repos_status[project]["deployed"] = False
172
- m = re.match(regex_deploy, c["text"])
173
- if m:
174
- project = m.group(1)
175
- try:
176
- repos_status[project]["deployed"] = True
177
- except Exception:
178
- pass
179
- return repos_status
180
-
181
- def _tags_drifted(self, versions):
182
- for country, version in versions.items():
183
- for c, v in versions.items():
184
- if country == c:
185
- continue
186
- if version is None or v is None or version.compare(v) != 0:
187
- return True
188
- return False
189
-
190
-
191
- def _check_migrations_deploy(commits):
192
- if not commits:
193
- logger.error("ERROR: no commit found")
194
- sys.exit(-1)
195
- elif len(commits) == 1:
196
- files_changed = git.files_changed_between_commits("--raw", f"{commits[0].sha}~")
197
- else:
198
- files_changed = git.files_changed_between_commits(
199
- f"{commits[-1].sha}~", commits[0].sha
200
- )
201
- if git.migrations_found(files_changed):
202
- logger.warning("WARNING: migrations detected in the code")
203
- if not prompt_utils.ask_confirm(
204
- "Are you sure you want to continue?", default=False
205
- ):
206
- sys.exit()
File without changes
File without changes