Skip to content

Commit ddd664f

Browse files
authored
Translatable collector messages (schema 4) (#18)
* Give collector warnings and errors codes so the page can translate them Warnings, service errors and the Jira error become messages with a code, params and the English text. Report schema 4. The page renders Russian from the code and params and falls back to the text. * Import datetime as dt for ruff 0.17 and fix Russian wording Ruff 0.17 adds datetime to ICN001's conventions. Russian: genitive after 'из' in the shipped count, number-neutral commit limit, dative and accusative resource forms, clearer range and pick texts.
1 parent d1541d7 commit ddd664f

14 files changed

Lines changed: 371 additions & 109 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ The report is versioned by `schema_version`; the models live in
8686
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
8787
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category,
8888
issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed.
89+
Warnings and errors, on services and in `jira`, are messages: a `code`, its `params`, and the English `text`.
8990
Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
9091
rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
9192
when the service has no production deployment. One row, trimmed:
@@ -110,7 +111,8 @@ installed package and change only with it, so the page always matches the report
110111

111112
Switches at the top pick the language, English or Russian, and the theme: as in the system, light, or dark. The page
112113
starts in the browser's language and remembers both choices in the browser. The copied lists and release post follow
113-
the language; warnings and errors from `collect`, and texts from GitLab and Jira, stay as written.
114+
the language, and so do warnings and errors from `collect`; texts from GitLab and Jira, such as titles, statuses and
115+
Jira's own error details, stay as written.
114116

115117
**Services** lists every service with a production deployment or with rows, and every service that failed to collect,
116118
as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
@@ -150,7 +152,7 @@ release-report:
150152
rules:
151153
- if: $CI_PIPELINE_SOURCE == "schedule"
152154
script:
153-
- uvx --from 'release-scope>=0.5,<0.6' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
155+
- uvx --from 'release-scope>=0.6,<0.7' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
154156
pages: true
155157
```
156158
@@ -189,4 +191,4 @@ npx skills add modern-python/release-scope
189191
```
190192

191193
The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
192-
The skill runs `release-scope>=0.5,<0.6`, the range whose flags and report schema it describes.
194+
The skill runs `release-scope>=0.6,<0.7`, the range whose flags and report schema it describes.

‎release_scope/__main__.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,11 @@ def _collect_command( # noqa: PLR0913, PLR0917
9797
write_site(output, report.model_dump_json(indent=2))
9898
if cache_path:
9999
cache.save(cache_path)
100-
failed: typing.Final = [service for service in report.services if service.error]
100+
failed: typing.Final = [service.error for service in report.services if service.error]
101101
rows: typing.Final = sum(len(service.rows) for service in report.services)
102102
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
103-
for service in failed:
104-
typer.echo(f"Error: {service.error}", err=True)
103+
for error in failed:
104+
typer.echo(f"Error: {error.text}", err=True)
105105
jira_errors: typing.Final = _jira_errors(report)
106106
for message in jira_errors:
107107
typer.echo(f"Error: {message}", err=True)
@@ -129,7 +129,7 @@ def _jira_errors(report: Report) -> list[str]:
129129
return []
130130
errors: typing.Final = [f"Jira has no issue {key}." for key in report.jira_scope if key in report.jira.missing]
131131
if report.jira.error:
132-
errors.append(report.jira.error)
132+
errors.append(report.jira.error.text)
133133
return errors
134134

135135

‎release_scope/_errors.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import typing
22

3+
from release_scope._report import Message
4+
35

46
class ReleaseScopeError(Exception):
57
exit_code: typing.ClassVar[int] = 1
@@ -24,4 +26,6 @@ def __init__(self, message: str, *, resource: str, status: int | None = None, re
2426

2527

2628
class JiraError(ReleaseScopeError):
27-
pass
29+
def __init__(self, message: Message) -> None:
30+
super().__init__(message.text)
31+
self.message = message

‎release_scope/_gitlab.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import dataclasses
2-
import datetime
2+
import datetime as dt
33
import math
44
import typing
55
from urllib.parse import quote, unquote
@@ -54,7 +54,7 @@ class Commit(pydantic.BaseModel):
5454
title: str
5555
message: str = ""
5656
author_name: str | None = None
57-
committed_date: datetime.datetime
57+
committed_date: dt.datetime
5858
web_url: str | None = None
5959

6060

@@ -228,7 +228,7 @@ def list_first_parent_commits(
228228
)
229229

230230
def list_merged_merge_requests(
231-
self, project_id: int, *, target_branch: str, updated_after: datetime.datetime
231+
self, project_id: int, *, target_branch: str, updated_after: dt.datetime
232232
) -> list[MergeRequest]:
233233
merge_requests, _ = self._pages(
234234
f"{_API}/projects/{project_id}/merge_requests",
@@ -252,7 +252,7 @@ def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]
252252
)
253253
return merge_requests
254254

255-
def list_push_pipelines(self, project_id: int, *, ref: str, updated_after: datetime.datetime) -> list[Pipeline]:
255+
def list_push_pipelines(self, project_id: int, *, ref: str, updated_after: dt.datetime) -> list[Pipeline]:
256256
pipelines, _ = self._pages(
257257
f"{_API}/projects/{project_id}/pipelines",
258258
{"ref": ref, "source": "push", "updated_after": updated_after.isoformat()},

‎release_scope/_jira.py‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pydantic
77

88
from release_scope._errors import JiraError
9+
from release_scope._messages import jira_status, jira_token_rejected, jira_unreachable
910

1011

1112
_SEARCH: typing.Final = "/rest/api/2/search"
@@ -66,15 +67,12 @@ def _error_messages(exc: httpware.StatusError) -> str:
6667
return " ".join(str(message) for message in messages)
6768

6869

69-
def _translate(exc: httpware.ClientError, *, target: str) -> JiraError:
70+
def _translate(exc: httpware.ClientError, *, issue: str | None) -> JiraError:
7071
if isinstance(exc, httpware.UnauthorizedError):
71-
return JiraError("Jira rejected the token (401). Check that it is valid and not expired.")
72+
return JiraError(jira_token_rejected())
7273
if isinstance(exc, httpware.StatusError):
73-
status: typing.Final = exc.response.status_code
74-
details: typing.Final = _error_messages(exc)
75-
suffix: typing.Final = f": {details.rstrip('.')}." if details else "."
76-
return JiraError(f"Jira returned {status} for {target}{suffix}")
77-
return JiraError(f"Jira request for {target} failed: {type(exc).__name__}.")
74+
return JiraError(jira_status(exc.response.status_code, _error_messages(exc).rstrip("."), issue))
75+
return JiraError(jira_unreachable(type(exc).__name__, issue))
7876

7977

8078
def _batches(keys: collections.abc.Sequence[str]) -> collections.abc.Iterator[collections.abc.Sequence[str]]:
@@ -106,7 +104,7 @@ def _search(self, jql: str) -> list[Issue]:
106104
try:
107105
page = self.http.post(_SEARCH, json=body, response_model=_SearchResults)
108106
except httpware.ClientError as exc:
109-
raise _translate(exc, target="the issue search") from exc
107+
raise _translate(exc, issue=None) from exc
110108
issues.extend(page.issues)
111109
if not page.issues or len(issues) >= page.total:
112110
return issues
@@ -115,4 +113,4 @@ def remote_links(self, key: str) -> list[RemoteLink]:
115113
try:
116114
return self.http.get(f"{_ISSUE}/{key}/remotelink", response_model=_RemoteLinks).root
117115
except httpware.ClientError as exc:
118-
raise _translate(exc, target=f"the remote links of {key}") from exc
116+
raise _translate(exc, issue=key) from exc

‎release_scope/_messages.py‎

Lines changed: 108 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,45 +3,136 @@
33

44
from release_scope._errors import GitLabError
55
from release_scope._gitlab import Project
6+
from release_scope._report import Message, MessageCode
67

78

89
_RESOURCES: typing.Final[dict[str, tuple[str, tuple[str, ...]]]] = {
10+
"project": ("the project", ()),
911
"deployments": ("deployments", ("Environments", "CI/CD")),
1012
"pipelines": ("pipelines", ("CI/CD",)),
1113
"repository": ("the repository", ("Repository",)),
1214
"merge_requests": ("merge requests", ("Merge requests",)),
1315
}
1416
_PLURAL_FEATURES: typing.Final = frozenset({"Environments", "Merge requests"})
17+
_PERMISSIONS: typing.Final = "Visibility, project features, permissions"
1518

1619

17-
def _feature_setting(project: Project, feature: str) -> str:
18-
return f"{project.web_url}/edit#js-shared-permissions → Visibility, project features, permissions → {feature}"
20+
def _settings_url(web_url: str) -> str:
21+
return f"{web_url}/edit#js-shared-permissions"
1922

2023

21-
def explain_failure(project: Project, error: GitLabError) -> str:
22-
name: typing.Final = project.path_with_namespace
23-
resource, features = _RESOURCES.get(error.resource, ("project data", ()))
24+
def explain_failure(name: str, web_url: str, error: GitLabError) -> Message:
25+
label, features = _RESOURCES.get(error.resource, ("project data", ()))
2426
if error.status is None:
25-
return f"{name}: GitLab request for {resource} failed ({error.reason})."
27+
return Message(
28+
code=MessageCode.GITLAB_UNREACHABLE,
29+
params={"project": name, "resource": error.resource, "reason": error.reason},
30+
text=f"{name}: GitLab request for {label} failed ({error.reason}).",
31+
)
2632
if error.status != http.HTTPStatus.FORBIDDEN:
27-
return f"{name}: GitLab returned {error.status} for {resource}."
33+
return Message(
34+
code=MessageCode.GITLAB_STATUS,
35+
params={"project": name, "resource": error.resource, "status": error.status},
36+
text=f"{name}: GitLab returned {error.status} for {label}.",
37+
)
38+
settings: typing.Final = _settings_url(web_url)
39+
members: typing.Final = f"{web_url}/-/project_members"
2840
checks: typing.Final = [
29-
f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {_feature_setting(project, feature)}"
41+
f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {settings} → {_PERMISSIONS} → {feature}"
3042
for feature in features
3143
]
32-
checks.append(f"- the token's user has a role that can read them: {project.web_url}/-/project_members")
33-
return "\n".join([f"{name}: GitLab denied access to {resource} (403). Check that:", *checks])
44+
checks.append(f"- the token's user has a role that can read them: {members}")
45+
return Message(
46+
code=MessageCode.GITLAB_DENIED,
47+
params={
48+
"project": name,
49+
"resource": error.resource,
50+
"features": list(features),
51+
"settings_url": settings,
52+
"members_url": members,
53+
},
54+
text="\n".join([f"{name}: GitLab denied access to {label} (403). Check that:", *checks]),
55+
)
3456

3557

36-
def skip_reason(project: Project) -> str | None:
58+
def skip_reason(project: Project) -> Message | None:
59+
settings: typing.Final = _settings_url(project.web_url)
3760
if project.builds_access_level == "disabled":
38-
return (
39-
"CI/CD is disabled, so it has no pipelines or deployments. "
40-
f"Enable it at {_feature_setting(project, 'CI/CD')}."
61+
return Message(
62+
code=MessageCode.CI_DISABLED,
63+
params={"settings_url": settings},
64+
text="CI/CD is disabled, so it has no pipelines or deployments. "
65+
f"Enable it at {settings} → {_PERMISSIONS} → CI/CD.",
4166
)
4267
if project.environments_access_level == "disabled":
43-
return (
44-
"Environments are disabled, so it has no deployments. "
45-
f"Enable them at {_feature_setting(project, 'Environments')}."
68+
return Message(
69+
code=MessageCode.ENVIRONMENTS_DISABLED,
70+
params={"settings_url": settings},
71+
text="Environments are disabled, so it has no deployments. "
72+
f"Enable them at {settings} → {_PERMISSIONS} → Environments.",
4673
)
4774
return None
75+
76+
77+
def no_default_branch() -> Message:
78+
return Message(code=MessageCode.NO_DEFAULT_BRANCH, text="Project has no default branch.")
79+
80+
81+
def no_production(environment: str, branch: str) -> Message:
82+
return Message(
83+
code=MessageCode.NO_PRODUCTION,
84+
params={"environment": environment, "branch": branch},
85+
text=f"No successful deployment to '{environment}'; rows run from the first commit of {branch}.",
86+
)
87+
88+
89+
def commits_truncated(max_commits: int) -> Message:
90+
return Message(
91+
code=MessageCode.COMMITS_TRUNCATED,
92+
params={"max_commits": max_commits},
93+
text=f"Stopped after {max_commits} commits; older changes are omitted.",
94+
)
95+
96+
97+
def tags_truncated() -> Message:
98+
return Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.")
99+
100+
101+
def merged_elsewhere(iid: int, target_branch: str, default_branch: str) -> Message:
102+
return Message(
103+
code=MessageCode.MERGED_ELSEWHERE,
104+
params={"iid": iid, "target_branch": target_branch, "default_branch": default_branch},
105+
text=f"!{iid} was merged into {target_branch}, not {default_branch}.",
106+
)
107+
108+
109+
def _jira_target(issue: str | None) -> tuple[dict[str, str], str]:
110+
if issue is None:
111+
return {}, "the issue search"
112+
return {"issue": issue}, f"the remote links of {issue}"
113+
114+
115+
def jira_token_rejected() -> Message:
116+
return Message(
117+
code=MessageCode.JIRA_TOKEN_REJECTED,
118+
text="Jira rejected the token (401). Check that it is valid and not expired.",
119+
)
120+
121+
122+
def jira_status(status: int, detail: str, issue: str | None) -> Message:
123+
params, target = _jira_target(issue)
124+
suffix: typing.Final = f": {detail}." if detail else "."
125+
return Message(
126+
code=MessageCode.JIRA_STATUS,
127+
params={"status": status, "detail": detail, **params},
128+
text=f"Jira returned {status} for {target}{suffix}",
129+
)
130+
131+
132+
def jira_unreachable(reason: str, issue: str | None) -> Message:
133+
params, target = _jira_target(issue)
134+
return Message(
135+
code=MessageCode.JIRA_UNREACHABLE,
136+
params={"reason": reason, **params},
137+
text=f"Jira request for {target} failed: {reason}.",
138+
)

‎release_scope/_report.py‎

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
1-
import datetime
1+
import datetime as dt
2+
import enum
23
import typing
34

45
import pydantic
56

67

7-
SCHEMA_VERSION: typing.Final = 3
8+
SCHEMA_VERSION: typing.Final = 4
9+
10+
11+
class MessageCode(enum.StrEnum):
12+
CI_DISABLED = "ci_disabled"
13+
ENVIRONMENTS_DISABLED = "environments_disabled"
14+
NO_DEFAULT_BRANCH = "no_default_branch"
15+
NO_PRODUCTION = "no_production"
16+
COMMITS_TRUNCATED = "commits_truncated"
17+
TAGS_TRUNCATED = "tags_truncated"
18+
MERGED_ELSEWHERE = "merged_elsewhere"
19+
GITLAB_UNREACHABLE = "gitlab_unreachable"
20+
GITLAB_STATUS = "gitlab_status"
21+
GITLAB_DENIED = "gitlab_denied"
22+
JIRA_TOKEN_REJECTED = "jira_token_rejected"
23+
JIRA_STATUS = "jira_status"
24+
JIRA_UNREACHABLE = "jira_unreachable"
25+
26+
27+
class Message(pydantic.BaseModel):
28+
code: MessageCode
29+
params: dict[str, str | int | list[str]] = pydantic.Field(default_factory=dict)
30+
text: str
831

932

1033
class FailedJob(pydantic.BaseModel):
@@ -45,7 +68,7 @@ class CommitRef(pydantic.BaseModel):
4568
title: str
4669
url: str | None
4770
author: str | None
48-
committed_at: datetime.datetime
71+
committed_at: dt.datetime
4972

5073

5174
class JiraKeyRef(pydantic.BaseModel):
@@ -95,8 +118,8 @@ class Service(pydantic.BaseModel):
95118
rows: list[Row] = pydantic.Field(default_factory=list)
96119
candidates: list[Candidate] = pydantic.Field(default_factory=list)
97120
truncated: bool = False
98-
warnings: list[str] = pydantic.Field(default_factory=list)
99-
error: str | None = None
121+
warnings: list[Message] = pydantic.Field(default_factory=list)
122+
error: Message | None = None
100123
release: Release | None = None
101124

102125

@@ -122,12 +145,12 @@ class JiraIssue(pydantic.BaseModel):
122145
class JiraState(pydantic.BaseModel):
123146
issues: dict[str, JiraIssue] = pydantic.Field(default_factory=dict)
124147
missing: list[str] = pydantic.Field(default_factory=list)
125-
error: str | None = None
148+
error: Message | None = None
126149

127150

128151
class Report(pydantic.BaseModel):
129-
schema_version: typing.Literal[3] = SCHEMA_VERSION
130-
collected_at: datetime.datetime
152+
schema_version: typing.Literal[4] = SCHEMA_VERSION
153+
collected_at: dt.datetime
131154
production_environment: str
132155
services: list[Service]
133156
jira: JiraState | None = None

0 commit comments

Comments
 (0)