|
3 | 3 |
|
4 | 4 | from release_scope._errors import GitLabError |
5 | 5 | from release_scope._gitlab import Project |
| 6 | +from release_scope._report import Message, MessageCode |
6 | 7 |
|
7 | 8 |
|
8 | 9 | _RESOURCES: typing.Final[dict[str, tuple[str, tuple[str, ...]]]] = { |
| 10 | + "project": ("the project", ()), |
9 | 11 | "deployments": ("deployments", ("Environments", "CI/CD")), |
10 | 12 | "pipelines": ("pipelines", ("CI/CD",)), |
11 | 13 | "repository": ("the repository", ("Repository",)), |
12 | 14 | "merge_requests": ("merge requests", ("Merge requests",)), |
13 | 15 | } |
14 | 16 | _PLURAL_FEATURES: typing.Final = frozenset({"Environments", "Merge requests"}) |
| 17 | +_PERMISSIONS: typing.Final = "Visibility, project features, permissions" |
15 | 18 |
|
16 | 19 |
|
17 | | -def _feature_setting(project: Project, feature: str) -> str: |
18 | | - return f"{project.web_url}/edit#js-shared-permissions → Visibility, project features, permissions → {feature}" |
| 20 | +def _settings_url(web_url: str) -> str: |
| 21 | + return f"{web_url}/edit#js-shared-permissions" |
19 | 22 |
|
20 | 23 |
|
21 | | -def explain_failure(project: Project, error: GitLabError) -> str: |
22 | | - name: typing.Final = project.path_with_namespace |
23 | | - resource, features = _RESOURCES.get(error.resource, ("project data", ())) |
| 24 | +def explain_failure(name: str, web_url: str, error: GitLabError) -> Message: |
| 25 | + label, features = _RESOURCES.get(error.resource, ("project data", ())) |
24 | 26 | if error.status is None: |
25 | | - return f"{name}: GitLab request for {resource} failed ({error.reason})." |
| 27 | + return Message( |
| 28 | + code=MessageCode.GITLAB_UNREACHABLE, |
| 29 | + params={"project": name, "resource": error.resource, "reason": error.reason}, |
| 30 | + text=f"{name}: GitLab request for {label} failed ({error.reason}).", |
| 31 | + ) |
26 | 32 | if error.status != http.HTTPStatus.FORBIDDEN: |
27 | | - return f"{name}: GitLab returned {error.status} for {resource}." |
| 33 | + return Message( |
| 34 | + code=MessageCode.GITLAB_STATUS, |
| 35 | + params={"project": name, "resource": error.resource, "status": error.status}, |
| 36 | + text=f"{name}: GitLab returned {error.status} for {label}.", |
| 37 | + ) |
| 38 | + settings: typing.Final = _settings_url(web_url) |
| 39 | + members: typing.Final = f"{web_url}/-/project_members" |
28 | 40 | checks: typing.Final = [ |
29 | | - f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {_feature_setting(project, feature)}" |
| 41 | + f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {settings} → {_PERMISSIONS} → {feature}" |
30 | 42 | for feature in features |
31 | 43 | ] |
32 | | - checks.append(f"- the token's user has a role that can read them: {project.web_url}/-/project_members") |
33 | | - return "\n".join([f"{name}: GitLab denied access to {resource} (403). Check that:", *checks]) |
| 44 | + checks.append(f"- the token's user has a role that can read them: {members}") |
| 45 | + return Message( |
| 46 | + code=MessageCode.GITLAB_DENIED, |
| 47 | + params={ |
| 48 | + "project": name, |
| 49 | + "resource": error.resource, |
| 50 | + "features": list(features), |
| 51 | + "settings_url": settings, |
| 52 | + "members_url": members, |
| 53 | + }, |
| 54 | + text="\n".join([f"{name}: GitLab denied access to {label} (403). Check that:", *checks]), |
| 55 | + ) |
34 | 56 |
|
35 | 57 |
|
36 | | -def skip_reason(project: Project) -> str | None: |
| 58 | +def skip_reason(project: Project) -> Message | None: |
| 59 | + settings: typing.Final = _settings_url(project.web_url) |
37 | 60 | if project.builds_access_level == "disabled": |
38 | | - return ( |
39 | | - "CI/CD is disabled, so it has no pipelines or deployments. " |
40 | | - f"Enable it at {_feature_setting(project, 'CI/CD')}." |
| 61 | + return Message( |
| 62 | + code=MessageCode.CI_DISABLED, |
| 63 | + params={"settings_url": settings}, |
| 64 | + text="CI/CD is disabled, so it has no pipelines or deployments. " |
| 65 | + f"Enable it at {settings} → {_PERMISSIONS} → CI/CD.", |
41 | 66 | ) |
42 | 67 | if project.environments_access_level == "disabled": |
43 | | - return ( |
44 | | - "Environments are disabled, so it has no deployments. " |
45 | | - f"Enable them at {_feature_setting(project, 'Environments')}." |
| 68 | + return Message( |
| 69 | + code=MessageCode.ENVIRONMENTS_DISABLED, |
| 70 | + params={"settings_url": settings}, |
| 71 | + text="Environments are disabled, so it has no deployments. " |
| 72 | + f"Enable them at {settings} → {_PERMISSIONS} → Environments.", |
46 | 73 | ) |
47 | 74 | return None |
| 75 | + |
| 76 | + |
| 77 | +def no_default_branch() -> Message: |
| 78 | + return Message(code=MessageCode.NO_DEFAULT_BRANCH, text="Project has no default branch.") |
| 79 | + |
| 80 | + |
| 81 | +def no_production(environment: str, branch: str) -> Message: |
| 82 | + return Message( |
| 83 | + code=MessageCode.NO_PRODUCTION, |
| 84 | + params={"environment": environment, "branch": branch}, |
| 85 | + text=f"No successful deployment to '{environment}'; rows run from the first commit of {branch}.", |
| 86 | + ) |
| 87 | + |
| 88 | + |
| 89 | +def commits_truncated(max_commits: int) -> Message: |
| 90 | + return Message( |
| 91 | + code=MessageCode.COMMITS_TRUNCATED, |
| 92 | + params={"max_commits": max_commits}, |
| 93 | + text=f"Stopped after {max_commits} commits; older changes are omitted.", |
| 94 | + ) |
| 95 | + |
| 96 | + |
| 97 | +def tags_truncated() -> Message: |
| 98 | + return Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.") |
| 99 | + |
| 100 | + |
| 101 | +def merged_elsewhere(iid: int, target_branch: str, default_branch: str) -> Message: |
| 102 | + return Message( |
| 103 | + code=MessageCode.MERGED_ELSEWHERE, |
| 104 | + params={"iid": iid, "target_branch": target_branch, "default_branch": default_branch}, |
| 105 | + text=f"!{iid} was merged into {target_branch}, not {default_branch}.", |
| 106 | + ) |
| 107 | + |
| 108 | + |
| 109 | +def _jira_target(issue: str | None) -> tuple[dict[str, str], str]: |
| 110 | + if issue is None: |
| 111 | + return {}, "the issue search" |
| 112 | + return {"issue": issue}, f"the remote links of {issue}" |
| 113 | + |
| 114 | + |
| 115 | +def jira_token_rejected() -> Message: |
| 116 | + return Message( |
| 117 | + code=MessageCode.JIRA_TOKEN_REJECTED, |
| 118 | + text="Jira rejected the token (401). Check that it is valid and not expired.", |
| 119 | + ) |
| 120 | + |
| 121 | + |
| 122 | +def jira_status(status: int, detail: str, issue: str | None) -> Message: |
| 123 | + params, target = _jira_target(issue) |
| 124 | + suffix: typing.Final = f": {detail}." if detail else "." |
| 125 | + return Message( |
| 126 | + code=MessageCode.JIRA_STATUS, |
| 127 | + params={"status": status, "detail": detail, **params}, |
| 128 | + text=f"Jira returned {status} for {target}{suffix}", |
| 129 | + ) |
| 130 | + |
| 131 | + |
| 132 | +def jira_unreachable(reason: str, issue: str | None) -> Message: |
| 133 | + params, target = _jira_target(issue) |
| 134 | + return Message( |
| 135 | + code=MessageCode.JIRA_UNREACHABLE, |
| 136 | + params={"reason": reason, **params}, |
| 137 | + text=f"Jira request for {target} failed: {reason}.", |
| 138 | + ) |
0 commit comments