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.
- release_scope-0.1.0/PKG-INFO +101 -0
- release_scope-0.1.0/README.md +68 -0
- release_scope-0.1.0/pyproject.toml +118 -0
- release_scope-0.1.0/pyproject.toml.orig +105 -0
- release_scope-0.1.0/release_scope/__init__.py +0 -0
- release_scope-0.1.0/release_scope/__main__.py +105 -0
- release_scope-0.1.0/release_scope/_cache.py +71 -0
- release_scope-0.1.0/release_scope/_errors.py +17 -0
- release_scope-0.1.0/release_scope/_files.py +14 -0
- release_scope-0.1.0/release_scope/_gitlab.py +261 -0
- release_scope-0.1.0/release_scope/_jira_keys.py +22 -0
- release_scope-0.1.0/release_scope/_report.py +88 -0
- release_scope-0.1.0/release_scope/_rows.py +37 -0
- release_scope-0.1.0/release_scope/_settings.py +56 -0
- release_scope-0.1.0/release_scope/_use_case.py +272 -0
- release_scope-0.1.0/release_scope/ioc.py +51 -0
- release_scope-0.1.0/release_scope/py.typed +0 -0
|
@@ -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,68 @@
|
|
|
1
|
+
[](https://pypi.org/project/release-scope/)
|
|
2
|
+
[](https://pypi.org/project/release-scope/)
|
|
3
|
+
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
5
|
+
[](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)
|