diff --git a/scripts/test_jira_cloud_uat.py b/scripts/test_jira_cloud_uat.py new file mode 100644 index 00000000..a0088a0c --- /dev/null +++ b/scripts/test_jira_cloud_uat.py @@ -0,0 +1,200 @@ +#!/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, + 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="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 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: 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 + 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 7: Add a label + print("\n[7/15] Add issue label") + add_issue_label(issue_key, "test_complete") + print(f"Added 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 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 10: Change issue status + print("\n[10/15] 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 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( + issue_key, + [(test_filename, test_content, "text/plain")], + comment="Test attachment added by UAT script" + ) + print(f"Added attachment: {test_filename}") + + # 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 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 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 comment update worked + if "updated" in latest_comment.body.lower(): + print(f"Comment update verified") + + # 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") + 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()) diff --git a/supervisor/jira_utils.py b/supervisor/jira_utils.py index 6f4ca479..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 @@ -32,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(): @@ -57,6 +65,11 @@ def jira_url() -> str: return url.rstrip("/") +def is_jira_cloud() -> bool: + """Returns True if connected to Jira Cloud (atlassian.net).""" + return "atlassian.net" in jira_url() + + class JiraNotLoggedInError(Exception): pass @@ -65,8 +78,20 @@ class JiraNotLoggedInError(Exception): def jira_headers() -> dict[str, str]: jira_token = os.environ["JIRA_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") + + 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", } @@ -114,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/2/{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( @@ -142,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/2/{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( @@ -183,7 +210,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/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 @@ -215,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/2/{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( @@ -293,7 +321,7 @@ def custom_enum_list(enum_class: Type[_E], name) -> list[_E] | None: if full: return FullIssue( **issue.__dict__, - description=issue_data["fields"]["description"], + description=issue_data["fields"]["description"] or "", comments=[ JiraComment( authorName=c["author"]["displayName"], @@ -367,26 +395,56 @@ 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 is_jira_cloud(): + # Cloud: Use v3 search/jql endpoint (v2 is deprecated) + next_page_token = None + while True: + body = { + "jql": jql, + "maxResults": max_results, + # 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, api_version="3") + logger.debug("Got %d issues", len(response_data["issues"])) + + for issue_data in response_data["issues"]: + 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: + # Server: Use v2 search endpoint + 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,41 +468,61 @@ 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 is_jira_cloud(): + # Cloud: Use v3 search/jql endpoint (v2 is deprecated) + body = { + "jql": jql, + "maxResults": max_results, + # 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, api_version="3") + else: + # Server: Use v2 search endpoint + 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 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]: 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 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 = { issue_data["key"]: IssueStatus(issue_data["fields"]["status"]["name"]) @@ -476,6 +554,7 @@ def _comment_to_dict(comment: CommentSpec) -> dict[str, Any] | None: else: comment_value, visibility = comment + # v2 API uses plain text for comments if visibility == CommentVisibility.PUBLIC: return {"body": comment_value} else: @@ -696,12 +775,20 @@ 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}) + # 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}) + 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] + + return user.get("name") or user.get("displayName") or user["accountId"] @overload @@ -752,6 +839,7 @@ def create_issue( if tag is not None: description = f"{tag}\n\n{description}" + fields = { "project": {"key": project}, "summary": summary, 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)