Skip to content
Draft
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
200 changes: 200 additions & 0 deletions scripts/test_jira_cloud_uat.py
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The total number of tests in the progress message is inconsistent. This one says 1/14, but later tests say x/15. Please update this and the other occurrences on lines 53 and 64 to use /15 for consistency.

print("\n[1/15] 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())
69 changes: 47 additions & 22 deletions supervisor/jira_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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():
Expand All @@ -59,6 +66,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

Expand Down Expand Up @@ -111,8 +123,8 @@ 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()}/{path}" if path.startswith("rest/") else f"{jira_url()}/rest/api/2/{path}"
def jira_api_get(path: str, *, params: dict | None = None, api_version: Literal["2", "3"] = "3") -> Any:
url = f"{jira_url()}/rest/api/{api_version}/{path}"
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

The new URL construction logic breaks callers that provide a full REST path. For example, JIRA_SEARCH_PATH is rest/api/3/search/jql, and calling this function with it will result in a malformed URL like .../rest/api/3/rest/api/3/search/jql. You should retain the logic to handle paths that already start with rest/.

This issue also exists in jira_api_post, jira_api_put, and jira_api_upload.

Suggested change
url = f"{jira_url()}/rest/api/{api_version}/{path}"
url = f"{jira_url()}/{path}" if path.startswith("rest/") else f"{jira_url()}/rest/api/{api_version}/{path}"

response = requests_session().get(url, headers=jira_headers(), params=params)
if not response.ok:
logger.error(
Expand All @@ -139,9 +151,9 @@ 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"] = "3"
) -> Any | None:
url = f"{jira_url()}/{path}" if path.startswith("rest/") else f"{jira_url()}/rest/api/2/{path}"
url = f"{jira_url()}/rest/api/{api_version}/{path}"
response = requests_session().post(url, headers=jira_headers(), json=json)
if not response.ok:
logger.error(
Expand Down Expand Up @@ -180,7 +192,7 @@ def jira_api_upload(
*,
decode_response: bool = False,
) -> Any | None:
url = f"{jira_url()}/{path}" if path.startswith("rest/") else 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
Expand Down Expand Up @@ -212,9 +224,9 @@ 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"] = "3"
) -> Any | None:
url = f"{jira_url()}/{path}" if path.startswith("rest/") else f"{jira_url()}/rest/api/2/{path}"
url = f"{jira_url()}/rest/api/{api_version}/{path}"
response = requests_session().put(url, headers=jira_headers(), json=json)
if not response.ok:
logger.error(
Expand Down Expand Up @@ -290,7 +302,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"],
Expand Down Expand Up @@ -370,22 +382,18 @@ def get_current_issues(
body: dict[str, Any] = {
"jql": jql,
"maxResults": max_results,
"fields": _fields(full),
"fields": [] if full else _fields(False),
}

if next_page_token:
body["nextPageToken"] = next_page_token

logger.debug("Fetching JIRA issues, max=%d, nextPageToken=%s", max_results, next_page_token)
response_data = jira_api_post(JIRA_SEARCH_PATH, 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)

next_page_token = response_data.get("nextPageToken")
if not next_page_token or len(response_data["issues"]) == 0:
break
if response_data.get("isLast", True):
break
next_page_token = response_data.get("nextPageToken")
Comment on lines 382 to +396
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

This loop appears to be broken due to incomplete refactoring:

  1. The response_data variable is used but never assigned because the jira_api_post call has been removed. This will cause an UnboundLocalError.
  2. The pagination logic is broken. The next_page_token is retrieved but not passed in the body of the subsequent request, which would likely cause an infinite loop if there are multiple pages of results.
  3. next_page_token is assigned twice (lines 391 and 396), which is redundant.

Please restore the API call and fix the pagination logic.



@overload
Expand All @@ -409,14 +417,16 @@ 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:
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,
"maxResults": 2,
"fields": _fields(full),
"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 by jotnar tag %s", tag)
Expand All @@ -427,15 +437,21 @@ 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]:
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"],
}
Expand Down Expand Up @@ -472,6 +488,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:
Expand Down Expand Up @@ -692,13 +709,20 @@ def get_issue_attachment(issue_key: str, filename: str) -> bytes:

@cache
def get_user_account_id(email: str) -> str:
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})
Copy link
Contributor

Choose a reason for hiding this comment

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

high

According to the comment at the top of the file, Jira API v3 only exists on Jira Cloud. For Jira Server, you must use v2. This call does not specify the API version, so it will default to '3' and likely fail on Jira Server.

Suggested change
users = jira_api_get("user/search", params={"username": email})
users = jira_api_get("user/search", params={"username": email}, api_version="2")

matches = [u for u in users if u.get("emailAddress") == email]
if len(matches) == 0:
raise ValueError(f"No JIRA user with email {email}")
elif len(matches) > 1:
raise ValueError(f"Multiple JIRA users with email {email}")
return matches[0]["accountId"]

user = users[0]

return user.get("name") or user.get("displayName") or user["accountId"]


@overload
Expand Down Expand Up @@ -749,6 +773,7 @@ def create_issue(
if tag is not None:
description = f"{tag}\n\n{description}"


fields = {
"project": {"key": project},
"summary": summary,
Expand Down
Loading