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.
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()
@@ -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
+ }
@@ -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
@@ -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)
@@ -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)
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/release-scope.svg)](https://pypi.org/project/release-scope/)
35
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/release-scope.svg)](https://pypi.org/project/release-scope/)
36
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
37
+ [![CI](https://github.com/modern-python/release-scope/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
38
+ [![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ release-scope = release_scope.__main__:main
3
+