|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Close Jira issues listed in releaseNotes after an advisory is published.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import re |
| 7 | +from pathlib import Path |
| 8 | +from typing import Any |
| 9 | + |
| 10 | +import advisory_data |
| 11 | +import authentication |
| 12 | +import file |
| 13 | +import http_client |
| 14 | +import requests |
| 15 | +import tekton |
| 16 | +from logger import logger |
| 17 | +from requests.auth import HTTPBasicAuth |
| 18 | + |
| 19 | +SUPPORTED_JIRA_SERVER = "redhat.atlassian.net" |
| 20 | +LEGACY_JIRA_SERVER = "issues.redhat.com" |
| 21 | + |
| 22 | +ISSUE_TRACKERS: dict[str, dict[str, Any]] = { |
| 23 | + "Jira": { |
| 24 | + "api": "rest/api/2/issue", |
| 25 | + "servers": [ |
| 26 | + LEGACY_JIRA_SERVER, |
| 27 | + "jira.atlassian.com", |
| 28 | + SUPPORTED_JIRA_SERVER, |
| 29 | + ], |
| 30 | + }, |
| 31 | + "bugzilla": { |
| 32 | + "api": "rest/bug", |
| 33 | + "servers": ["bugzilla.redhat.com"], |
| 34 | + }, |
| 35 | +} |
| 36 | + |
| 37 | +_VALID_JIRA_ISSUE_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_]+-\d+$|^\d+$") |
| 38 | + |
| 39 | + |
| 40 | +def normalize_issue_server(source: str) -> str: |
| 41 | + """Map legacy issue tracker hostnames to the current Jira server.""" |
| 42 | + if source == LEGACY_JIRA_SERVER: |
| 43 | + return SUPPORTED_JIRA_SERVER |
| 44 | + return source |
| 45 | + |
| 46 | + |
| 47 | +def is_jira_eligible_issue(issue: dict[str, Any]) -> bool: |
| 48 | + """Return whether *issue* will be processed against the Jira API.""" |
| 49 | + issue_id = issue.get("id") |
| 50 | + source = issue.get("source") |
| 51 | + if not isinstance(issue_id, str) or not issue_id.strip(): |
| 52 | + return False |
| 53 | + if not isinstance(source, str) or not source.strip(): |
| 54 | + return False |
| 55 | + server = normalize_issue_server(source.strip()) |
| 56 | + if server != SUPPORTED_JIRA_SERVER: |
| 57 | + return False |
| 58 | + return _VALID_JIRA_ISSUE_ID.fullmatch(issue_id.strip()) is not None |
| 59 | + |
| 60 | + |
| 61 | +def api_path_for_server(server: str) -> str: |
| 62 | + """Return the REST API prefix for *server*.""" |
| 63 | + for tracker in ISSUE_TRACKERS.values(): |
| 64 | + servers = tracker.get("servers") |
| 65 | + if isinstance(servers, list) and server in servers: |
| 66 | + return str(tracker["api"]) |
| 67 | + msg = f"no API mapping for server: {server}" |
| 68 | + raise ValueError(msg) |
| 69 | + |
| 70 | + |
| 71 | +def load_fixed_issues(data: dict[str, Any]) -> list[dict[str, Any]]: |
| 72 | + """Return fixed issues from releaseNotes, or an empty list when absent.""" |
| 73 | + fixed = advisory_data.content_array_from_decoded( |
| 74 | + data, |
| 75 | + ".releaseNotes.issues.fixed", |
| 76 | + ) |
| 77 | + return [row for row in fixed if isinstance(row, dict)] |
| 78 | + |
| 79 | + |
| 80 | +def close_comment(advisory_url: str) -> str: |
| 81 | + """Build the Jira comment posted when an issue is closed.""" |
| 82 | + return f"Fixed in Konflux Advisory {advisory_url}" |
| 83 | + |
| 84 | + |
| 85 | +def read_jira_credentials(secret_path: Path) -> tuple[str, str]: |
| 86 | + """Read Jira basic-auth credentials from mounted secret files.""" |
| 87 | + email = authentication.read_mounted_text(secret_path, "email") |
| 88 | + token = authentication.read_mounted_text(secret_path, "token") |
| 89 | + if not email or not token: |
| 90 | + msg = f"Jira secret at {secret_path} must include email and token" |
| 91 | + raise ValueError(msg) |
| 92 | + return email, token |
| 93 | + |
| 94 | + |
| 95 | +def jira_issue_url(server: str, issue_id: str) -> str: |
| 96 | + """Build the Jira issue API URL for *issue_id* on *server*.""" |
| 97 | + api_path = api_path_for_server(server) |
| 98 | + return f"https://{server}/{api_path}/{issue_id}" |
| 99 | + |
| 100 | + |
| 101 | +def jira_get_json( |
| 102 | + session: requests.Session, |
| 103 | + url: str, |
| 104 | + auth: HTTPBasicAuth, |
| 105 | +) -> dict[str, Any]: |
| 106 | + """Perform a GET request and return the parsed JSON object.""" |
| 107 | + response = session.get(url, auth=auth, timeout=60.0) |
| 108 | + response.raise_for_status() |
| 109 | + data = response.json() |
| 110 | + if not isinstance(data, dict): |
| 111 | + msg = f"expected JSON object from {url}" |
| 112 | + raise ValueError(msg) |
| 113 | + return data |
| 114 | + |
| 115 | + |
| 116 | +def jira_post_json( |
| 117 | + session: requests.Session, |
| 118 | + url: str, |
| 119 | + auth: HTTPBasicAuth, |
| 120 | + payload: dict[str, Any], |
| 121 | +) -> None: |
| 122 | + """Perform a POST request and raise when the response is not successful.""" |
| 123 | + response = session.post( |
| 124 | + url, |
| 125 | + auth=auth, |
| 126 | + json=payload, |
| 127 | + headers={"Content-Type": "application/json"}, |
| 128 | + timeout=60.0, |
| 129 | + ) |
| 130 | + response.raise_for_status() |
| 131 | + |
| 132 | + |
| 133 | +def issue_status_name(issue: dict[str, Any]) -> str: |
| 134 | + """Return the human-readable Jira status name for *issue*.""" |
| 135 | + fields = issue.get("fields") |
| 136 | + if not isinstance(fields, dict): |
| 137 | + return "" |
| 138 | + status = fields.get("status") |
| 139 | + if not isinstance(status, dict): |
| 140 | + return "" |
| 141 | + name = status.get("name") |
| 142 | + return name if isinstance(name, str) else "" |
| 143 | + |
| 144 | + |
| 145 | +def closed_transition_id(transitions: dict[str, Any]) -> str | None: |
| 146 | + """Return the transition id for the Closed state, if present.""" |
| 147 | + items = transitions.get("transitions") |
| 148 | + if not isinstance(items, list): |
| 149 | + return None |
| 150 | + for transition in items: |
| 151 | + if not isinstance(transition, dict): |
| 152 | + continue |
| 153 | + if transition.get("name") != "Closed": |
| 154 | + continue |
| 155 | + transition_id = transition.get("id") |
| 156 | + if transition_id is not None: |
| 157 | + return str(transition_id) |
| 158 | + return None |
| 159 | + |
| 160 | + |
| 161 | +def close_issue_with_comment( |
| 162 | + session: requests.Session, |
| 163 | + issue_url: str, |
| 164 | + auth: HTTPBasicAuth, |
| 165 | + transition_id: str, |
| 166 | + comment: str, |
| 167 | +) -> None: |
| 168 | + """Transition the issue to Closed and add *comment*.""" |
| 169 | + payload = { |
| 170 | + "transition": {"id": transition_id}, |
| 171 | + "update": {"comment": [{"add": {"body": comment}}]}, |
| 172 | + } |
| 173 | + jira_post_json(session, f"{issue_url}/transitions", auth, payload) |
| 174 | + |
| 175 | + |
| 176 | +def add_issue_comment( |
| 177 | + session: requests.Session, |
| 178 | + issue_url: str, |
| 179 | + auth: HTTPBasicAuth, |
| 180 | + comment: str, |
| 181 | +) -> None: |
| 182 | + """Add *comment* to the issue without changing its state.""" |
| 183 | + jira_post_json(session, f"{issue_url}/comment", auth, {"body": comment}) |
| 184 | + |
| 185 | + |
| 186 | +def process_fixed_issue( |
| 187 | + issue: dict[str, Any], |
| 188 | + *, |
| 189 | + advisory_url: str, |
| 190 | + auth: HTTPBasicAuth, |
| 191 | + session: requests.Session, |
| 192 | +) -> None: |
| 193 | + """Close one fixed issue or add an advisory comment when closing fails.""" |
| 194 | + issue_id = issue.get("id") |
| 195 | + source = issue.get("source") |
| 196 | + if not isinstance(issue_id, str) or not issue_id.strip(): |
| 197 | + logger.warning("Skipping issue with missing id: %s", issue) |
| 198 | + return |
| 199 | + if not isinstance(source, str) or not source.strip(): |
| 200 | + logger.warning("Skipping issue with missing source: %s", issue) |
| 201 | + return |
| 202 | + |
| 203 | + normalized_source = source.strip() |
| 204 | + server = normalize_issue_server(normalized_source) |
| 205 | + if server != SUPPORTED_JIRA_SERVER: |
| 206 | + logger.warning( |
| 207 | + "This task currently only supports closing issues on " |
| 208 | + "issues.redhat.com and redhat.atlassian.net. Skipping issue %s " |
| 209 | + "as it is on %s", |
| 210 | + issue, |
| 211 | + normalized_source, |
| 212 | + ) |
| 213 | + return |
| 214 | + |
| 215 | + normalized_id = issue_id.strip() |
| 216 | + if _VALID_JIRA_ISSUE_ID.fullmatch(normalized_id) is None: |
| 217 | + logger.warning( |
| 218 | + "Skipping issue with invalid Jira id %r: %s", |
| 219 | + normalized_id, |
| 220 | + issue, |
| 221 | + ) |
| 222 | + return |
| 223 | + |
| 224 | + issue_url = jira_issue_url(server, normalized_id) |
| 225 | + comment = close_comment(advisory_url) |
| 226 | + |
| 227 | + issue_data = jira_get_json(session, issue_url, auth) |
| 228 | + if issue_status_name(issue_data) == "Closed": |
| 229 | + logger.info("Issue %s is already in Closed state. Skipping it.", issue) |
| 230 | + return |
| 231 | + |
| 232 | + logger.info("Closing issue %s", issue) |
| 233 | + closing_failed = False |
| 234 | + try: |
| 235 | + transitions = jira_get_json(session, f"{issue_url}/transitions", auth) |
| 236 | + transition_id = closed_transition_id(transitions) |
| 237 | + if transition_id is None: |
| 238 | + logger.warning( |
| 239 | + "Warning: failed to fetch the closed state id for issue %s. " |
| 240 | + "We most likely do not have permission to close it. Will try " |
| 241 | + "to add a comment instead.", |
| 242 | + issue, |
| 243 | + ) |
| 244 | + closing_failed = True |
| 245 | + else: |
| 246 | + close_issue_with_comment(session, issue_url, auth, transition_id, comment) |
| 247 | + except requests.RequestException as exc: |
| 248 | + logger.warning( |
| 249 | + "Warning: failed to close issue %s. Will try to add a comment " "instead. %s", |
| 250 | + issue, |
| 251 | + exc, |
| 252 | + ) |
| 253 | + closing_failed = True |
| 254 | + |
| 255 | + if not closing_failed: |
| 256 | + return |
| 257 | + |
| 258 | + try: |
| 259 | + add_issue_comment(session, issue_url, auth, comment) |
| 260 | + except requests.RequestException as exc: |
| 261 | + logger.warning("Warning: failed to add comment to issue %s. %s", issue, exc) |
| 262 | + |
| 263 | + |
| 264 | +def close_advisory_issues( |
| 265 | + *, |
| 266 | + data_dir: Path, |
| 267 | + data_path: Path, |
| 268 | + advisory_url: str, |
| 269 | + secret_path: Path, |
| 270 | +) -> None: |
| 271 | + """Close fixed Jira issues referenced in the release data file.""" |
| 272 | + data_file = data_dir / data_path |
| 273 | + if not data_file.is_file(): |
| 274 | + msg = "No data JSON was provided." |
| 275 | + raise FileNotFoundError(msg) |
| 276 | + |
| 277 | + logger.info("Loading release data from %s", data_file) |
| 278 | + data = file.load_json_dict(data_file) |
| 279 | + fixed_issues = load_fixed_issues(data) |
| 280 | + if not any(is_jira_eligible_issue(issue) for issue in fixed_issues): |
| 281 | + return |
| 282 | + |
| 283 | + email, token = read_jira_credentials(secret_path) |
| 284 | + auth = HTTPBasicAuth(email, token) |
| 285 | + session = http_client.get_retry_session( |
| 286 | + total=3, |
| 287 | + connect=3, |
| 288 | + read=3, |
| 289 | + status=2, |
| 290 | + allowed_methods=frozenset({"GET", "POST"}), |
| 291 | + ) |
| 292 | + |
| 293 | + for issue in fixed_issues: |
| 294 | + process_fixed_issue( |
| 295 | + issue, |
| 296 | + advisory_url=advisory_url, |
| 297 | + auth=auth, |
| 298 | + session=session, |
| 299 | + ) |
| 300 | + |
| 301 | + |
| 302 | +def main() -> int: |
| 303 | + """Run the close-advisory-issues workflow.""" |
| 304 | + close_advisory_issues( |
| 305 | + data_dir=Path(tekton.require_env("PARAM_DATA_DIR")), |
| 306 | + data_path=Path(tekton.require_env("PARAM_DATA_PATH")), |
| 307 | + advisory_url=tekton.require_env("PARAM_ADVISORY_URL"), |
| 308 | + secret_path=file.path_from_env_variable("JIRA_SECRET_PATH", "/etc/secrets"), |
| 309 | + ) |
| 310 | + return 0 |
| 311 | + |
| 312 | + |
| 313 | +if __name__ == "__main__": |
| 314 | + raise SystemExit(main()) |
0 commit comments