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
36 changes: 36 additions & 0 deletions scripts/jira_sync_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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,
)
Expand Down Expand Up @@ -91,6 +92,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")
Expand Down Expand Up @@ -255,6 +263,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")
Expand Down Expand Up @@ -372,6 +387,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")
Expand Down Expand Up @@ -522,6 +544,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")
Expand Down Expand Up @@ -658,6 +687,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")
Expand Down
94 changes: 93 additions & 1 deletion scripts/jira_sync_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -644,7 +645,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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target function and surrounding code ---'
sed -n '620,685p' scripts/jira_sync_modules.py

printf '%s\n' '--- JSON and urlopen usage ---'
rg -n -C 3 'json\.loads|urlopen|_jira_get\(' scripts/jira_sync_modules.py

Repository: scylladb/github-automation

Length of output: 6278


🏁 Script executed:

python3 - <<'PY'
import json

try:
    json.loads(b'{"incomplete":')
except Exception as exc:
    print(type(exc).__name__)
    print(isinstance(exc, json.JSONDecodeError))
    print(isinstance(exc, (ConnectionError, json.JSONDecodeError)))
PY

Repository: scylladb/github-automation

Length of output: 190


Handle malformed JSON responses.

If Jira returns malformed JSON, json.loads raises json.JSONDecodeError, which the current handler does not catch. Catch it so _jira_get returns None as documented.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/jira_sync_modules.py` at line 648, Update _jira_get to catch
json.JSONDecodeError when parsing Jira responses and return None, preserving the
documented behavior for malformed JSON while leaving other response handling
unchanged.

"""GET JSON from a Jira REST endpoint. Returns parsed JSON or None on failure."""
encoded_auth = base64.b64encode(jira_auth.encode()).decode()

Expand Down Expand Up @@ -1319,6 +1320,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):
Comment on lines +1353 to +1396

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- target context ---'
sed -n '1300,1425p' scripts/jira_sync_modules.py
printf '%s\n' '--- remote-link helpers and call sites ---'
rg -n -C 3 '_jira_(get|post)|globalId|remotelink|remote link' scripts/jira_sync_modules.py
printf '%s\n' '--- repository status ---'
git status --short

Repository: scylladb/github-automation

Length of output: 7259


🏁 Script executed:

#!/bin/bash
set -e
sed -n '1300,1425p' scripts/jira_sync_modules.py
rg -n -C 3 '_jira_(get|post)|globalId|remotelink|remote link' scripts/jira_sync_modules.py
git status --short

Repository: scylladb/github-automation

Length of output: 7167


🌐 Web query:

Atlassian Jira Cloud REST API v3 issue remote links globalId POST duplicate update existing remote link

💡 Result:

In the Jira Cloud REST API v3, using the POST method on the /rest/api/3/issue/{issueIdOrKey}/remotelink endpoint provides an "upsert" (create or update) behavior based on the globalId [1][2]. When you send a request to create a remote link: 1. If a globalId is provided in the request body, Jira checks if a remote link with that same globalId already exists for the specified issue [1][3]. 2. If the globalId is found, the existing remote link is updated with the values provided in the request [1][4]. 3. If the globalId is not found, a new remote link is created [1][3]. Important considerations for this update behavior: - Field Overwriting: When an update occurs, the API replaces the existing fields with the new values provided in the request [1][5]. Crucially, any fields omitted from your request body will be set to null [1][3][4]. Therefore, ensure your request includes the full object structure you wish to persist to avoid accidental data loss [5][6]. - URL Encoding: If your globalId contains reserved URL characters, you must ensure it is correctly encoded for the request [1][2]. - Feature Requirement: This operation requires the "Issue Linking" feature to be active in your Jira instance [1][3]. If you wish to update a remote link without potentially triggering a "create" action or if you have the internal Jira link ID (rather than the globalId), you can alternatively use the PUT /rest/api/3/issue/{issueIdOrKey}/remotelink/{linkId} endpoint, which specifically performs an update on a known link [3][4].

Citations:


🌐 Web query:

site:developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-remote-links "globalId" "If a global ID is provided"

💡 Result:

In software systems and APIs, a global ID (or globalId) is a unique identifier used to reference an entity across different systems or datasets, distinguishing it from local IDs that are only unique within a single database or application instance [1]. When a documentation or API specification states that "if a global ID is provided," it typically describes conditional logic for how the system handles a request [1]. A common pattern, as seen in the Jira Cloud platform REST API, involves the following behaviors [1]: 1. Retrieval: If a global ID is provided in a request, the system retrieves the specific record associated with that identifier; if omitted, the system may return a collection of all available records instead [1]. 2. Upsert (Update or Create): In some API operations, providing a global ID serves as a signal to update an existing record if it exists, or to create a new one if it does not [1]. 3. Handling/Escaping: Because global IDs often contain complex strings (such as URLs or system-specific paths), technical documentation frequently notes that these values must be properly escaped if they include reserved characters before being sent in a request URL [1]. In essence, the global ID acts as a primary, system-agnostic key that allows external services to perform deterministic operations on entities without needing to know the internal database ID assigned by the host system [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from urllib.request import urlopen
from re import finditer

urls = [
    "https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-remote-links/",
    "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json",
]
for url in urls:
    print(f"--- {url} ---")
    try:
        text = urlopen(url, timeout=20).read().decode("utf-8", "replace")
    except Exception as exc:
        print(type(exc).__name__, exc)
        continue
    for needle in ("globalId", "Create or update remote issue link", "If a global ID"):
        positions = [m.start() for m in finditer(needle, text, flags=0)]
        print(f"{needle}: {len(positions)} matches")
        for pos in positions[:3]:
            print(text[max(0, pos-500):pos+1000].replace("\n", " ")[:1600])
PY

Repository: scylladb/github-automation

Length of output: 630


🏁 Script executed:

#!/bin/bash
set -e
for url in \
  "https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-remote-links/" \
  "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"
do
  echo "--- $url ---"
  curl -k -L --max-time 30 -sS "$url" |
    grep -oEi '.{0,500}(globalId|create or update remote issue link|if a global ID).{0,1200}' |
    head -c 12000 || true
  echo
done

Repository: scylladb/github-automation

Length of output: 24444


Add a stable globalId derived from normalized_pr_url to payload. Jira’s POST /remotelink endpoint updates an existing link with the same globalId, which prevents concurrent flows from creating duplicates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/jira_sync_modules.py` around lines 1353 - 1396, Add a stable globalId
field to the payload object, deriving its value deterministically from
normalized_pr_url so repeated or concurrent remotelink requests identify the
same Jira link. Keep the existing object URL and title fields unchanged.

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.")


Comment on lines +1323 to +1413

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- _jira_get definition and callers ---'
rg -n -A35 -B10 'def _jira_get|_jira_get\(' scripts/jira_sync_modules.py
printf '%s\n' '--- urlopen usage in the module ---'
rg -n -A8 -B5 'urlopen|Request\(' scripts/jira_sync_modules.py
printf '%s\n' '--- synchronization entry points and sequencing ---'
rg -n -A25 -B15 'add_pr_weblink_to_jira|sync|event|flow|main\(' scripts/jira_sync_modules.py

Repository: scylladb/github-automation

Length of output: 32525


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate orchestration files ---'
fd -t f -i 'jira_sync_logic|jira_sync_modules|jira.*yml|jira.*yaml' .
printf '%s\n' '--- orchestration symbols and relevant call order ---'
rg -n -A18 -B12 'extract_jira_issue_details|add_pr_weblink_to_jira|_filter_out_excluded_issue_types|jira_status_transition|add_comment_to_jira|apply_jira_labels_to_pr' .
printf '%s\n' '--- helper source ranges ---'
cat -n scripts/jira_sync_modules.py | sed -n '95,160p;640,665p;1018,1045p'

Repository: scylladb/github-automation

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
import ast
import socket
from pathlib import Path

path = Path("scripts/jira_sync_modules.py")
tree = ast.parse(path.read_text())

def calls_named(node, name):
    return [
        n for n in ast.walk(node)
        if isinstance(n, ast.Call)
        and ((isinstance(n.func, ast.Name) and n.func.id == name)
             or (isinstance(n.func, ast.Attribute) and n.func.attr == name))
    ]

jira_get = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "_jira_get")
calls = calls_named(jira_get, "urlopen")
print("jira_get_urlopen_calls:", len(calls))
for call in calls:
    print("line", call.lineno, "timeout_keyword:",
          next((kw.value.value for kw in call.keywords
                if kw.arg == "timeout" and isinstance(kw.value, ast.Constant)), "<absent>"))

print("socket_default_timeout:", socket.getdefaulttimeout())
print("module_sets_global_socket_timeout:",
      any(isinstance(n, ast.Call)
          and isinstance(n.func, ast.Attribute)
          and isinstance(n.func.value, ast.Name)
          and n.func.value.id == "socket"
          and n.func.attr == "setdefaulttimeout"
          for n in ast.walk(tree)))

for name in ("_jira_get", "_jira_post", "_jira_put"):
    fn = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == name)
    calls = calls_named(fn, "urlopen")
    print(name, "urlopen_timeout_keywords:",
          [kw.arg for call in calls for kw in call.keywords if kw.arg == "timeout"])
PY

Repository: scylladb/github-automation

Length of output: 413


Set a finite timeout on Jira HTTP requests.

_jira_get, _jira_post, and _jira_put call urlopen without a timeout. A stalled Jira request can block later synchronization steps indefinitely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/jira_sync_modules.py` around lines 1323 - 1413, Update the Jira HTTP
helpers _jira_get, _jira_post, and _jira_put so every urlopen call supplies a
finite timeout value. Reuse an existing timeout configuration or define one
shared constant, ensuring all Jira requests—including those used by
add_pr_weblink_to_jira—cannot block indefinitely.

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.

Expand Down