diff --git a/scripts/jira_sync_logic.py b/scripts/jira_sync_logic.py index 3dd23ac..fb3f9b8 100644 --- a/scripts/jira_sync_logic.py +++ b/scripts/jira_sync_logic.py @@ -21,6 +21,7 @@ apply_jira_labels_to_pr, jira_status_transition, add_comment_to_jira, + add_pr_weblink_to_jira, remove_label_from_jira_issue, get_done_issue_keys, ) @@ -90,6 +91,13 @@ def manage_labeled_gh_event( print("No Jira keys found. Nothing to do.") return + # --- Step 1b: ensure PR URL is linked on Jira issues --- + print("\n" + "=" * 60) + print(" Step 1b / add_pr_weblink_to_jira") + print("=" * 60) + pr_url = f"https://github.com/{owner_repo}/pull/{pr_number}" + add_pr_weblink_to_jira(jira_keys_json, pr_title, pr_url, jira_auth) + # --- Step 2: add the triggering label --- print("\n" + "=" * 60) print(" Step 2 / add_label_to_jira_issue") @@ -263,6 +271,13 @@ def manage_review_gh_event( print("No Jira keys found. Nothing to do.") return + # --- Step 1b: ensure PR URL is linked on Jira issues --- + print("\n" + "=" * 60) + print(" Step 1b / add_pr_weblink_to_jira") + print("=" * 60) + pr_url = f"https://github.com/{owner_repo}/pull/{pr_number}" + add_pr_weblink_to_jira(jira_keys_json, pr_title, pr_url, jira_auth) + # --- Step 2: extract issue details --- print("\n" + "=" * 60) print(" Step 2 / extract_jira_issue_details") @@ -380,6 +395,13 @@ def manage_closed_gh_event( print("No Jira keys found. Nothing to do.") return + # --- Step 1b: ensure PR URL is linked on Jira issues --- + print("\n" + "=" * 60) + print(" Step 1b / add_pr_weblink_to_jira") + print("=" * 60) + pr_url = f"https://github.com/{owner_repo}/pull/{pr_number}" + add_pr_weblink_to_jira(jira_keys_json, pr_title, pr_url, jira_auth) + # --- Step 2: extract issue details --- print("\n" + "=" * 60) print(" Step 2 / extract_jira_issue_details") @@ -530,6 +552,13 @@ def manage_opened_gh_event( print("No Jira keys found. Nothing to do.") return + # --- Step 1b: ensure PR URL is linked on Jira issues --- + print("\n" + "=" * 60) + print(" Step 1b / add_pr_weblink_to_jira") + print("=" * 60) + pr_url = f"https://github.com/{owner_repo}/pull/{pr_number}" + add_pr_weblink_to_jira(jira_keys_json, pr_title, pr_url, jira_auth) + # --- Step 2: extract issue details --- print("\n" + "=" * 60) print(" Step 2 / extract_jira_issue_details") @@ -666,6 +695,13 @@ def manage_unlabeled_gh_event( print("No Jira keys found. Nothing to do.") return + # --- Step 1b: ensure PR URL is linked on Jira issues --- + print("\n" + "=" * 60) + print(" Step 1b / add_pr_weblink_to_jira") + print("=" * 60) + pr_url = f"https://github.com/{owner_repo}/pull/{pr_number}" + add_pr_weblink_to_jira(jira_keys_json, pr_title, pr_url, jira_auth) + # --- Step 2: remove label from Jira (skip priority labels) --- print("\n" + "=" * 60) print(" Step 2 / remove_label_from_jira_issue") diff --git a/scripts/jira_sync_modules.py b/scripts/jira_sync_modules.py index a2b9c6f..398c375 100644 --- a/scripts/jira_sync_modules.py +++ b/scripts/jira_sync_modules.py @@ -14,6 +14,7 @@ apply_jira_labels_to_pr jira_status_transition add_comment_to_jira + add_pr_weblink_to_jira The orchestrator functions and CLI dispatcher live in jira_sync_logic.py which imports from this module. @@ -587,7 +588,7 @@ def remove_label_from_jira_issue(jira_keys_json: str, label: str, jira_auth: str _DETAIL_DELIM = ";" -def _jira_get(url: str, jira_auth: str) -> dict | None: +def _jira_get(url: str, jira_auth: str) -> dict | list | None: """GET JSON from a Jira REST endpoint. Returns parsed JSON or None on failure.""" encoded_auth = base64.b64encode(jira_auth.encode()).decode() @@ -1262,6 +1263,97 @@ def add_comment_to_jira( print(f"WARNING: {failed} comment(s) failed. Continuing.") +def _normalize_url(url: str) -> str: + """Normalize URL for stable comparisons.""" + if not url: + return "" + return url.strip().rstrip("/") + + +def add_pr_weblink_to_jira( + jira_keys_json: str, + pr_title: str, + pr_url: str, + jira_auth: str, +) -> None: + """Ensure each Jira issue has a remote link to the PR URL (idempotent).""" + if not jira_auth: + print("Error: jira_auth is not set or empty.") + sys.exit(1) + + keys = _parse_jira_keys_json(jira_keys_json) + if not keys: + print("No Jira keys to sync web links for.") + return + + normalized_pr_url = _normalize_url(pr_url) + if not normalized_pr_url: + print("PR URL is empty; skipping Jira web-link sync.") + return + + stripped_title = pr_title.strip() if pr_title else "" + link_title = stripped_title or normalized_pr_url + payload = { + "object": { + "url": normalized_pr_url, + "title": link_title, + } + } + + ok = 0 + skipped = 0 + failed = 0 + + for key in keys: + get_url = f"{JIRA_BASE_URL}/rest/api/3/issue/{key}/remotelink" + print(f"Fetching existing remote links for {key} ...") + existing = _jira_get(get_url, jira_auth) + if existing is None: + print(f"SKIP {key}: failed to fetch existing remote links.") + skipped += 1 + continue + if not isinstance(existing, list): + print(f"SKIP {key}: unexpected remotelink response format.") + skipped += 1 + continue + + has_pr_link = False + for item in existing: + if not isinstance(item, dict): + continue + obj = item.get("object") + if not isinstance(obj, dict): + continue + existing_url = _normalize_url(obj.get("url", "")) + if existing_url and existing_url == normalized_pr_url: + has_pr_link = True + break + + if has_pr_link: + print(f"SKIP {key}: PR web link already exists.") + skipped += 1 + continue + + post_url = f"{JIRA_BASE_URL}/rest/api/3/issue/{key}/remotelink" + code, body_text = _jira_post(post_url, payload, jira_auth) + if code in (200, 201): + print(f"OK {key} ({code}) web link added.") + ok += 1 + elif code == 404: + print(f"SKIP {key} ({code}) issue not found or no permission. Continuing.") + skipped += 1 + else: + print(f"FAIL {key} ({code}) First 400 chars:") + print(body_text[:400]) + failed += 1 + + time.sleep(0.2) + + print(f"Web-link sync summary: ok={ok} skipped={skipped} failed={failed}") + if failed > 0: + print(f"WARNING: {failed} web-link update(s) failed. Continuing.") + + def get_done_issue_keys(details_csv: str) -> set[str]: """Parse the details CSV and return issue keys whose status is in a closed/done state.