Skip to content

Commit 152c3ce

Browse files
committed
feat(RELEASE-2475): add close_advisory_issues.py script
This commit adds a python script to replicate the functionality of the inline bash script in the close-advisory-issues managed task in the catalog repo. The task will be updated to use this python module instead. Assisted-By: Cursor Signed-off-by: Johnny Bieren <jbieren@redhat.com>
1 parent 586cbe7 commit 152c3ce

2 files changed

Lines changed: 1040 additions & 0 deletions

File tree

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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

Comments
 (0)