Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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.
8 changes: 4 additions & 4 deletions release_scope/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
6 changes: 5 additions & 1 deletion release_scope/_errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import typing

from release_scope._report import Message


class ReleaseScopeError(Exception):
exit_code: typing.ClassVar[int] = 1
Expand All @@ -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
8 changes: 4 additions & 4 deletions release_scope/_gitlab.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import dataclasses
import datetime
import datetime as dt
import math
import typing
from urllib.parse import quote, unquote
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand All @@ -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()},
Expand Down
16 changes: 7 additions & 9 deletions release_scope/_jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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
Expand All @@ -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
125 changes: 108 additions & 17 deletions release_scope/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
)
39 changes: 31 additions & 8 deletions release_scope/_report.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down
Loading
Loading