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/_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/_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..74f7e2f 100644 --- a/release_scope/_report.py +++ b/release_scope/_report.py @@ -1,10 +1,33 @@ -import datetime +import datetime as dt +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): @@ -45,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): @@ -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,12 +145,12 @@ 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 - collected_at: datetime.datetime + schema_version: typing.Literal[4] = SCHEMA_VERSION + 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 380a5ee..402b73a 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 @@
@@ -326,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", @@ -372,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: "Изменение", @@ -406,7 +409,7 @@ allowedToFail: "(падать разрешено)", clearPick: "Сбросить выбор", release: "Релиз", - noCandidates: "Ни у одного сервиса нет тега между продом и головой основной ветки.", + noCandidates: "Ни у одного сервиса нет тега между продом и последним коммитом основной ветки.", pickToFill: "Выберите тег в сервисе выше, чтобы заполнить списки.", copyFailed: "Кнопка копирования работает только по HTTPS; выделите текст в поле.", tasks: "Задачи Jira ({count})", @@ -422,7 +425,51 @@ postTasks: "**Задачи Jira ({count})**", postNone: "- нет", postPipeline: "пайплайн #{id}", - legend: "Обозначения: ✅ успешно · ❌ упал · 🔄 выполняется · ⏭ отменён или пропущен · ⚠️ нет пайплайна", + legend: "Обозначения: ✅ прошёл · ❌ упал · 🔄 выполняется · ⏭ отменён или пропущен · ⚠️ нет пайплайна", + }, + }; + + const PERMISSIONS = "Visibility, project features, permissions"; + const RESOURCES_DATIVE_RU = { + project: "проекту", + deployments: "деплоям", + pipelines: "пайплайнам", + repository: "репозиторию", + merge_requests: "MR", + }; + 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, поэтому у него нет пайплайнов и деплоев. Включите 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}: не удалось получить ${resourceAccusativeRu(p)} из GitLab (${p.reason}).`, + gitlab_status: (p) => `${p.project}: не удалось получить ${resourceAccusativeRu(p)} из GitLab: ответ ${p.status}.`, + gitlab_denied: (p) => + [ + `${p.project}: GitLab отказал в доступе к ${resourceDativeRu(p)} (403). Проверьте, что:`, + ...p.features.map((feature) => `- включено «${feature}»: ${p.settings_url} → ${PERMISSIONS} → ${feature}`), + `- у владельца токена есть роль с доступом к ${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) => `Не удалось обратиться к Jira ${jiraTargetRu(p)}: ${p.reason}.`, }, }; @@ -479,6 +526,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; @@ -605,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 e32e80b..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 @@ -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( @@ -122,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, @@ -148,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, @@ -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( @@ -380,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 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