-
Notifications
You must be signed in to change notification settings - Fork 25
feat: add Jira Cloud API support #392
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
| 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()) | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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(): | ||||||
|
|
@@ -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 | ||||||
|
|
||||||
|
|
@@ -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}" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new URL construction logic breaks callers that provide a full REST path. For example, This issue also exists in
Suggested change
|
||||||
| response = requests_session().get(url, headers=jira_headers(), params=params) | ||||||
| if not response.ok: | ||||||
| logger.error( | ||||||
|
|
@@ -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( | ||||||
|
|
@@ -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 | ||||||
|
|
@@ -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( | ||||||
|
|
@@ -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"], | ||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This loop appears to be broken due to incomplete refactoring:
Please restore the API call and fix the pagination logic. |
||||||
|
|
||||||
|
|
||||||
| @overload | ||||||
|
|
@@ -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) | ||||||
|
|
@@ -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"], | ||||||
| } | ||||||
|
|
@@ -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: | ||||||
|
|
@@ -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}) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
|
||||||
| 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 | ||||||
|
|
@@ -749,6 +773,7 @@ def create_issue( | |||||
| if tag is not None: | ||||||
| description = f"{tag}\n\n{description}" | ||||||
|
|
||||||
|
|
||||||
| fields = { | ||||||
| "project": {"key": project}, | ||||||
| "summary": summary, | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The total number of tests in the progress message is inconsistent. This one says
1/14, but later tests sayx/15. Please update this and the other occurrences on lines 53 and 64 to use/15for consistency.