git-tide 0.1.5__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.
- git_tide-0.1.5.dist-info/METADATA +157 -0
- git_tide-0.1.5.dist-info/RECORD +9 -0
- git_tide-0.1.5.dist-info/WHEEL +4 -0
- git_tide-0.1.5.dist-info/entry_points.txt +2 -0
- tide/__init__.py +1 -0
- tide/__main__.py +4 -0
- tide/cli.py +349 -0
- tide/core.py +821 -0
- tide/gitutils.py +340 -0
tide/core.py
ADDED
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
from __future__ import absolute_import, print_function, annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomli as tomllib # noqa: F401
|
|
11
|
+
except ImportError:
|
|
12
|
+
import tomllib # type: ignore[no-redef]
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from urllib.parse import urlparse, urlunparse
|
|
17
|
+
from abc import abstractmethod
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import TYPE_CHECKING, cast
|
|
20
|
+
|
|
21
|
+
from .gitutils import git, checkout, get_tags, branch_exists, join, current_rev, GitRepo
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import gitlab.v4.objects
|
|
25
|
+
import commitizen.providers
|
|
26
|
+
import commitizen.config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
TOOL_NAME = "tide"
|
|
30
|
+
ENVVAR_PREFIX = TOOL_NAME.upper()
|
|
31
|
+
PROMOTION_BASE_MSG = "promotion base"
|
|
32
|
+
HERE = os.path.dirname(__file__)
|
|
33
|
+
GITLAB_REMOTE = "gitlab_origin"
|
|
34
|
+
|
|
35
|
+
# FIXME: add these to config file
|
|
36
|
+
HOTFIX_MESSAGE = "auto-hotfix into {upstream_branch}: {message}"
|
|
37
|
+
PROMOTION_CYCLE_START_MESSAGE = "starting new {release_id} cycle."
|
|
38
|
+
PROMOTION_MESSAGE = "promoting {upstream_branch} to {branch}!"
|
|
39
|
+
|
|
40
|
+
cache = lru_cache(maxsize=None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReleaseID(str, Enum):
|
|
44
|
+
"""Represents semver pre-releases, plus 'stable' (i.e. non-pre-release)"""
|
|
45
|
+
|
|
46
|
+
alpha = "alpha"
|
|
47
|
+
beta = "beta"
|
|
48
|
+
rc = "rc"
|
|
49
|
+
stable = "stable"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_url(s: str) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Return whether the string looks like a URL.
|
|
55
|
+
"""
|
|
56
|
+
return "://" in s
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_config(path: str | None = None, verbose: bool = False) -> Config:
|
|
60
|
+
"""
|
|
61
|
+
Load and return the GitFlow configuration from the pyproject.toml file.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A configuration object
|
|
65
|
+
"""
|
|
66
|
+
if path is None:
|
|
67
|
+
path = os.path.join(os.getcwd(), "pyproject.toml")
|
|
68
|
+
if not os.path.isfile(path):
|
|
69
|
+
raise click.ClickException("No pyproject.toml found")
|
|
70
|
+
|
|
71
|
+
with open(path, "rb") as f:
|
|
72
|
+
data = tomllib.load(f)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
settings = data["tool"][TOOL_NAME]
|
|
76
|
+
except KeyError:
|
|
77
|
+
raise click.ClickException(f"'tool.{TOOL_NAME}' section missing: {path}")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
branches = settings["branches"]
|
|
81
|
+
except KeyError:
|
|
82
|
+
raise click.ClickException(
|
|
83
|
+
f"'tool.{TOOL_NAME}.branches' section missing: {path}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
config = Config(verbose=verbose)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
config.tag_format = settings["tag_format"]
|
|
90
|
+
except KeyError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Loop through branches once and extract all needed information
|
|
94
|
+
for release_id in ReleaseID:
|
|
95
|
+
branch_name = branches.get(release_id.value)
|
|
96
|
+
if branch_name is None:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Append branch name to CONFIG.branches list
|
|
100
|
+
config.branches.append(branch_name)
|
|
101
|
+
|
|
102
|
+
# Update CONFIG.branch_to_release_id dictionary
|
|
103
|
+
config.branch_to_release_id[branch_name] = release_id
|
|
104
|
+
setattr(config, release_id.value, branch_name)
|
|
105
|
+
return config
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Config:
|
|
110
|
+
stable: str = "master"
|
|
111
|
+
rc: str | None = None
|
|
112
|
+
beta: str | None = None
|
|
113
|
+
alpha: str | None = None
|
|
114
|
+
# branches in order from most-experimental to stable
|
|
115
|
+
branches: list[str] = field(default_factory=list)
|
|
116
|
+
# branch name to pre-release name (alpha, beta, rc). None for stable.
|
|
117
|
+
branch_to_release_id: dict[str, ReleaseID] = field(default_factory=dict)
|
|
118
|
+
verbose: bool = False
|
|
119
|
+
tag_format: str = "$version"
|
|
120
|
+
|
|
121
|
+
def get_upstream_branch(self, branch: str) -> str | None:
|
|
122
|
+
"""
|
|
123
|
+
Determine the upstream branch for a given branch name based on configuration.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
branch: The name of the branch for which to find the upstream branch.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The name of the upstream branch, or None if there is no upstream branch.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ClickException: If the branch is not found in the configuration.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
index = self.branches.index(branch)
|
|
136
|
+
except ValueError:
|
|
137
|
+
raise click.ClickException(f"Invalid branch: {branch}")
|
|
138
|
+
|
|
139
|
+
if index > 0:
|
|
140
|
+
return self.branches[index - 1]
|
|
141
|
+
else:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def most_experimental_branch(self) -> str | None:
|
|
145
|
+
"""
|
|
146
|
+
Return the most experimental branch.
|
|
147
|
+
|
|
148
|
+
This branch corresponds to the earliest pre-release specified by the config.
|
|
149
|
+
"""
|
|
150
|
+
if self.branches[0] == self.stable:
|
|
151
|
+
return None
|
|
152
|
+
else:
|
|
153
|
+
return self.branches[0]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Runtime:
|
|
157
|
+
"""
|
|
158
|
+
Interact with a git repo that is local to the current process
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, config: Config):
|
|
162
|
+
self.config = config
|
|
163
|
+
|
|
164
|
+
@abstractmethod
|
|
165
|
+
def current_branch(self) -> str:
|
|
166
|
+
"""
|
|
167
|
+
Get the current git branch name.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The name of the current branch.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
RuntimeError: If the current branch name is not obtainable
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
@abstractmethod
|
|
177
|
+
def get_base_rev(self) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Get the git revision that represents the state of the repo before the changes
|
|
180
|
+
that triggered the current pipeline
|
|
181
|
+
|
|
182
|
+
The files changed after this revision will be used to determine which
|
|
183
|
+
project tags to increment.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
def get_remote(self) -> str:
|
|
188
|
+
"""
|
|
189
|
+
Configure and retrieve the name of a Git remote for use in CI environments.
|
|
190
|
+
|
|
191
|
+
This function configures a Git remote using environment variables
|
|
192
|
+
that should be set in the GitLab CI/CD environment. It configures the git user credentials,
|
|
193
|
+
splits the repository URL to format it with the access token, and adds the remote to the
|
|
194
|
+
local git configuration.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The name of the configured remote
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Backend:
|
|
202
|
+
"""
|
|
203
|
+
Interact with a remote git backend.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self, config: Config):
|
|
207
|
+
self.config = config
|
|
208
|
+
|
|
209
|
+
def push(
|
|
210
|
+
self, *args: str, variables: dict[str, str] | None = None, skip_ci: bool = False
|
|
211
|
+
) -> None:
|
|
212
|
+
opts = []
|
|
213
|
+
if skip_ci:
|
|
214
|
+
opts.extend(["-o", "ci.skip"])
|
|
215
|
+
if variables:
|
|
216
|
+
for key, value in variables.items():
|
|
217
|
+
opts.extend(
|
|
218
|
+
[
|
|
219
|
+
"-o",
|
|
220
|
+
f"ci.variable={key}={value}",
|
|
221
|
+
]
|
|
222
|
+
)
|
|
223
|
+
git("push", *args, *opts)
|
|
224
|
+
|
|
225
|
+
def init_local_repo(self, remote_name: str) -> None:
|
|
226
|
+
"""
|
|
227
|
+
Setup the local repository.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
remote_name: name of the git remote, used to query the url
|
|
231
|
+
"""
|
|
232
|
+
# initialize the local repo
|
|
233
|
+
git("fetch", remote_name, quiet=self.config.verbose)
|
|
234
|
+
|
|
235
|
+
if self.config.verbose:
|
|
236
|
+
git("branch", "-la")
|
|
237
|
+
git("remote", "-v")
|
|
238
|
+
|
|
239
|
+
# setup branches.
|
|
240
|
+
# loop from stable to pre-release branches, bc we set all release_id branches to
|
|
241
|
+
# the location of stable
|
|
242
|
+
for branch in reversed(self.config.branches):
|
|
243
|
+
if branch_exists(branch):
|
|
244
|
+
if branch != self.config.stable:
|
|
245
|
+
click.echo(
|
|
246
|
+
f"{branch} already exists. This can potentially cause problems",
|
|
247
|
+
err=True,
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
git("branch", "-f", branch, self.config.stable)
|
|
251
|
+
|
|
252
|
+
remote_branch = f"{remote_name}/{branch}"
|
|
253
|
+
if branch_exists(remote_branch):
|
|
254
|
+
git("branch", f"--set-upstream-to={remote_branch}", branch)
|
|
255
|
+
else:
|
|
256
|
+
self.push("--set-upstream", remote_name, branch, skip_ci=True)
|
|
257
|
+
|
|
258
|
+
@abstractmethod
|
|
259
|
+
def init_remote_repo(
|
|
260
|
+
self, remote_url: str, access_token: str, save_token: bool
|
|
261
|
+
) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Setup the remote repository.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
remote_url: URL of the git remote
|
|
267
|
+
access_token: token used to authenticate changes to the remote.
|
|
268
|
+
save_token: whether to save `access_token` into the remote.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class GitlabRuntime(Runtime):
|
|
273
|
+
def current_branch(self) -> str:
|
|
274
|
+
try:
|
|
275
|
+
return os.environ["CI_COMMIT_BRANCH"]
|
|
276
|
+
except KeyError:
|
|
277
|
+
raise RuntimeError
|
|
278
|
+
|
|
279
|
+
def get_base_rev(self) -> str:
|
|
280
|
+
return os.environ["CI_COMMIT_BEFORE_SHA"]
|
|
281
|
+
|
|
282
|
+
@cache
|
|
283
|
+
def _setup_remote(self, url: str) -> None:
|
|
284
|
+
try:
|
|
285
|
+
access_token = os.environ["ACCESS_TOKEN"]
|
|
286
|
+
except KeyError:
|
|
287
|
+
raise click.ClickException(
|
|
288
|
+
"You must setup a CI variable in the Gitlab process called ACCESS_TOKEN\n"
|
|
289
|
+
"See https://docs.gitlab.com/ee/ci/variables/#for-a-project"
|
|
290
|
+
)
|
|
291
|
+
git("config", "user.email", os.environ["GITLAB_USER_EMAIL"])
|
|
292
|
+
git("config", "user.name", os.environ["GITLAB_USER_NAME"])
|
|
293
|
+
url = url.split("@")[-1]
|
|
294
|
+
git("remote", "add", GITLAB_REMOTE, f"https://oauth2:{access_token}@{url}")
|
|
295
|
+
|
|
296
|
+
def get_remote(self) -> str:
|
|
297
|
+
url = os.environ["CI_REPOSITORY_URL"]
|
|
298
|
+
self._setup_remote(url)
|
|
299
|
+
return GITLAB_REMOTE
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class GitlabBackend(Backend):
|
|
303
|
+
"""Gitlab-specific behavior"""
|
|
304
|
+
|
|
305
|
+
PROMOTION_SCHEDULED_JOB_NAME = "Promote Gitflow Branches"
|
|
306
|
+
|
|
307
|
+
@cache
|
|
308
|
+
def _conn(self, base_url: str, access_token: str) -> gitlab.Gitlab:
|
|
309
|
+
"""
|
|
310
|
+
Get a cached gitlab connection object.
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
import gitlab
|
|
314
|
+
except ImportError:
|
|
315
|
+
raise click.ClickException(
|
|
316
|
+
f"To use the init command you must run: pip install {TOOL_NAME}[init]"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return gitlab.Gitlab(
|
|
320
|
+
url=base_url,
|
|
321
|
+
private_token=access_token,
|
|
322
|
+
retry_transient_errors=True,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _find_promote_job(
|
|
326
|
+
self, project: gitlab.v4.objects.Project
|
|
327
|
+
) -> gitlab.v4.objects.ProjectPipelineSchedule | None:
|
|
328
|
+
"""
|
|
329
|
+
Find the scheduled job that is used to trigger promotion.
|
|
330
|
+
"""
|
|
331
|
+
schedules = project.pipelineschedules.list(get_all=True)
|
|
332
|
+
for schedule in schedules:
|
|
333
|
+
if schedule.description == self.PROMOTION_SCHEDULED_JOB_NAME:
|
|
334
|
+
return schedule
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
def init_remote_repo(
|
|
338
|
+
self, remote_url: str, access_token: str, save_token: bool
|
|
339
|
+
) -> None:
|
|
340
|
+
try:
|
|
341
|
+
import gitlab.const
|
|
342
|
+
import gitlab.exceptions
|
|
343
|
+
except ImportError:
|
|
344
|
+
raise click.ClickException(
|
|
345
|
+
f"To use the init command you must run: pip install {TOOL_NAME}[init]"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if remote_url.endswith(".git"):
|
|
349
|
+
remote_url = remote_url[:-4]
|
|
350
|
+
|
|
351
|
+
# separate 'https://gitlab.com/groupname/projectname' into
|
|
352
|
+
# 'https://gitlab.com' and 'groupname/projectname'
|
|
353
|
+
url = urlparse(remote_url)
|
|
354
|
+
base_url = urlunparse(url._replace(path=""))
|
|
355
|
+
# remove leading "/"
|
|
356
|
+
project_and_ns = url.path[1:]
|
|
357
|
+
|
|
358
|
+
gl = self._conn(base_url, access_token)
|
|
359
|
+
try:
|
|
360
|
+
project = gl.projects.get(project_and_ns)
|
|
361
|
+
except gitlab.exceptions.GitlabGetError:
|
|
362
|
+
raise click.ClickException(f"Could not find project '{project_and_ns}")
|
|
363
|
+
|
|
364
|
+
if save_token:
|
|
365
|
+
try:
|
|
366
|
+
project.variables.get("ACCESS_TOKEN")
|
|
367
|
+
except gitlab.exceptions.GitlabGetError:
|
|
368
|
+
project.variables.create(
|
|
369
|
+
{
|
|
370
|
+
"key": "ACCESS_TOKEN",
|
|
371
|
+
"value": access_token,
|
|
372
|
+
"protected": True,
|
|
373
|
+
"masked": True,
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
click.echo("Created ACCESS_TOKEN project variable")
|
|
377
|
+
else:
|
|
378
|
+
click.echo("ACCESS_TOKEN project variable already exists. Skipping")
|
|
379
|
+
else:
|
|
380
|
+
# FIXME: validate that ACCESS_TOKEN has been set at the project or group level
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
for branch in self.config.branches:
|
|
384
|
+
try:
|
|
385
|
+
p_branch = project.protectedbranches.get(branch)
|
|
386
|
+
except gitlab.exceptions.GitlabGetError:
|
|
387
|
+
project.protectedbranches.create(
|
|
388
|
+
{
|
|
389
|
+
"name": branch,
|
|
390
|
+
"merge_access_level": gitlab.const.AccessLevel.DEVELOPER,
|
|
391
|
+
"push_access_level": gitlab.const.AccessLevel.MAINTAINER,
|
|
392
|
+
"allow_force_push": True,
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
p_branch.allow_force_push = True
|
|
397
|
+
p_branch.save()
|
|
398
|
+
click.echo("Setup protected branches")
|
|
399
|
+
|
|
400
|
+
default_branch = self.config.most_experimental_branch() or self.config.stable
|
|
401
|
+
gl.projects.update(project.id, {"default_branch": default_branch})
|
|
402
|
+
|
|
403
|
+
if not self._find_promote_job(project):
|
|
404
|
+
# this must happen after the branch has been created in the remote and initial
|
|
405
|
+
# commit pushed
|
|
406
|
+
schedule = project.pipelineschedules.create(
|
|
407
|
+
{
|
|
408
|
+
"ref": self.config.stable,
|
|
409
|
+
"description": self.PROMOTION_SCHEDULED_JOB_NAME,
|
|
410
|
+
"cron": "6 6 * * 4",
|
|
411
|
+
"active": False,
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
schedule.variables.create({"key": "SCHEDULED_JOB_NAME", "value": "promote"})
|
|
415
|
+
click.echo(
|
|
416
|
+
f"Created '{self.PROMOTION_SCHEDULED_JOB_NAME}' scheduled job, in non-active state"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class LocalRuntime(Runtime):
|
|
421
|
+
"""
|
|
422
|
+
Used for processes running in local git repos, not in CI.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
def current_branch(self) -> str:
|
|
426
|
+
branch = git("branch", "--show-current", capture=True)
|
|
427
|
+
if not branch:
|
|
428
|
+
raise RuntimeError
|
|
429
|
+
return branch
|
|
430
|
+
|
|
431
|
+
def get_base_rev(self) -> str:
|
|
432
|
+
try:
|
|
433
|
+
return git("rev-parse", "HEAD^", capture=True)
|
|
434
|
+
except subprocess.CalledProcessError:
|
|
435
|
+
return "0000000000000000000000000000000000000000"
|
|
436
|
+
|
|
437
|
+
def get_remote(self) -> str:
|
|
438
|
+
# FIXME: this should probably be configurable somehow, but it's a user pref
|
|
439
|
+
# so it shouldn't live in pyproject.toml
|
|
440
|
+
return "origin"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class TestGitlabRuntime(GitlabRuntime):
|
|
444
|
+
@cache
|
|
445
|
+
def _setup_remote(self, url: str) -> None:
|
|
446
|
+
# overridden to prevent adding the oath token to the remote url
|
|
447
|
+
git("config", "user.email", os.environ["GITLAB_USER_EMAIL"])
|
|
448
|
+
git("config", "user.name", os.environ["GITLAB_USER_NAME"])
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class TestGitlabBackend(GitlabBackend):
|
|
452
|
+
@cache
|
|
453
|
+
def _conn(self, base_url: str, access_token: str) -> gitlab.Gitlab:
|
|
454
|
+
# overridden to return a mocked Gitlab connection object.
|
|
455
|
+
import unittest.mock
|
|
456
|
+
|
|
457
|
+
return cast("gitlab.Gitlab", unittest.mock.MagicMock())
|
|
458
|
+
|
|
459
|
+
def push(
|
|
460
|
+
self, *args: str, variables: dict[str, str] | None = None, skip_ci: bool = False
|
|
461
|
+
) -> None:
|
|
462
|
+
# overridden to write variables to a json object rather than use push options
|
|
463
|
+
# which are not supported by local git repos.
|
|
464
|
+
if variables:
|
|
465
|
+
json_file = os.path.join(os.environ["CI_REPOSITORY_URL"], "push-opts.json")
|
|
466
|
+
click.echo(f"Writing local output to {json_file}")
|
|
467
|
+
if os.path.exists(json_file):
|
|
468
|
+
os.remove(json_file)
|
|
469
|
+
|
|
470
|
+
with open(json_file, "w") as f:
|
|
471
|
+
json.dump(variables, f)
|
|
472
|
+
|
|
473
|
+
git("push", *args)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def cz(*args: str, folder: str | Path | None = None) -> str:
|
|
477
|
+
"""
|
|
478
|
+
Run commitizen in a subprocess.
|
|
479
|
+
"""
|
|
480
|
+
output = subprocess.check_output(
|
|
481
|
+
["cz"] + list(args),
|
|
482
|
+
text=True,
|
|
483
|
+
cwd=folder,
|
|
484
|
+
)
|
|
485
|
+
return output.strip()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def is_pending_bump(
|
|
489
|
+
config: Config,
|
|
490
|
+
provider: commitizen.providers.ScmProvider,
|
|
491
|
+
remote: str,
|
|
492
|
+
branch: str,
|
|
493
|
+
folder: Path,
|
|
494
|
+
) -> bool:
|
|
495
|
+
"""
|
|
496
|
+
Return whether the given branch and folder combination are awaiting a minor bump.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
remote: The remote repository name
|
|
500
|
+
branch: one of the registered gitflow branches
|
|
501
|
+
folder: folder within the repo that defines commitizen tag rules
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
whether it is pending or not
|
|
505
|
+
"""
|
|
506
|
+
if branch != config.most_experimental_branch():
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
# Find the closest promotion note to the current branch
|
|
510
|
+
promotion_rev = get_promotion_marker(remote)
|
|
511
|
+
if promotion_rev is None:
|
|
512
|
+
if config.verbose:
|
|
513
|
+
click.echo("No promote marker found", err=True)
|
|
514
|
+
return True
|
|
515
|
+
|
|
516
|
+
matcher = provider._tag_format_matcher()
|
|
517
|
+
if config.verbose:
|
|
518
|
+
click.echo(f"Found promotion base rev: {promotion_rev}")
|
|
519
|
+
# List any tags for this project folder between this branch and the promotion note
|
|
520
|
+
all_tags = get_tags(end_rev=promotion_rev)
|
|
521
|
+
tags = [t for t in all_tags if matcher(t)]
|
|
522
|
+
return not tags
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def get_promotion_marker(remote: str) -> str | None:
|
|
526
|
+
"""
|
|
527
|
+
Get the hash for the most recent promotion commit.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
remote: The remote repository name
|
|
531
|
+
"""
|
|
532
|
+
git("fetch", remote, "refs/notes/*:refs/notes/*")
|
|
533
|
+
output = git("log", "--format=%H %N", "-n20", capture=True)
|
|
534
|
+
for line in output.splitlines():
|
|
535
|
+
line = line.strip()
|
|
536
|
+
if not line:
|
|
537
|
+
continue
|
|
538
|
+
parts = line.split(" ", 1)
|
|
539
|
+
if len(parts) == 1:
|
|
540
|
+
continue
|
|
541
|
+
if parts[1] == PROMOTION_BASE_MSG:
|
|
542
|
+
return parts[0]
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def set_promotion_marker(remote: str, branch: str) -> None:
|
|
547
|
+
"""
|
|
548
|
+
Store a state for whether the given project on the given branch needs to have a minor bump.
|
|
549
|
+
|
|
550
|
+
If it is true for a given branch, then get_next_tag() will return a minor increment.
|
|
551
|
+
After this, autotag() will set the pending state to False until.
|
|
552
|
+
|
|
553
|
+
This pending state is reset to True after each promotion event.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
remote: The remote repository name
|
|
557
|
+
branch: one of the registered gitflow branches
|
|
558
|
+
"""
|
|
559
|
+
git("fetch", remote, "refs/notes/*:refs/notes/*")
|
|
560
|
+
# FIXME: forcing here, because the same commit can be the promotion base more than once. should we skip?
|
|
561
|
+
git("notes", "add", "--force", "-m", PROMOTION_BASE_MSG, branch)
|
|
562
|
+
git("push", remote, "refs/notes/*")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _get_cz_config(tag_format: str, project_name: str) -> commitizen.config.BaseConfig:
|
|
566
|
+
from commitizen.config.base_config import BaseConfig
|
|
567
|
+
from commitizen.defaults import Settings
|
|
568
|
+
|
|
569
|
+
cz_config = BaseConfig()
|
|
570
|
+
cz_config.update(
|
|
571
|
+
Settings(
|
|
572
|
+
name="cz_conventional_commits",
|
|
573
|
+
tag_format=tag_format.replace("$project", project_name),
|
|
574
|
+
version_scheme="pep440",
|
|
575
|
+
version_provider="scm",
|
|
576
|
+
major_version_zero=False,
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
return cz_config
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def get_current_version(
|
|
583
|
+
config: Config, project_name: str, folder: Path, as_tag: bool = False
|
|
584
|
+
) -> str:
|
|
585
|
+
"""
|
|
586
|
+
Return the current version.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
project_name: The name of the project, used to look up the commitizen
|
|
590
|
+
configuration
|
|
591
|
+
folder: The folder within the repo that controls the tag.
|
|
592
|
+
as_tag: Whether to format the version based on tool.tide.tag_format
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
The current version or tag
|
|
596
|
+
"""
|
|
597
|
+
from commitizen.providers import ScmProvider
|
|
598
|
+
from commitizen.version_schemes import get_version_scheme
|
|
599
|
+
from commitizen import bump
|
|
600
|
+
|
|
601
|
+
cz_config = _get_cz_config(config.tag_format, project_name)
|
|
602
|
+
provider = ScmProvider(cz_config)
|
|
603
|
+
scheme = get_version_scheme(cz_config)
|
|
604
|
+
current_version = provider.get_version()
|
|
605
|
+
|
|
606
|
+
tag_version = bump.normalize_tag(
|
|
607
|
+
current_version,
|
|
608
|
+
tag_format=cz_config.settings["tag_format"] if as_tag else "$version",
|
|
609
|
+
scheme=scheme,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return tag_version
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def get_next_version(
|
|
616
|
+
config: Config,
|
|
617
|
+
remote: str,
|
|
618
|
+
branch: str,
|
|
619
|
+
project_name: str,
|
|
620
|
+
folder: Path,
|
|
621
|
+
as_tag: bool = False,
|
|
622
|
+
) -> str:
|
|
623
|
+
"""
|
|
624
|
+
Return the next version for a given branch based on the latest changes.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
remote: The remote repository name
|
|
628
|
+
branch: The name of the branch for which to generate the tag.
|
|
629
|
+
project_name: The name of the project, used to look up the commitizen
|
|
630
|
+
configuration
|
|
631
|
+
folder: The folder within the repo that controls the tag.
|
|
632
|
+
as_tag: Whether to format the version based on tool.tide.tag_format
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
The next version or tag to be created
|
|
636
|
+
"""
|
|
637
|
+
from commitizen.providers import ScmProvider
|
|
638
|
+
from commitizen.version_schemes import get_version_scheme, Increment
|
|
639
|
+
from commitizen import bump
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
release_id = config.branch_to_release_id[branch]
|
|
643
|
+
except KeyError:
|
|
644
|
+
raise click.ClickException(
|
|
645
|
+
f"{branch} is not a valid release branch. "
|
|
646
|
+
f"Must be one of {', '.join(config.branches)}"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
cz_config = _get_cz_config(config.tag_format, project_name)
|
|
650
|
+
|
|
651
|
+
provider = ScmProvider(cz_config)
|
|
652
|
+
scheme = get_version_scheme(cz_config)
|
|
653
|
+
current_version = scheme(provider.get_version())
|
|
654
|
+
|
|
655
|
+
# Only apply minor increment the most experimental branch
|
|
656
|
+
if is_pending_bump(config, provider, remote, branch, folder):
|
|
657
|
+
increment: Increment = "MINOR"
|
|
658
|
+
exact_increment = True
|
|
659
|
+
else:
|
|
660
|
+
increment = "PATCH"
|
|
661
|
+
exact_increment = False
|
|
662
|
+
|
|
663
|
+
if release_id != ReleaseID.stable:
|
|
664
|
+
prerelease = release_id.value
|
|
665
|
+
else:
|
|
666
|
+
prerelease = None
|
|
667
|
+
|
|
668
|
+
new_version = current_version.bump(
|
|
669
|
+
increment,
|
|
670
|
+
prerelease=prerelease,
|
|
671
|
+
exact_increment=exact_increment,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
return bump.normalize_tag(
|
|
675
|
+
new_version,
|
|
676
|
+
tag_format=cz_config.settings["tag_format"] if as_tag else "$version",
|
|
677
|
+
scheme=scheme,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def get_projects() -> list[tuple[Path, str]]:
|
|
682
|
+
"""Get the list of projects within the repo.
|
|
683
|
+
|
|
684
|
+
A project is a folder with a pyproject.toml file with a `tool.tide` section
|
|
685
|
+
and a `tool.tide.project`.
|
|
686
|
+
"""
|
|
687
|
+
results = []
|
|
688
|
+
repo = GitRepo(".")
|
|
689
|
+
for path in repo.file_matches(include=("**/pyproject.toml",)):
|
|
690
|
+
with open(path, "rb") as f:
|
|
691
|
+
data = tomllib.load(f)
|
|
692
|
+
try:
|
|
693
|
+
name = data["tool"][TOOL_NAME]["project"]
|
|
694
|
+
except KeyError:
|
|
695
|
+
pass
|
|
696
|
+
else:
|
|
697
|
+
results.append((Path(path).parent, name))
|
|
698
|
+
return sorted(results)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def get_modified_projects(
|
|
702
|
+
base_rev: str, verbose: bool = False
|
|
703
|
+
) -> list[tuple[Path, str]]:
|
|
704
|
+
"""Get the list of projects with changes files.
|
|
705
|
+
|
|
706
|
+
A project is defined as a folder with a pyproject.toml file with a `tool.tide` section.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
base_rev: The Git revision to compare against when identifying changed files
|
|
710
|
+
"""
|
|
711
|
+
# Compare the current commit with the branch you want to merge with:
|
|
712
|
+
# FIXME: do not included deleted files
|
|
713
|
+
output = git("diff-tree", "--name-only", "-r", base_rev, "HEAD", capture=True)
|
|
714
|
+
all_files = output.splitlines()
|
|
715
|
+
if verbose:
|
|
716
|
+
if all_files:
|
|
717
|
+
click.echo(f"Modified files between {base_rev} and HEAD:")
|
|
718
|
+
for path in all_files:
|
|
719
|
+
click.echo(f" {path}")
|
|
720
|
+
else:
|
|
721
|
+
click.echo(f"No modified files between {base_rev} and HEAD", err=True)
|
|
722
|
+
|
|
723
|
+
# find the deepest project that the file belongs to
|
|
724
|
+
projects = dict(get_projects())
|
|
725
|
+
project_dirs = list(reversed(projects))
|
|
726
|
+
|
|
727
|
+
results = set()
|
|
728
|
+
for changed_file in all_files:
|
|
729
|
+
for project_dir in project_dirs:
|
|
730
|
+
if project_dir in Path(changed_file).parents:
|
|
731
|
+
results.add(project_dir)
|
|
732
|
+
|
|
733
|
+
return [(project_dir, projects[project_dir]) for project_dir in sorted(results)]
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def promote(config: Config, backend: Backend, runtime: Runtime) -> None:
|
|
737
|
+
"""
|
|
738
|
+
Promote changes through the branch hierarchy.
|
|
739
|
+
|
|
740
|
+
e.g. from alpha -> beta -> rc -> stable.
|
|
741
|
+
"""
|
|
742
|
+
remote = runtime.get_remote()
|
|
743
|
+
if config.verbose:
|
|
744
|
+
click.echo(f"remote = {remote}")
|
|
745
|
+
|
|
746
|
+
local_output = []
|
|
747
|
+
|
|
748
|
+
def promote_branch(branch: str, log_msg_template: str) -> None:
|
|
749
|
+
"""
|
|
750
|
+
- Checkout the branch
|
|
751
|
+
- Merge with the upstream branch, if it exists
|
|
752
|
+
- Push, skipping hotfixes
|
|
753
|
+
|
|
754
|
+
The branch is left checked out.
|
|
755
|
+
"""
|
|
756
|
+
upstream_branch = config.get_upstream_branch(branch)
|
|
757
|
+
release_id = config.branch_to_release_id[branch]
|
|
758
|
+
log_msg = log_msg_template.format(
|
|
759
|
+
branch=branch, upstream_branch=upstream_branch, release_id=release_id.value
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
click.echo(f"Fetching {remote}/{branch}")
|
|
763
|
+
git("fetch", remote, branch)
|
|
764
|
+
|
|
765
|
+
base_rev = checkout(remote, branch, create=True)
|
|
766
|
+
|
|
767
|
+
if upstream_branch:
|
|
768
|
+
git("fetch", remote, upstream_branch)
|
|
769
|
+
click.echo(f"Merging with upstream branch {remote}/{upstream_branch}")
|
|
770
|
+
git("merge", join(remote, upstream_branch), "-m", f"{log_msg}")
|
|
771
|
+
|
|
772
|
+
variables = {
|
|
773
|
+
f"{ENVVAR_PREFIX}_SKIP_HOTFIX": "true",
|
|
774
|
+
f"{ENVVAR_PREFIX}_AUTOTAG_ANNOTATION": log_msg,
|
|
775
|
+
f"{ENVVAR_PREFIX}_AUTOTAG_BASE_REV": base_rev,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
# Trigger test/tag jobs for these new versions, but skip auto-hotfix
|
|
779
|
+
# --atomic means if there's a failure to push any of the refs, the entire
|
|
780
|
+
# operation will fail (like a databases). May not be strictly necessary here.
|
|
781
|
+
click.echo("Pushing changes")
|
|
782
|
+
backend.push("--atomic", remote, branch, variables=variables)
|
|
783
|
+
|
|
784
|
+
# FIXME: switch to using push-opts.json
|
|
785
|
+
if (
|
|
786
|
+
isinstance(backend, TestGitlabBackend)
|
|
787
|
+
and upstream_branch
|
|
788
|
+
and base_rev != current_rev()
|
|
789
|
+
):
|
|
790
|
+
push_info = {
|
|
791
|
+
"annotation": log_msg,
|
|
792
|
+
"base_rev": base_rev,
|
|
793
|
+
"branch": branch,
|
|
794
|
+
}
|
|
795
|
+
local_output.append(push_info)
|
|
796
|
+
click.echo(f"Trigger: {json.dumps(push_info)}")
|
|
797
|
+
|
|
798
|
+
# Promotion time!
|
|
799
|
+
|
|
800
|
+
# loop from stable to most experimental
|
|
801
|
+
for branch in reversed(config.branches):
|
|
802
|
+
# It doesn't matter what the active branch is when promote is run: the next thing
|
|
803
|
+
# that we do is checkout CONFIG.stable.
|
|
804
|
+
if branch == config.most_experimental_branch():
|
|
805
|
+
msg = PROMOTION_CYCLE_START_MESSAGE
|
|
806
|
+
else:
|
|
807
|
+
msg = PROMOTION_MESSAGE
|
|
808
|
+
|
|
809
|
+
promote_branch(branch, msg)
|
|
810
|
+
|
|
811
|
+
if local_output:
|
|
812
|
+
json_file = os.path.join(os.environ["CI_REPOSITORY_URL"], "push-data.json")
|
|
813
|
+
click.echo(f"Writing local output to {json_file}")
|
|
814
|
+
with open(json_file, "w") as f:
|
|
815
|
+
json.dump(local_output, f)
|
|
816
|
+
|
|
817
|
+
# Note: we do not make a tag on our cycle-start branch at this time. Instead, we wait
|
|
818
|
+
# for the first commit on the branch to do so.
|
|
819
|
+
experimental_branch = config.most_experimental_branch()
|
|
820
|
+
if experimental_branch:
|
|
821
|
+
set_promotion_marker(remote, experimental_branch)
|