release-scope 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,68 @@
1
+ [![PyPI version](https://img.shields.io/pypi/v/release-scope.svg)](https://pypi.org/project/release-scope/)
2
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/release-scope.svg)](https://pypi.org/project/release-scope/)
3
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
4
+ [![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)
5
+ [![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](https://github.com/modern-python/release-scope/blob/main/LICENSE)
6
+
7
+ `release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
8
+ Jira keys, failed jobs.
9
+
10
+ For every service it reads the latest successful production deployment, walks the default branch down to that
11
+ commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
12
+ point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
13
+ and tag pipelines.
14
+
15
+ ## Quickstart
16
+
17
+ ```sh
18
+ export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
19
+ export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
20
+ export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
21
+ export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
22
+
23
+ uvx release-scope collect --group team/backend --output report.json --cache cache.json
24
+ ```
25
+
26
+ `--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to
27
+ collect; the report is still written and names the error on that service.
28
+
29
+ ## Configuration
30
+
31
+ Every setting is an environment variable; nothing about a GitLab or Jira instance is built in.
32
+
33
+ | Variable | Default | Meaning |
34
+ |---|---|---|
35
+ | `RELEASE_SCOPE_GITLAB__ENDPOINT` | `https://gitlab.com` | GitLab base URL |
36
+ | `RELEASE_SCOPE_GITLAB__TOKEN` or `GITLAB_TOKEN` | required | Token with `read_api` |
37
+ | `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
38
+ | `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
39
+ | `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
40
+ | `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
41
+ | `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
42
+ | `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
43
+
44
+ ## Report
45
+
46
+ The report is versioned by `schema_version`; the models live in
47
+ [`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
48
+ One row, trimmed:
49
+
50
+ ```json
51
+ {
52
+ "kind": "merge_request",
53
+ "tags": [{"name": "1.2.0", "url": "...", "pipeline": {"id": 201, "status": "success", "failed_jobs": []}}],
54
+ "merge_requests": [{"iid": 12, "title": "SHOP-12 new endpoint", "url": "..."}],
55
+ "commits": [{"sha": "c3...", "title": "Merge branch 'feature/SHOP-12'"}],
56
+ "jira_keys": [{"key": "SHOP-12", "url": "https://jira.example.com/browse/SHOP-12"}],
57
+ "environments": ["preview"],
58
+ "main_pipeline": {"id": 103, "status": "failed", "failed_jobs": [{"kind": "job", "name": "lint", "allow_failure": false}]}
59
+ }
60
+ ```
61
+
62
+ ## Cache
63
+
64
+ `--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
65
+ that do not change once settled: which merge requests a commit belongs to, and the failed jobs of a finished
66
+ pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Entries the run did not use are
67
+ dropped. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves requests and
68
+ never changes the report.
@@ -0,0 +1,118 @@
1
+ [project]
2
+ name = "release-scope"
3
+ description = "Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs"
4
+ requires-python = ">=3.11,<4"
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ keywords = [
8
+ "release",
9
+ "gitlab",
10
+ "jira",
11
+ "deployments",
12
+ "merge-requests",
13
+ "ci",
14
+ "cli",
15
+ "python",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Programming Language :: Python :: Free Threading :: 2 - Beta",
25
+ "Typing :: Typed",
26
+ "Topic :: Software Development :: Build Tools",
27
+ ]
28
+ version = "0.1.0"
29
+ dependencies = [
30
+ "typer>=0.13",
31
+ "pydantic>=2; python_version < '3.12'",
32
+ "pydantic>=2.0.2; python_version == '3.12'",
33
+ "pydantic>=2.8.2; python_version == '3.13'",
34
+ "pydantic>=2.12; python_version >= '3.14'",
35
+ "pydantic-settings>=2",
36
+ "modern-di-typer>=3,<4",
37
+ "httpx2>=2",
38
+ "httpware[pydantic]>=0.15.0",
39
+ ]
40
+
41
+ [[project.authors]]
42
+ name = "Artur Shiriev"
43
+ email = "me@shiriev.ru"
44
+
45
+ [project.scripts]
46
+ release-scope = "release_scope.__main__:main"
47
+
48
+ [project.urls]
49
+ Homepage = "https://modern-python.org"
50
+ Repository = "https://github.com/modern-python/release-scope"
51
+ Issues = "https://github.com/modern-python/release-scope/issues"
52
+ Changelog = "https://github.com/modern-python/release-scope/releases"
53
+
54
+ [build-system]
55
+ requires = ["uv_build>=0.11,<1"]
56
+ build-backend = "uv_build"
57
+
58
+ [tool.uv.build-backend]
59
+ module-name = "release_scope"
60
+ module-root = ""
61
+
62
+ [tool.ruff]
63
+ fix = true
64
+ unsafe-fixes = true
65
+ line-length = 120
66
+
67
+ [tool.ruff.lint]
68
+ select = ["ALL"]
69
+ ignore = [
70
+ "D1",
71
+ "D203",
72
+ "D213",
73
+ "COM812",
74
+ "ISC001",
75
+ "CPY001",
76
+ "FBT",
77
+ "TCH",
78
+ "S105",
79
+ ]
80
+
81
+ [tool.ruff.lint.isort]
82
+ lines-after-imports = 2
83
+ no-lines-before = [
84
+ "standard-library",
85
+ "local-folder",
86
+ ]
87
+
88
+ [tool.ruff.lint.per-file-ignores]
89
+ "tests/**" = [
90
+ "S101",
91
+ "PLR2004",
92
+ ]
93
+
94
+ [tool.pytest.ini_options]
95
+ addopts = ""
96
+ testpaths = ["tests"]
97
+ markers = ["httpx2: configure the httpx2_mock fixture"]
98
+
99
+ [tool.coverage.report]
100
+ fail_under = 100
101
+ exclude_also = ["if typing.TYPE_CHECKING:"]
102
+
103
+ [dependency-groups]
104
+ dev = [
105
+ "pytest>=8",
106
+ "pytest-cov>=5",
107
+ "pytest-xdist>=3",
108
+ "pytest-randomly>=3",
109
+ "pytest-httpx2>=1",
110
+ "respx>=0.23.1",
111
+ "httpx>=0.28.1",
112
+ "httpcore2>=2",
113
+ ]
114
+ lint = [
115
+ "ruff",
116
+ "ty",
117
+ "eof-fixer",
118
+ ]
@@ -0,0 +1,105 @@
1
+ [project]
2
+ name = "release-scope"
3
+ description = "Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs"
4
+ authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
5
+ requires-python = ">=3.11,<4"
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ keywords = ["release", "gitlab", "jira", "deployments", "merge-requests", "ci", "cli", "python"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Programming Language :: Python :: Free Threading :: 2 - Beta",
17
+ "Typing :: Typed",
18
+ "Topic :: Software Development :: Build Tools",
19
+ ]
20
+ version = "0.1.0"
21
+ dependencies = [
22
+ "typer>=0.13",
23
+ # The first release whose pydantic-core ships a wheel for each interpreter.
24
+ "pydantic>=2; python_version < '3.12'",
25
+ "pydantic>=2.0.2; python_version == '3.12'",
26
+ "pydantic>=2.8.2; python_version == '3.13'",
27
+ "pydantic>=2.12; python_version >= '3.14'",
28
+ "pydantic-settings>=2",
29
+ "modern-di-typer>=3,<4",
30
+ "httpx2>=2",
31
+ "httpware[pydantic]>=0.15.0",
32
+ ]
33
+
34
+ [project.scripts]
35
+ release-scope = "release_scope.__main__:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://modern-python.org"
39
+ Repository = "https://github.com/modern-python/release-scope"
40
+ Issues = "https://github.com/modern-python/release-scope/issues"
41
+ Changelog = "https://github.com/modern-python/release-scope/releases"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.11,<1"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.uv.build-backend]
48
+ module-name = "release_scope"
49
+ module-root = ""
50
+
51
+ [dependency-groups]
52
+ dev = [
53
+ "pytest>=8",
54
+ "pytest-cov>=5",
55
+ "pytest-xdist>=3",
56
+ "pytest-randomly>=3",
57
+ "pytest-httpx2>=1",
58
+ "respx>=0.23.1",
59
+ "httpx>=0.28.1",
60
+ "httpcore2>=2",
61
+ ]
62
+ lint = [
63
+ "ruff",
64
+ "ty",
65
+ "eof-fixer",
66
+ ]
67
+
68
+ [tool.ruff]
69
+ fix = true
70
+ unsafe-fixes = true
71
+ line-length = 120
72
+
73
+ [tool.ruff.lint]
74
+ select = ["ALL"]
75
+ ignore = [
76
+ "D1", # docstrings are not forced; a docstring exists when it says something
77
+ "D203", # conflicts with D211
78
+ "D213", # conflicts with D212
79
+ "COM812", # conflicts with the formatter
80
+ "ISC001", # conflicts with the formatter
81
+ "CPY001", # no per-file copyright header
82
+ "FBT", # boolean positional arguments are fine
83
+ "TCH", # imports stay real; TYPE_CHECKING-only imports break runtime introspection
84
+ "S105", # token names in env-var constants and test fixtures, not secrets
85
+ ]
86
+ isort.lines-after-imports = 2
87
+ isort.no-lines-before = ["standard-library", "local-folder"]
88
+
89
+ [tool.ruff.lint.per-file-ignores]
90
+ "tests/**" = [
91
+ "S101", # assert is the test idiom; in library code it is a finding
92
+ "PLR2004", # expected ids and exit codes read clearer inline than as named constants
93
+ ]
94
+
95
+ [tool.pytest.ini_options]
96
+ addopts = ""
97
+ testpaths = ["tests"]
98
+ # pytest-httpx2 defines this marker in a hook its package never exports, so pytest does not see it.
99
+ markers = ["httpx2: configure the httpx2_mock fixture"]
100
+
101
+ [tool.coverage.report]
102
+ fail_under = 100
103
+ exclude_also = [
104
+ "if typing.TYPE_CHECKING:",
105
+ ]
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)