From 15d735a0aea01ba291e0c43afaebe837ce7603f3 Mon Sep 17 00:00:00 2001 From: Keerthana Panyam Date: Mon, 10 Nov 2025 13:11:56 -0500 Subject: [PATCH 1/3] feat:add Jira Cloud API support implemented support for Jira Cloud REST API v3 while maintaining backward compatibility with Jira Server v2. - added Basic Auth (email:token) support for Jira Cloud v3 - added JIRA_EMAIL env var requirement for v3 - added _text_to_adf() and _adf_to_text() helper functions for ADF <-> plain text conversion - stored original ADF in JiraComment.adf for roundtrip preservation - converted descriptions and comments automatically based on API version - added adftotxt library for ADF parsing - fixed pagination: use nextPageToken for v3, startAt for v2 - user lookup: v3 uses 'query' parameter and v2 uses 'username' parameter --- pyproject.toml | 1 + supervisor/jira_utils.py | 219 ++++++++++++++++++++++++++------- supervisor/supervisor_types.py | 1 + templates/supervisor.env | 8 +- 4 files changed, 181 insertions(+), 48 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6d9a2ec7..b281388f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ readme = "README.md" requires-python = ">=3.13,<3.14" # we are installing bee 0.1.55 in containers now dependencies = [ + "adftotxt>=0.0.18", "aiohttp>=3.12.15", "aiofiles>=24.1.0", "arize-phoenix-otel>=0.13.0", diff --git a/supervisor/jira_utils.py b/supervisor/jira_utils.py index 6f4ca479..93fdc92f 100644 --- a/supervisor/jira_utils.py +++ b/supervisor/jira_utils.py @@ -16,6 +16,7 @@ ) from urllib.parse import quote as urlquote +from adftotxt.adftotxt import startParsing as adf_to_text import requests from .http_utils import requests_session @@ -57,22 +58,51 @@ def jira_url() -> str: return url.rstrip("/") +@cache +def jira_api_version() -> str: + if "JIRA_API_VERSION" in os.environ: + version = os.environ["JIRA_API_VERSION"] + if version not in ("2", "3"): + raise ValueError(f"JIRA_API_VERSION must be '2' or '3'") + return version + + url = jira_url() + if "atlassian.net" in url: + return "3" + + return "2" + + class JiraNotLoggedInError(Exception): pass @cache def jira_headers() -> dict[str, str]: + import base64 + jira_token = os.environ["JIRA_TOKEN"] + if jira_api_version() == "3": + # for jira cloud: Basic Auth with email:token + jira_email = os.environ.get("JIRA_EMAIL") + if not jira_email: + raise ValueError("JIRA_EMAIL environment variable is required for Jira Cloud") + + auth_value = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode() + auth_header = f"Basic {auth_value}" + else: + # Server: Bearer token + auth_header = f"Bearer {jira_token}" + headers = { - "Authorization": f"Bearer {jira_token}", + "Authorization": auth_header, "Content-Type": "application/json", } # Test if the token can log in successfully response = requests_session().get( - f"{jira_url()}/rest/api/2/myself", headers=headers + f"{jira_url()}/rest/api/{jira_api_version()}/myself", headers=headers ) if response.status_code == 401: @@ -115,7 +145,7 @@ def retry_on_rate_limit(func): @retry_on_rate_limit def jira_api_get(path: str, *, params: dict | None = None) -> Any: - url = f"{jira_url()}/rest/api/2/{path}" + url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" response = requests_session().get(url, headers=jira_headers(), params=params) if not response.ok: logger.error( @@ -144,7 +174,7 @@ def jira_api_post( def jira_api_post( path: str, json: dict[str, Any], *, decode_response: bool = False ) -> Any | None: - url = f"{jira_url()}/rest/api/2/{path}" + url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" response = requests_session().post(url, headers=jira_headers(), json=json) if not response.ok: logger.error( @@ -183,7 +213,7 @@ def jira_api_upload( *, decode_response: bool = False, ) -> Any | None: - url = f"{jira_url()}/rest/api/2/{path}" + url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" files = [("file", a) for a in attachments] headers = dict(jira_headers()) del headers["Content-Type"] # requests will set this correctly for multipart @@ -217,7 +247,7 @@ def jira_api_put( def jira_api_put( path: str, json: dict[str, Any], *, decode_response: bool = False ) -> Any | None: - url = f"{jira_url()}/rest/api/2/{path}" + url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" response = requests_session().put(url, headers=jira_headers(), json=json) if not response.ok: logger.error( @@ -293,14 +323,16 @@ def custom_enum_list(enum_class: Type[_E], name) -> list[_E] | None: if full: return FullIssue( **issue.__dict__, - description=issue_data["fields"]["description"], + description=_adf_to_text(issue_data["fields"]["description"]), comments=[ JiraComment( authorName=c["author"]["displayName"], authorEmail=c["author"].get("emailAddress"), created=datetime.fromisoformat(c["created"]), - body=c["body"], + body=_adf_to_text(c["body"]), id=c["id"], + #storing original ADF if its a dict + adf=c["body"] if isinstance(c["body"], dict) else None, ) for c in issue_data["fields"]["comment"]["comments"] ], @@ -367,26 +399,50 @@ def get_current_issues( jql: str, full: bool = False, ) -> Generator[Issue, None, None] | Generator[FullIssue, None, None]: - start_at = 0 max_results = 1000 - while True: - body = { - "jql": jql, - "startAt": start_at, - "maxResults": max_results, - "fields": _fields(full), - } - logger.debug("Fetching JIRA issues, start=%d, max=%d", start_at, max_results) - response_data = jira_api_post("search", json=body, decode_response=True) - logger.debug("Got %d issues", len(response_data["issues"])) - - for issue_data in response_data["issues"]: - yield decode_issue(issue_data, full) - - start_at += max_results - if response_data["total"] <= start_at: - break + if jira_api_version() == "3": + #v3 uses nextPageToken for pagination + next_page_token = None + while True: + body = { + "jql": jql, + "maxResults": max_results, + "fields": _fields(full), + } + if next_page_token: + body["nextPageToken"] = next_page_token + + logger.debug("Fetching JIRA issues (v3), token=%s, max=%d", next_page_token, max_results) + response_data = jira_api_post("search/jql", json=body, decode_response=True) + logger.debug("Got %d issues", len(response_data["issues"])) + + for issue_data in response_data["issues"]: + yield decode_issue(issue_data, full) + + if response_data.get("isLast", True): + break + next_page_token = response_data.get("nextPageToken") + else: + #v2 uses startAt for pagination + start_at = 0 + while True: + body = { + "jql": jql, + "startAt": start_at, + "maxResults": max_results, + "fields": _fields(full), + } + logger.debug("Fetching JIRA issues (v2), start=%d, max=%d", start_at, max_results) + response_data = jira_api_post("search", json=body, decode_response=True) + logger.debug("Got %d issues", len(response_data["issues"])) + + for issue_data in response_data["issues"]: + yield decode_issue(issue_data, full) + + if response_data["total"] <= start_at + max_results: + break + start_at += max_results @overload @@ -410,21 +466,30 @@ def get_issue_by_jotnar_tag( def get_issue_by_jotnar_tag( project: str, tag: JotnarTag, full: bool = False, with_label: str | None = None ) -> Issue | FullIssue | None: - start_at = 0 max_results = 2 jql = f'project = {project} AND status NOT IN (Done, Closed) AND description ~ "\\"{tag}\\""' if with_label is not None: jql += f' AND labels = "{with_label}"' - body = { - "jql": jql, - "startAt": 0, - "maxResults": 2, - "fields": _fields(full), - } - - logger.debug("Fetching JIRA issues, start=%d, max=%d", start_at, max_results) - response_data = jira_api_post("search", json=body, decode_response=True) + if jira_api_version() == "3": + #v3 doesn't support startAt only uses nextPageToken + body = { + "jql": jql, + "maxResults": max_results, + "fields": _fields(full), + } + logger.debug("Fetching JIRA issues (v3), max=%d", max_results) + response_data = jira_api_post("search/jql", json=body, decode_response=True) + else: + #v2 uses startAt + body = { + "jql": jql, + "startAt": 0, + "maxResults": max_results, + "fields": _fields(full), + } + logger.debug("Fetching JIRA issues (v2), start=0, max=%d", max_results) + response_data = jira_api_post("search", json=body, decode_response=True) if len(response_data["issues"]) == 0: return None @@ -438,13 +503,16 @@ def get_issues_statuses(issue_keys: Collection[str]) -> dict[str, IssueStatus]: if len(issue_keys) == 0: return {} + jql = f"key in ({','.join(issue_keys)})" body = { - "jql": f"key in ({','.join(issue_keys)})", + "jql": jql, "maxResults": len(issue_keys), "fields": ["status"], } - - response_data = jira_api_post("search", json=body, decode_response=True) + if jira_api_version() == "3": + response_data = jira_api_post("search/jql", json=body, decode_response=True) + else: + response_data = jira_api_post("search", json=body, decode_response=True) result = { issue_data["key"]: IssueStatus(issue_data["fields"]["status"]["name"]) @@ -463,24 +531,66 @@ class CommentVisibility(StrEnum): RED_HAT_EMPLOYEE = "Red Hat Employee" -CommentSpec = None | str | tuple[str, CommentVisibility] +CommentSpec = None | str | dict[str, Any] | tuple[str | dict[str, Any], CommentVisibility] + + +def _text_to_adf(text: str) -> dict[str, Any]: + return { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": text + } + ] + } + ] + } + + +def _adf_to_text(adf_or_text: dict[str, Any] | str) -> str: + if isinstance(adf_or_text, str): + return adf_or_text + + #if its ADF, convert to text + text = adf_to_text(adf_or_text, runReplacers=False) + #removing trailing newline added by adftotxt library + return text.rstrip('\n') def _comment_to_dict(comment: CommentSpec) -> dict[str, Any] | None: if comment is None: return - if isinstance(comment, str): + if isinstance(comment, (str, dict)): comment_value = comment visibility = CommentVisibility.PUBLIC else: comment_value, visibility = comment + # determine the body content based on API version and input type + if isinstance(comment_value, dict): + #already ADF, use as is for v3 or convert to text for v2 + if jira_api_version() == "3": + body_content = comment_value + else: + body_content = _adf_to_text(comment_value) + else: + #plain text convert to ADF for v3, use as is for v2 + if jira_api_version() == "3": + body_content = _text_to_adf(comment_value) + else: + body_content = comment_value + if visibility == CommentVisibility.PUBLIC: - return {"body": comment_value} + return {"body": body_content} else: return { - "body": comment_value, + "body": body_content, "visibility": {"type": "group", "value": str(visibility)}, } @@ -696,12 +806,23 @@ def get_issue_attachment(issue_key: str, filename: str) -> bytes: @cache def get_user_name(email: str) -> str: - users = jira_api_get("user/search", params={"username": email}) + # v3 uses 'query' v2 uses 'username' param + if jira_api_version() == "3": + users = jira_api_get("user/search", params={"query": email}) + else: + users = jira_api_get("user/search", params={"username": email}) + if len(users) == 0: raise ValueError(f"No JIRA user with email {email}") elif len(users) > 1: raise ValueError(f"Multiple JIRA users with email {email}") - return users[0]["name"] + + user = users[0] + + if jira_api_version() == "3": + return user.get("displayName") or user["accountId"] + else: + return user.get("name") or user.get("displayName") or user["accountId"] @overload @@ -752,10 +873,16 @@ def create_issue( if tag is not None: description = f"{tag}\n\n{description}" + #for cloud v3 convert description to ADF format + if jira_api_version() == "3": + description_content = _text_to_adf(description) + else: + description_content = description + fields = { "project": {"key": project}, "summary": summary, - "description": description, + "description": description_content, "issuetype": {"name": "Task"}, } diff --git a/supervisor/supervisor_types.py b/supervisor/supervisor_types.py index 73631097..e919ce52 100644 --- a/supervisor/supervisor_types.py +++ b/supervisor/supervisor_types.py @@ -109,6 +109,7 @@ class Issue(BaseModel): class JiraComment(Comment): id: str + adf: dict[str, Any] | None = None class FullIssue(Issue): diff --git a/templates/supervisor.env b/templates/supervisor.env index a4ec012e..992c4609 100644 --- a/templates/supervisor.env +++ b/templates/supervisor.env @@ -7,10 +7,14 @@ GEMINI_API_KEY= #ANTHROPIC_API_KEY= # Required: Jira instance URL +# For Server/Data Center: https://issues.redhat.com +# For Cloud UAT: https://uat-2-2-redhat.atlassian.net JIRA_URL=https://issues.redhat.com +# Required: Email for Jira Cloud +JIRA_EMAIL= -# Required: Jira API token (Bearer token) -# Get this from: https://issues.redhat.com/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens +# For Server: PAT from https://issues.redhat.com/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens +# For Cloud: API token from https://id.atlassian.com/manage-profile/security/api-tokens JIRA_TOKEN= # Required: testing farm API token (Bearer token) From 54d1730162ecbf449a8e36b03baa496070091a63 Mon Sep 17 00:00:00 2001 From: Keerthana Panyam Date: Mon, 10 Nov 2025 13:15:29 -0500 Subject: [PATCH 2/3] test:add Jira Cloud UAT script added test_jira_cloud_uat.py script that validates all Jira API operations - test cases covering authentication, ADF conversion - tests for CRUD operations, comments, attachments, labels, status changes - validate JQL search and jotnar tag functionality --- scripts/test_jira_cloud_uat.py | 179 +++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 scripts/test_jira_cloud_uat.py diff --git a/scripts/test_jira_cloud_uat.py b/scripts/test_jira_cloud_uat.py new file mode 100644 index 00000000..f7e7d978 --- /dev/null +++ b/scripts/test_jira_cloud_uat.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Test script for UAT Jira Cloud API. +""" + +import asyncio +import sys +import os +import time +from datetime import datetime + +# Add current directory to path +sys.path.insert(0, '.') + +from supervisor.http_utils import with_requests_session +from supervisor.jira_utils import ( + change_issue_status, + jira_url, + jira_api_version, + get_custom_fields, + create_issue, + get_issue, + add_issue_comment, + add_issue_label, + get_issues_statuses, + remove_issue_label, + update_issue_comment, + add_issue_attachments, + get_issue_attachment, + get_current_issues, + get_issue_by_jotnar_tag, + get_user_name, +) +from supervisor.supervisor_types import IssueStatus, JotnarTag + + +@with_requests_session() +async def main(): + # Get project key from command line + if len(sys.argv) < 2: + print("Usage: python test_uat.py PROJECT_KEY") + print("Example: python test_uat.py RHELMISC") + sys.exit(1) + + project = sys.argv[1] + + print("UAT Test Script") + + # Test 1: Get custom fields + print("\n[1/14] Get custom fields") + fields = get_custom_fields() + print(f"Found {len(fields)} custom fields") + + # Test 2: Create an issue + print("\n[2/14] Create test issue") + issue_key = create_issue( + project=project, + summary="[TEST] UAT API Test", + description="This is a test issue created to verify Jira Cloud API.", + labels=["uat_test", "automated_test"], + components=["jotnar-package-automation"] + ) + print(f" Created issue: {issue_key}") + + # Test 3: Get the issue + print("\n[3/14] Get issue") + issue = get_issue(issue_key) + print(f"Got issue: {issue.summary}") + print(f"Status: {issue.status}") + + # Test 4: Add a comment + print("\n[4/14] Add a comment") + add_issue_comment(issue_key, "This is a test comment from the UAT test script.") + print(f"Added comment") + + # Test 5: Update the comment + print("\n[5/14] Update the comment") + full_issue = get_issue(issue_key, full=True) + if full_issue.comments: + comment_id = full_issue.comments[-1].id + update_issue_comment(issue_key, comment_id, "This is the updated test comment from the UAT test script.") + print(f"Updated comment") + else: + print(f"No comments found") + + # Test 6: Add a label + print("\n[6/14] Add issue label") + add_issue_label(issue_key, "test_complete") + print(f"Added label") + + # Test 7: Remove the label + print("\n[7/14] Remove issue label") + remove_issue_label(issue_key, "test_complete") + print(f"Removed label") + + # Test 8: Get issue status (using get_issue instead of JQL search) + print("\n[8/14] Get issue status") + issue_for_status = get_issue(issue_key) + print(f"Status: {issue_for_status.status}") + + # Test 9: Change issue status + print("\n[9/14] Change issue status") + change_issue_status( + issue_key, + IssueStatus.IN_PROGRESS, + comment="Status changed to In Progress by UAT test" + ) + print(f"Changed status to In Progress") + + # Test 10: Add issue attachments + print("\n[10/14] Added attachment") + test_content = f"Test file created at {datetime.now().isoformat()}\n".encode('utf-8') + test_filename = f"test_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + add_issue_attachments( + issue_key, + [(test_filename, test_content, "text/plain")], + comment="Test attachment added by UAT script" + ) + print(f"Added attachment: {test_filename}") + + # Test 11: Get issue attachment + print("\n[11/14] Get issue attachment") + time.sleep(1) # wait for attachment to be available + attachment_content = get_issue_attachment(issue_key, test_filename) + print(f"Retrieved attachment ({len(attachment_content)} bytes)") + + # Test 12: Search with JQL + print("\n[12/14] Search issues with JQL") + jql = f'project = {project} AND labels = "uat_test" ORDER BY created DESC' + matching_issues = list(get_current_issues(jql)) + print(f"Found {len(matching_issues)} issues with 'uat_test' label") + + # Test 13: Get full issue (with comments and description) + print("\n[13/14] Getting full issue with comments") + full_issue = get_issue(issue_key, full=True) + if full_issue.comments: + latest_comment = full_issue.comments[-1] + print(f"Comments: {latest_comment.body[:50]}...") + # Verify ADF roundtrip worked + if "UPDATED" in latest_comment.body: + print(f"Comment update verified") + + # Test 14: Test get_issue_by_jotnar_tag + print("\n[14/14] Testing Jotnar tag search") + try: + #create an issue with a Jotnar tag from the start + tag = JotnarTag(type="needs_attention", resource="erratum", id="TEST-456") + tag_str = str(tag) + + tagged_issue_key = create_issue( + project=project, + summary="[TEST] UAT - Issue with Jotnar Tag", + description=f"Test issue for Jotnar tag search\n\n{tag_str}", + labels=["uat_test", "jotnar_tag_test"], + components=["jotnar-package-automation"] + ) + print(f"Created issue with Jotnar tag: {tagged_issue_key}") + + #wait for Jira to index + time.sleep(3) + + #try to find jotnar issue by tag + found_issue = get_issue_by_jotnar_tag(project, tag, with_label="jotnar_tag_test") + if found_issue: + print(f" Found issue with jotnar tag: {found_issue.key}") + else: + print(f"issue not found by jotnar tag") + except Exception as e: + print(f"Warning: jotnar tag test failed: {e}") + import traceback + traceback.print_exc() + + + + print("All the tests passed") + + +if __name__ == "__main__": + asyncio.run(main()) From db239f00d681fd1869f5f357687e07f64dd18bfb Mon Sep 17 00:00:00 2001 From: Keerthana Panyam Date: Fri, 14 Nov 2025 12:13:45 -0500 Subject: [PATCH 3/3] feat: simplify Jira Cloud API support with hybrid v2/v3 simplified the Jira Cloud migration by using v2 API as the default for all operations and using v3 only for search endpoints where required. (avoids ADF conversion and uses v3 because v2 search is deprecated) --- pyproject.toml | 1 - scripts/test_jira_cloud_uat.py | 75 +++++++++------ supervisor/jira_utils.py | 167 +++++++++++++-------------------- supervisor/supervisor_types.py | 1 - 4 files changed, 112 insertions(+), 132 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b281388f..6d9a2ec7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ readme = "README.md" requires-python = ">=3.13,<3.14" # we are installing bee 0.1.55 in containers now dependencies = [ - "adftotxt>=0.0.18", "aiohttp>=3.12.15", "aiofiles>=24.1.0", "arize-phoenix-otel>=0.13.0", diff --git a/scripts/test_jira_cloud_uat.py b/scripts/test_jira_cloud_uat.py index f7e7d978..a0088a0c 100644 --- a/scripts/test_jira_cloud_uat.py +++ b/scripts/test_jira_cloud_uat.py @@ -15,8 +15,6 @@ from supervisor.http_utils import with_requests_session from supervisor.jira_utils import ( change_issue_status, - jira_url, - jira_api_version, get_custom_fields, create_issue, get_issue, @@ -56,7 +54,7 @@ async def main(): issue_key = create_issue( project=project, summary="[TEST] UAT API Test", - description="This is a test issue created to verify Jira Cloud API.", + description="Test issue created to verify Jira Cloud API.", labels=["uat_test", "automated_test"], components=["jotnar-package-automation"] ) @@ -68,13 +66,36 @@ async def main(): print(f"Got issue: {issue.summary}") print(f"Status: {issue.status}") - # Test 4: Add a comment - print("\n[4/14] Add a comment") + # Test 4: Add a simple comment + print("\n[4/15] Add a simple comment") add_issue_comment(issue_key, "This is a test comment from the UAT test script.") print(f"Added comment") - # Test 5: Update the comment - print("\n[5/14] Update the comment") + # Test 5: Add complex Jira markup comment (baseline test format) + print("\n[5/15] Add complex Jira markup comment") + baseline_test_comment = """\ +Automated testing for libtiff-4.4.0-13.el9_6.2 has failed. + +Test results are available at: https://reportportal-rhel.apps.dno.ocp-hub.prod.psi.redhat.com/ui/#baseosqe/launches/all/9ccdf038-ca7b-462d-a236-c9e40a464b2f + +Failed test runs: +* [REQ-1.4.1|https://artifacts.osci.redhat.com/testing-farm/65f0eff4-ecad-4c8a-890a-24da164d0499] +* [REQ-2.4.2|https://artifacts.osci.redhat.com/testing-farm/b5686ad4-32db-44f7-8ef0-499d99afb220] +* [REQ-3.4.3|https://artifacts.osci.redhat.com/testing-farm/a95ac61f-daab-4710-a9db-f96148657b08] +* [REQ-4.4.4|https://artifacts.osci.redhat.com/testing-farm/a7fb70cf-0688-4fa2-a00a-6782fe8bb3dd] + +Reproduced failed tests with previous build libtiff-4.4.0-13.el9: +||Architecture||Original Request||Request With Old Build||Result||Comparison|| +|x86_64|[65f0eff4-ecad-4c8a-890a-24da164d0499|https://api.testing-farm.io/v0.1/requests/65f0eff4-ecad-4c8a-890a-24da164d0499]|[b9d52a86-3b0c-4e78-89ab-c32a1e0cc60a|https://api.testing-farm.io/v0.1/requests/b9d52a86-3b0c-4e78-89ab-c32a1e0cc60a]|failed|[compare|^comparison-b9d52a86-3b0c-4e78-89ab-c32a1e0cc60a--65f0eff4-ecad-4c8a-890a-24da164d0499.toml]| +|ppc64le|[b5686ad4-32db-44f7-8ef0-499d99afb220|https://api.testing-farm.io/v0.1/requests/b5686ad4-32db-44f7-8ef0-499d99afb220]|[08d261c2-3540-4878-9306-cd405f14699d|https://api.testing-farm.io/v0.1/requests/08d261c2-3540-4878-9306-cd405f14699d]|failed|[compare|^comparison-08d261c2-3540-4878-9306-cd405f14699d--b5686ad4-32db-44f7-8ef0-499d99afb220.toml]| +|aarch64|[a95ac61f-daab-4710-a9db-f96148657b08|https://api.testing-farm.io/v0.1/requests/a95ac61f-daab-4710-a9db-f96148657b08]|[2e4b43f9-4654-4f98-9276-42b65afbfb9b|https://api.testing-farm.io/v0.1/requests/2e4b43f9-4654-4f98-9276-42b65afbfb9b]|failed|[compare|^comparison-2e4b43f9-4654-4f98-9276-42b65afbfb9b--a95ac61f-daab-4710-a9db-f96148657b08.toml]| +|s390x|[a7fb70cf-0688-4fa2-a00a-6782fe8bb3dd|https://api.testing-farm.io/v0.1/requests/a7fb70cf-0688-4fa2-a00a-6782fe8bb3dd]|[3425b603-d9f7-439a-827b-6d65acd2e066|https://api.testing-farm.io/v0.1/requests/3425b603-d9f7-439a-827b-6d65acd2e066]|failed|[compare|^comparison-3425b603-d9f7-439a-827b-6d65acd2e066--a7fb70cf-0688-4fa2-a00a-6782fe8bb3dd.toml]| +""" + add_issue_comment(issue_key, baseline_test_comment) + print(f"Added complex comment") + + # Test 6: Update the comment + print("\n[6/15] Update the comment") full_issue = get_issue(issue_key, full=True) if full_issue.comments: comment_id = full_issue.comments[-1].id @@ -83,23 +104,23 @@ async def main(): else: print(f"No comments found") - # Test 6: Add a label - print("\n[6/14] Add issue label") + # Test 7: Add a label + print("\n[7/15] Add issue label") add_issue_label(issue_key, "test_complete") print(f"Added label") - # Test 7: Remove the label - print("\n[7/14] Remove issue label") + # Test 8: Remove the label + print("\n[8/15] Remove issue label") remove_issue_label(issue_key, "test_complete") print(f"Removed label") - # Test 8: Get issue status (using get_issue instead of JQL search) - print("\n[8/14] Get issue status") + # Test 9: Get issue status (using get_issue instead of JQL search) + print("\n[9/15] Get issue status") issue_for_status = get_issue(issue_key) print(f"Status: {issue_for_status.status}") - # Test 9: Change issue status - print("\n[9/14] Change issue status") + # Test 10: Change issue status + print("\n[10/15] Change issue status") change_issue_status( issue_key, IssueStatus.IN_PROGRESS, @@ -107,8 +128,8 @@ async def main(): ) print(f"Changed status to In Progress") - # Test 10: Add issue attachments - print("\n[10/14] Added attachment") + # Test 11: Add issue attachments + print("\n[11/15] Added attachment") test_content = f"Test file created at {datetime.now().isoformat()}\n".encode('utf-8') test_filename = f"test_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" add_issue_attachments( @@ -118,30 +139,30 @@ async def main(): ) print(f"Added attachment: {test_filename}") - # Test 11: Get issue attachment - print("\n[11/14] Get issue attachment") + # Test 12: Get issue attachment + print("\n[12/15] Get issue attachment") time.sleep(1) # wait for attachment to be available attachment_content = get_issue_attachment(issue_key, test_filename) print(f"Retrieved attachment ({len(attachment_content)} bytes)") - # Test 12: Search with JQL - print("\n[12/14] Search issues with JQL") + # Test 13: Search with JQL + print("\n[13/15] Search issues with JQL") jql = f'project = {project} AND labels = "uat_test" ORDER BY created DESC' matching_issues = list(get_current_issues(jql)) print(f"Found {len(matching_issues)} issues with 'uat_test' label") - # Test 13: Get full issue (with comments and description) - print("\n[13/14] Getting full issue with comments") + # Test 14: Get full issue (with comments and description) + print("\n[14/15] Getting full issue with comments") full_issue = get_issue(issue_key, full=True) if full_issue.comments: latest_comment = full_issue.comments[-1] print(f"Comments: {latest_comment.body[:50]}...") - # Verify ADF roundtrip worked - if "UPDATED" in latest_comment.body: + # Verify comment update worked + if "updated" in latest_comment.body.lower(): print(f"Comment update verified") - # Test 14: Test get_issue_by_jotnar_tag - print("\n[14/14] Testing Jotnar tag search") + # Test 15: Test get_issue_by_jotnar_tag + print("\n[15/15] Testing Jotnar tag search") try: #create an issue with a Jotnar tag from the start tag = JotnarTag(type="needs_attention", resource="erratum", id="TEST-456") diff --git a/supervisor/jira_utils.py b/supervisor/jira_utils.py index 93fdc92f..f4d0cafb 100644 --- a/supervisor/jira_utils.py +++ b/supervisor/jira_utils.py @@ -1,4 +1,5 @@ import backoff +import base64 from datetime import datetime from enum import Enum, StrEnum from functools import cache @@ -16,7 +17,6 @@ ) from urllib.parse import quote as urlquote -from adftotxt.adftotxt import startParsing as adf_to_text import requests from .http_utils import requests_session @@ -33,6 +33,13 @@ logger = logging.getLogger(__name__) +# Jira API support for both Jira cloud and server. +# uses jira API v2 by default, v3 only where v2 is deprecated. +# v2 returns plain text and is easier to parse, v3 returns ADF in complex JSON obj format. +# v2 works on both cloud and server, v3 only exists on cloud. +# v3 is used only for: +# - cloud's /search/jql endpoint +# - cloud's /user/search endpoint (requires 'query' param instead of 'username') @cache def components(): @@ -58,19 +65,9 @@ def jira_url() -> str: return url.rstrip("/") -@cache -def jira_api_version() -> str: - if "JIRA_API_VERSION" in os.environ: - version = os.environ["JIRA_API_VERSION"] - if version not in ("2", "3"): - raise ValueError(f"JIRA_API_VERSION must be '2' or '3'") - return version - - url = jira_url() - if "atlassian.net" in url: - return "3" - - return "2" +def is_jira_cloud() -> bool: + """Returns True if connected to Jira Cloud (atlassian.net).""" + return "atlassian.net" in jira_url() class JiraNotLoggedInError(Exception): @@ -79,12 +76,10 @@ class JiraNotLoggedInError(Exception): @cache def jira_headers() -> dict[str, str]: - import base64 - jira_token = os.environ["JIRA_TOKEN"] - if jira_api_version() == "3": - # for jira cloud: Basic Auth with email:token + if is_jira_cloud(): + # Cloud: Basic Auth with email:token (required for both v2 and v3 endpoints) jira_email = os.environ.get("JIRA_EMAIL") if not jira_email: raise ValueError("JIRA_EMAIL environment variable is required for Jira Cloud") @@ -102,7 +97,7 @@ def jira_headers() -> dict[str, str]: # Test if the token can log in successfully response = requests_session().get( - f"{jira_url()}/rest/api/{jira_api_version()}/myself", headers=headers + f"{jira_url()}/rest/api/2/myself", headers=headers ) if response.status_code == 401: @@ -144,8 +139,9 @@ def retry_on_rate_limit(func): @retry_on_rate_limit -def jira_api_get(path: str, *, params: dict | None = None) -> Any: - url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" +def jira_api_get(path: str, *, params: dict | None = None, api_version: Literal["2", "3"] = "2") -> Any: + version = api_version #defaults to v2 for plain text + url = f"{jira_url()}/rest/api/{version}/{path}" response = requests_session().get(url, headers=jira_headers(), params=params) if not response.ok: logger.error( @@ -172,9 +168,10 @@ def jira_api_post( @retry_on_rate_limit def jira_api_post( - path: str, json: dict[str, Any], *, decode_response: bool = False + path: str, json: dict[str, Any], *, decode_response: bool = False, api_version: Literal["2", "3"] = "2" ) -> Any | None: - url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" + version = api_version #defaults to v2 for plain text + url = f"{jira_url()}/rest/api/{version}/{path}" response = requests_session().post(url, headers=jira_headers(), json=json) if not response.ok: logger.error( @@ -213,7 +210,7 @@ def jira_api_upload( *, decode_response: bool = False, ) -> Any | None: - url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" + url = f"{jira_url()}/rest/api/2/{path}" #use v2 for uploads files = [("file", a) for a in attachments] headers = dict(jira_headers()) del headers["Content-Type"] # requests will set this correctly for multipart @@ -245,9 +242,10 @@ def jira_api_put( @retry_on_rate_limit def jira_api_put( - path: str, json: dict[str, Any], *, decode_response: bool = False + path: str, json: dict[str, Any], *, decode_response: bool = False, api_version: Literal["2", "3"] = "2" ) -> Any | None: - url = f"{jira_url()}/rest/api/{jira_api_version()}/{path}" + version = api_version # Default to v2 for plain text compatibility + url = f"{jira_url()}/rest/api/{version}/{path}" response = requests_session().put(url, headers=jira_headers(), json=json) if not response.ok: logger.error( @@ -323,16 +321,14 @@ def custom_enum_list(enum_class: Type[_E], name) -> list[_E] | None: if full: return FullIssue( **issue.__dict__, - description=_adf_to_text(issue_data["fields"]["description"]), + description=issue_data["fields"]["description"] or "", comments=[ JiraComment( authorName=c["author"]["displayName"], authorEmail=c["author"].get("emailAddress"), created=datetime.fromisoformat(c["created"]), - body=_adf_to_text(c["body"]), + body=c["body"], id=c["id"], - #storing original ADF if its a dict - adf=c["body"] if isinstance(c["body"], dict) else None, ) for c in issue_data["fields"]["comment"]["comments"] ], @@ -401,30 +397,36 @@ def get_current_issues( ) -> Generator[Issue, None, None] | Generator[FullIssue, None, None]: max_results = 1000 - if jira_api_version() == "3": - #v3 uses nextPageToken for pagination + if is_jira_cloud(): + # Cloud: Use v3 search/jql endpoint (v2 is deprecated) next_page_token = None while True: body = { "jql": jql, "maxResults": max_results, - "fields": _fields(full), + # when full=True, just fetch issue key (will re-fetch full issue with v2) + "fields": [] if full else _fields(False), } if next_page_token: body["nextPageToken"] = next_page_token logger.debug("Fetching JIRA issues (v3), token=%s, max=%d", next_page_token, max_results) - response_data = jira_api_post("search/jql", json=body, decode_response=True) + response_data = jira_api_post("search/jql", json=body, decode_response=True, api_version="3") logger.debug("Got %d issues", len(response_data["issues"])) for issue_data in response_data["issues"]: - yield decode_issue(issue_data, full) + if full: + # Fetch full issue with v2 to get plain text descriptions/comments + issue_key = issue_data["key"] + yield get_issue(issue_key, full=True) + else: + yield decode_issue(issue_data, full=False) if response_data.get("isLast", True): break next_page_token = response_data.get("nextPageToken") else: - #v2 uses startAt for pagination + # Server: Use v2 search endpoint start_at = 0 while True: body = { @@ -471,17 +473,18 @@ def get_issue_by_jotnar_tag( if with_label is not None: jql += f' AND labels = "{with_label}"' - if jira_api_version() == "3": - #v3 doesn't support startAt only uses nextPageToken + if is_jira_cloud(): + # Cloud: Use v3 search/jql endpoint (v2 is deprecated) body = { "jql": jql, "maxResults": max_results, - "fields": _fields(full), + # when full=True, just fetch issue key (will re-fetch full issue with v2) + "fields": [] if full else _fields(False), } logger.debug("Fetching JIRA issues (v3), max=%d", max_results) - response_data = jira_api_post("search/jql", json=body, decode_response=True) + response_data = jira_api_post("search/jql", json=body, decode_response=True, api_version="3") else: - #v2 uses startAt + # Server: Use v2 search endpoint body = { "jql": jql, "startAt": 0, @@ -496,7 +499,12 @@ def get_issue_by_jotnar_tag( elif len(response_data["issues"]) > 1: raise ValueError(f"Multiple open issues found with JOTNAR tag {tag}") else: - return decode_issue(response_data["issues"][0], full) + issue_key = response_data["issues"][0]["key"] + if is_jira_cloud() and full: + # Cloud: Fetch full issue with v2 to get plain text + return get_issue(issue_key, full=True) + else: + return decode_issue(response_data["issues"][0], full) def get_issues_statuses(issue_keys: Collection[str]) -> dict[str, IssueStatus]: @@ -509,9 +517,11 @@ def get_issues_statuses(issue_keys: Collection[str]) -> dict[str, IssueStatus]: "maxResults": len(issue_keys), "fields": ["status"], } - if jira_api_version() == "3": - response_data = jira_api_post("search/jql", json=body, decode_response=True) + if is_jira_cloud(): + # Cloud: Use v3 search/jql endpoint (v2 is deprecated) + response_data = jira_api_post("search/jql", json=body, decode_response=True, api_version="3") else: + # Server: Use v2 search endpoint response_data = jira_api_post("search", json=body, decode_response=True) result = { @@ -531,66 +541,25 @@ class CommentVisibility(StrEnum): RED_HAT_EMPLOYEE = "Red Hat Employee" -CommentSpec = None | str | dict[str, Any] | tuple[str | dict[str, Any], CommentVisibility] - - -def _text_to_adf(text: str) -> dict[str, Any]: - return { - "version": 1, - "type": "doc", - "content": [ - { - "type": "paragraph", - "content": [ - { - "type": "text", - "text": text - } - ] - } - ] - } - - -def _adf_to_text(adf_or_text: dict[str, Any] | str) -> str: - if isinstance(adf_or_text, str): - return adf_or_text - - #if its ADF, convert to text - text = adf_to_text(adf_or_text, runReplacers=False) - #removing trailing newline added by adftotxt library - return text.rstrip('\n') +CommentSpec = None | str | tuple[str, CommentVisibility] def _comment_to_dict(comment: CommentSpec) -> dict[str, Any] | None: if comment is None: return - if isinstance(comment, (str, dict)): + if isinstance(comment, str): comment_value = comment visibility = CommentVisibility.PUBLIC else: comment_value, visibility = comment - # determine the body content based on API version and input type - if isinstance(comment_value, dict): - #already ADF, use as is for v3 or convert to text for v2 - if jira_api_version() == "3": - body_content = comment_value - else: - body_content = _adf_to_text(comment_value) - else: - #plain text convert to ADF for v3, use as is for v2 - if jira_api_version() == "3": - body_content = _text_to_adf(comment_value) - else: - body_content = comment_value - + # v2 API uses plain text for comments if visibility == CommentVisibility.PUBLIC: - return {"body": body_content} + return {"body": comment_value} else: return { - "body": body_content, + "body": comment_value, "visibility": {"type": "group", "value": str(visibility)}, } @@ -806,9 +775,9 @@ def get_issue_attachment(issue_key: str, filename: str) -> bytes: @cache def get_user_name(email: str) -> str: - # v3 uses 'query' v2 uses 'username' param - if jira_api_version() == "3": - users = jira_api_get("user/search", params={"query": email}) + # Cloud: v3 uses 'query' parameter; Server: v2 uses 'username' parameter + if is_jira_cloud(): + users = jira_api_get("user/search", params={"query": email}, api_version="3") else: users = jira_api_get("user/search", params={"username": email}) @@ -819,10 +788,7 @@ def get_user_name(email: str) -> str: user = users[0] - if jira_api_version() == "3": - return user.get("displayName") or user["accountId"] - else: - return user.get("name") or user.get("displayName") or user["accountId"] + return user.get("name") or user.get("displayName") or user["accountId"] @overload @@ -873,16 +839,11 @@ def create_issue( if tag is not None: description = f"{tag}\n\n{description}" - #for cloud v3 convert description to ADF format - if jira_api_version() == "3": - description_content = _text_to_adf(description) - else: - description_content = description fields = { "project": {"key": project}, "summary": summary, - "description": description_content, + "description": description, "issuetype": {"name": "Task"}, } diff --git a/supervisor/supervisor_types.py b/supervisor/supervisor_types.py index e919ce52..73631097 100644 --- a/supervisor/supervisor_types.py +++ b/supervisor/supervisor_types.py @@ -109,7 +109,6 @@ class Issue(BaseModel): class JiraComment(Comment): id: str - adf: dict[str, Any] | None = None class FullIssue(Issue):