release-scope 0.3.1__tar.gz → 0.4.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.
Files changed (22) hide show
  1. {release_scope-0.3.1 → release_scope-0.4.0}/PKG-INFO +38 -4
  2. {release_scope-0.3.1 → release_scope-0.4.0}/README.md +37 -3
  3. {release_scope-0.3.1 → release_scope-0.4.0}/pyproject.toml +1 -1
  4. {release_scope-0.3.1 → release_scope-0.4.0}/pyproject.toml.orig +1 -1
  5. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/__main__.py +44 -25
  6. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_gitlab.py +18 -1
  7. release_scope-0.4.0/release_scope/_publish.py +51 -0
  8. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_settings.py +2 -2
  9. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/ioc.py +4 -2
  10. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/__init__.py +0 -0
  11. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_cache.py +0 -0
  12. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_errors.py +0 -0
  13. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_files.py +0 -0
  14. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_jira.py +0 -0
  15. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_jira_keys.py +0 -0
  16. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_links.py +0 -0
  17. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_messages.py +0 -0
  18. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_render.py +0 -0
  19. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_report.py +0 -0
  20. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_rows.py +0 -0
  21. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/_use_case.py +0 -0
  22. {release_scope-0.3.1 → release_scope-0.4.0}/release_scope/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: release-scope
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs
5
5
  Keywords: release,gitlab,jira,deployments,merge-requests,ci,cli,python
6
6
  Author: Artur Shiriev
@@ -65,7 +65,7 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it
65
65
 
66
66
  ```sh
67
67
  export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
68
- export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
68
+ export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope; api for publish
69
69
  export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
70
70
  export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
71
71
 
@@ -152,6 +152,39 @@ Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service fa
152
152
  should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
153
153
  read the report or write the page.
154
154
 
155
+ ## Wiki
156
+
157
+ `publish` replaces the content of an existing page in a project wiki with a rendered page:
158
+
159
+ ```sh
160
+ uvx release-scope publish report.md --project team/docs --page releases/backend
161
+ ```
162
+
163
+ `--page` is the page slug, as in its URL after `/-/wikis/`. The page must already exist; `publish` never creates
164
+ one, so create it once in GitLab. It keeps the page title and format, and skips the write when the content is
165
+ unchanged, so a scheduled run does not add a page version every time. Publishing needs a token with the `api` scope
166
+ whose user has at least the Developer role in the wiki's project; a denied token exits `3`, a missing page or any
167
+ other failed request exits `4`. GitLab rejects pages larger than its wiki page size limit, 5 MB by default; the error
168
+ then names the size of the page.
169
+
170
+ A scheduled GitLab CI job keeps the page current. It publishes even when a service failed, then fails the job:
171
+
172
+ ```yaml
173
+ release-page:
174
+ image: python:3.13-slim
175
+ rules:
176
+ - if: $CI_PIPELINE_SOURCE == "schedule"
177
+ script:
178
+ - pip install 'release-scope>=0.4,<0.5'
179
+ - release-scope collect --group team/backend --output report.json || status=$?
180
+ - release-scope render report.json --output report.md
181
+ - release-scope publish report.md --project team/docs --page releases/backend
182
+ - exit "${status:-0}"
183
+ ```
184
+
185
+ Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project
186
+ that runs the job, along with the other settings.
187
+
155
188
  ## Cache
156
189
 
157
190
  `--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
@@ -167,7 +200,8 @@ requests and never changes the report.
167
200
  skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent
168
201
  what in the current repository has not reached production, what a group will ship with the next tag, or whether a
169
202
  Jira issue is released and which services it touches. For the current repository the skill takes `--project` from
170
- the git remote. It keeps the report and cache outside the repository and never writes to GitLab or Jira.
203
+ the git remote. It keeps the report and cache outside the repository, and publishes to a wiki page only when you ask
204
+ for it and name the page.
171
205
 
172
206
  Install it with [skills](https://github.com/vercel-labs/skills):
173
207
 
@@ -176,4 +210,4 @@ npx skills add modern-python/release-scope
176
210
  ```
177
211
 
178
212
  The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
179
- The skill runs `release-scope>=0.3,<0.4`, the range whose flags and report schema it describes.
213
+ The skill runs `release-scope>=0.4,<0.5`, the range whose flags and report schema it describes.
@@ -32,7 +32,7 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it
32
32
 
33
33
  ```sh
34
34
  export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
35
- export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
35
+ export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope; api for publish
36
36
  export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
37
37
  export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
38
38
 
@@ -119,6 +119,39 @@ Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service fa
119
119
  should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
120
120
  read the report or write the page.
121
121
 
122
+ ## Wiki
123
+
124
+ `publish` replaces the content of an existing page in a project wiki with a rendered page:
125
+
126
+ ```sh
127
+ uvx release-scope publish report.md --project team/docs --page releases/backend
128
+ ```
129
+
130
+ `--page` is the page slug, as in its URL after `/-/wikis/`. The page must already exist; `publish` never creates
131
+ one, so create it once in GitLab. It keeps the page title and format, and skips the write when the content is
132
+ unchanged, so a scheduled run does not add a page version every time. Publishing needs a token with the `api` scope
133
+ whose user has at least the Developer role in the wiki's project; a denied token exits `3`, a missing page or any
134
+ other failed request exits `4`. GitLab rejects pages larger than its wiki page size limit, 5 MB by default; the error
135
+ then names the size of the page.
136
+
137
+ A scheduled GitLab CI job keeps the page current. It publishes even when a service failed, then fails the job:
138
+
139
+ ```yaml
140
+ release-page:
141
+ image: python:3.13-slim
142
+ rules:
143
+ - if: $CI_PIPELINE_SOURCE == "schedule"
144
+ script:
145
+ - pip install 'release-scope>=0.4,<0.5'
146
+ - release-scope collect --group team/backend --output report.json || status=$?
147
+ - release-scope render report.json --output report.md
148
+ - release-scope publish report.md --project team/docs --page releases/backend
149
+ - exit "${status:-0}"
150
+ ```
151
+
152
+ Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project
153
+ that runs the job, along with the other settings.
154
+
122
155
  ## Cache
123
156
 
124
157
  `--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
@@ -134,7 +167,8 @@ requests and never changes the report.
134
167
  skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent
135
168
  what in the current repository has not reached production, what a group will ship with the next tag, or whether a
136
169
  Jira issue is released and which services it touches. For the current repository the skill takes `--project` from
137
- the git remote. It keeps the report and cache outside the repository and never writes to GitLab or Jira.
170
+ the git remote. It keeps the report and cache outside the repository, and publishes to a wiki page only when you ask
171
+ for it and name the page.
138
172
 
139
173
  Install it with [skills](https://github.com/vercel-labs/skills):
140
174
 
@@ -143,4 +177,4 @@ npx skills add modern-python/release-scope
143
177
  ```
144
178
 
145
179
  The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
146
- The skill runs `release-scope>=0.3,<0.4`, the range whose flags and report schema it describes.
180
+ The skill runs `release-scope>=0.4,<0.5`, the range whose flags and report schema it describes.
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.3.1"
28
+ version = "0.4.0"
29
29
  dependencies = [
30
30
  "typer>=0.13",
31
31
  "pydantic>=2; python_version < '3.12'",
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  "Topic :: Software Development :: Build Tools",
19
19
  ]
20
- version = "0.3.1"
20
+ version = "0.4.0"
21
21
  dependencies = [
22
22
  "typer>=0.13",
23
23
  # The first release whose pydantic-core ships a wheel for each interpreter.
@@ -1,3 +1,4 @@
1
+ import functools
1
2
  import importlib.metadata
2
3
  import json
3
4
  import pathlib
@@ -11,9 +12,9 @@ from release_scope._cache import Cache
11
12
  from release_scope._errors import ConfigError, ReleaseScopeError
12
13
  from release_scope._files import write_text_atomic
13
14
  from release_scope._jira_keys import JIRA_KEY_PATTERN
15
+ from release_scope._publish import PublishUseCase
14
16
  from release_scope._render import render_markdown
15
17
  from release_scope._report import SCHEMA_VERSION, Report
16
- from release_scope._settings import Settings, load_settings
17
18
  from release_scope._use_case import CollectUseCase
18
19
 
19
20
 
@@ -47,16 +48,26 @@ def _main_callback(
47
48
  pass
48
49
 
49
50
 
50
- @modern_di_typer.inject
51
- def _resolve_use_case(
52
- use_case: typing.Annotated[CollectUseCase, modern_di_typer.FromDI(CollectUseCase)],
53
- ) -> CollectUseCase:
54
- return use_case
51
+ _P = typing.ParamSpec("_P")
52
+
53
+
54
+ def _exit_on_error(func: typing.Callable[_P, None]) -> typing.Callable[_P, None]:
55
+ @functools.wraps(func)
56
+ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
57
+ try:
58
+ func(*args, **kwargs)
59
+ except ReleaseScopeError as err:
60
+ typer.echo(f"Error: {err}", err=True)
61
+ raise typer.Exit(code=err.exit_code) from err
62
+
63
+ return wrapper
55
64
 
56
65
 
57
66
  @MAIN_APP.command("collect", help="Collect pending changes per service into a JSON report.")
67
+ @_exit_on_error
68
+ @modern_di_typer.inject
58
69
  def _collect_command( # noqa: PLR0913, PLR0917
59
- ctx: typer.Context,
70
+ use_case: typing.Annotated[CollectUseCase, modern_di_typer.FromDI(CollectUseCase)],
60
71
  output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the report JSON.")],
61
72
  group: typing.Annotated[
62
73
  list[str] | None, typer.Option("--group", "-g", help="GitLab group path; repeatable.")
@@ -76,24 +87,14 @@ def _collect_command( # noqa: PLR0913, PLR0917
76
87
  typer.Option("--jira", "-j", help="Jira issue key; collect only the services it links to. Repeatable."),
77
88
  ] = None,
78
89
  ) -> None:
79
- try:
80
- _check_selection(groups=group or [], projects=project or [], keys=jira or [])
81
- settings = load_settings({})
82
- modern_di_typer.fetch_di_container(ctx).set_context(Settings, settings)
83
- cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
84
- if cache_warning:
85
- typer.echo(f"Warning: {cache_warning}", err=True)
86
- use_case = _resolve_use_case(ctx=ctx)
87
- if jira:
88
- report = use_case.for_issues(keys=list(dict.fromkeys(jira)), cache=cache)
89
- else:
90
- report = use_case(
91
- groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache
92
- )
93
- except ReleaseScopeError as err:
94
- typer.echo(f"Error: {err}", err=True)
95
- raise typer.Exit(code=err.exit_code) from err
96
-
90
+ _check_selection(groups=group or [], projects=project or [], keys=jira or [])
91
+ cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
92
+ if cache_warning:
93
+ typer.echo(f"Warning: {cache_warning}", err=True)
94
+ if jira:
95
+ report = use_case.for_issues(keys=list(dict.fromkeys(jira)), cache=cache)
96
+ else:
97
+ report = use_case(groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache)
97
98
  write_text_atomic(output, report.model_dump_json(indent=2))
98
99
  if cache_path:
99
100
  cache.save(cache_path)
@@ -160,6 +161,24 @@ def _render_command(
160
161
  typer.echo(f"{len(report.services)} services -> {output}", err=True)
161
162
 
162
163
 
164
+ @MAIN_APP.command("publish", help="Replace the content of an existing GitLab wiki page with a rendered page.")
165
+ @_exit_on_error
166
+ @modern_di_typer.inject
167
+ def _publish_command(
168
+ use_case: typing.Annotated[PublishUseCase, modern_di_typer.FromDI(PublishUseCase)],
169
+ page_path: typing.Annotated[pathlib.Path, typer.Argument(help="Markdown page written by `render`.")],
170
+ project: typing.Annotated[str, typer.Option("--project", "-p", help="GitLab project path that holds the wiki.")],
171
+ page: typing.Annotated[str, typer.Option("--page", help="Slug of the wiki page, such as releases/backend.")],
172
+ ) -> None:
173
+ try:
174
+ content = page_path.read_text(encoding="utf-8")
175
+ except (OSError, ValueError) as exc:
176
+ typer.echo(f"Error: Cannot read page {page_path}: {type(exc).__name__}.", err=True)
177
+ raise typer.Exit(code=ConfigError.exit_code) from exc
178
+ published: typing.Final = use_case(project=project, slug=page, content=content)
179
+ typer.echo(f"{'Updated' if published.updated else 'Unchanged'} {published.url}", err=True)
180
+
181
+
163
182
  def main() -> None:
164
183
  with ioc.container:
165
184
  MAIN_APP()
@@ -104,6 +104,11 @@ class Bridge(Job):
104
104
  downstream_pipeline: DownstreamPipeline | None = None
105
105
 
106
106
 
107
+ class WikiPage(pydantic.BaseModel):
108
+ slug: str
109
+ content: str
110
+
111
+
107
112
  class _Projects(pydantic.RootModel[list[Project]]):
108
113
  pass
109
114
 
@@ -137,7 +142,7 @@ class _Bridges(pydantic.RootModel[list[Bridge]]):
137
142
 
138
143
 
139
144
  Resource: typing.TypeAlias = typing.Literal[
140
- "group", "project", "deployments", "pipelines", "repository", "merge_requests"
145
+ "group", "project", "deployments", "pipelines", "repository", "merge_requests", "wiki"
141
146
  ]
142
147
 
143
148
 
@@ -165,6 +170,12 @@ class GitLabApi:
165
170
  except httpware.ClientError as exc:
166
171
  raise _translate(exc, url=url, resource=resource) from exc
167
172
 
173
+ def _put(self, url: str, body: dict[str, typing.Any], *, resource: Resource) -> None:
174
+ try:
175
+ self.http.put(url, json=body)
176
+ except httpware.ClientError as exc:
177
+ raise _translate(exc, url=url, resource=resource) from exc
178
+
168
179
  def _pages(
169
180
  self,
170
181
  url: str,
@@ -288,3 +299,9 @@ class GitLabApi:
288
299
  resource="pipelines",
289
300
  )
290
301
  return bridges
302
+
303
+ def get_wiki_page(self, project: str, slug: str) -> WikiPage:
304
+ return self._get(f"{_API}/projects/{_quote(project)}/wikis/{_quote(slug)}", {}, WikiPage, resource="wiki")
305
+
306
+ def update_wiki_page(self, project: str, slug: str, *, content: str) -> None:
307
+ self._put(f"{_API}/projects/{_quote(project)}/wikis/{_quote(slug)}", {"content": content}, resource="wiki")
@@ -0,0 +1,51 @@
1
+ import dataclasses
2
+ import http
3
+ import typing
4
+
5
+ from release_scope._errors import AuthError, GitLabError, ReleaseScopeError
6
+ from release_scope._gitlab import GitLabApi
7
+ from release_scope._settings import Settings
8
+
9
+
10
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
11
+ class Published:
12
+ url: str
13
+ updated: bool
14
+
15
+
16
+ def _wiki_error(error: GitLabError, *, project: str, slug: str, size: int | None) -> ReleaseScopeError:
17
+ if error.status == http.HTTPStatus.FORBIDDEN:
18
+ return AuthError(
19
+ f"GitLab denied access to the wiki of project '{project}' (403). Check that the token has the 'api' "
20
+ "scope and that its user has at least the Developer role there."
21
+ )
22
+ if error.status == http.HTTPStatus.NOT_FOUND:
23
+ return GitLabError(
24
+ f"Wiki page '{slug}' does not exist in project '{project}', or the token cannot see it. "
25
+ "Create the page in GitLab first.",
26
+ resource="wiki",
27
+ status=error.status,
28
+ )
29
+ if size is None:
30
+ return error
31
+ return GitLabError(f"{error} The page is {size} bytes.", resource="wiki", status=error.status, reason=error.reason)
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
35
+ class PublishUseCase:
36
+ api: GitLabApi
37
+ settings: Settings
38
+
39
+ def __call__(self, *, project: str, slug: str, content: str) -> Published:
40
+ url: typing.Final = f"{self.settings.gitlab.endpoint.rstrip('/')}/{project}/-/wikis/{slug}"
41
+ try:
42
+ page = self.api.get_wiki_page(project, slug)
43
+ except GitLabError as exc:
44
+ raise _wiki_error(exc, project=project, slug=slug, size=None) from exc
45
+ if page.content == content:
46
+ return Published(url=url, updated=False)
47
+ try:
48
+ self.api.update_wiki_page(project, slug, content=content)
49
+ except GitLabError as exc:
50
+ raise _wiki_error(exc, project=project, slug=slug, size=len(content.encode())) from exc
51
+ return Published(url=url, updated=True)
@@ -49,9 +49,9 @@ class Settings(pydantic_settings.BaseSettings):
49
49
  return self
50
50
 
51
51
 
52
- def load_settings(overrides: dict[str, typing.Any]) -> Settings:
52
+ def load_settings() -> Settings:
53
53
  try:
54
- settings: typing.Final = Settings(**overrides)
54
+ settings: typing.Final = Settings()
55
55
  except pydantic.ValidationError as exc:
56
56
  msg = f"Invalid configuration: {exc}"
57
57
  raise ConfigError(msg) from exc
@@ -6,7 +6,8 @@ from modern_di import Scope, providers
6
6
 
7
7
  from release_scope._gitlab import GitLabApi
8
8
  from release_scope._jira import JiraApi
9
- from release_scope._settings import Settings
9
+ from release_scope._publish import PublishUseCase
10
+ from release_scope._settings import Settings, load_settings
10
11
  from release_scope._use_case import CollectUseCase
11
12
 
12
13
 
@@ -47,7 +48,7 @@ def _close_client(client: httpware.Client | None) -> None:
47
48
 
48
49
 
49
50
  class SettingsGroup(modern_di.Group):
50
- settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings)
51
+ settings = providers.Factory(scope=Scope.APP, creator=load_settings, cache=True)
51
52
 
52
53
 
53
54
  class ClientsGroup(modern_di.Group):
@@ -73,6 +74,7 @@ class UseCasesGroup(modern_di.Group):
73
74
  collect_use_case = providers.Factory(
74
75
  scope=Scope.APP, creator=CollectUseCase, kwargs={"jira": ClientsGroup.jira_api}
75
76
  )
77
+ publish_use_case = providers.Factory(scope=Scope.APP, creator=PublishUseCase)
76
78
 
77
79
 
78
80
  ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [SettingsGroup, ClientsGroup, UseCasesGroup]