release-scope 0.1.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.
- release_scope/__init__.py +0 -0
- release_scope/__main__.py +105 -0
- release_scope/_cache.py +71 -0
- release_scope/_errors.py +17 -0
- release_scope/_files.py +14 -0
- release_scope/_gitlab.py +261 -0
- release_scope/_jira_keys.py +22 -0
- release_scope/_report.py +88 -0
- release_scope/_rows.py +37 -0
- release_scope/_settings.py +56 -0
- release_scope/_use_case.py +272 -0
- release_scope/ioc.py +51 -0
- release_scope/py.typed +0 -0
- release_scope-0.1.0.dist-info/METADATA +101 -0
- release_scope-0.1.0.dist-info/RECORD +17 -0
- release_scope-0.1.0.dist-info/WHEEL +4 -0
- release_scope-0.1.0.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import pathlib
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import modern_di_typer
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from release_scope import ioc
|
|
9
|
+
from release_scope._cache import Cache
|
|
10
|
+
from release_scope._errors import ConfigError, ReleaseScopeError
|
|
11
|
+
from release_scope._files import write_text_atomic
|
|
12
|
+
from release_scope._settings import Settings, load_settings
|
|
13
|
+
from release_scope._use_case import CollectUseCase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_PACKAGE_NAME: typing.Final = "release-scope"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAIN_APP: typing.Final = typer.Typer(
|
|
20
|
+
name="release-scope",
|
|
21
|
+
help="Collect what sits between production and the default branch across GitLab services.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
modern_di_typer.setup_di(MAIN_APP, ioc.container)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if not value:
|
|
31
|
+
return
|
|
32
|
+
typer.echo(importlib.metadata.version(_PACKAGE_NAME))
|
|
33
|
+
raise typer.Exit
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@MAIN_APP.callback()
|
|
37
|
+
def _main_callback(
|
|
38
|
+
_version: typing.Annotated[
|
|
39
|
+
bool | None,
|
|
40
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@modern_di_typer.inject
|
|
47
|
+
def _resolve_use_case(
|
|
48
|
+
use_case: typing.Annotated[CollectUseCase, modern_di_typer.FromDI(CollectUseCase)],
|
|
49
|
+
) -> CollectUseCase:
|
|
50
|
+
return use_case
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@MAIN_APP.command("collect", help="Collect pending changes per service into a JSON report.")
|
|
54
|
+
def _collect_command( # noqa: PLR0913, PLR0917
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the report JSON.")],
|
|
57
|
+
group: typing.Annotated[
|
|
58
|
+
list[str] | None, typer.Option("--group", "-g", help="GitLab group path; repeatable.")
|
|
59
|
+
] = None,
|
|
60
|
+
project: typing.Annotated[
|
|
61
|
+
list[str] | None, typer.Option("--project", "-p", help="GitLab project path; repeatable.")
|
|
62
|
+
] = None,
|
|
63
|
+
include_subgroups: typing.Annotated[
|
|
64
|
+
bool, typer.Option("--include-subgroups", help="Also collect projects in subgroups of each --group.")
|
|
65
|
+
] = False,
|
|
66
|
+
cache_path: typing.Annotated[
|
|
67
|
+
pathlib.Path | None,
|
|
68
|
+
typer.Option("--cache", help="Cache JSON; read if present, rewritten in place after the run."),
|
|
69
|
+
] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
try:
|
|
72
|
+
if not group and not project:
|
|
73
|
+
msg = "Pass at least one --group or --project."
|
|
74
|
+
raise ConfigError(msg)
|
|
75
|
+
settings = load_settings({})
|
|
76
|
+
modern_di_typer.fetch_di_container(ctx).set_context(Settings, settings)
|
|
77
|
+
cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
|
|
78
|
+
if cache_warning:
|
|
79
|
+
typer.echo(f"Warning: {cache_warning}", err=True)
|
|
80
|
+
report = _resolve_use_case(ctx=ctx)(
|
|
81
|
+
groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache
|
|
82
|
+
)
|
|
83
|
+
except ReleaseScopeError as err:
|
|
84
|
+
typer.echo(f"Error: {err}", err=True)
|
|
85
|
+
raise typer.Exit(code=err.exit_code) from err
|
|
86
|
+
|
|
87
|
+
write_text_atomic(output, report.model_dump_json(indent=2))
|
|
88
|
+
if cache_path:
|
|
89
|
+
cache.save(cache_path)
|
|
90
|
+
failed: typing.Final = [service for service in report.services if service.error]
|
|
91
|
+
rows: typing.Final = sum(len(service.rows) for service in report.services)
|
|
92
|
+
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
|
|
93
|
+
for service in failed:
|
|
94
|
+
typer.echo(f"Error: {service.project}: {service.error}", err=True)
|
|
95
|
+
if failed:
|
|
96
|
+
raise typer.Exit(code=1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
with ioc.container:
|
|
101
|
+
MAIN_APP()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
main()
|
release_scope/_cache.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import pathlib
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from release_scope._files import write_text_atomic
|
|
8
|
+
from release_scope._gitlab import MergeRequest
|
|
9
|
+
from release_scope._report import FailedJob
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CACHE_SCHEMA_VERSION: typing.Final = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CachedPipeline(pydantic.BaseModel):
|
|
16
|
+
updated_at: str
|
|
17
|
+
failed_jobs: list[FailedJob]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheData(pydantic.BaseModel):
|
|
21
|
+
schema_version: typing.Literal[1] = CACHE_SCHEMA_VERSION
|
|
22
|
+
commit_merge_requests: dict[str, dict[str, list[MergeRequest]]] = pydantic.Field(default_factory=dict)
|
|
23
|
+
pipelines: dict[str, dict[str, CachedPipeline]] = pydantic.Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(slots=True, kw_only=True)
|
|
27
|
+
class Cache:
|
|
28
|
+
previous: CacheData = dataclasses.field(default_factory=CacheData)
|
|
29
|
+
current: CacheData = dataclasses.field(default_factory=CacheData)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, path: pathlib.Path) -> tuple["Cache", str | None]:
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return cls(), None
|
|
35
|
+
try:
|
|
36
|
+
return cls(previous=CacheData.model_validate_json(path.read_bytes())), None
|
|
37
|
+
except (OSError, pydantic.ValidationError) as exc:
|
|
38
|
+
return cls(), f"Ignoring unreadable cache {path}: {type(exc).__name__}."
|
|
39
|
+
|
|
40
|
+
def save(self, path: pathlib.Path) -> None:
|
|
41
|
+
write_text_atomic(path, self.current.model_dump_json(indent=2))
|
|
42
|
+
|
|
43
|
+
def get_commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest] | None:
|
|
44
|
+
cached = self.previous.commit_merge_requests.get(str(project_id), {}).get(sha)
|
|
45
|
+
if cached is not None:
|
|
46
|
+
self.put_commit_merge_requests(project_id, sha, cached)
|
|
47
|
+
return cached
|
|
48
|
+
|
|
49
|
+
def put_commit_merge_requests(self, project_id: int, sha: str, merge_requests: list[MergeRequest]) -> None:
|
|
50
|
+
self.current.commit_merge_requests.setdefault(str(project_id), {})[sha] = merge_requests
|
|
51
|
+
|
|
52
|
+
def get_failed_jobs(self, project_id: int, pipeline_id: int, updated_at: str) -> list[FailedJob] | None:
|
|
53
|
+
cached = self.previous.pipelines.get(str(project_id), {}).get(str(pipeline_id))
|
|
54
|
+
if cached is None or cached.updated_at != updated_at:
|
|
55
|
+
return None
|
|
56
|
+
self.put_failed_jobs(project_id, pipeline_id, cached)
|
|
57
|
+
return cached.failed_jobs
|
|
58
|
+
|
|
59
|
+
def put_failed_jobs(self, project_id: int, pipeline_id: int, entry: CachedPipeline) -> None:
|
|
60
|
+
self.current.pipelines.setdefault(str(project_id), {})[str(pipeline_id)] = entry
|
|
61
|
+
|
|
62
|
+
def keep_project(self, project_id: int) -> None:
|
|
63
|
+
key: typing.Final = str(project_id)
|
|
64
|
+
self.current.commit_merge_requests[key] = {
|
|
65
|
+
**self.previous.commit_merge_requests.get(key, {}),
|
|
66
|
+
**self.current.commit_merge_requests.get(key, {}),
|
|
67
|
+
}
|
|
68
|
+
self.current.pipelines[key] = {
|
|
69
|
+
**self.previous.pipelines.get(key, {}),
|
|
70
|
+
**self.current.pipelines.get(key, {}),
|
|
71
|
+
}
|
release_scope/_errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ReleaseScopeError(Exception):
|
|
5
|
+
exit_code: typing.ClassVar[int] = 1
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(ReleaseScopeError):
|
|
9
|
+
exit_code: typing.ClassVar[int] = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(ReleaseScopeError):
|
|
13
|
+
exit_code: typing.ClassVar[int] = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitLabError(ReleaseScopeError):
|
|
17
|
+
exit_code: typing.ClassVar[int] = 4
|
release_scope/_files.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pathlib
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def write_text_atomic(path: pathlib.Path, text: str) -> None:
|
|
7
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
with tempfile.NamedTemporaryFile(
|
|
9
|
+
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False
|
|
10
|
+
) as handle:
|
|
11
|
+
handle.write(text)
|
|
12
|
+
handle.flush()
|
|
13
|
+
os.fsync(handle.fileno())
|
|
14
|
+
pathlib.Path(handle.name).replace(path)
|
release_scope/_gitlab.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
import math
|
|
4
|
+
import typing
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpware
|
|
8
|
+
import pydantic
|
|
9
|
+
|
|
10
|
+
from release_scope._errors import AuthError, GitLabError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_API: typing.Final = "/api/v4"
|
|
14
|
+
_PER_PAGE: typing.Final = 100
|
|
15
|
+
_MAX_PAGES: typing.Final = 50
|
|
16
|
+
|
|
17
|
+
_ModelT = typing.TypeVar("_ModelT", bound=pydantic.BaseModel)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Project(pydantic.BaseModel):
|
|
21
|
+
id: int
|
|
22
|
+
path_with_namespace: str
|
|
23
|
+
web_url: str
|
|
24
|
+
default_branch: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Deployable(pydantic.BaseModel):
|
|
28
|
+
web_url: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Deployment(pydantic.BaseModel):
|
|
32
|
+
id: int
|
|
33
|
+
ref: str
|
|
34
|
+
sha: str
|
|
35
|
+
created_at: str
|
|
36
|
+
deployable: Deployable | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TagCommit(pydantic.BaseModel):
|
|
40
|
+
id: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Tag(pydantic.BaseModel):
|
|
44
|
+
name: str
|
|
45
|
+
commit: TagCommit
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Commit(pydantic.BaseModel):
|
|
49
|
+
id: str
|
|
50
|
+
short_id: str
|
|
51
|
+
title: str
|
|
52
|
+
message: str = ""
|
|
53
|
+
author_name: str | None = None
|
|
54
|
+
committed_date: datetime.datetime
|
|
55
|
+
web_url: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Author(pydantic.BaseModel):
|
|
59
|
+
username: str | None = None
|
|
60
|
+
name: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MergeRequest(pydantic.BaseModel):
|
|
64
|
+
iid: int
|
|
65
|
+
title: str
|
|
66
|
+
description: str | None = None
|
|
67
|
+
source_branch: str | None = None
|
|
68
|
+
target_branch: str
|
|
69
|
+
state: str
|
|
70
|
+
web_url: str
|
|
71
|
+
sha: str | None = None
|
|
72
|
+
merge_commit_sha: str | None = None
|
|
73
|
+
squash_commit_sha: str | None = None
|
|
74
|
+
merged_at: str | None = None
|
|
75
|
+
author: Author | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Pipeline(pydantic.BaseModel):
|
|
79
|
+
id: int
|
|
80
|
+
sha: str
|
|
81
|
+
ref: str
|
|
82
|
+
status: str
|
|
83
|
+
web_url: str
|
|
84
|
+
updated_at: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Job(pydantic.BaseModel):
|
|
88
|
+
name: str
|
|
89
|
+
stage: str | None = None
|
|
90
|
+
status: str
|
|
91
|
+
allow_failure: bool = False
|
|
92
|
+
web_url: str | None = None
|
|
93
|
+
failure_reason: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DownstreamPipeline(pydantic.BaseModel):
|
|
97
|
+
web_url: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Bridge(Job):
|
|
101
|
+
downstream_pipeline: DownstreamPipeline | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _Projects(pydantic.RootModel[list[Project]]):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Deployments(pydantic.RootModel[list[Deployment]]):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Tags(pydantic.RootModel[list[Tag]]):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _Commits(pydantic.RootModel[list[Commit]]):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _MergeRequests(pydantic.RootModel[list[MergeRequest]]):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _Pipelines(pydantic.RootModel[list[Pipeline]]):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _Jobs(pydantic.RootModel[list[Job]]):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Bridges(pydantic.RootModel[list[Bridge]]):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _translate(exc: httpware.ClientError, *, url: str) -> Exception:
|
|
137
|
+
if isinstance(exc, (httpware.UnauthorizedError, httpware.ForbiddenError)):
|
|
138
|
+
return AuthError(
|
|
139
|
+
f"GitLab rejected the token ({exc.response.status_code}) for {url}. It needs 'read_api' scope."
|
|
140
|
+
)
|
|
141
|
+
if isinstance(exc, httpware.StatusError):
|
|
142
|
+
return GitLabError(f"GitLab returned {exc.response.status_code} for {url}.")
|
|
143
|
+
return GitLabError(f"GitLab request {url} failed: {type(exc).__name__}.")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _quote(value: str) -> str:
|
|
147
|
+
return quote(value, safe="")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
151
|
+
class GitLabApi:
|
|
152
|
+
http: httpware.Client
|
|
153
|
+
|
|
154
|
+
def _get(self, url: str, params: dict[str, typing.Any], model: type[_ModelT]) -> _ModelT:
|
|
155
|
+
try:
|
|
156
|
+
return self.http.get(url, params=params, response_model=model)
|
|
157
|
+
except httpware.ClientError as exc:
|
|
158
|
+
raise _translate(exc, url=url) from exc
|
|
159
|
+
|
|
160
|
+
def _pages(
|
|
161
|
+
self,
|
|
162
|
+
url: str,
|
|
163
|
+
params: dict[str, typing.Any],
|
|
164
|
+
model: type[pydantic.RootModel[list[_ModelT]]],
|
|
165
|
+
*,
|
|
166
|
+
max_items: int | None = None,
|
|
167
|
+
) -> tuple[list[_ModelT], bool]:
|
|
168
|
+
max_pages: typing.Final = _MAX_PAGES if max_items is None else math.ceil(max_items / _PER_PAGE)
|
|
169
|
+
items: list[_ModelT] = []
|
|
170
|
+
for page in range(1, max_pages + 1):
|
|
171
|
+
try:
|
|
172
|
+
response, batch = self.http.get_with_response(
|
|
173
|
+
url, params={**params, "per_page": _PER_PAGE, "page": page}, response_model=model
|
|
174
|
+
)
|
|
175
|
+
except httpware.ClientError as exc:
|
|
176
|
+
raise _translate(exc, url=url) from exc
|
|
177
|
+
items.extend(batch.root)
|
|
178
|
+
if not response.headers.get("x-next-page"):
|
|
179
|
+
break
|
|
180
|
+
else:
|
|
181
|
+
return items[:max_items], True
|
|
182
|
+
if max_items is not None and len(items) > max_items:
|
|
183
|
+
return items[:max_items], True
|
|
184
|
+
return items, False
|
|
185
|
+
|
|
186
|
+
def get_project(self, path: str) -> Project:
|
|
187
|
+
return self._get(f"{_API}/projects/{_quote(path)}", {}, Project)
|
|
188
|
+
|
|
189
|
+
def list_group_projects(self, group: str, *, include_subgroups: bool) -> list[Project]:
|
|
190
|
+
projects, _ = self._pages(
|
|
191
|
+
f"{_API}/groups/{_quote(group)}/projects",
|
|
192
|
+
{"archived": "false", "with_shared": "false", "include_subgroups": str(include_subgroups).lower()},
|
|
193
|
+
_Projects,
|
|
194
|
+
)
|
|
195
|
+
return projects
|
|
196
|
+
|
|
197
|
+
def latest_deployment(self, project_id: int, environment: str) -> Deployment | None:
|
|
198
|
+
deployments = self._get(
|
|
199
|
+
f"{_API}/projects/{project_id}/deployments",
|
|
200
|
+
{"environment": environment, "status": "success", "order_by": "id", "sort": "desc", "per_page": 1},
|
|
201
|
+
_Deployments,
|
|
202
|
+
)
|
|
203
|
+
return deployments.root[0] if deployments.root else None
|
|
204
|
+
|
|
205
|
+
def list_tags(self, project_id: int) -> tuple[list[Tag], bool]:
|
|
206
|
+
return self._pages(f"{_API}/projects/{project_id}/repository/tags", {}, _Tags)
|
|
207
|
+
|
|
208
|
+
def list_first_parent_commits(
|
|
209
|
+
self, project_id: int, ref_range: str, *, max_items: int
|
|
210
|
+
) -> tuple[list[Commit], bool]:
|
|
211
|
+
return self._pages(
|
|
212
|
+
f"{_API}/projects/{project_id}/repository/commits",
|
|
213
|
+
{"ref_name": ref_range, "first_parent": "true"},
|
|
214
|
+
_Commits,
|
|
215
|
+
max_items=max_items,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def list_merged_merge_requests(
|
|
219
|
+
self, project_id: int, *, target_branch: str, updated_after: datetime.datetime
|
|
220
|
+
) -> list[MergeRequest]:
|
|
221
|
+
merge_requests, _ = self._pages(
|
|
222
|
+
f"{_API}/projects/{project_id}/merge_requests",
|
|
223
|
+
{"state": "merged", "target_branch": target_branch, "updated_after": updated_after.isoformat()},
|
|
224
|
+
_MergeRequests,
|
|
225
|
+
)
|
|
226
|
+
return merge_requests
|
|
227
|
+
|
|
228
|
+
def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]:
|
|
229
|
+
merge_requests, _ = self._pages(
|
|
230
|
+
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests", {}, _MergeRequests
|
|
231
|
+
)
|
|
232
|
+
return merge_requests
|
|
233
|
+
|
|
234
|
+
def list_push_pipelines(self, project_id: int, *, ref: str, updated_after: datetime.datetime) -> list[Pipeline]:
|
|
235
|
+
pipelines, _ = self._pages(
|
|
236
|
+
f"{_API}/projects/{project_id}/pipelines",
|
|
237
|
+
{"ref": ref, "source": "push", "updated_after": updated_after.isoformat()},
|
|
238
|
+
_Pipelines,
|
|
239
|
+
)
|
|
240
|
+
return pipelines
|
|
241
|
+
|
|
242
|
+
def latest_pipeline(self, project_id: int, *, ref: str) -> Pipeline | None:
|
|
243
|
+
pipelines = self._get(
|
|
244
|
+
f"{_API}/projects/{project_id}/pipelines",
|
|
245
|
+
{"ref": ref, "order_by": "id", "sort": "desc", "per_page": 1},
|
|
246
|
+
_Pipelines,
|
|
247
|
+
)
|
|
248
|
+
return pipelines.root[0] if pipelines.root else None
|
|
249
|
+
|
|
250
|
+
def failed_jobs(self, project_id: int, pipeline_id: int) -> list[Job]:
|
|
251
|
+
jobs, _ = self._pages(
|
|
252
|
+
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/jobs", {"scope[]": "failed"}, _Jobs
|
|
253
|
+
)
|
|
254
|
+
return jobs
|
|
255
|
+
|
|
256
|
+
def failed_bridges(self, project_id: int, pipeline_id: int) -> list[Bridge]:
|
|
257
|
+
# `trigger_jobs` replaces this route only from GitLab 19.2; older instances have `bridges` alone.
|
|
258
|
+
bridges, _ = self._pages(
|
|
259
|
+
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/bridges", {"scope[]": "failed"}, _Bridges
|
|
260
|
+
)
|
|
261
|
+
return bridges
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import collections.abc
|
|
2
|
+
import re
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
JIRA_KEY_PATTERN: typing.Final = re.compile(r"(?<![A-Za-z0-9])[A-Z][A-Z0-9]+-\d+(?![A-Z0-9])")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_jira_keys(
|
|
10
|
+
texts: collections.abc.Iterable[str | None],
|
|
11
|
+
*,
|
|
12
|
+
allowed_projects: collections.abc.Collection[str] = (),
|
|
13
|
+
) -> list[str]:
|
|
14
|
+
keys: dict[str, None] = {}
|
|
15
|
+
for text in texts:
|
|
16
|
+
if not text:
|
|
17
|
+
continue
|
|
18
|
+
for key in JIRA_KEY_PATTERN.findall(text):
|
|
19
|
+
if allowed_projects and key.split("-", 1)[0] not in allowed_projects:
|
|
20
|
+
continue
|
|
21
|
+
keys[key] = None
|
|
22
|
+
return list(keys)
|
release_scope/_report.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SCHEMA_VERSION: typing.Final = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FailedJob(pydantic.BaseModel):
|
|
11
|
+
kind: typing.Literal["job", "bridge"]
|
|
12
|
+
name: str
|
|
13
|
+
stage: str | None
|
|
14
|
+
status: str
|
|
15
|
+
allow_failure: bool
|
|
16
|
+
url: str | None
|
|
17
|
+
failure_reason: str | None
|
|
18
|
+
downstream_pipeline_url: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PipelineState(pydantic.BaseModel):
|
|
22
|
+
id: int
|
|
23
|
+
status: str
|
|
24
|
+
url: str
|
|
25
|
+
failed_jobs: list[FailedJob]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TagRef(pydantic.BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
url: str
|
|
31
|
+
pipeline: PipelineState | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MergeRequestRef(pydantic.BaseModel):
|
|
35
|
+
iid: int
|
|
36
|
+
title: str
|
|
37
|
+
url: str
|
|
38
|
+
author: str | None
|
|
39
|
+
merged_at: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CommitRef(pydantic.BaseModel):
|
|
43
|
+
sha: str
|
|
44
|
+
short_sha: str
|
|
45
|
+
title: str
|
|
46
|
+
url: str | None
|
|
47
|
+
author: str | None
|
|
48
|
+
committed_at: datetime.datetime
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JiraKeyRef(pydantic.BaseModel):
|
|
52
|
+
key: str
|
|
53
|
+
url: str | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Row(pydantic.BaseModel):
|
|
57
|
+
kind: typing.Literal["merge_request", "commit"]
|
|
58
|
+
tags: list[TagRef]
|
|
59
|
+
merge_requests: list[MergeRequestRef]
|
|
60
|
+
commits: list[CommitRef]
|
|
61
|
+
jira_keys: list[JiraKeyRef]
|
|
62
|
+
environments: list[str]
|
|
63
|
+
main_pipeline: PipelineState | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EnvironmentState(pydantic.BaseModel):
|
|
67
|
+
name: str
|
|
68
|
+
ref: str
|
|
69
|
+
sha: str
|
|
70
|
+
deployed_at: str
|
|
71
|
+
deployment_url: str | None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Service(pydantic.BaseModel):
|
|
75
|
+
project: str
|
|
76
|
+
project_url: str
|
|
77
|
+
default_branch: str | None = None
|
|
78
|
+
environments: list[EnvironmentState] = pydantic.Field(default_factory=list)
|
|
79
|
+
rows: list[Row] = pydantic.Field(default_factory=list)
|
|
80
|
+
warnings: list[str] = pydantic.Field(default_factory=list)
|
|
81
|
+
error: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Report(pydantic.BaseModel):
|
|
85
|
+
schema_version: typing.Literal[1] = SCHEMA_VERSION
|
|
86
|
+
collected_at: datetime.datetime
|
|
87
|
+
production_environment: str
|
|
88
|
+
services: list[Service]
|
release_scope/_rows.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import collections.abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
|
|
4
|
+
from release_scope._gitlab import Commit, MergeRequest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(slots=True, kw_only=True)
|
|
8
|
+
class RowDraft:
|
|
9
|
+
commits: list[Commit]
|
|
10
|
+
merge_requests: list[MergeRequest]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def match_merge_requests(
|
|
14
|
+
commits: collections.abc.Sequence[Commit],
|
|
15
|
+
merge_requests: collections.abc.Iterable[MergeRequest],
|
|
16
|
+
) -> dict[str, list[MergeRequest]]:
|
|
17
|
+
by_sha: dict[str, dict[int, MergeRequest]] = {}
|
|
18
|
+
for merge_request in merge_requests:
|
|
19
|
+
for sha in (merge_request.merge_commit_sha, merge_request.squash_commit_sha, merge_request.sha):
|
|
20
|
+
if sha:
|
|
21
|
+
by_sha.setdefault(sha, {})[merge_request.iid] = merge_request
|
|
22
|
+
return {commit.id: list(by_sha[commit.id].values()) for commit in commits if commit.id in by_sha}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def group_rows(
|
|
26
|
+
commits: collections.abc.Sequence[Commit],
|
|
27
|
+
commit_merge_requests: collections.abc.Mapping[str, collections.abc.Sequence[MergeRequest]],
|
|
28
|
+
) -> list[RowDraft]:
|
|
29
|
+
rows: dict[tuple[object, ...], RowDraft] = {}
|
|
30
|
+
for commit in commits:
|
|
31
|
+
merge_requests = sorted(commit_merge_requests.get(commit.id, ()), key=lambda item: item.iid)
|
|
32
|
+
key: tuple[object, ...] = (
|
|
33
|
+
tuple(item.iid for item in merge_requests) if merge_requests else ("commit", commit.id)
|
|
34
|
+
)
|
|
35
|
+
row = rows.setdefault(key, RowDraft(commits=[], merge_requests=list(merge_requests)))
|
|
36
|
+
row.commits.append(commit)
|
|
37
|
+
return list(rows.values())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
import pydantic_settings
|
|
5
|
+
|
|
6
|
+
from release_scope._errors import ConfigError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitLabConfig(pydantic_settings.BaseSettings):
|
|
10
|
+
model_config = pydantic_settings.SettingsConfigDict(
|
|
11
|
+
env_prefix="RELEASE_SCOPE_GITLAB__",
|
|
12
|
+
case_sensitive=False,
|
|
13
|
+
extra="ignore",
|
|
14
|
+
populate_by_name=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
endpoint: str = "https://gitlab.com"
|
|
18
|
+
token: pydantic.SecretStr = pydantic.Field(
|
|
19
|
+
default=pydantic.SecretStr(""),
|
|
20
|
+
validation_alias=pydantic.AliasChoices("RELEASE_SCOPE_GITLAB__TOKEN", "GITLAB_TOKEN"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Settings(pydantic_settings.BaseSettings):
|
|
25
|
+
model_config = pydantic_settings.SettingsConfigDict(
|
|
26
|
+
env_prefix="RELEASE_SCOPE_",
|
|
27
|
+
env_nested_delimiter="__",
|
|
28
|
+
case_sensitive=False,
|
|
29
|
+
extra="ignore",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
gitlab: GitLabConfig = pydantic.Field(default_factory=GitLabConfig)
|
|
33
|
+
jira_endpoint: str | None = None
|
|
34
|
+
jira_project_keys: list[str] = pydantic.Field(default_factory=list)
|
|
35
|
+
environments: list[str] = pydantic.Field(default_factory=lambda: ["production"])
|
|
36
|
+
production_environment: str = "production"
|
|
37
|
+
request_timeout: float = 10.0
|
|
38
|
+
max_commits: int = 1000
|
|
39
|
+
|
|
40
|
+
@pydantic.model_validator(mode="after")
|
|
41
|
+
def _production_is_listed(self) -> typing.Self:
|
|
42
|
+
if self.production_environment not in self.environments:
|
|
43
|
+
self.environments = [self.production_environment, *self.environments]
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_settings(overrides: dict[str, typing.Any]) -> Settings:
|
|
48
|
+
try:
|
|
49
|
+
settings: typing.Final = Settings(**overrides)
|
|
50
|
+
except pydantic.ValidationError as exc:
|
|
51
|
+
msg = f"Invalid configuration: {exc}"
|
|
52
|
+
raise ConfigError(msg) from exc
|
|
53
|
+
if not settings.gitlab.token.get_secret_value():
|
|
54
|
+
msg = "GitLab token is missing. Set RELEASE_SCOPE_GITLAB__TOKEN or GITLAB_TOKEN."
|
|
55
|
+
raise ConfigError(msg)
|
|
56
|
+
return settings
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import collections.abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
import datetime
|
|
4
|
+
import typing
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from release_scope._cache import Cache, CachedPipeline
|
|
8
|
+
from release_scope._errors import GitLabError
|
|
9
|
+
from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project
|
|
10
|
+
from release_scope._jira_keys import extract_jira_keys
|
|
11
|
+
from release_scope._report import (
|
|
12
|
+
CommitRef,
|
|
13
|
+
EnvironmentState,
|
|
14
|
+
FailedJob,
|
|
15
|
+
JiraKeyRef,
|
|
16
|
+
MergeRequestRef,
|
|
17
|
+
PipelineState,
|
|
18
|
+
Report,
|
|
19
|
+
Row,
|
|
20
|
+
Service,
|
|
21
|
+
TagRef,
|
|
22
|
+
)
|
|
23
|
+
from release_scope._rows import RowDraft, group_rows, match_merge_requests
|
|
24
|
+
from release_scope._settings import Settings
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SETTLED_PIPELINE_STATUSES: typing.Final = frozenset({"success", "failed", "canceled", "skipped"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _environment_state(name: str, deployment: Deployment) -> EnvironmentState:
|
|
31
|
+
return EnvironmentState(
|
|
32
|
+
name=name,
|
|
33
|
+
ref=deployment.ref,
|
|
34
|
+
sha=deployment.sha,
|
|
35
|
+
deployed_at=deployment.created_at,
|
|
36
|
+
deployment_url=deployment.deployable.web_url if deployment.deployable else None,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _merge_request_ref(merge_request: MergeRequest) -> MergeRequestRef:
|
|
41
|
+
author: typing.Final = merge_request.author
|
|
42
|
+
return MergeRequestRef(
|
|
43
|
+
iid=merge_request.iid,
|
|
44
|
+
title=merge_request.title,
|
|
45
|
+
url=merge_request.web_url,
|
|
46
|
+
author=(author.username or author.name) if author else None,
|
|
47
|
+
merged_at=merge_request.merged_at,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _commit_ref(commit: Commit) -> CommitRef:
|
|
52
|
+
return CommitRef(
|
|
53
|
+
sha=commit.id,
|
|
54
|
+
short_sha=commit.short_id,
|
|
55
|
+
title=commit.title,
|
|
56
|
+
url=commit.web_url,
|
|
57
|
+
author=commit.author_name,
|
|
58
|
+
committed_at=commit.committed_date,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
63
|
+
class CollectUseCase:
|
|
64
|
+
api: GitLabApi
|
|
65
|
+
settings: Settings
|
|
66
|
+
|
|
67
|
+
def __call__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
groups: collections.abc.Sequence[str],
|
|
71
|
+
projects: collections.abc.Sequence[str],
|
|
72
|
+
include_subgroups: bool,
|
|
73
|
+
cache: Cache,
|
|
74
|
+
) -> Report:
|
|
75
|
+
services: typing.Final = []
|
|
76
|
+
for project in self._resolve_projects(groups=groups, projects=projects, include_subgroups=include_subgroups):
|
|
77
|
+
try:
|
|
78
|
+
services.append(self._collect_service(project, cache))
|
|
79
|
+
except GitLabError as exc:
|
|
80
|
+
cache.keep_project(project.id)
|
|
81
|
+
services.append(
|
|
82
|
+
Service(project=project.path_with_namespace, project_url=project.web_url, error=str(exc))
|
|
83
|
+
)
|
|
84
|
+
return Report(
|
|
85
|
+
collected_at=datetime.datetime.now(datetime.UTC),
|
|
86
|
+
production_environment=self.settings.production_environment,
|
|
87
|
+
services=services,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _resolve_projects(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
groups: collections.abc.Sequence[str],
|
|
94
|
+
projects: collections.abc.Sequence[str],
|
|
95
|
+
include_subgroups: bool,
|
|
96
|
+
) -> list[Project]:
|
|
97
|
+
resolved: dict[int, Project] = {}
|
|
98
|
+
for group in groups:
|
|
99
|
+
for project in self.api.list_group_projects(group, include_subgroups=include_subgroups):
|
|
100
|
+
resolved[project.id] = project
|
|
101
|
+
for path in projects:
|
|
102
|
+
project = self.api.get_project(path)
|
|
103
|
+
resolved[project.id] = project
|
|
104
|
+
return sorted(resolved.values(), key=lambda item: item.path_with_namespace)
|
|
105
|
+
|
|
106
|
+
def _collect_service(self, project: Project, cache: Cache) -> Service:
|
|
107
|
+
service: typing.Final = Service(
|
|
108
|
+
project=project.path_with_namespace, project_url=project.web_url, default_branch=project.default_branch
|
|
109
|
+
)
|
|
110
|
+
for name in self.settings.environments:
|
|
111
|
+
deployment = self.api.latest_deployment(project.id, name)
|
|
112
|
+
if deployment is not None:
|
|
113
|
+
service.environments.append(_environment_state(name, deployment))
|
|
114
|
+
production: typing.Final = next(
|
|
115
|
+
(item for item in service.environments if item.name == self.settings.production_environment), None
|
|
116
|
+
)
|
|
117
|
+
if project.default_branch is None:
|
|
118
|
+
service.warnings.append("Project has no default branch.")
|
|
119
|
+
return service
|
|
120
|
+
if production is None:
|
|
121
|
+
service.warnings.append(f"No successful deployment to '{self.settings.production_environment}'.")
|
|
122
|
+
return service
|
|
123
|
+
|
|
124
|
+
commits, truncated = self.api.list_first_parent_commits(
|
|
125
|
+
project.id, f"{production.sha}..{project.default_branch}", max_items=self.settings.max_commits
|
|
126
|
+
)
|
|
127
|
+
if truncated:
|
|
128
|
+
service.warnings.append(f"Stopped after {self.settings.max_commits} commits; older changes are omitted.")
|
|
129
|
+
if not commits:
|
|
130
|
+
return service
|
|
131
|
+
|
|
132
|
+
since: typing.Final = min(commit.committed_date for commit in commits)
|
|
133
|
+
drafts: typing.Final = group_rows(
|
|
134
|
+
commits, self._commit_merge_requests(project, project.default_branch, commits, since, cache)
|
|
135
|
+
)
|
|
136
|
+
tags, tags_truncated = self.api.list_tags(project.id)
|
|
137
|
+
if tags_truncated:
|
|
138
|
+
service.warnings.append("Tag list was truncated; some tags may be missing from rows.")
|
|
139
|
+
tags_by_sha: typing.Final[dict[str, list[str]]] = {}
|
|
140
|
+
for tag in tags:
|
|
141
|
+
tags_by_sha.setdefault(tag.commit.id, []).append(tag.name)
|
|
142
|
+
main_pipelines: typing.Final = self._latest_by_sha(
|
|
143
|
+
self.api.list_push_pipelines(project.id, ref=project.default_branch, updated_after=since)
|
|
144
|
+
)
|
|
145
|
+
service.rows.extend(
|
|
146
|
+
self._build_row(
|
|
147
|
+
project=project,
|
|
148
|
+
draft=draft,
|
|
149
|
+
tags_by_sha=tags_by_sha,
|
|
150
|
+
main_pipelines=main_pipelines,
|
|
151
|
+
environments=service.environments,
|
|
152
|
+
cache=cache,
|
|
153
|
+
)
|
|
154
|
+
for draft in drafts
|
|
155
|
+
)
|
|
156
|
+
return service
|
|
157
|
+
|
|
158
|
+
def _commit_merge_requests(
|
|
159
|
+
self, project: Project, default_branch: str, commits: list[Commit], since: datetime.datetime, cache: Cache
|
|
160
|
+
) -> dict[str, list[MergeRequest]]:
|
|
161
|
+
merged: typing.Final = self.api.list_merged_merge_requests(
|
|
162
|
+
project.id, target_branch=default_branch, updated_after=since
|
|
163
|
+
)
|
|
164
|
+
matched: typing.Final = match_merge_requests(commits, merged)
|
|
165
|
+
for commit in commits:
|
|
166
|
+
if commit.id in matched:
|
|
167
|
+
continue
|
|
168
|
+
cached = cache.get_commit_merge_requests(project.id, commit.id)
|
|
169
|
+
if cached is None:
|
|
170
|
+
cached = [
|
|
171
|
+
item
|
|
172
|
+
for item in self.api.commit_merge_requests(project.id, commit.id)
|
|
173
|
+
if item.state == "merged" and item.target_branch == default_branch
|
|
174
|
+
]
|
|
175
|
+
cache.put_commit_merge_requests(project.id, commit.id, cached)
|
|
176
|
+
if cached:
|
|
177
|
+
matched[commit.id] = cached
|
|
178
|
+
return matched
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _latest_by_sha(pipelines: list[Pipeline]) -> dict[str, Pipeline]:
|
|
182
|
+
latest: dict[str, Pipeline] = {}
|
|
183
|
+
for pipeline in pipelines:
|
|
184
|
+
if pipeline.sha not in latest or pipeline.id > latest[pipeline.sha].id:
|
|
185
|
+
latest[pipeline.sha] = pipeline
|
|
186
|
+
return latest
|
|
187
|
+
|
|
188
|
+
def _build_row( # noqa: PLR0913
|
|
189
|
+
self,
|
|
190
|
+
*,
|
|
191
|
+
project: Project,
|
|
192
|
+
draft: RowDraft,
|
|
193
|
+
tags_by_sha: dict[str, list[str]],
|
|
194
|
+
main_pipelines: dict[str, Pipeline],
|
|
195
|
+
environments: list[EnvironmentState],
|
|
196
|
+
cache: Cache,
|
|
197
|
+
) -> Row:
|
|
198
|
+
shas: typing.Final = {commit.id for commit in draft.commits}
|
|
199
|
+
if draft.merge_requests:
|
|
200
|
+
texts: list[str | None] = []
|
|
201
|
+
for merge_request in draft.merge_requests:
|
|
202
|
+
texts.extend((merge_request.title, merge_request.source_branch, merge_request.description))
|
|
203
|
+
else:
|
|
204
|
+
texts = [commit.message or commit.title for commit in draft.commits]
|
|
205
|
+
head_pipeline: typing.Final = main_pipelines.get(draft.commits[0].id)
|
|
206
|
+
return Row(
|
|
207
|
+
kind="merge_request" if draft.merge_requests else "commit",
|
|
208
|
+
tags=[
|
|
209
|
+
self._tag_ref(project, name, cache)
|
|
210
|
+
for commit in draft.commits
|
|
211
|
+
for name in tags_by_sha.get(commit.id, [])
|
|
212
|
+
],
|
|
213
|
+
merge_requests=[_merge_request_ref(item) for item in draft.merge_requests],
|
|
214
|
+
commits=[_commit_ref(commit) for commit in draft.commits],
|
|
215
|
+
jira_keys=[
|
|
216
|
+
JiraKeyRef(key=key, url=self._jira_url(key))
|
|
217
|
+
for key in extract_jira_keys(texts, allowed_projects=self.settings.jira_project_keys)
|
|
218
|
+
],
|
|
219
|
+
environments=[item.name for item in environments if item.sha in shas],
|
|
220
|
+
main_pipeline=self._pipeline_state(project, head_pipeline, cache) if head_pipeline else None,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _tag_ref(self, project: Project, name: str, cache: Cache) -> TagRef:
|
|
224
|
+
pipeline: typing.Final = self.api.latest_pipeline(project.id, ref=name)
|
|
225
|
+
return TagRef(
|
|
226
|
+
name=name,
|
|
227
|
+
url=f"{project.web_url}/-/tags/{quote(name, safe='')}",
|
|
228
|
+
pipeline=self._pipeline_state(project, pipeline, cache) if pipeline else None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _jira_url(self, key: str) -> str | None:
|
|
232
|
+
if not self.settings.jira_endpoint:
|
|
233
|
+
return None
|
|
234
|
+
return f"{self.settings.jira_endpoint.rstrip('/')}/browse/{key}"
|
|
235
|
+
|
|
236
|
+
def _pipeline_state(self, project: Project, pipeline: Pipeline, cache: Cache) -> PipelineState:
|
|
237
|
+
failed_jobs = cache.get_failed_jobs(project.id, pipeline.id, pipeline.updated_at)
|
|
238
|
+
if failed_jobs is None:
|
|
239
|
+
failed_jobs = self._fetch_failed_jobs(project, pipeline)
|
|
240
|
+
if pipeline.status in _SETTLED_PIPELINE_STATUSES:
|
|
241
|
+
cache.put_failed_jobs(
|
|
242
|
+
project.id, pipeline.id, CachedPipeline(updated_at=pipeline.updated_at, failed_jobs=failed_jobs)
|
|
243
|
+
)
|
|
244
|
+
return PipelineState(id=pipeline.id, status=pipeline.status, url=pipeline.web_url, failed_jobs=failed_jobs)
|
|
245
|
+
|
|
246
|
+
def _fetch_failed_jobs(self, project: Project, pipeline: Pipeline) -> list[FailedJob]:
|
|
247
|
+
failed: typing.Final = [
|
|
248
|
+
FailedJob(
|
|
249
|
+
kind="job",
|
|
250
|
+
name=job.name,
|
|
251
|
+
stage=job.stage,
|
|
252
|
+
status=job.status,
|
|
253
|
+
allow_failure=job.allow_failure,
|
|
254
|
+
url=job.web_url,
|
|
255
|
+
failure_reason=job.failure_reason,
|
|
256
|
+
)
|
|
257
|
+
for job in self.api.failed_jobs(project.id, pipeline.id)
|
|
258
|
+
]
|
|
259
|
+
failed.extend(
|
|
260
|
+
FailedJob(
|
|
261
|
+
kind="bridge",
|
|
262
|
+
name=bridge.name,
|
|
263
|
+
stage=bridge.stage,
|
|
264
|
+
status=bridge.status,
|
|
265
|
+
allow_failure=bridge.allow_failure,
|
|
266
|
+
url=bridge.web_url,
|
|
267
|
+
failure_reason=bridge.failure_reason,
|
|
268
|
+
downstream_pipeline_url=bridge.downstream_pipeline.web_url if bridge.downstream_pipeline else None,
|
|
269
|
+
)
|
|
270
|
+
for bridge in self.api.failed_bridges(project.id, pipeline.id)
|
|
271
|
+
)
|
|
272
|
+
return failed
|
release_scope/ioc.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import httpware
|
|
4
|
+
import modern_di
|
|
5
|
+
from modern_di import Scope, providers
|
|
6
|
+
|
|
7
|
+
from release_scope._gitlab import GitLabApi
|
|
8
|
+
from release_scope._settings import Settings
|
|
9
|
+
from release_scope._use_case import CollectUseCase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504})
|
|
13
|
+
_MAX_RESPONSE_BODY_BYTES: typing.Final = 16 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_gitlab_client(settings: Settings) -> httpware.Client:
|
|
17
|
+
return httpware.Client(
|
|
18
|
+
base_url=settings.gitlab.endpoint,
|
|
19
|
+
timeout=settings.request_timeout,
|
|
20
|
+
headers={"PRIVATE-TOKEN": settings.gitlab.token.get_secret_value()},
|
|
21
|
+
middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)],
|
|
22
|
+
max_response_body_bytes=_MAX_RESPONSE_BODY_BYTES,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _close_client(client: httpware.Client) -> None:
|
|
27
|
+
client.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SettingsGroup(modern_di.Group):
|
|
31
|
+
settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ClientsGroup(modern_di.Group):
|
|
35
|
+
gitlab_client = providers.Factory(
|
|
36
|
+
scope=Scope.APP,
|
|
37
|
+
creator=_build_gitlab_client,
|
|
38
|
+
bound_type=None,
|
|
39
|
+
cache=providers.CacheSettings(finalizer=_close_client),
|
|
40
|
+
)
|
|
41
|
+
gitlab_api = providers.Factory(scope=Scope.APP, creator=GitLabApi, kwargs={"http": gitlab_client})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class UseCasesGroup(modern_di.Group):
|
|
45
|
+
collect_use_case = providers.Factory(scope=Scope.APP, creator=CollectUseCase)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [SettingsGroup, ClientsGroup, UseCasesGroup]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
container: typing.Final = modern_di.Container(groups=ALL_GROUPS)
|
release_scope/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: release-scope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs
|
|
5
|
+
Keywords: release,gitlab,jira,deployments,merge-requests,ci,cli,python
|
|
6
|
+
Author: Artur Shiriev
|
|
7
|
+
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: Free Threading :: 2 - Beta
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
18
|
+
Requires-Dist: typer>=0.13
|
|
19
|
+
Requires-Dist: pydantic>=2 ; python_full_version < '3.12'
|
|
20
|
+
Requires-Dist: pydantic>=2.0.2 ; python_full_version == '3.12.*'
|
|
21
|
+
Requires-Dist: pydantic>=2.8.2 ; python_full_version == '3.13.*'
|
|
22
|
+
Requires-Dist: pydantic>=2.12 ; python_full_version >= '3.14'
|
|
23
|
+
Requires-Dist: pydantic-settings>=2
|
|
24
|
+
Requires-Dist: modern-di-typer>=3,<4
|
|
25
|
+
Requires-Dist: httpx2>=2
|
|
26
|
+
Requires-Dist: httpware[pydantic]>=0.15.0
|
|
27
|
+
Requires-Python: >=3.11, <4
|
|
28
|
+
Project-URL: Homepage, https://modern-python.org
|
|
29
|
+
Project-URL: Repository, https://github.com/modern-python/release-scope
|
|
30
|
+
Project-URL: Issues, https://github.com/modern-python/release-scope/issues
|
|
31
|
+
Project-URL: Changelog, https://github.com/modern-python/release-scope/releases
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/release-scope/)
|
|
35
|
+
[](https://pypi.org/project/release-scope/)
|
|
36
|
+
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
37
|
+
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
38
|
+
[](https://github.com/modern-python/release-scope/blob/main/LICENSE)
|
|
39
|
+
|
|
40
|
+
`release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
|
|
41
|
+
Jira keys, failed jobs.
|
|
42
|
+
|
|
43
|
+
For every service it reads the latest successful production deployment, walks the default branch down to that
|
|
44
|
+
commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
|
|
45
|
+
point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
|
|
46
|
+
and tag pipelines.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
|
|
52
|
+
export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
|
|
53
|
+
export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
|
|
54
|
+
export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
|
|
55
|
+
|
|
56
|
+
uvx release-scope collect --group team/backend --output report.json --cache cache.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to
|
|
60
|
+
collect; the report is still written and names the error on that service.
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
Every setting is an environment variable; nothing about a GitLab or Jira instance is built in.
|
|
65
|
+
|
|
66
|
+
| Variable | Default | Meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `RELEASE_SCOPE_GITLAB__ENDPOINT` | `https://gitlab.com` | GitLab base URL |
|
|
69
|
+
| `RELEASE_SCOPE_GITLAB__TOKEN` or `GITLAB_TOKEN` | required | Token with `read_api` |
|
|
70
|
+
| `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
|
|
71
|
+
| `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
|
|
72
|
+
| `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
|
|
73
|
+
| `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
|
|
74
|
+
| `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
|
|
75
|
+
| `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
|
|
76
|
+
|
|
77
|
+
## Report
|
|
78
|
+
|
|
79
|
+
The report is versioned by `schema_version`; the models live in
|
|
80
|
+
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
|
|
81
|
+
One row, trimmed:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"kind": "merge_request",
|
|
86
|
+
"tags": [{"name": "1.2.0", "url": "...", "pipeline": {"id": 201, "status": "success", "failed_jobs": []}}],
|
|
87
|
+
"merge_requests": [{"iid": 12, "title": "SHOP-12 new endpoint", "url": "..."}],
|
|
88
|
+
"commits": [{"sha": "c3...", "title": "Merge branch 'feature/SHOP-12'"}],
|
|
89
|
+
"jira_keys": [{"key": "SHOP-12", "url": "https://jira.example.com/browse/SHOP-12"}],
|
|
90
|
+
"environments": ["preview"],
|
|
91
|
+
"main_pipeline": {"id": 103, "status": "failed", "failed_jobs": [{"kind": "job", "name": "lint", "allow_failure": false}]}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Cache
|
|
96
|
+
|
|
97
|
+
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
98
|
+
that do not change once settled: which merge requests a commit belongs to, and the failed jobs of a finished
|
|
99
|
+
pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Entries the run did not use are
|
|
100
|
+
dropped. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves requests and
|
|
101
|
+
never changes the report.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
release_scope/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
release_scope/__main__.py,sha256=GR-KbxpdSSTxUOFnbf72zrlVe5B39svSQ1UsOLSE6P8,3577
|
|
3
|
+
release_scope/_cache.py,sha256=vIa4IBjWWLprb8cJ9PxKukfeSjM-TDvKjlTcPqzfwSk,2886
|
|
4
|
+
release_scope/_errors.py,sha256=HmKVxgb0-nNIuf0SXgqfKFFRm9cHwOiIDjSleNXgt48,330
|
|
5
|
+
release_scope/_files.py,sha256=qbeLKAFe5VsGrOmexMZh4IP3mA3Mth9I3AghOVv7OYA,440
|
|
6
|
+
release_scope/_gitlab.py,sha256=FvxLJ4myahfe3Ej0NnMBSp0BXRdwHffutgIJrVp1LG0,7813
|
|
7
|
+
release_scope/_jira_keys.py,sha256=4cQ-sI2_6L8Bj-_JWJlUrg959nipGvji5lj7uoRooDw,606
|
|
8
|
+
release_scope/_report.py,sha256=9Zxf09SCK10Xlx5mCMl5RRpbUkE4dArGxOCS0LSSI_k,1873
|
|
9
|
+
release_scope/_rows.py,sha256=3BkSoGlJCc8uO2z3fFdW4lz9HQID0dMp7-_HsCUk1y0,1466
|
|
10
|
+
release_scope/_settings.py,sha256=IN1Mbcy7Iq-UqN_pmfuZ8OjPwZN-JDoGoLWRIoi6JbI,1915
|
|
11
|
+
release_scope/_use_case.py,sha256=oKObj6tNP8yTqr6yV-maVaMFQlLzSjS5ZUkhZzsu6Do,11060
|
|
12
|
+
release_scope/ioc.py,sha256=obvstFVph9fHwpOGINpk5vRvuX1GxpPLPGs-g2U6vlI,1589
|
|
13
|
+
release_scope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
release_scope-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
15
|
+
release_scope-0.1.0.dist-info/entry_points.txt,sha256=94nlGzT5C-pL_trA6Wbjs3UL63mhSk_n_4AiBaHG718,63
|
|
16
|
+
release_scope-0.1.0.dist-info/METADATA,sha256=eiX9ys_wU9g5x4pskBSHjArgBjHJrJQGOpqXhy30yYk,5441
|
|
17
|
+
release_scope-0.1.0.dist-info/RECORD,,
|