From a4dbb7149cc98d4b3d2862b6cedebc3921651a86 Mon Sep 17 00:00:00 2001
From: Artur Shiriev
Date: Fri, 9 Oct 2026 22:57:11 +0300
Subject: [PATCH 1/2] 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.
---
README.md | 8 +-
release_scope/__main__.py | 8 +-
release_scope/_errors.py | 6 +-
release_scope/_jira.py | 16 ++--
release_scope/_messages.py | 125 ++++++++++++++++++++++++++-----
release_scope/_report.py | 33 ++++++--
release_scope/_static/index.html | 51 +++++++++++--
release_scope/_use_case.py | 45 ++++++-----
skills/release-scope/SKILL.md | 11 +--
tests/test_cli.py | 4 +-
tests/test_page.py | 32 ++++++++
tests/test_scope.py | 23 ++++--
tests/test_use_case.py | 67 +++++++++++++----
13 files changed, 340 insertions(+), 89 deletions(-)
create mode 100644 tests/test_page.py
diff --git a/README.md b/README.md
index 893a19e..c997118 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,7 @@ The report is versioned by `schema_version`; the models live in
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category,
issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed.
+Warnings and errors, on services and in `jira`, are messages: a `code`, its `params`, and the English `text`.
Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
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
Switches at the top pick the language, English or Russian, and the theme: as in the system, light, or dark. The page
starts in the browser's language and remembers both choices in the browser. The copied lists and release post follow
-the language; warnings and errors from `collect`, and texts from GitLab and Jira, stay as written.
+the language, and so do warnings and errors from `collect`; texts from GitLab and Jira, such as titles, statuses and
+Jira's own error details, stay as written.
**Services** lists every service with a production deployment or with rows, and every service that failed to collect,
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:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- - uvx --from 'release-scope>=0.5,<0.6' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
+ - uvx --from 'release-scope>=0.6,<0.7' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
pages: true
```
@@ -189,4 +191,4 @@ npx skills add modern-python/release-scope
```
The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
-The skill runs `release-scope>=0.5,<0.6`, the range whose flags and report schema it describes.
+The skill runs `release-scope>=0.6,<0.7`, the range whose flags and report schema it describes.
diff --git a/release_scope/__main__.py b/release_scope/__main__.py
index 9ef3611..fa8d669 100644
--- a/release_scope/__main__.py
+++ b/release_scope/__main__.py
@@ -97,11 +97,11 @@ def _collect_command( # noqa: PLR0913, PLR0917
write_site(output, report.model_dump_json(indent=2))
if cache_path:
cache.save(cache_path)
- failed: typing.Final = [service for service in report.services if service.error]
+ failed: typing.Final = [service.error for service in report.services if service.error]
rows: typing.Final = sum(len(service.rows) for service in report.services)
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
- for service in failed:
- typer.echo(f"Error: {service.error}", err=True)
+ for error in failed:
+ typer.echo(f"Error: {error.text}", err=True)
jira_errors: typing.Final = _jira_errors(report)
for message in jira_errors:
typer.echo(f"Error: {message}", err=True)
@@ -129,7 +129,7 @@ def _jira_errors(report: Report) -> list[str]:
return []
errors: typing.Final = [f"Jira has no issue {key}." for key in report.jira_scope if key in report.jira.missing]
if report.jira.error:
- errors.append(report.jira.error)
+ errors.append(report.jira.error.text)
return errors
diff --git a/release_scope/_errors.py b/release_scope/_errors.py
index d96ae7d..9590ce2 100644
--- a/release_scope/_errors.py
+++ b/release_scope/_errors.py
@@ -1,5 +1,7 @@
import typing
+from release_scope._report import Message
+
class ReleaseScopeError(Exception):
exit_code: typing.ClassVar[int] = 1
@@ -24,4 +26,6 @@ def __init__(self, message: str, *, resource: str, status: int | None = None, re
class JiraError(ReleaseScopeError):
- pass
+ def __init__(self, message: Message) -> None:
+ super().__init__(message.text)
+ self.message = message
diff --git a/release_scope/_jira.py b/release_scope/_jira.py
index e33a14c..e4d717e 100644
--- a/release_scope/_jira.py
+++ b/release_scope/_jira.py
@@ -6,6 +6,7 @@
import pydantic
from release_scope._errors import JiraError
+from release_scope._messages import jira_status, jira_token_rejected, jira_unreachable
_SEARCH: typing.Final = "/rest/api/2/search"
@@ -66,15 +67,12 @@ def _error_messages(exc: httpware.StatusError) -> str:
return " ".join(str(message) for message in messages)
-def _translate(exc: httpware.ClientError, *, target: str) -> JiraError:
+def _translate(exc: httpware.ClientError, *, issue: str | None) -> JiraError:
if isinstance(exc, httpware.UnauthorizedError):
- return JiraError("Jira rejected the token (401). Check that it is valid and not expired.")
+ return JiraError(jira_token_rejected())
if isinstance(exc, httpware.StatusError):
- status: typing.Final = exc.response.status_code
- details: typing.Final = _error_messages(exc)
- suffix: typing.Final = f": {details.rstrip('.')}." if details else "."
- return JiraError(f"Jira returned {status} for {target}{suffix}")
- return JiraError(f"Jira request for {target} failed: {type(exc).__name__}.")
+ return JiraError(jira_status(exc.response.status_code, _error_messages(exc).rstrip("."), issue))
+ return JiraError(jira_unreachable(type(exc).__name__, issue))
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]:
try:
page = self.http.post(_SEARCH, json=body, response_model=_SearchResults)
except httpware.ClientError as exc:
- raise _translate(exc, target="the issue search") from exc
+ raise _translate(exc, issue=None) from exc
issues.extend(page.issues)
if not page.issues or len(issues) >= page.total:
return issues
@@ -115,4 +113,4 @@ def remote_links(self, key: str) -> list[RemoteLink]:
try:
return self.http.get(f"{_ISSUE}/{key}/remotelink", response_model=_RemoteLinks).root
except httpware.ClientError as exc:
- raise _translate(exc, target=f"the remote links of {key}") from exc
+ raise _translate(exc, issue=key) from exc
diff --git a/release_scope/_messages.py b/release_scope/_messages.py
index 449e12c..6e59be4 100644
--- a/release_scope/_messages.py
+++ b/release_scope/_messages.py
@@ -3,45 +3,136 @@
from release_scope._errors import GitLabError
from release_scope._gitlab import Project
+from release_scope._report import Message, MessageCode
_RESOURCES: typing.Final[dict[str, tuple[str, tuple[str, ...]]]] = {
+ "project": ("the project", ()),
"deployments": ("deployments", ("Environments", "CI/CD")),
"pipelines": ("pipelines", ("CI/CD",)),
"repository": ("the repository", ("Repository",)),
"merge_requests": ("merge requests", ("Merge requests",)),
}
_PLURAL_FEATURES: typing.Final = frozenset({"Environments", "Merge requests"})
+_PERMISSIONS: typing.Final = "Visibility, project features, permissions"
-def _feature_setting(project: Project, feature: str) -> str:
- return f"{project.web_url}/edit#js-shared-permissions → Visibility, project features, permissions → {feature}"
+def _settings_url(web_url: str) -> str:
+ return f"{web_url}/edit#js-shared-permissions"
-def explain_failure(project: Project, error: GitLabError) -> str:
- name: typing.Final = project.path_with_namespace
- resource, features = _RESOURCES.get(error.resource, ("project data", ()))
+def explain_failure(name: str, web_url: str, error: GitLabError) -> Message:
+ label, features = _RESOURCES.get(error.resource, ("project data", ()))
if error.status is None:
- return f"{name}: GitLab request for {resource} failed ({error.reason})."
+ return Message(
+ code=MessageCode.GITLAB_UNREACHABLE,
+ params={"project": name, "resource": error.resource, "reason": error.reason},
+ text=f"{name}: GitLab request for {label} failed ({error.reason}).",
+ )
if error.status != http.HTTPStatus.FORBIDDEN:
- return f"{name}: GitLab returned {error.status} for {resource}."
+ return Message(
+ code=MessageCode.GITLAB_STATUS,
+ params={"project": name, "resource": error.resource, "status": error.status},
+ text=f"{name}: GitLab returned {error.status} for {label}.",
+ )
+ settings: typing.Final = _settings_url(web_url)
+ members: typing.Final = f"{web_url}/-/project_members"
checks: typing.Final = [
- f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {_feature_setting(project, feature)}"
+ f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {settings} → {_PERMISSIONS} → {feature}"
for feature in features
]
- checks.append(f"- the token's user has a role that can read them: {project.web_url}/-/project_members")
- return "\n".join([f"{name}: GitLab denied access to {resource} (403). Check that:", *checks])
+ checks.append(f"- the token's user has a role that can read them: {members}")
+ return Message(
+ code=MessageCode.GITLAB_DENIED,
+ params={
+ "project": name,
+ "resource": error.resource,
+ "features": list(features),
+ "settings_url": settings,
+ "members_url": members,
+ },
+ text="\n".join([f"{name}: GitLab denied access to {label} (403). Check that:", *checks]),
+ )
-def skip_reason(project: Project) -> str | None:
+def skip_reason(project: Project) -> Message | None:
+ settings: typing.Final = _settings_url(project.web_url)
if project.builds_access_level == "disabled":
- return (
- "CI/CD is disabled, so it has no pipelines or deployments. "
- f"Enable it at {_feature_setting(project, 'CI/CD')}."
+ return Message(
+ code=MessageCode.CI_DISABLED,
+ params={"settings_url": settings},
+ text="CI/CD is disabled, so it has no pipelines or deployments. "
+ f"Enable it at {settings} → {_PERMISSIONS} → CI/CD.",
)
if project.environments_access_level == "disabled":
- return (
- "Environments are disabled, so it has no deployments. "
- f"Enable them at {_feature_setting(project, 'Environments')}."
+ return Message(
+ code=MessageCode.ENVIRONMENTS_DISABLED,
+ params={"settings_url": settings},
+ text="Environments are disabled, so it has no deployments. "
+ f"Enable them at {settings} → {_PERMISSIONS} → Environments.",
)
return None
+
+
+def no_default_branch() -> Message:
+ return Message(code=MessageCode.NO_DEFAULT_BRANCH, text="Project has no default branch.")
+
+
+def no_production(environment: str, branch: str) -> Message:
+ return Message(
+ code=MessageCode.NO_PRODUCTION,
+ params={"environment": environment, "branch": branch},
+ text=f"No successful deployment to '{environment}'; rows run from the first commit of {branch}.",
+ )
+
+
+def commits_truncated(max_commits: int) -> Message:
+ return Message(
+ code=MessageCode.COMMITS_TRUNCATED,
+ params={"max_commits": max_commits},
+ text=f"Stopped after {max_commits} commits; older changes are omitted.",
+ )
+
+
+def tags_truncated() -> Message:
+ return Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.")
+
+
+def merged_elsewhere(iid: int, target_branch: str, default_branch: str) -> Message:
+ return Message(
+ code=MessageCode.MERGED_ELSEWHERE,
+ params={"iid": iid, "target_branch": target_branch, "default_branch": default_branch},
+ text=f"!{iid} was merged into {target_branch}, not {default_branch}.",
+ )
+
+
+def _jira_target(issue: str | None) -> tuple[dict[str, str], str]:
+ if issue is None:
+ return {}, "the issue search"
+ return {"issue": issue}, f"the remote links of {issue}"
+
+
+def jira_token_rejected() -> Message:
+ return Message(
+ code=MessageCode.JIRA_TOKEN_REJECTED,
+ text="Jira rejected the token (401). Check that it is valid and not expired.",
+ )
+
+
+def jira_status(status: int, detail: str, issue: str | None) -> Message:
+ params, target = _jira_target(issue)
+ suffix: typing.Final = f": {detail}." if detail else "."
+ return Message(
+ code=MessageCode.JIRA_STATUS,
+ params={"status": status, "detail": detail, **params},
+ text=f"Jira returned {status} for {target}{suffix}",
+ )
+
+
+def jira_unreachable(reason: str, issue: str | None) -> Message:
+ params, target = _jira_target(issue)
+ return Message(
+ code=MessageCode.JIRA_UNREACHABLE,
+ params={"reason": reason, **params},
+ text=f"Jira request for {target} failed: {reason}.",
+ )
diff --git a/release_scope/_report.py b/release_scope/_report.py
index 60fdbaf..b767cdb 100644
--- a/release_scope/_report.py
+++ b/release_scope/_report.py
@@ -1,10 +1,33 @@
import datetime
+import enum
import typing
import pydantic
-SCHEMA_VERSION: typing.Final = 3
+SCHEMA_VERSION: typing.Final = 4
+
+
+class MessageCode(enum.StrEnum):
+ CI_DISABLED = "ci_disabled"
+ ENVIRONMENTS_DISABLED = "environments_disabled"
+ NO_DEFAULT_BRANCH = "no_default_branch"
+ NO_PRODUCTION = "no_production"
+ COMMITS_TRUNCATED = "commits_truncated"
+ TAGS_TRUNCATED = "tags_truncated"
+ MERGED_ELSEWHERE = "merged_elsewhere"
+ GITLAB_UNREACHABLE = "gitlab_unreachable"
+ GITLAB_STATUS = "gitlab_status"
+ GITLAB_DENIED = "gitlab_denied"
+ JIRA_TOKEN_REJECTED = "jira_token_rejected"
+ JIRA_STATUS = "jira_status"
+ JIRA_UNREACHABLE = "jira_unreachable"
+
+
+class Message(pydantic.BaseModel):
+ code: MessageCode
+ params: dict[str, str | int | list[str]] = pydantic.Field(default_factory=dict)
+ text: str
class FailedJob(pydantic.BaseModel):
@@ -95,8 +118,8 @@ class Service(pydantic.BaseModel):
rows: list[Row] = pydantic.Field(default_factory=list)
candidates: list[Candidate] = pydantic.Field(default_factory=list)
truncated: bool = False
- warnings: list[str] = pydantic.Field(default_factory=list)
- error: str | None = None
+ warnings: list[Message] = pydantic.Field(default_factory=list)
+ error: Message | None = None
release: Release | None = None
@@ -122,11 +145,11 @@ class JiraIssue(pydantic.BaseModel):
class JiraState(pydantic.BaseModel):
issues: dict[str, JiraIssue] = pydantic.Field(default_factory=dict)
missing: list[str] = pydantic.Field(default_factory=list)
- error: str | None = None
+ error: Message | None = None
class Report(pydantic.BaseModel):
- schema_version: typing.Literal[3] = SCHEMA_VERSION
+ schema_version: typing.Literal[4] = SCHEMA_VERSION
collected_at: datetime.datetime
production_environment: str
services: list[Service]
diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html
index 380a5ee..3588d61 100644
--- a/release_scope/_static/index.html
+++ b/release_scope/_static/index.html
@@ -55,6 +55,7 @@
.muted { color: var(--muted); }
.bad { color: var(--bad); }
.warn { color: var(--warn); }
+ .message { white-space: pre-line; }
.scroll { overflow-x: auto; }
.outputs { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; margin-top: 16px; }
.outputs .wide { grid-column: 1 / -1; }
@@ -114,8 +115,8 @@
-
+
+
@@ -142,9 +143,9 @@
·
-
-
-
+
+
+
@@ -426,6 +427,42 @@
},
};
+ const PERMISSIONS = "Visibility, project features, permissions";
+ const RESOURCES_RU = {
+ project: "проекта",
+ deployments: "деплоев",
+ pipelines: "пайплайнов",
+ repository: "репозитория",
+ merge_requests: "MR",
+ };
+ const resourceRu = (p) => RESOURCES_RU[p.resource] ?? "данных проекта";
+ const jiraTargetRu = (p) => (p.issue ? `удалённых ссылок ${p.issue}` : "поиска задач");
+ const MESSAGES = {
+ ru: {
+ ci_disabled: (p) =>
+ `CI/CD выключен, поэтому пайплайнов и деплоев нет. Включите его: ${p.settings_url} → ${PERMISSIONS} → CI/CD.`,
+ environments_disabled: (p) =>
+ `Environments выключены, поэтому деплоев нет. Включите их: ${p.settings_url} → ${PERMISSIONS} → Environments.`,
+ no_default_branch: () => "У проекта нет основной ветки.",
+ no_production: (p) =>
+ `Нет успешного деплоя на «${p.environment}»; строки идут от первого коммита ${p.branch}.`,
+ commits_truncated: (p) => `Остановились на ${p.max_commits} коммитах; более старые изменения не показаны.`,
+ tags_truncated: () => "Список тегов обрезан; у части строк теги могут не показываться.",
+ merged_elsewhere: (p) => `!${p.iid} влит в ${p.target_branch}, а не в ${p.default_branch}.`,
+ gitlab_unreachable: (p) => `${p.project}: запрос ${resourceRu(p)} к GitLab не удался (${p.reason}).`,
+ gitlab_status: (p) => `${p.project}: GitLab вернул ${p.status} на запрос ${resourceRu(p)}.`,
+ gitlab_denied: (p) =>
+ [
+ `${p.project}: GitLab запретил запрос ${resourceRu(p)} (403). Проверьте, что:`,
+ ...p.features.map((feature) => `- включено «${feature}»: ${p.settings_url} → ${PERMISSIONS} → ${feature}`),
+ `- у пользователя токена есть роль с доступом к ним: ${p.members_url}`,
+ ].join("\n"),
+ jira_token_rejected: () => "Jira отклонила токен (401). Проверьте, что он действителен и не истёк.",
+ jira_status: (p) => `Jira вернула ${p.status} на запрос ${jiraTargetRu(p)}${p.detail ? `: ${p.detail}.` : "."}`,
+ jira_unreachable: (p) => `Запрос ${jiraTargetRu(p)} к Jira не удался: ${p.reason}.`,
+ },
+ };
+
const PLURALS = Object.fromEntries(Object.keys(STRINGS).map((lang) => [lang, new Intl.PluralRules(lang)]));
const stored = (key) => {
@@ -479,6 +516,10 @@
}
return text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? params[name] : match));
},
+ message(item) {
+ const render = MESSAGES[this.lang] && MESSAGES[this.lang][item.code];
+ return render ? render(item.params) : item.text;
+ },
setLang(lang) {
this.lang = lang;
document.documentElement.lang = lang;
diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py
index e32e80b..d199cb0 100644
--- a/release_scope/_use_case.py
+++ b/release_scope/_use_case.py
@@ -12,7 +12,15 @@
from release_scope._jira import JiraApi
from release_scope._jira_keys import extract_jira_keys
from release_scope._links import parse_gitlab_link
-from release_scope._messages import explain_failure, skip_reason
+from release_scope._messages import (
+ commits_truncated,
+ explain_failure,
+ merged_elsewhere,
+ no_default_branch,
+ no_production,
+ skip_reason,
+ tags_truncated,
+)
from release_scope._report import (
CommitRef,
EnvironmentState,
@@ -22,6 +30,7 @@
JiraState,
LinkedChange,
MergeRequestRef,
+ Message,
PipelineState,
Release,
Report,
@@ -95,7 +104,7 @@ class _LinkTarget:
merged: set[int]
shas: list[str]
pending: list[MergeRequestRef]
- warnings: list[str]
+ warnings: list[Message]
def matches(self, draft: RowDraft) -> bool:
return any(item.iid in self.merged for item in draft.merge_requests) or any(
@@ -159,7 +168,8 @@ def _scoped_service(self, path: str, links: list[LinkedChange], cache: Cache) ->
try:
project: typing.Final = self.api.get_project(path)
except GitLabError as exc:
- return Service(project=path, project_url=links[0].project_url, error=f"{path}: {exc}")
+ project_url: typing.Final = links[0].project_url
+ return Service(project=path, project_url=project_url, error=explain_failure(path, project_url, exc))
return self._collect_or_explain(project, cache, links=links)
def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[LinkedChange] | None) -> Service:
@@ -168,7 +178,9 @@ def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[Lin
except GitLabError as exc:
cache.keep_project(project.id)
return Service(
- project=project.path_with_namespace, project_url=project.web_url, error=explain_failure(project, exc)
+ project=project.path_with_namespace,
+ project_url=project.web_url,
+ error=explain_failure(project.path_with_namespace, project.web_url, exc),
)
def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> JiraState:
@@ -178,7 +190,7 @@ def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> Ji
try:
issues: typing.Final = {issue.key: issue for issue in jira.search_issues(keys)}
except JiraError as exc:
- state.error = str(exc)
+ state.error = exc.message
return state
for key in keys:
issue = issues.get(key)
@@ -198,7 +210,7 @@ def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> Ji
for key, issue in state.issues.items():
issue.links = self._linked_changes(jira, key)
except JiraError as exc:
- state.error = str(exc)
+ state.error = exc.message
return state
def _linked_changes(self, jira: JiraApi, key: str) -> list[LinkedChange]:
@@ -248,13 +260,10 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked
(item for item in service.environments if item.name == self.settings.production_environment), None
)
if project.default_branch is None:
- service.warnings.append("Project has no default branch.")
+ service.warnings.append(no_default_branch())
return service
if production is None:
- service.warnings.append(
- f"No successful deployment to '{self.settings.production_environment}'; "
- f"rows run from the first commit of {project.default_branch}."
- )
+ service.warnings.append(no_production(self.settings.production_environment, project.default_branch))
walk: typing.Final = self._walk(
project, project.default_branch, production.sha if production else None, service, cache
@@ -287,14 +296,14 @@ def _walk(
walk: typing.Final = _Walk(truncated=truncated)
service.truncated = truncated
if truncated:
- service.warnings.append(f"Stopped after {self.settings.max_commits} commits; older changes are omitted.")
+ service.warnings.append(commits_truncated(self.settings.max_commits))
if not commits:
return walk
since: typing.Final = min(commit.committed_date for commit in commits)
walk.drafts = group_rows(commits, self._commit_merge_requests(project, default_branch, commits, since, cache))
- tags, tags_truncated = self.api.list_tags(project.id)
- if tags_truncated:
- service.warnings.append("Tag list was truncated; some tags may be missing from rows.")
+ tags, tag_list_cut = self.api.list_tags(project.id)
+ if tag_list_cut:
+ service.warnings.append(tags_truncated())
for tag in tags:
walk.tags_by_sha.setdefault(tag.commit.id, []).append(tag.name)
walk.main_pipelines = self._latest_by_sha(
@@ -330,7 +339,7 @@ def _link_target(
) -> _LinkTarget:
merged: typing.Final[set[int]] = set()
pending: typing.Final[list[MergeRequestRef]] = []
- warnings: typing.Final[list[str]] = []
+ warnings: typing.Final[list[Message]] = []
for link in links:
if link.iid is None:
continue
@@ -338,9 +347,7 @@ def _link_target(
if merge_request.state != "merged":
pending.append(_merge_request_ref(merge_request))
elif merge_request.target_branch != default_branch:
- warnings.append(
- f"!{merge_request.iid} was merged into {merge_request.target_branch}, not {default_branch}."
- )
+ warnings.append(merged_elsewhere(merge_request.iid, merge_request.target_branch, default_branch))
else:
merged.add(merge_request.iid)
return _LinkTarget(
diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md
index 4a64f14..501225b 100644
--- a/skills/release-scope/SKILL.md
+++ b/skills/release-scope/SKILL.md
@@ -14,7 +14,7 @@ description: >
through uvx with this version range; it matches the flags and report schema described here:
```bash
-uvx --from 'release-scope>=0.5,<0.6' release-scope --help
+uvx --from 'release-scope>=0.6,<0.7' release-scope --help
```
## Check the settings
@@ -56,7 +56,7 @@ run and only saves requests:
```bash
out="${XDG_CACHE_HOME:-$HOME/.cache}/release-scope"
mkdir -p "$out"
-uvx --from 'release-scope>=0.5,<0.6' release-scope collect --project team/backend/shop \
+uvx --from 'release-scope>=0.6,<0.7' release-scope collect --project team/backend/shop \
--output "$out/site" --cache "$out/cache.json"
```
@@ -76,12 +76,13 @@ For `2` to `4` nothing was written; show the message and stop.
Read `$out/site/report.json` and answer the user's question from it, briefly.
- `services[]`: `project`, `environments` (what each environment runs), `rows` (newest first, from the default
- branch head down to production), `warnings`, `error`.
+ branch head down to production), `warnings`, `error`. Warnings and errors are messages: read their `text`;
+ `code` and `params` carry the same facts in structured form.
- A row is one merged merge request or a direct commit: `merge_requests`, `commits`, `tags` pointing into it,
`environments` already running it, `jira_keys`, and `main_pipeline` with `failed_jobs`. Tag pipelines carry their
own `failed_jobs`.
- A service with no rows is up to date with production.
-- A service never deployed to production has a warning saying so; its rows run down to the first commit of the
+- A service never deployed to production has a `no_production` warning; its rows run down to the first commit of the
default branch and its `compare_url` lists the tag's commits.
- `truncated: true`: the walk stopped at `RELEASE_SCOPE_MAX_COMMITS`, so the oldest candidates miss rows and keys.
- `candidates` (newest first): the tags a release could ship, each with `tag` and its `pipeline`, `rows` (how many
@@ -89,7 +90,7 @@ Read `$out/site/report.json` and answer the user's question from it, briefly.
`compare_url`.
To answer which issues a set of tags releases, merge the `jira_keys` of the picked candidates.
- `jira.issues` by key: `summary`, `status`, `status_category` (anything but `done` is not done), `links` to GitLab
- merge requests and commits. `jira.missing` lists keys Jira did not return; `jira.error` a failed request. `jira` is
+ merge requests and commits. `jira.missing` lists keys Jira did not return; `jira.error` a failed request, as a message. `jira` is
`null` without a Jira token.
- In a `--jira` report, `jira_scope` lists the issues, rows with `linked: true` are the ones the issues point to,
rows with `in_scope: false` sit above the latest linked change and do not ship with the issues, and each service
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4ad43a4..c918aa3 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -49,7 +49,7 @@ def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib
"report.json",
]
report: typing.Final = json.loads((output / "report.json").read_text())
- assert report["schema_version"] == 3
+ assert report["schema_version"] == 4
assert report["jira"] is None
assert report["production_environment"] == "production"
assert [len(item["rows"]) for item in report["services"]] == [5]
@@ -153,7 +153,7 @@ def test_collect_for_jira_issues_writes_a_scoped_report(tmp_path: pathlib.Path)
assert report["jira_scope"] == ["SHOP-12"]
assert [item["project"] for item in report["services"]] == ["team/svc", "team/web", "team/worker"]
assert json.loads(cache.read_text())["merge_requests"]["1"]["12"]["iid"] == 12
- assert "Error: team/web: GitLab returned 404" in result.output
+ assert "Error: team/web: GitLab returned 404 for the project." in result.output
@pytest.mark.usefixtures("cli_env", "jira_env", "scoped")
diff --git a/tests/test_page.py b/tests/test_page.py
new file mode 100644
index 0000000..7c5238d
--- /dev/null
+++ b/tests/test_page.py
@@ -0,0 +1,32 @@
+import importlib.resources
+import re
+import typing
+
+from release_scope._report import MessageCode
+
+
+_PAGE: typing.Final = importlib.resources.files("release_scope").joinpath("_static/index.html").read_text()
+
+
+def _block(start: str, end: str) -> str:
+ return _PAGE[_PAGE.index(start) : _PAGE.index(end)]
+
+
+def _keys(block: str, indent: int) -> set[str]:
+ return set(re.findall(rf"^ {{{indent}}}(\w+):", block, re.MULTILINE))
+
+
+def test_every_translation_key_exists_in_both_languages() -> None:
+ strings: typing.Final = _block("const STRINGS = {", "const PERMISSIONS")
+ english, russian = strings.split("\n ru: {")
+ used: typing.Final = set(re.findall(r"(? None:
+ russian: typing.Final = _block("const MESSAGES = {", "const PLURALS")
+
+ assert _keys(russian, 6) == {code.value for code in MessageCode}
diff --git a/tests/test_scope.py b/tests/test_scope.py
index 51dde78..d0c5491 100644
--- a/tests/test_scope.py
+++ b/tests/test_scope.py
@@ -8,7 +8,7 @@
from release_scope._cache import Cache
from release_scope._gitlab import GitLabApi
from release_scope._jira import JiraApi
-from release_scope._report import Report, Service
+from release_scope._report import Message, MessageCode, Report, Service
from release_scope._settings import GitLabConfig, Settings
from release_scope._use_case import CollectUseCase
from tests.payloads import (
@@ -52,9 +52,13 @@ def test_scope_collects_every_service_the_issues_link_to() -> None:
assert report.jira_scope == ["SHOP-12"]
assert [item.project for item in report.services] == ["team/svc", "team/web", "team/worker"]
- assert _service(report, "team/web").error == "team/web: GitLab returned 404 for /api/v4/projects/team/web."
+ assert _service(report, "team/web").error == Message(
+ code=MessageCode.GITLAB_STATUS,
+ params={"project": "team/web", "resource": "project", "status": 404},
+ text="team/web: GitLab returned 404 for the project.",
+ )
assert _service(report, "team/web").project_url == f"{ENDPOINT}/team/web"
- assert _service(report, "team/worker").warnings[0].startswith("CI/CD is disabled")
+ assert _service(report, "team/worker").warnings[0].code == MessageCode.CI_DISABLED
@pytest.mark.httpx2(assert_all_called=False)
@@ -139,7 +143,13 @@ def test_open_and_off_branch_merge_requests_are_reported(scoped: respx.Router) -
assert service.release is not None
assert service.release.state == "not_merged"
assert [item.iid for item in service.release.pending_merge_requests] == [13]
- assert service.warnings == ["!14 was merged into release/1.x, not main."]
+ assert service.warnings == [
+ Message(
+ code=MessageCode.MERGED_ELSEWHERE,
+ params={"iid": 14, "target_branch": "release/1.x", "default_branch": "main"},
+ text="!14 was merged into release/1.x, not main.",
+ )
+ ]
@pytest.mark.httpx2(assert_all_called=False)
@@ -198,6 +208,7 @@ def test_scope_stops_when_jira_fails(scoped: respx.Router) -> None:
assert report.services == []
assert report.jira is not None
assert report.jira.error is not None
+ assert report.jira.error.code == MessageCode.JIRA_TOKEN_REJECTED
@pytest.mark.httpx2(assert_all_called=False)
@@ -216,4 +227,6 @@ def test_a_failing_scoped_service_keeps_the_others(scoped: respx.Router) -> None
report: typing.Final = _scope()
- assert _service(report).error == "team/svc: GitLab returned 500 for merge requests."
+ error: typing.Final = _service(report).error
+ assert error is not None
+ assert error.text == "team/svc: GitLab returned 500 for merge requests."
diff --git a/tests/test_use_case.py b/tests/test_use_case.py
index ac54f1b..467e1f3 100644
--- a/tests/test_use_case.py
+++ b/tests/test_use_case.py
@@ -12,7 +12,7 @@
from release_scope._errors import AuthError, GitLabError
from release_scope._gitlab import GitLabApi
from release_scope._jira import JiraApi
-from release_scope._report import Report, Service
+from release_scope._report import Message, MessageCode, Report, Service
from release_scope._settings import GitLabConfig, Settings
from release_scope._use_case import CollectUseCase
from tests.payloads import (
@@ -266,7 +266,8 @@ def test_jira_failure_is_reported_and_services_are_kept(
report: typing.Final = _collect(with_jira=True)
assert report.jira is not None
- assert report.jira.error == error
+ assert report.jira.error is not None
+ assert report.jira.error.text == error
assert report.jira.issues == {}
assert len(_only_service(report).rows) == 5
@@ -293,7 +294,11 @@ def test_remote_link_failure_is_reported_and_keeps_the_issues(jira: respx.Router
state: typing.Final = _collect(with_jira=True).jira
assert state is not None
- assert state.error == "Jira returned 404 for the remote links of SHOP-12."
+ assert state.error == Message(
+ code=MessageCode.JIRA_STATUS,
+ params={"status": 404, "detail": "", "issue": "SHOP-12"},
+ text="Jira returned 404 for the remote links of SHOP-12.",
+ )
assert sorted(state.issues) == ["SHOP-12", "SHOP-9"]
@@ -337,7 +342,7 @@ def test_a_failing_service_keeps_its_cache_and_does_not_stop_the_others(gitlab:
report: typing.Final = _collect(next_cache)
- assert [(item.project, item.error) for item in report.services] == [
+ assert [(item.project, item.error and item.error.text) for item in report.services] == [
("team/broken", "team/broken: GitLab returned 500 for deployments."),
("team/svc", "team/svc: GitLab returned 502 for deployments."),
]
@@ -352,7 +357,19 @@ def test_forbidden_deployments_fail_only_that_service_and_say_where_to_look(gitl
report: typing.Final = _collect()
assert [item.project for item in report.services] == ["team/nodeploy", "team/svc"]
- assert report.services[0].error == (
+ error: typing.Final = report.services[0].error
+ assert error is not None
+ assert (error.code, error.params) == (
+ MessageCode.GITLAB_DENIED,
+ {
+ "project": "team/nodeploy",
+ "resource": "deployments",
+ "features": ["Environments", "CI/CD"],
+ "settings_url": settings,
+ "members_url": f"{ENDPOINT}/team/nodeploy/-/project_members",
+ },
+ )
+ assert error.text == (
"team/nodeploy: GitLab denied access to deployments (403). Check that:\n"
f"- Environments are enabled: {settings} → Visibility, project features, permissions → Environments\n"
f"- CI/CD is enabled: {settings} → Visibility, project features, permissions → CI/CD\n"
@@ -378,7 +395,8 @@ def test_forbidden_resource_lists_the_feature_that_guards_it(
error: typing.Final = _only_service(_collect()).error
- assert error == (
+ assert error is not None
+ assert error.text == (
f"team/svc: GitLab denied access to {resource} (403). Check that:\n"
f"- {enabled}: {settings} → Visibility, project features, permissions → {feature}\n"
f"- the token's user has a role that can read them: {ENDPOINT}/team/svc/-/project_members"
@@ -392,7 +410,9 @@ def test_forbidden_commit_lookup_points_at_merge_requests(gitlab: respx.Router)
error: typing.Final = _only_service(_collect()).error
assert error is not None
- assert error.startswith("team/svc: GitLab denied access to merge requests (403). Check that:\n- Merge requests are")
+ assert error.text.startswith(
+ "team/svc: GitLab denied access to merge requests (403). Check that:\n- Merge requests are"
+ )
def test_forbidden_project_stops_the_run(httpx2_mock: respx.Router) -> None:
@@ -406,7 +426,11 @@ def test_forbidden_project_stops_the_run(httpx2_mock: respx.Router) -> None:
def test_network_failure_names_the_project(gitlab: respx.Router) -> None:
gitlab["tags"].mock(side_effect=httpcore2.ConnectError("down"))
- assert _only_service(_collect()).error == "team/svc: GitLab request for the repository failed (NetworkError)."
+ assert _only_service(_collect()).error == Message(
+ code=MessageCode.GITLAB_UNREACHABLE,
+ params={"project": "team/svc", "resource": "repository", "reason": "NetworkError"},
+ text="team/svc: GitLab request for the repository failed (NetworkError).",
+ )
_LIB_FEATURES: typing.Final = (
@@ -435,7 +459,8 @@ def test_project_without_deployments_is_skipped_with_a_warning(
report: typing.Final = _collect()
skipped: typing.Final = report.services[0]
- assert (skipped.project, skipped.rows, skipped.error, skipped.warnings) == ("team/lib", [], None, [warning])
+ assert (skipped.project, skipped.rows, skipped.error) == ("team/lib", [], None)
+ assert [item.text for item in skipped.warnings] == [warning]
assert len(report.services[1].rows) == 5
@@ -487,7 +512,13 @@ def test_service_without_production_deployment_lists_the_whole_default_branch(gi
("1.2.0", 4, f"{ENDPOINT}/team/svc/-/commits/1.2.0"),
("1.1.0", 2, f"{ENDPOINT}/team/svc/-/commits/1.1.0"),
]
- assert service.warnings == ["No successful deployment to 'production'; rows run from the first commit of main."]
+ assert service.warnings == [
+ Message(
+ code=MessageCode.NO_PRODUCTION,
+ params={"environment": "production", "branch": "main"},
+ text="No successful deployment to 'production'; rows run from the first commit of main.",
+ )
+ ]
@pytest.mark.httpx2(assert_all_called=False)
@@ -497,7 +528,7 @@ def test_service_without_default_branch_has_no_rows(gitlab: respx.Router) -> Non
service: typing.Final = _only_service(_collect())
assert service.rows == []
- assert service.warnings == ["Project has no default branch."]
+ assert service.warnings == [Message(code=MessageCode.NO_DEFAULT_BRANCH, text="Project has no default branch.")]
@pytest.mark.httpx2(assert_all_called=False)
@@ -530,7 +561,13 @@ def test_long_range_is_truncated_with_a_warning(gitlab: respx.Router) -> None:
service: typing.Final = _only_service(_collect(max_commits=2))
assert [row.commits[0].sha for row in service.rows] == ["head", "c3"]
- assert service.warnings == ["Stopped after 2 commits; older changes are omitted."]
+ assert service.warnings == [
+ Message(
+ code=MessageCode.COMMITS_TRUNCATED,
+ params={"max_commits": 2},
+ text="Stopped after 2 commits; older changes are omitted.",
+ )
+ ]
assert service.truncated
assert gitlab["commits"].call_count == 1
@@ -540,7 +577,7 @@ def test_range_page_larger_than_the_limit_is_trimmed(gitlab: respx.Router) -> No
service: typing.Final = _only_service(_collect(max_commits=3))
assert [row.commits[0].sha for row in service.rows] == ["head", "c3", "c2"]
- assert service.warnings == ["Stopped after 3 commits; older changes are omitted."]
+ assert [item.text for item in service.warnings] == ["Stopped after 3 commits; older changes are omitted."]
assert gitlab["commits"].call_count == 1
@@ -550,7 +587,9 @@ def test_long_tag_list_is_truncated_with_a_warning(gitlab: respx.Router) -> None
service: typing.Final = _only_service(_collect())
- assert service.warnings == ["Tag list was truncated; some tags may be missing from rows."]
+ assert service.warnings == [
+ Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.")
+ ]
assert gitlab["tags"].call_count == 50
From 9031ec9d906c3f69725faa49db93fc0fdad8cebe Mon Sep 17 00:00:00 2001
From: Artur Shiriev
Date: Sat, 10 Oct 2026 10:58:21 +0300
Subject: [PATCH 2/2] Import datetime as dt for ruff 0.17 and fix Russian
wording
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
release_scope/_gitlab.py | 8 ++--
release_scope/_report.py | 6 +--
release_scope/_static/index.html | 65 +++++++++++++++++++-------------
release_scope/_use_case.py | 8 ++--
4 files changed, 49 insertions(+), 38 deletions(-)
diff --git a/release_scope/_gitlab.py b/release_scope/_gitlab.py
index 65585d2..98028e3 100644
--- a/release_scope/_gitlab.py
+++ b/release_scope/_gitlab.py
@@ -1,5 +1,5 @@
import dataclasses
-import datetime
+import datetime as dt
import math
import typing
from urllib.parse import quote, unquote
@@ -54,7 +54,7 @@ class Commit(pydantic.BaseModel):
title: str
message: str = ""
author_name: str | None = None
- committed_date: datetime.datetime
+ committed_date: dt.datetime
web_url: str | None = None
@@ -228,7 +228,7 @@ def list_first_parent_commits(
)
def list_merged_merge_requests(
- self, project_id: int, *, target_branch: str, updated_after: datetime.datetime
+ self, project_id: int, *, target_branch: str, updated_after: dt.datetime
) -> list[MergeRequest]:
merge_requests, _ = self._pages(
f"{_API}/projects/{project_id}/merge_requests",
@@ -252,7 +252,7 @@ def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]
)
return merge_requests
- def list_push_pipelines(self, project_id: int, *, ref: str, updated_after: datetime.datetime) -> list[Pipeline]:
+ def list_push_pipelines(self, project_id: int, *, ref: str, updated_after: dt.datetime) -> list[Pipeline]:
pipelines, _ = self._pages(
f"{_API}/projects/{project_id}/pipelines",
{"ref": ref, "source": "push", "updated_after": updated_after.isoformat()},
diff --git a/release_scope/_report.py b/release_scope/_report.py
index b767cdb..74f7e2f 100644
--- a/release_scope/_report.py
+++ b/release_scope/_report.py
@@ -1,4 +1,4 @@
-import datetime
+import datetime as dt
import enum
import typing
@@ -68,7 +68,7 @@ class CommitRef(pydantic.BaseModel):
title: str
url: str | None
author: str | None
- committed_at: datetime.datetime
+ committed_at: dt.datetime
class JiraKeyRef(pydantic.BaseModel):
@@ -150,7 +150,7 @@ class JiraState(pydantic.BaseModel):
class Report(pydantic.BaseModel):
schema_version: typing.Literal[4] = SCHEMA_VERSION
- collected_at: datetime.datetime
+ collected_at: dt.datetime
production_environment: str
services: list[Service]
jira: JiraState | None = None
diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html
index 3588d61..402b73a 100644
--- a/release_scope/_static/index.html
+++ b/release_scope/_static/index.html
@@ -327,6 +327,7 @@
noTagYet: "no tag yet",
upToDate: "up to date",
rows: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
+ rowsOf: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
taskCount: { one: "{count} task", other: "{count} tasks" },
shipsOf: "{shipped} of {rows}",
failedCount: "❌ {count} failed",
@@ -373,27 +374,28 @@
loadFailed: "Не удалось загрузить отчёт: {error}.",
title: "Состав релиза",
titleIssues: "Состав релиза: {keys}",
- rangeStart: "Собрано {at} UTC. Изменения считаются от коммита на",
- rangeEnd: "до головы основной ветки.",
+ rangeStart: "Данные собраны {at} UTC. Показаны изменения от коммита на",
+ rangeEnd: "до последнего коммита основной ветки.",
rangeEndIssues:
- "до головы основной ветки; строки выше последнего MR или коммита, связанного с задачами, вне скоупа.",
+ "до последнего коммита основной ветки; строки выше последнего MR или коммита, связанного с задачами, вне скоупа.",
notInJira: "нет в Jira",
services: "Сервисы",
servicesIntro:
- "Откройте сервис и выберите тег для релиза: в него войдут все MR и коммиты от его строки до прода. Выбор хранится в адресе страницы, так что им можно поделиться ссылкой.",
- ignored: "Из адреса пропущены теги, которых нет в этом отчёте: {picks}.",
+ "Откройте сервис и выберите тег для релиза: в релиз войдут все MR и коммиты от строки этого тега до прода. Выбор хранится в адресе страницы, так что им можно поделиться ссылкой.",
+ ignored: "В адресе есть выбор, которого нет в этом отчёте; он не учтён: {picks}.",
failedToCollect: "не удалось собрать",
notInProduction: "нет на проде",
pickTag: "выберите тег",
noTagYet: "тега ещё нет",
upToDate: "всё на проде",
rows: { one: "{count} MR или коммит", few: "{count} MR или коммита", many: "{count} MR или коммитов" },
+ rowsOf: { one: "{count} MR или коммита", few: "{count} MR или коммитов", many: "{count} MR или коммитов" },
taskCount: { one: "{count} задача", few: "{count} задачи", many: "{count} задач" },
shipsOf: "{shipped} из {rows}",
failedCount: "❌ упало: {count}",
allowedCount: "упало, но падать разрешено: {count}",
rangeCut: "диапазон обрезан",
- rangeCutTitle: "Старые MR и коммиты не собраны; подробности внутри",
+ rangeCutTitle: "Более ранние MR и коммиты не собраны; подробности внутри",
notMerged: "Не влиты:",
columnTag: "Тег",
columnChange: "Изменение",
@@ -407,7 +409,7 @@
allowedToFail: "(падать разрешено)",
clearPick: "Сбросить выбор",
release: "Релиз",
- noCandidates: "Ни у одного сервиса нет тега между продом и головой основной ветки.",
+ noCandidates: "Ни у одного сервиса нет тега между продом и последним коммитом основной ветки.",
pickToFill: "Выберите тег в сервисе выше, чтобы заполнить списки.",
copyFailed: "Кнопка копирования работает только по HTTPS; выделите текст в поле.",
tasks: "Задачи Jira ({count})",
@@ -423,43 +425,51 @@
postTasks: "**Задачи Jira ({count})**",
postNone: "- нет",
postPipeline: "пайплайн #{id}",
- legend: "Обозначения: ✅ успешно · ❌ упал · 🔄 выполняется · ⏭ отменён или пропущен · ⚠️ нет пайплайна",
+ legend: "Обозначения: ✅ прошёл · ❌ упал · 🔄 выполняется · ⏭ отменён или пропущен · ⚠️ нет пайплайна",
},
};
const PERMISSIONS = "Visibility, project features, permissions";
- const RESOURCES_RU = {
- project: "проекта",
- deployments: "деплоев",
- pipelines: "пайплайнов",
- repository: "репозитория",
+ const RESOURCES_DATIVE_RU = {
+ project: "проекту",
+ deployments: "деплоям",
+ pipelines: "пайплайнам",
+ repository: "репозиторию",
merge_requests: "MR",
};
- const resourceRu = (p) => RESOURCES_RU[p.resource] ?? "данных проекта";
- const jiraTargetRu = (p) => (p.issue ? `удалённых ссылок ${p.issue}` : "поиска задач");
+ const RESOURCES_ACCUSATIVE_RU = {
+ project: "проект",
+ deployments: "деплои",
+ pipelines: "пайплайны",
+ repository: "репозиторий",
+ merge_requests: "MR",
+ };
+ const resourceDativeRu = (p) => RESOURCES_DATIVE_RU[p.resource] ?? "данным проекта";
+ const resourceAccusativeRu = (p) => RESOURCES_ACCUSATIVE_RU[p.resource] ?? "данные проекта";
+ const jiraTargetRu = (p) => (p.issue ? `при чтении внешних ссылок задачи ${p.issue}` : "при поиске задач");
const MESSAGES = {
ru: {
ci_disabled: (p) =>
- `CI/CD выключен, поэтому пайплайнов и деплоев нет. Включите его: ${p.settings_url} → ${PERMISSIONS} → CI/CD.`,
+ `В проекте отключён CI/CD, поэтому у него нет пайплайнов и деплоев. Включите CI/CD: ${p.settings_url} → ${PERMISSIONS} → CI/CD.`,
environments_disabled: (p) =>
- `Environments выключены, поэтому деплоев нет. Включите их: ${p.settings_url} → ${PERMISSIONS} → Environments.`,
+ `В проекте отключены Environments, поэтому у него нет деплоев. Включите их: ${p.settings_url} → ${PERMISSIONS} → Environments.`,
no_default_branch: () => "У проекта нет основной ветки.",
no_production: (p) =>
- `Нет успешного деплоя на «${p.environment}»; строки идут от первого коммита ${p.branch}.`,
- commits_truncated: (p) => `Остановились на ${p.max_commits} коммитах; более старые изменения не показаны.`,
- tags_truncated: () => "Список тегов обрезан; у части строк теги могут не показываться.",
+ `Нет успешного деплоя на «${p.environment}»; строки начинаются с первого коммита ветки ${p.branch}.`,
+ commits_truncated: (p) => `Достигнут лимит коммитов (${p.max_commits}); более старые изменения не показаны.`,
+ tags_truncated: () => "Список тегов обрезан; у некоторых строк теги могут не отображаться.",
merged_elsewhere: (p) => `!${p.iid} влит в ${p.target_branch}, а не в ${p.default_branch}.`,
- gitlab_unreachable: (p) => `${p.project}: запрос ${resourceRu(p)} к GitLab не удался (${p.reason}).`,
- gitlab_status: (p) => `${p.project}: GitLab вернул ${p.status} на запрос ${resourceRu(p)}.`,
+ gitlab_unreachable: (p) => `${p.project}: не удалось получить ${resourceAccusativeRu(p)} из GitLab (${p.reason}).`,
+ gitlab_status: (p) => `${p.project}: не удалось получить ${resourceAccusativeRu(p)} из GitLab: ответ ${p.status}.`,
gitlab_denied: (p) =>
[
- `${p.project}: GitLab запретил запрос ${resourceRu(p)} (403). Проверьте, что:`,
+ `${p.project}: GitLab отказал в доступе к ${resourceDativeRu(p)} (403). Проверьте, что:`,
...p.features.map((feature) => `- включено «${feature}»: ${p.settings_url} → ${PERMISSIONS} → ${feature}`),
- `- у пользователя токена есть роль с доступом к ним: ${p.members_url}`,
+ `- у владельца токена есть роль с доступом к ${resourceDativeRu(p)}: ${p.members_url}`,
].join("\n"),
jira_token_rejected: () => "Jira отклонила токен (401). Проверьте, что он действителен и не истёк.",
- jira_status: (p) => `Jira вернула ${p.status} на запрос ${jiraTargetRu(p)}${p.detail ? `: ${p.detail}.` : "."}`,
- jira_unreachable: (p) => `Запрос ${jiraTargetRu(p)} к Jira не удался: ${p.reason}.`,
+ jira_status: (p) => `Jira вернула ${p.status} ${jiraTargetRu(p)}${p.detail ? `: ${p.detail}.` : "."}`,
+ jira_unreachable: (p) => `Не удалось обратиться к Jira ${jiraTargetRu(p)}: ${p.reason}.`,
},
};
@@ -646,7 +656,8 @@
const candidate = this.pickOf(service);
const rows = this.t("rows", { count: service.rows.length });
if (candidate) {
- const shipped = this.t("shipsOf", { shipped: candidate.rows, rows });
+ const of = this.t("rowsOf", { count: service.rows.length });
+ const shipped = this.t("shipsOf", { shipped: candidate.rows, rows: of });
return `${shipped} · ${this.t("taskCount", { count: candidate.jira_keys.length })}`;
}
if (!service.rows.length) return "";
diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py
index d199cb0..e9a7ea6 100644
--- a/release_scope/_use_case.py
+++ b/release_scope/_use_case.py
@@ -1,6 +1,6 @@
import collections.abc
import dataclasses
-import datetime
+import datetime as dt
import http
import typing
from urllib.parse import quote
@@ -131,7 +131,7 @@ def __call__(
for project in self._resolve_projects(groups=groups, projects=projects, include_subgroups=include_subgroups)
]
return Report(
- collected_at=datetime.datetime.now(datetime.UTC),
+ collected_at=dt.datetime.now(dt.UTC),
production_environment=self.settings.production_environment,
services=services,
jira=self._read_issues(self.jira, _row_keys(services)) if self.jira else None,
@@ -157,7 +157,7 @@ def for_issues(self, *, keys: collections.abc.Sequence[str], cache: Cache) -> Re
state.missing.extend(row_state.missing)
state.error = row_state.error
return Report(
- collected_at=datetime.datetime.now(datetime.UTC),
+ collected_at=dt.datetime.now(dt.UTC),
production_environment=self.settings.production_environment,
services=services,
jira=state,
@@ -387,7 +387,7 @@ def _release(self, project: Project, target: _LinkTarget, walk: _Walk, index: in
)
def _commit_merge_requests(
- self, project: Project, default_branch: str, commits: list[Commit], since: datetime.datetime, cache: Cache
+ self, project: Project, default_branch: str, commits: list[Commit], since: dt.datetime, cache: Cache
) -> dict[str, list[MergeRequest]]:
merged: typing.Final = self.api.list_merged_merge_requests(
project.id, target_branch=default_branch, updated_after=since