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 @@

·

-

-