From f954de4f95a078cfa17c958f4d0c7715aeab4f43 Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Sun, 30 Mar 2025 21:26:03 -0400 Subject: [PATCH 01/13] JIRA prod support agent --- examples/jira_prod_support.py | 611 ++++++++++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 examples/jira_prod_support.py diff --git a/examples/jira_prod_support.py b/examples/jira_prod_support.py new file mode 100644 index 0000000..a375ae9 --- /dev/null +++ b/examples/jira_prod_support.py @@ -0,0 +1,611 @@ +""" +Example demonstrating how to use the browser automation framework to interact with JIRA tickets. + +This example shows how to: +1. Initialize a local browser instance using the LocalPlaywrightBrowser +2. Navigate to a JIRA ticket +3. Log in (if necessary) +4. Extract the ticket summary, description, and other fields +5. Debug selector issues with element info +""" + +import os +import json +from app.browser_agent.local_playwright import LocalPlaywrightBrowser +from dotenv import load_dotenv + +# Load environment variables +load_dotenv(override=True) + + +def read_jira_ticket(ticket_id, jira_url=None, use_sso=True, prefer_google=True, extract_fields=None): + """ + Opens a JIRA ticket and extracts its information. + + Args: + ticket_id (str): The JIRA ticket ID (e.g., "PROJ-123") + jira_url (str, optional): The JIRA instance URL. Defaults to environment variable. + use_sso (bool): Whether to try SSO login flow (default: True) + prefer_google (bool): Whether to prefer Google SSO if available (default: True) + extract_fields (list, optional): List of additional fields to extract (e.g., ["status", "assignee"]) + + Returns: + dict: Ticket information including summary, description, and other fields + """ + # Default fields to extract if not specified + if extract_fields is None: + extract_fields = ["status", "assignee", "priority", "type"] + + # Get JIRA URL from environment variable if not provided + jira_url = jira_url or os.getenv("JIRA_URL", "https://mydomain.atlassian.net") + + # Credentials from environment variables + username = os.getenv("JIRA_USERNAME") + password = os.getenv("JIRA_PASSWORD") + + print(f"Opening JIRA ticket {ticket_id}...") + + # Create a browser instance (set headless=False to see the browser) + with LocalPlaywrightBrowser(headless=False) as browser: + # Navigate to the JIRA ticket + ticket_url = f"{jira_url}/browse/{ticket_id}" + browser.goto(ticket_url) + + # Wait for the page to load + browser.wait(3000) # 3 seconds + + # Check if login is required + if _is_login_page(browser): + if not username or not password: + print("Login required but no credentials provided in environment variables.") + return {"error": "Login required but no credentials provided"} + + print("Login required. Attempting to log in...") + _login(browser, username, password, use_sso=use_sso, prefer_google=prefer_google) + + # Wait for redirect after login + browser.wait(5000) + + # Navigate to ticket again if needed + current_url = browser.get_current_url() + if ticket_id not in current_url: + browser.goto(ticket_url) + browser.wait(3000) + + # Initialize ticket information + ticket_info = { + "id": ticket_id, + "url": browser.get_current_url(), + } + + # Extract ticket fields + _extract_ticket_fields(browser, ticket_info, extract_fields) + + return ticket_info + + +def _is_login_page(browser): + """Check if we're on a login page.""" + login_selectors = [ + "input[name='username']", + "input[id='login-submit']", + "input[name='password']", + "button:contains('Continue with Google')", + "button:contains('Log in with SSO')", + "a:contains('Single Sign-on')" + ] + + for selector in login_selectors: + # Use get_element_info for better detection + element_info = browser.get_element_info(selector) + if element_info: + print(f"Login element detected: {element_info['tag']}") + return True + + # Fallback to wait_for_selector for complex selectors + if browser.wait_for_selector(selector, timeout=1000): + return True + + return False + + +def _extract_ticket_fields(browser, ticket_info, extract_fields): + """Extract all requested fields from the JIRA ticket. + + Args: + browser: Browser instance + ticket_info: Dictionary to update with extracted data + extract_fields: List of fields to extract + """ + # Standard fields with their selectors + field_selectors = { + "summary": "[data-test-id='issue.views.issue-base.foundation.summary.heading']", + "description": "[data-test-id='issue.views.field.rich-text.description']", + "status": "[data-test-id='issue.views.issue-base.foundation.status.status-field-wrapper']", + "assignee": "[data-test-id='issue.views.field.user.assignee']", + "priority": "[data-test-id='issue.views.field.select.priority']", + "type": "[data-test-id='issue.views.issue-base.foundation.issue-type.issue-type-field-wrapper']", + "reporter": "[data-test-id='issue.views.field.user.reporter']", + "created": "[data-test-id='issue.views.field.date.created']", + "updated": "[data-test-id='issue.views.field.date.updated']", + "comments": "[data-test-id='issue.views.comments.comment-container']", + "labels": "[data-test-id='issue.views.field.labels']", + "components": "[data-test-id='issue.views.field.select.components']", + "fix_versions": "[data-test-id='issue.views.field.select.fixversions']" + } + + # Always extract summary and description + fields_to_extract = ["summary", "description"] + extract_fields + + # Remove duplicates + fields_to_extract = list(dict.fromkeys(fields_to_extract)) + + for field in fields_to_extract: + if field in field_selectors: + selector = field_selectors[field] + try: + # First check if the element exists and is visible + element_info = browser.get_element_info(selector) + + if element_info and element_info.get('isVisible', False): + text = browser.extract_text(selector) + ticket_info[field] = text + print(f"Extracted {field}: {text[:50]}..." if len(text) > 50 else f"Extracted {field}: {text}") + else: + # Log debug info + if element_info: + print(f"{field} element found but not visible: {element_info}") + else: + print(f"{field} element not found") + + # Try alternative selectors + alt_selector = f"[data-testid*='{field}'], [id*='{field}'], [class*='{field}']" + if browser.wait_for_selector(alt_selector, timeout=1000): + text = browser.extract_text(alt_selector) + ticket_info[field] = text + print(f"Extracted {field} (alt): {text[:50]}..." if len( + text) > 50 else f"Extracted {field} (alt): {text}") + else: + ticket_info[field] = "Not found" + except Exception as e: + print(f"Error extracting {field}: {e}") + ticket_info[field] = "Error extracting" + + # Extract comments as a list if present + if "comments" in fields_to_extract: + try: + comments_selector = field_selectors["comments"] + if browser.wait_for_selector(comments_selector, timeout=1000): + # Get all comment elements + comment_elements = browser._page.query_selector_all(comments_selector) + comments = [] + for i, element in enumerate(comment_elements, 1): + comment_text = element.text_content() + if comment_text: + comments.append({ + "number": i, + "text": comment_text.strip() + }) + ticket_info["comments"] = comments + print(f"Extracted {len(comments)} comments") + else: + ticket_info["comments"] = [] + except Exception as e: + print(f"Error extracting comments: {e}") + ticket_info["comments"] = [] + + # As a fallback, get the entire HTML if extraction fails + if "summary" not in ticket_info or ticket_info["summary"] == "Not found": + try: + # Save HTML for debugging + ticket_info["_html"] = browser.get_page_html() + print("Saved page HTML as fallback") + except Exception as e: + print(f"Error getting page HTML: {e}") + + +def _login(browser, username, password, use_sso=True, prefer_google=True): + """Handle JIRA login with support for SSO. + + Args: + browser: Browser instance + username: JIRA username/email + password: JIRA password/API token + use_sso: Whether to attempt SSO login (default: True) + prefer_google: Whether to prefer Google SSO if available (default: True) + """ + try: + # First check for SSO options if requested + if use_sso: + # Prioritize Google SSO if preferred + if prefer_google: + google_sso_selectors = [ + "button:contains('Continue with Google')", + "button:contains('Sign in with Google')", + "a:contains('Google')", + "button:contains('Google')", + "button[data-provider='google']", + "div:contains('Google') button" + ] + + for selector in google_sso_selectors: + # Better element detection + element_info = browser.get_element_info(selector) + if element_info: + print(f"Found Google SSO option: {element_info['tag']} (visible: {element_info['isVisible']})") + if element_info['isVisible']: + browser.click_selector(selector) + browser.wait(5000) # Wait for redirect + + # Handle Google SSO provider page + return _handle_google_sso(browser, username, password) + + # Fallback + if browser.wait_for_selector(selector, timeout=1000): + print(f"Found Google SSO option: {selector}") + browser.click_selector(selector) + browser.wait(5000) # Wait for redirect + + # Handle Google SSO provider page + return _handle_google_sso(browser, username, password) + + # If Google not found or not preferred, try other SSO options + sso_selectors = [ + "button:contains('Log in with SSO')", + "a:contains('Single Sign-on')", + "button:contains('SSO')", + "button:contains('Continue with Microsoft')", + "button:contains('Continue with Okta')" + ] + + for selector in sso_selectors: + if browser.wait_for_selector(selector, timeout=1000): + print(f"Found SSO option: {selector}") + browser.click_selector(selector) + browser.wait(5000) # Wait for redirect + + # Handle SSO provider page + return _handle_sso_provider(browser, username, password) + + # Regular username/password flow if no SSO or SSO not requested + # Enter username + username_selector = "input[name='username'], input[id='username'], input[name='email']" + if browser.wait_for_selector(username_selector, timeout=3000): + browser.click_selector(username_selector) + browser.type(username) + + # Look for Continue or Submit button + continue_selector = "button[id='login-submit'], button[type='submit'], button:contains('Continue')" + if browser.wait_for_selector(continue_selector, timeout=2000): + browser.click_selector(continue_selector) + browser.wait(3000) + + # Enter password (might be on a second screen after username) + password_selector = "input[name='password'], input[id='password']" + if browser.wait_for_selector(password_selector, timeout=3000): + browser.click_selector(password_selector) + browser.type(password) + + # Click login button + login_selector = "button[id='login-submit'], button[type='submit'], button:contains('Log in')" + if browser.wait_for_selector(login_selector, timeout=2000): + browser.click_selector(login_selector) + browser.wait(5000) # Wait longer for login to complete + + return True + except Exception as e: + print(f"Login error: {e}") + return False + + +def _handle_sso_provider(browser, username, password): + """Handle SSO provider authentication flow. + + This function attempts to detect and handle various SSO providers like + Google, Microsoft, Okta, etc. + """ + # Wait for SSO page to load + browser.wait(3000) + + # Get current URL to determine provider + current_url = browser.get_current_url() + print(f"SSO redirect URL: {current_url}") + + # Google SSO + if "google" in current_url.lower(): + return _handle_google_sso(browser, username, password) + # Microsoft/Azure SSO + elif any(provider in current_url.lower() for provider in ["microsoft", "azure", "live"]): + return _handle_microsoft_sso(browser, username, password) + # Okta SSO + elif "okta" in current_url.lower(): + return _handle_okta_sso(browser, username, password) + # Generic SSO as fallback + else: + return _handle_generic_sso(browser, username, password) + + +def _handle_google_sso(browser, username, password): + """Handle Google SSO login flow.""" + try: + # Enter email + email_selector = "input[type='email']" + if browser.wait_for_selector(email_selector, timeout=5000): + # Check element state + element_info = browser.get_element_info(email_selector) + if element_info: + print(f"Found email input: {element_info.get('attributes', {})}") + + browser.click_selector(email_selector) + browser.type(username) + + # Click next + next_selector = "button:contains('Next'), button[id='identifierNext']" + if browser.wait_for_selector(next_selector, timeout=2000): + browser.click_selector(next_selector) + browser.wait(3000) + + # Enter password + password_selector = "input[type='password']" + if browser.wait_for_selector(password_selector, timeout=5000): + browser.click_selector(password_selector) + browser.type(password) + + # Click next/sign in + signin_selector = "button:contains('Next'), button[id='passwordNext']" + if browser.wait_for_selector(signin_selector, timeout=2000): + browser.click_selector(signin_selector) + browser.wait(5000) + + return True + except Exception as e: + print(f"Google SSO error: {e}") + return False + + +def _handle_microsoft_sso(browser, username, password): + """Handle Microsoft/Azure SSO login flow.""" + try: + # Enter email + email_selector = "input[type='email'], input[name='loginfmt']" + if browser.wait_for_selector(email_selector, timeout=5000): + element_info = browser.get_element_info(email_selector) + if element_info: + print(f"Found Microsoft email input: {element_info.get('attributes', {})}") + + browser.click_selector(email_selector) + browser.type(username) + + # Click next + next_selector = "input[type='submit'], button:contains('Next')" + if browser.wait_for_selector(next_selector, timeout=2000): + browser.click_selector(next_selector) + browser.wait(3000) + + # Enter password + password_selector = "input[type='password'], input[name='passwd']" + if browser.wait_for_selector(password_selector, timeout=5000): + browser.click_selector(password_selector) + browser.type(password) + + # Click sign in + signin_selector = "input[type='submit'], button:contains('Sign in')" + if browser.wait_for_selector(signin_selector, timeout=2000): + browser.click_selector(signin_selector) + browser.wait(3000) + + # Handle "Stay signed in?" if it appears + stay_selector = "input[type='submit'][value='Yes'], button:contains('Yes')" + if browser.wait_for_selector(stay_selector, timeout=3000): + browser.click_selector(stay_selector) + browser.wait(3000) + + return True + except Exception as e: + print(f"Microsoft SSO error: {e}") + return False + + +def _handle_okta_sso(browser, username, password): + """Handle Okta SSO login flow.""" + try: + # Enter username/email + username_selector = "input[name='username'], input[id='okta-signin-username']" + if browser.wait_for_selector(username_selector, timeout=5000): + element_info = browser.get_element_info(username_selector) + if element_info: + print(f"Found Okta username input: {element_info.get('attributes', {})}") + + browser.click_selector(username_selector) + browser.type(username) + + # Enter password + password_selector = "input[name='password'], input[id='okta-signin-password']" + if browser.wait_for_selector(password_selector, timeout=3000): + browser.click_selector(password_selector) + browser.type(password) + + # Click sign in + submit_selector = "input[type='submit'], button[type='submit'], button:contains('Sign in')" + if browser.wait_for_selector(submit_selector, timeout=2000): + browser.click_selector(submit_selector) + browser.wait(5000) + + # Handle MFA if present (this is very org-specific) + # This is a simplified example - real MFA handling would need to be customized + push_selector = "button:contains('Send Push'), a:contains('Send Push')" + if browser.wait_for_selector(push_selector, timeout=3000): + browser.click_selector(push_selector) + print("MFA push notification sent - please approve on your device") + browser.wait(20000) # Wait longer for MFA approval + + return True + except Exception as e: + print(f"Okta SSO error: {e}") + return False + + +def _handle_generic_sso(browser, username, password): + """Handle a generic SSO login flow for unknown providers.""" + try: + # Look for common username/email fields + username_selectors = [ + "input[type='email']", + "input[name='username']", + "input[id='username']", + "input[name='email']" + ] + + for selector in username_selectors: + element_info = browser.get_element_info(selector) + if element_info and element_info.get('isVisible', False): + print( + f"Found username input: {element_info['tag']} with attributes: {element_info.get('attributes', {})}") + browser.click_selector(selector) + browser.type(username) + browser.wait(1000) + + # Look for a next/continue button + next_selectors = [ + "button:contains('Next')", + "button:contains('Continue')", + "input[type='submit']", + "button[type='submit']" + ] + + for next_selector in next_selectors: + if browser.wait_for_selector(next_selector, timeout=1000): + browser.click_selector(next_selector) + browser.wait(3000) + break + break + elif browser.wait_for_selector(selector, timeout=1000): + browser.click_selector(selector) + browser.type(username) + browser.wait(1000) + + # Look for a next/continue button + for next_selector in ["button:contains('Next')", "button:contains('Continue')", "input[type='submit']"]: + if browser.wait_for_selector(next_selector, timeout=1000): + browser.click_selector(next_selector) + browser.wait(3000) + break + break + + # Look for common password fields + password_selectors = [ + "input[type='password']", + "input[name='password']", + "input[id='password']" + ] + + for selector in password_selectors: + element_info = browser.get_element_info(selector) + if element_info and element_info.get('isVisible', False): + browser.click_selector(selector) + browser.type(password) + browser.wait(1000) + + # Look for a login/submit button + submit_selectors = [ + "button:contains('Sign in')", + "button:contains('Log in')", + "input[type='submit']", + "button[type='submit']" + ] + + for submit_selector in submit_selectors: + if browser.wait_for_selector(submit_selector, timeout=1000): + browser.click_selector(submit_selector) + browser.wait(5000) + break + break + elif browser.wait_for_selector(selector, timeout=3000): + browser.click_selector(selector) + browser.type(password) + browser.wait(1000) + + # Look for a login/submit button + for submit_selector in ["button:contains('Sign in')", "button:contains('Log in')", + "input[type='submit']"]: + if browser.wait_for_selector(submit_selector, timeout=1000): + browser.click_selector(submit_selector) + browser.wait(5000) + break + break + + return True + except Exception as e: + print(f"Generic SSO error: {e}") + return False + + +def save_ticket_data(ticket_info, output_dir=None): + """Save ticket data to a JSON file. + + Args: + ticket_info: Dictionary of ticket data + output_dir: Directory to save file (defaults to ./ticket_data) + + Returns: + Path to saved file + """ + if output_dir is None: + output_dir = os.path.join(os.getcwd(), "ticket_data") + + # Create directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Filename from ticket ID + filename = f"{ticket_info['id'].replace('-', '_').lower()}.json" + filepath = os.path.join(output_dir, filename) + + # Save data + with open(filepath, 'w') as f: + json.dump(ticket_info, f, indent=2) + + print(f"Ticket data saved to: {filepath}") + return filepath + + +if __name__ == "__main__": + # Example usage + ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") + + # Additional fields to extract + fields = input("Enter comma-separated list of additional fields to extract (leave blank for defaults): ") + if fields.strip(): + extract_fields = [f.strip() for f in fields.split(",")] + else: + extract_fields = None + + # Automatically use Google SSO by default + ticket_info = read_jira_ticket(ticket_id, use_sso=True, prefer_google=True, extract_fields=extract_fields) + + # Save to file option + save_option = input("Save ticket data to file? (y/n): ").lower() + if save_option == 'y': + save_path = save_ticket_data(ticket_info) + + print("\nTicket Information:") + for key, value in ticket_info.items(): + # Skip HTML content in the output + if key == "_html": + print(f" {key}: [HTML content saved]") + # Format comments + elif key == "comments" and isinstance(value, list): + print(f" {key}: {len(value)} comments") + if value and len(value) > 0: + print(f" First comment: {value[0]['text'][:100]}..." if len( + value[0]['text']) > 100 else f" First comment: {value[0]['text']}") + # Format long strings + elif isinstance(value, str) and len(value) > 150: + print(f" {key}: {value[:150]}...") + else: + print(f" {key}: {value}") + + print("\nNote: This example demonstrates JIRA ticket retrieval.") + print("To use with your JIRA instance, set the following environment variables:") + print(" - JIRA_URL: Your JIRA instance URL (e.g., https://your-company.atlassian.net)") + print(" - JIRA_USERNAME: Your JIRA username/email") + print(" - JIRA_PASSWORD: Your JIRA password or API token") From 9f654e6390c75705ee0d0aaeb3b7d890f171c32c Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Sun, 30 Mar 2025 21:40:51 -0400 Subject: [PATCH 02/13] Refactor --- app/jira_agent/__init__.py | 11 + app/jira_agent/auth.py | 450 +++++++++++++++++++++++++++++++ app/jira_agent/jira.py | 403 +++++++++++++++++++++++++++ app/jira_agent/selectors.py | 74 +++++ examples/jira_api_integration.py | 180 +++++++++++++ examples/jira_example.py | 123 +++++++++ 6 files changed, 1241 insertions(+) create mode 100644 app/jira_agent/__init__.py create mode 100644 app/jira_agent/auth.py create mode 100644 app/jira_agent/jira.py create mode 100644 app/jira_agent/selectors.py create mode 100644 examples/jira_api_integration.py create mode 100644 examples/jira_example.py diff --git a/app/jira_agent/__init__.py b/app/jira_agent/__init__.py new file mode 100644 index 0000000..2b91bdf --- /dev/null +++ b/app/jira_agent/__init__.py @@ -0,0 +1,11 @@ +"""JIRA agent for automating interaction with JIRA tickets. + +This package provides tools for: +1. Authenticating with JIRA (including SSO) +2. Navigating to and extracting data from JIRA tickets +3. Performing actions on tickets (commenting, status updates, etc.) +""" + +from app.jira_agent.jira import JiraAgent + +__all__ = ["JiraAgent"] \ No newline at end of file diff --git a/app/jira_agent/auth.py b/app/jira_agent/auth.py new file mode 100644 index 0000000..9f0e2d7 --- /dev/null +++ b/app/jira_agent/auth.py @@ -0,0 +1,450 @@ +"""Authentication utilities for JIRA. + +This module provides functions for authenticating with JIRA using various methods: +- Username/password +- SSO providers (Google, Microsoft, Okta, etc.) +- API tokens +""" + +import logging +from typing import Optional, Tuple +from app.browser_agent.browser import Browser +from app.jira_agent.selectors import DEFAULT_SELECTORS, SSO_SELECTORS + +logger = logging.getLogger(__name__) + + +def is_login_page(browser: Browser) -> bool: + """Check if we're on a login page. + + Args: + browser: Browser instance + + Returns: + True if this appears to be a login page + """ + login_selectors = [ + DEFAULT_SELECTORS["login_page"]["username_field"], + DEFAULT_SELECTORS["login_page"]["password_field"], + DEFAULT_SELECTORS["login_page"]["login_button"] + ] + + # Add SSO selectors + for provider_selectors in SSO_SELECTORS.values(): + login_selectors.extend(provider_selectors) + + for selector in login_selectors: + # Use get_element_info for better detection + element_info = browser.get_element_info(selector) + if element_info: + logger.info(f"Login element detected: {element_info['tag']}") + return True + + # Fallback to wait_for_selector for complex selectors + if browser.wait_for_selector(selector, timeout=1000): + return True + + return False + + +def login(browser: Browser, username: str, password: str, use_sso: bool = True, + prefer_google: bool = True) -> bool: + """Handle JIRA login with support for SSO. + + Args: + browser: Browser instance + username: JIRA username/email + password: JIRA password/API token + use_sso: Whether to attempt SSO login (default: True) + prefer_google: Whether to prefer Google SSO if available (default: True) + + Returns: + True if login appears successful + """ + try: + # First check for SSO options if requested + if use_sso: + # Prioritize Google SSO if preferred + if prefer_google: + return _try_sso_provider(browser, username, password, provider="google") + + # Otherwise try each SSO option in order + for provider in ["generic_sso", "google", "microsoft", "okta"]: + if _try_sso_provider(browser, username, password, provider): + return True + + # Regular username/password flow if no SSO or SSO not requested + return _standard_login(browser, username, password) + + except Exception as e: + logger.error(f"Login error: {e}") + return False + + +def _try_sso_provider(browser: Browser, username: str, password: str, provider: str) -> bool: + """Try to log in using a specific SSO provider. + + Args: + browser: Browser instance + username: SSO username/email + password: SSO password + provider: Provider name ("google", "microsoft", "okta", or "generic_sso") + + Returns: + True if login appears successful + """ + if provider not in SSO_SELECTORS: + logger.warning(f"Unknown SSO provider: {provider}") + return False + + # Get selectors for the provider + provider_selectors = SSO_SELECTORS[provider] + + # Look for any SSO buttons for this provider + for selector in provider_selectors: + # Check if element exists and is visible + element_info = browser.get_element_info(selector) + if element_info and element_info.get('isVisible', False): + logger.info(f"Found {provider} SSO option: {element_info['tag']} (visible: {element_info['isVisible']})") + browser.click_selector(selector) + browser.wait(5000) # Wait for redirect + + # Handle provider-specific login + return _handle_sso_flow(browser, username, password, provider) + + # Fallback to simple selector check + if browser.wait_for_selector(selector, timeout=1000): + logger.info(f"Found {provider} SSO option: {selector}") + browser.click_selector(selector) + browser.wait(5000) # Wait for redirect + + # Handle provider-specific login + return _handle_sso_flow(browser, username, password, provider) + + return False + + +def _handle_sso_flow(browser: Browser, username: str, password: str, provider: str) -> bool: + """Handle SSO provider authentication flow. + + Args: + browser: Browser instance + username: SSO username/email + password: SSO password + provider: Provider name ("google", "microsoft", "okta", or "generic_sso") + + Returns: + True if login appears successful + """ + # Wait for SSO page to load + browser.wait(3000) + + # Get current URL to determine provider if not explicitly specified + if provider == "generic_sso": + current_url = browser.get_current_url() + logger.info(f"SSO redirect URL: {current_url}") + + # Detect provider from URL + if "google" in current_url.lower(): + provider = "google" + elif any(p in current_url.lower() for p in ["microsoft", "azure", "live"]): + provider = "microsoft" + elif "okta" in current_url.lower(): + provider = "okta" + + # Handle different providers + if provider == "google": + return _handle_google_sso(browser, username, password) + elif provider == "microsoft": + return _handle_microsoft_sso(browser, username, password) + elif provider == "okta": + return _handle_okta_sso(browser, username, password) + else: + return _handle_generic_sso(browser, username, password) + + +def _standard_login(browser: Browser, username: str, password: str) -> bool: + """Handle standard username/password login flow. + + Args: + browser: Browser instance + username: JIRA username + password: JIRA password + + Returns: + True if login appears successful + """ + # Enter username + username_selector = DEFAULT_SELECTORS["login_page"]["username_field"] + if browser.wait_for_selector(username_selector, timeout=3000): + browser.click_selector(username_selector) + browser.type(username) + + # Look for Continue or Submit button + continue_selector = "button[id='login-submit'], button[type='submit'], button:contains('Continue')" + if browser.wait_for_selector(continue_selector, timeout=2000): + browser.click_selector(continue_selector) + browser.wait(3000) + + # Enter password (might be on a second screen after username) + password_selector = DEFAULT_SELECTORS["login_page"]["password_field"] + if browser.wait_for_selector(password_selector, timeout=3000): + browser.click_selector(password_selector) + browser.type(password) + + # Click login button + login_selector = DEFAULT_SELECTORS["login_page"]["login_button"] + if browser.wait_for_selector(login_selector, timeout=2000): + browser.click_selector(login_selector) + browser.wait(5000) # Wait longer for login to complete + + return True + + +def _handle_google_sso(browser: Browser, username: str, password: str) -> bool: + """Handle Google SSO login flow. + + Args: + browser: Browser instance + username: Google email + password: Google password + + Returns: + True if login appears successful + """ + try: + # Enter email + email_selector = "input[type='email']" + if browser.wait_for_selector(email_selector, timeout=5000): + # Check element state + element_info = browser.get_element_info(email_selector) + if element_info: + logger.info(f"Found email input: {element_info.get('attributes', {})}") + + browser.click_selector(email_selector) + browser.type(username) + + # Click next + next_selector = "button:contains('Next'), button[id='identifierNext']" + if browser.wait_for_selector(next_selector, timeout=2000): + browser.click_selector(next_selector) + browser.wait(3000) + + # Enter password + password_selector = "input[type='password']" + if browser.wait_for_selector(password_selector, timeout=5000): + browser.click_selector(password_selector) + browser.type(password) + + # Click next/sign in + signin_selector = "button:contains('Next'), button[id='passwordNext']" + if browser.wait_for_selector(signin_selector, timeout=2000): + browser.click_selector(signin_selector) + browser.wait(5000) + + return True + except Exception as e: + logger.error(f"Google SSO error: {e}") + return False + + +def _handle_microsoft_sso(browser: Browser, username: str, password: str) -> bool: + """Handle Microsoft/Azure SSO login flow. + + Args: + browser: Browser instance + username: Microsoft email + password: Microsoft password + + Returns: + True if login appears successful + """ + try: + # Enter email + email_selector = "input[type='email'], input[name='loginfmt']" + if browser.wait_for_selector(email_selector, timeout=5000): + element_info = browser.get_element_info(email_selector) + if element_info: + logger.info(f"Found Microsoft email input: {element_info.get('attributes', {})}") + + browser.click_selector(email_selector) + browser.type(username) + + # Click next + next_selector = "input[type='submit'], button:contains('Next')" + if browser.wait_for_selector(next_selector, timeout=2000): + browser.click_selector(next_selector) + browser.wait(3000) + + # Enter password + password_selector = "input[type='password'], input[name='passwd']" + if browser.wait_for_selector(password_selector, timeout=5000): + browser.click_selector(password_selector) + browser.type(password) + + # Click sign in + signin_selector = "input[type='submit'], button:contains('Sign in')" + if browser.wait_for_selector(signin_selector, timeout=2000): + browser.click_selector(signin_selector) + browser.wait(3000) + + # Handle "Stay signed in?" if it appears + stay_selector = "input[type='submit'][value='Yes'], button:contains('Yes')" + if browser.wait_for_selector(stay_selector, timeout=3000): + browser.click_selector(stay_selector) + browser.wait(3000) + + return True + except Exception as e: + logger.error(f"Microsoft SSO error: {e}") + return False + + +def _handle_okta_sso(browser: Browser, username: str, password: str) -> bool: + """Handle Okta SSO login flow. + + Args: + browser: Browser instance + username: Okta username/email + password: Okta password + + Returns: + True if login appears successful + """ + try: + # Enter username/email + username_selector = "input[name='username'], input[id='okta-signin-username']" + if browser.wait_for_selector(username_selector, timeout=5000): + element_info = browser.get_element_info(username_selector) + if element_info: + logger.info(f"Found Okta username input: {element_info.get('attributes', {})}") + + browser.click_selector(username_selector) + browser.type(username) + + # Enter password + password_selector = "input[name='password'], input[id='okta-signin-password']" + if browser.wait_for_selector(password_selector, timeout=3000): + browser.click_selector(password_selector) + browser.type(password) + + # Click sign in + submit_selector = "input[type='submit'], button[type='submit'], button:contains('Sign in')" + if browser.wait_for_selector(submit_selector, timeout=2000): + browser.click_selector(submit_selector) + browser.wait(5000) + + # Handle MFA if present (this is very org-specific) + # This is a simplified example - real MFA handling would need to be customized + push_selector = "button:contains('Send Push'), a:contains('Send Push')" + if browser.wait_for_selector(push_selector, timeout=3000): + browser.click_selector(push_selector) + logger.info("MFA push notification sent - please approve on your device") + browser.wait(20000) # Wait longer for MFA approval + + return True + except Exception as e: + logger.error(f"Okta SSO error: {e}") + return False + + +def _handle_generic_sso(browser: Browser, username: str, password: str) -> bool: + """Handle a generic SSO login flow for unknown providers. + + Args: + browser: Browser instance + username: SSO username/email + password: SSO password + + Returns: + True if login appears successful + """ + try: + # Look for common username/email fields + username_selectors = [ + "input[type='email']", + "input[name='username']", + "input[id='username']", + "input[name='email']" + ] + + for selector in username_selectors: + element_info = browser.get_element_info(selector) + if element_info and element_info.get('isVisible', False): + logger.info(f"Found username input: {element_info['tag']} with attributes: {element_info.get('attributes', {})}") + browser.click_selector(selector) + browser.type(username) + browser.wait(1000) + + # Look for a next/continue button + next_selectors = [ + "button:contains('Next')", + "button:contains('Continue')", + "input[type='submit']", + "button[type='submit']" + ] + + for next_selector in next_selectors: + if browser.wait_for_selector(next_selector, timeout=1000): + browser.click_selector(next_selector) + browser.wait(3000) + break + break + elif browser.wait_for_selector(selector, timeout=1000): + browser.click_selector(selector) + browser.type(username) + browser.wait(1000) + + # Look for a next/continue button + for next_selector in ["button:contains('Next')", "button:contains('Continue')", "input[type='submit']"]: + if browser.wait_for_selector(next_selector, timeout=1000): + browser.click_selector(next_selector) + browser.wait(3000) + break + break + + # Look for common password fields + password_selectors = [ + "input[type='password']", + "input[name='password']", + "input[id='password']" + ] + + for selector in password_selectors: + element_info = browser.get_element_info(selector) + if element_info and element_info.get('isVisible', False): + browser.click_selector(selector) + browser.type(password) + browser.wait(1000) + + # Look for a login/submit button + submit_selectors = [ + "button:contains('Sign in')", + "button:contains('Log in')", + "input[type='submit']", + "button[type='submit']" + ] + + for submit_selector in submit_selectors: + if browser.wait_for_selector(submit_selector, timeout=1000): + browser.click_selector(submit_selector) + browser.wait(5000) + break + break + elif browser.wait_for_selector(selector, timeout=3000): + browser.click_selector(selector) + browser.type(password) + browser.wait(1000) + + # Look for a login/submit button + for submit_selector in ["button:contains('Sign in')", "button:contains('Log in')", "input[type='submit']"]: + if browser.wait_for_selector(submit_selector, timeout=1000): + browser.click_selector(submit_selector) + browser.wait(5000) + break + break + + return True + except Exception as e: + logger.error(f"Generic SSO error: {e}") + return False \ No newline at end of file diff --git a/app/jira_agent/jira.py b/app/jira_agent/jira.py new file mode 100644 index 0000000..df34298 --- /dev/null +++ b/app/jira_agent/jira.py @@ -0,0 +1,403 @@ +"""JIRA agent for browser automation with JIRA tickets. + +This module provides the JiraAgent class for interacting with JIRA: +- Logging in (with various authentication methods) +- Reading ticket information +- Adding comments +- Changing ticket status +- Extracting data for analysis +""" + +import os +import json +import time +import logging +from typing import Dict, List, Optional, Any +from pathlib import Path +from dotenv import load_dotenv +from app.browser_agent.local_playwright import LocalPlaywrightBrowser +from app.memory.selector_memory import SelectorMemory +from app.jira_agent.auth import is_login_page, login +from app.jira_agent.selectors import DEFAULT_SELECTORS, FIELD_SELECTORS + +# Load environment variables +load_dotenv(override=True) +logger = logging.getLogger(__name__) + + +class JiraAgent: + """Agent for interacting with JIRA to read and manipulate tickets.""" + + def __init__(self, + headless: bool = False, + jira_url: str = None, + username: Optional[str] = None, + password: Optional[str] = None, + use_sso: bool = True, + prefer_google: bool = True, + cache_dir: str = "./cache"): + """Initialize the JIRA agent. + + Args: + headless: Whether to run the browser in headless mode + jira_url: URL of the JIRA instance + username: JIRA username (if None, reads from JIRA_USERNAME env var) + password: JIRA password (if None, reads from JIRA_PASSWORD env var) + use_sso: Whether to use SSO for authentication + prefer_google: Whether to prefer Google SSO if available + cache_dir: Directory for caching memory + """ + self.headless = headless + self.jira_url = jira_url or os.getenv("JIRA_URL", "https://mydomain.atlassian.net") + self.username = username or os.getenv("JIRA_USERNAME") + self.password = password or os.getenv("JIRA_PASSWORD") + self.use_sso = use_sso + self.prefer_google = prefer_google + + if not self.username or not self.password: + raise ValueError("JIRA credentials are required. Set them in .env file or pass them to the constructor.") + + # Initialize selector memory + self.memory = SelectorMemory("jira", cache_dir) + + def get_ticket(self, ticket_id: str, extract_fields: Optional[List[str]] = None) -> Dict[str, Any]: + """Get information about a JIRA ticket. + + Args: + ticket_id: The JIRA ticket ID (e.g., "PROJ-123") + extract_fields: List of additional fields to extract (defaults to common fields) + + Returns: + Dictionary with ticket information + """ + # Default fields to extract if not specified + if extract_fields is None: + extract_fields = ["status", "assignee", "priority", "type"] + + logger.info(f"Getting information for ticket {ticket_id}") + + with LocalPlaywrightBrowser(headless=self.headless) as browser: + # Navigate to the JIRA ticket + ticket_url = f"{self.jira_url}/browse/{ticket_id}" + browser.goto(ticket_url) + browser.wait(3000) # Wait for page to load + + # Check if login is required + if is_login_page(browser): + logger.info("Login required") + login(browser, self.username, self.password, + use_sso=self.use_sso, prefer_google=self.prefer_google) + + # Wait for redirect after login + browser.wait(5000) + + # Navigate to ticket again if needed + current_url = browser.get_current_url() + if ticket_id not in current_url: + browser.goto(ticket_url) + browser.wait(3000) + + # Initialize ticket information + ticket_info = { + "id": ticket_id, + "url": browser.get_current_url(), + } + + # Extract ticket fields + self._extract_ticket_fields(browser, ticket_info, extract_fields) + + return ticket_info + + def add_comment(self, ticket_id: str, comment_text: str) -> bool: + """Add a comment to a JIRA ticket. + + Args: + ticket_id: The JIRA ticket ID (e.g., "PROJ-123") + comment_text: The text of the comment to add + + Returns: + True if comment was added successfully + """ + logger.info(f"Adding comment to ticket {ticket_id}") + + with LocalPlaywrightBrowser(headless=self.headless) as browser: + # Navigate to the JIRA ticket + ticket_url = f"{self.jira_url}/browse/{ticket_id}" + browser.goto(ticket_url) + browser.wait(3000) # Wait for page to load + + # Check if login is required + if is_login_page(browser): + logger.info("Login required") + login(browser, self.username, self.password, + use_sso=self.use_sso, prefer_google=self.prefer_google) + + # Wait for redirect after login + browser.wait(5000) + + # Navigate to ticket again if needed + current_url = browser.get_current_url() + if ticket_id not in current_url: + browser.goto(ticket_url) + browser.wait(3000) + + # Click the comment button + comment_button = DEFAULT_SELECTORS["ticket_page"]["add_comment"]["button"] + if not browser.wait_for_selector(comment_button, timeout=5000): + logger.error("Comment button not found") + return False + + browser.click_selector(comment_button) + browser.wait(1000) + + # Fill in the comment field + comment_field = DEFAULT_SELECTORS["ticket_page"]["add_comment"]["field"] + if not browser.wait_for_selector(comment_field, timeout=3000): + logger.error("Comment field not found") + return False + + browser.click_selector(comment_field) + browser.type(comment_text) + browser.wait(1000) + + # Click the submit button + submit_button = DEFAULT_SELECTORS["ticket_page"]["add_comment"]["submit"] + if not browser.wait_for_selector(submit_button, timeout=3000): + logger.error("Submit button not found") + return False + + browser.click_selector(submit_button) + browser.wait(5000) # Wait for comment to be added + + # Verify comment was added + latest_comment_selector = f"{DEFAULT_SELECTORS['ticket_page']['comments']}:nth-last-child(1)" + if browser.wait_for_selector(latest_comment_selector, timeout=5000): + comment_text_content = browser.extract_text(latest_comment_selector) + if comment_text in comment_text_content: + logger.info("Comment added successfully") + return True + + logger.warning("Could not verify comment was added") + return False + + def change_status(self, ticket_id: str, new_status: str) -> bool: + """Change the status of a JIRA ticket. + + Args: + ticket_id: The JIRA ticket ID (e.g., "PROJ-123") + new_status: The new status (e.g., "In Progress", "Done", etc.) + + Returns: + True if status was changed successfully + """ + logger.info(f"Changing status of ticket {ticket_id} to {new_status}") + + with LocalPlaywrightBrowser(headless=self.headless) as browser: + # Navigate to the JIRA ticket + ticket_url = f"{self.jira_url}/browse/{ticket_id}" + browser.goto(ticket_url) + browser.wait(3000) # Wait for page to load + + # Check if login is required + if is_login_page(browser): + logger.info("Login required") + login(browser, self.username, self.password, + use_sso=self.use_sso, prefer_google=self.prefer_google) + + # Wait for redirect after login + browser.wait(5000) + + # Navigate to ticket again if needed + current_url = browser.get_current_url() + if ticket_id not in current_url: + browser.goto(ticket_url) + browser.wait(3000) + + # Click the status dropdown + status_dropdown = DEFAULT_SELECTORS["ticket_page"]["status_transition"]["dropdown"] + if not browser.wait_for_selector(status_dropdown, timeout=5000): + logger.error("Status dropdown not found") + return False + + browser.click_selector(status_dropdown) + browser.wait(1000) + + # Click the new status option + # First try to find a specific selector for the requested status + status_key = new_status.lower().replace(" ", "_") + if status_key in DEFAULT_SELECTORS["ticket_page"]["status_transition"]["options"]: + status_option = DEFAULT_SELECTORS["ticket_page"]["status_transition"]["options"][status_key] + else: + # Otherwise, try a generic selector with the status text + status_option = f"button:contains('{new_status}'), [role='option']:contains('{new_status}')" + + if not browser.wait_for_selector(status_option, timeout=5000): + logger.error(f"Status option '{new_status}' not found") + return False + + browser.click_selector(status_option) + browser.wait(5000) # Wait for status to change + + # Verify status was changed + status_text = browser.extract_text(DEFAULT_SELECTORS["ticket_page"]["status"]) + if new_status.lower() in status_text.lower(): + logger.info("Status changed successfully") + return True + + logger.warning("Could not verify status was changed") + return False + + def save_ticket_data(self, ticket_info: Dict[str, Any], output_dir: Optional[str] = None) -> str: + """Save ticket data to a JSON file. + + Args: + ticket_info: Dictionary of ticket data + output_dir: Directory to save file (defaults to ./ticket_data) + + Returns: + Path to saved file + """ + if output_dir is None: + output_dir = os.path.join(os.getcwd(), "ticket_data") + + # Create directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Filename from ticket ID + filename = f"{ticket_info['id'].replace('-', '_').lower()}.json" + filepath = os.path.join(output_dir, filename) + + # Save data + with open(filepath, 'w') as f: + json.dump(ticket_info, f, indent=2) + + logger.info(f"Ticket data saved to: {filepath}") + return filepath + + def analyze_ticket(self, ticket_id: str, analysis_endpoint: Optional[str] = None, + analysis_params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Analyze a JIRA ticket and optionally call an external API. + + Args: + ticket_id: The JIRA ticket ID (e.g., "PROJ-123") + analysis_endpoint: Optional API endpoint URL for external analysis + analysis_params: Optional additional parameters for the API call + + Returns: + Dictionary with analysis results + """ + # Get ticket information + ticket_info = self.get_ticket(ticket_id, extract_fields=["status", "assignee", "priority", "type", + "reporter", "comments", "labels"]) + + # Basic analysis + analysis_results = { + "ticket_id": ticket_id, + "summary": ticket_info.get("summary", ""), + "status": ticket_info.get("status", ""), + "has_description": bool(ticket_info.get("description")), + "comment_count": len(ticket_info.get("comments", [])), + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") + } + + # Call external API if provided + if analysis_endpoint: + import requests + + params = analysis_params or {} + params.update({ + "ticket_id": ticket_id, + "summary": ticket_info.get("summary", ""), + "description": ticket_info.get("description", "") + }) + + try: + response = requests.post(analysis_endpoint, json=params) + if response.status_code == 200: + api_results = response.json() + analysis_results["api_results"] = api_results + logger.info(f"API analysis completed for ticket {ticket_id}") + else: + logger.error(f"API call failed with status {response.status_code}") + analysis_results["api_error"] = f"Status code: {response.status_code}" + except Exception as e: + logger.error(f"API call error: {e}") + analysis_results["api_error"] = str(e) + + return analysis_results + + def _extract_ticket_fields(self, browser, ticket_info: Dict[str, Any], extract_fields: List[str]) -> None: + """Extract all requested fields from the JIRA ticket. + + Args: + browser: Browser instance + ticket_info: Dictionary to update with extracted data + extract_fields: List of fields to extract + """ + # Always extract summary and description + fields_to_extract = ["summary", "description"] + extract_fields + + # Remove duplicates + fields_to_extract = list(dict.fromkeys(fields_to_extract)) + + for field in fields_to_extract: + if field in FIELD_SELECTORS: + selector = FIELD_SELECTORS[field] + try: + # First check if the element exists and is visible + element_info = browser.get_element_info(selector) + + if element_info and element_info.get('isVisible', False): + text = browser.extract_text(selector) + ticket_info[field] = text + logger.info(f"Extracted {field}: {text[:50]}..." if len(text) > 50 else f"Extracted {field}: {text}") + else: + # Log debug info + if element_info: + logger.debug(f"{field} element found but not visible: {element_info}") + else: + logger.debug(f"{field} element not found") + + # Try alternative selectors + alt_selector = f"[data-testid*='{field}'], [id*='{field}'], [class*='{field}']" + if browser.wait_for_selector(alt_selector, timeout=1000): + text = browser.extract_text(alt_selector) + ticket_info[field] = text + logger.info(f"Extracted {field} (alt): {text[:50]}..." if len(text) > 50 else f"Extracted {field} (alt): {text}") + else: + ticket_info[field] = "Not found" + except Exception as e: + logger.error(f"Error extracting {field}: {e}") + ticket_info[field] = "Error extracting" + + # Extract comments as a list if present + if "comments" in fields_to_extract: + try: + comments_selector = FIELD_SELECTORS["comments"] + if browser.wait_for_selector(comments_selector, timeout=1000): + # Get all comment elements + comment_elements = browser._page.query_selector_all(comments_selector) + comments = [] + for i, element in enumerate(comment_elements, 1): + comment_text = element.text_content() + if comment_text: + comments.append({ + "number": i, + "text": comment_text.strip() + }) + ticket_info["comments"] = comments + logger.info(f"Extracted {len(comments)} comments") + else: + ticket_info["comments"] = [] + except Exception as e: + logger.error(f"Error extracting comments: {e}") + ticket_info["comments"] = [] + + # As a fallback, get the entire HTML if extraction fails + if "summary" not in ticket_info or ticket_info["summary"] == "Not found": + try: + # Save HTML for debugging + ticket_info["_html"] = browser.get_page_html() + logger.info("Saved page HTML as fallback") + except Exception as e: + logger.error(f"Error getting page HTML: {e}") \ No newline at end of file diff --git a/app/jira_agent/selectors.py b/app/jira_agent/selectors.py new file mode 100644 index 0000000..9bcc726 --- /dev/null +++ b/app/jira_agent/selectors.py @@ -0,0 +1,74 @@ +"""Selectors for JIRA UI elements. + +This module provides selector dictionaries for various parts of the JIRA UI, +allowing the agent to locate and interact with UI elements consistently. +""" + +# Default selectors for common elements in JIRA UI +DEFAULT_SELECTORS = { + "login_page": { + "username_field": "input[name='username'], input[id='username'], input[name='email'], input[type='email']", + "password_field": "input[name='password'], input[id='password'], input[type='password']", + "login_button": "button[id='login-submit'], button[type='submit'], button:contains('Log in')", + "sso_options": { + "google": [ + "button:contains('Continue with Google')", + "button:contains('Sign in with Google')", + "a:contains('Google')", + "button:contains('Google')", + "button[data-provider='google']", + "div:contains('Google') button" + ], + "microsoft": [ + "button:contains('Continue with Microsoft')", + "button:contains('Sign in with Microsoft')", + "a:contains('Microsoft')", + "button:contains('Microsoft')" + ], + "okta": [ + "button:contains('Continue with Okta')", + "button:contains('Sign in with Okta')", + "a:contains('Okta')" + ], + "generic_sso": [ + "button:contains('Log in with SSO')", + "a:contains('Single Sign-on')", + "button:contains('SSO')" + ] + } + }, + "ticket_page": { + "summary": "[data-test-id='issue.views.issue-base.foundation.summary.heading']", + "description": "[data-test-id='issue.views.field.rich-text.description']", + "status": "[data-test-id='issue.views.issue-base.foundation.status.status-field-wrapper']", + "assignee": "[data-test-id='issue.views.field.user.assignee']", + "priority": "[data-test-id='issue.views.field.select.priority']", + "type": "[data-test-id='issue.views.issue-base.foundation.issue-type.issue-type-field-wrapper']", + "reporter": "[data-test-id='issue.views.field.user.reporter']", + "created": "[data-test-id='issue.views.field.date.created']", + "updated": "[data-test-id='issue.views.field.date.updated']", + "comments": "[data-test-id='issue.views.comments.comment-container']", + "labels": "[data-test-id='issue.views.field.labels']", + "components": "[data-test-id='issue.views.field.select.components']", + "fix_versions": "[data-test-id='issue.views.field.select.fixversions']", + "add_comment": { + "button": "[data-testid='comment-button'], button:contains('Comment')", + "field": "[data-testid='comment-field'], div[role='textbox']", + "submit": "button[type='submit'], button:contains('Save')" + }, + "status_transition": { + "dropdown": "[data-testid='status-dropdown'], button:contains('In Progress')", + "options": { + "in_progress": "button:contains('In Progress')", + "done": "button:contains('Done')", + "to_do": "button:contains('To Do')" + } + } + } +} + +# Common field selectors for extracting ticket data +FIELD_SELECTORS = DEFAULT_SELECTORS["ticket_page"] + +# Authentication providers and their selectors +SSO_SELECTORS = DEFAULT_SELECTORS["login_page"]["sso_options"] \ No newline at end of file diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py new file mode 100644 index 0000000..bb9f3c1 --- /dev/null +++ b/examples/jira_api_integration.py @@ -0,0 +1,180 @@ +""" +Example demonstrating how to use the JIRA agent with an external API. + +This example shows how to: +1. Initialize the JIRA agent +2. Extract data from JIRA tickets +3. Send ticket data to an external API for analysis +4. Post the analysis results back as a comment +""" + +import os +import json +import requests +from dotenv import load_dotenv +from app.jira_agent import JiraAgent + +# Load environment variables +load_dotenv(override=True) + +# Define your API endpoint +# This can be any API that takes JIRA ticket data and returns an analysis +API_ENDPOINT = os.getenv("ANALYSIS_API_ENDPOINT", "https://your-analysis-api.com/analyze") +API_KEY = os.getenv("ANALYSIS_API_KEY", "") + + +def analyze_with_api(ticket_data): + """ + Send ticket data to an external API for analysis. + + Args: + ticket_data: Dictionary containing ticket information + + Returns: + Dictionary with analysis results or error message + """ + # This is a simulation - in a real example, you would call your actual API + # For demonstration, we'll simulate an API response + + print(f"Sending data to API: {API_ENDPOINT}") + + # In a real implementation, you would do: + # headers = {"Authorization": f"Bearer {API_KEY}"} + # response = requests.post(API_ENDPOINT, json=ticket_data, headers=headers) + # return response.json() + + # For this example, we'll simulate a response + simulation_response = { + "analysis": { + "ticket_type": "Bug Report", + "priority_recommendation": "High" if "urgent" in ticket_data.get("summary", "").lower() else "Medium", + "estimated_effort": "4 hours", + "similar_tickets": ["PROJ-100", "PROJ-212", "PROJ-345"], + "recommended_action": "Assign to backend team", + "automated_checks": [ + {"name": "Security scan", "result": "Passed"}, + {"name": "Code quality", "result": "Failed", "details": "Insufficient test coverage"} + ] + }, + "timestamp": "2023-06-01T12:34:56Z" + } + + return simulation_response + + +def format_analysis_comment(analysis_results): + """ + Format analysis results as a markdown comment for JIRA. + + Args: + analysis_results: Dictionary with analysis results + + Returns: + Formatted comment text + """ + comment = "**Automated Analysis Results**\n\n" + + # Add ticket type and priority + analysis = analysis_results.get("analysis", {}) + comment += f"**Ticket Type**: {analysis.get('ticket_type', 'Unknown')}\n" + comment += f"**Recommended Priority**: {analysis.get('priority_recommendation', 'Unknown')}\n" + comment += f"**Estimated Effort**: {analysis.get('estimated_effort', 'Unknown')}\n\n" + + # Add recommended action + if "recommended_action" in analysis: + comment += f"**Recommended Action**: {analysis['recommended_action']}\n\n" + + # Add similar tickets + similar_tickets = analysis.get("similar_tickets", []) + if similar_tickets: + comment += "**Similar Tickets**:\n" + for ticket in similar_tickets: + comment += f"- {ticket}\n" + comment += "\n" + + # Add automated checks + automated_checks = analysis.get("automated_checks", []) + if automated_checks: + comment += "**Automated Checks**:\n" + for check in automated_checks: + result_icon = "✅" if check["result"] == "Passed" else "❌" + comment += f"- {result_icon} {check['name']}: {check['result']}" + if "details" in check: + comment += f" - {check['details']}" + comment += "\n" + + return comment + + +def main(): + """Run the JIRA API integration example.""" + # Get ticket ID from user + ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") + + # Initialize the JIRA agent + agent = JiraAgent( + headless=False, # Set to True to hide the browser + jira_url=os.getenv("JIRA_URL"), + use_sso=True, + prefer_google=True + ) + + # Get ticket information with additional fields + print(f"\nGetting information for ticket {ticket_id}...") + ticket_info = agent.get_ticket( + ticket_id, + extract_fields=["status", "assignee", "priority", "type", "reporter", "labels", "comments"] + ) + + # Display basic ticket information + print(f"\nTicket: {ticket_info.get('id')}") + print(f"Summary: {ticket_info.get('summary', 'Unknown')}") + print(f"Status: {ticket_info.get('status', 'Unknown')}") + + # Analyze with external API (or simulation) + print("\nSending to API for analysis...") + analysis_results = analyze_with_api(ticket_info) + + # Display analysis results + print("\nAnalysis Results:") + print(json.dumps(analysis_results, indent=2)) + + # Format as comment + comment_text = format_analysis_comment(analysis_results) + print("\nFormatted Comment:") + print(comment_text) + + # Ask if we should post the analysis as a comment + post_comment = input("\nPost analysis as comment to JIRA ticket? (y/n): ").lower() == 'y' + if post_comment: + print(f"Adding analysis as comment to ticket {ticket_id}...") + result = agent.add_comment(ticket_id, comment_text) + if result: + print("Analysis comment added successfully!") + else: + print("Failed to add analysis comment.") + + # Save analysis to file + save_option = input("\nSave full analysis to file? (y/n): ").lower() + if save_option == 'y': + # Create results dict with both ticket info and analysis + combined_results = { + "ticket_info": ticket_info, + "analysis": analysis_results + } + + # Save file + output_dir = os.path.join(os.getcwd(), "analysis_results") + os.makedirs(output_dir, exist_ok=True) + filename = f"{ticket_id.replace('-', '_').lower()}_analysis.json" + filepath = os.path.join(output_dir, filename) + + with open(filepath, 'w') as f: + json.dump(combined_results, f, indent=2) + + print(f"Analysis saved to: {filepath}") + + print("\nJIRA API integration example completed!") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/jira_example.py b/examples/jira_example.py new file mode 100644 index 0000000..b128d6a --- /dev/null +++ b/examples/jira_example.py @@ -0,0 +1,123 @@ +""" +Example demonstrating how to use the JIRA agent for interacting with JIRA tickets. + +This example shows how to: +1. Initialize the JIRA agent +2. Get information about a ticket +3. Add comments to tickets +4. Change ticket status +5. Save ticket data +""" + +import os +from dotenv import load_dotenv +from app.jira_agent import JiraAgent + +# Load environment variables +load_dotenv(override=True) + +def main(): + """Run the JIRA example.""" + # Get ticket ID from user + ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") + + # Initialize the JIRA agent + # You can customize these parameters or set them in your .env file + agent = JiraAgent( + headless=False, # Set to True to hide the browser + jira_url=os.getenv("JIRA_URL"), + use_sso=True, + prefer_google=True + ) + + # Display menu of options + print("\nJIRA Agent Example") + print("1. Get ticket information") + print("2. Add a comment to the ticket") + print("3. Change ticket status") + print("4. Analyze ticket") + print("5. Do all of the above") + choice = input("\nSelect an option (1-5): ") + + if choice in ("1", "5"): + # Get ticket information + print(f"\nGetting information for ticket {ticket_id}...") + ticket_info = agent.get_ticket(ticket_id) + + # Display ticket information + print("\nTicket Information:") + for key, value in ticket_info.items(): + if key == "_html": + print(f" {key}: [HTML content]") + elif key == "comments" and isinstance(value, list): + print(f" {key}: {len(value)} comments") + if value and len(value) > 0: + print(f" First comment: {value[0]['text'][:100]}..." if len(value[0]['text']) > 100 else f" First comment: {value[0]['text']}") + elif isinstance(value, str) and len(value) > 150: + print(f" {key}: {value[:150]}...") + else: + print(f" {key}: {value}") + + # Save ticket data + save_option = input("\nSave ticket data to file? (y/n): ").lower() + if save_option == 'y': + file_path = agent.save_ticket_data(ticket_info) + print(f"Ticket data saved to: {file_path}") + + if choice in ("2", "5"): + # Add a comment + comment_text = input("\nEnter comment text (leave empty to skip): ") + if comment_text: + print(f"Adding comment to ticket {ticket_id}...") + result = agent.add_comment(ticket_id, comment_text) + if result: + print("Comment added successfully!") + else: + print("Failed to add comment.") + + if choice in ("3", "5"): + # Change ticket status + status_options = ["To Do", "In Progress", "Done"] + print("\nStatus options:") + for i, status in enumerate(status_options, 1): + print(f"{i}. {status}") + + status_choice = input("Select new status (1-3, or enter custom status): ") + try: + new_status = status_options[int(status_choice) - 1] + except (ValueError, IndexError): + new_status = status_choice + + if new_status: + print(f"Changing status of ticket {ticket_id} to '{new_status}'...") + result = agent.change_status(ticket_id, new_status) + if result: + print("Status changed successfully!") + else: + print("Failed to change status.") + + if choice in ("4", "5"): + # Analyze ticket + print(f"\nAnalyzing ticket {ticket_id}...") + + # You can specify an API endpoint here if you have one + analysis_endpoint = input("Enter API endpoint for analysis (leave empty to skip): ") + + if analysis_endpoint: + analysis_results = agent.analyze_ticket(ticket_id, analysis_endpoint=analysis_endpoint) + else: + analysis_results = agent.analyze_ticket(ticket_id) + + print("\nAnalysis Results:") + for key, value in analysis_results.items(): + if isinstance(value, dict): + print(f" {key}:") + for k, v in value.items(): + print(f" {k}: {v}") + else: + print(f" {key}: {value}") + + print("\nJIRA example completed!") + +if __name__ == "__main__": + main() \ No newline at end of file From 5a378f579226f68d334a8217616866e764a6d02f Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 10:47:11 -0400 Subject: [PATCH 03/13] Local testing --- app/jira_agent/jira.py | 7 +- examples/jira_api_integration.py | 4 +- examples/jira_example.py | 4 +- examples/jira_prod_support.py | 611 ------------------------------- 4 files changed, 9 insertions(+), 617 deletions(-) delete mode 100644 examples/jira_prod_support.py diff --git a/app/jira_agent/jira.py b/app/jira_agent/jira.py index df34298..7c1da13 100644 --- a/app/jira_agent/jira.py +++ b/app/jira_agent/jira.py @@ -20,8 +20,11 @@ from app.jira_agent.auth import is_login_page, login from app.jira_agent.selectors import DEFAULT_SELECTORS, FIELD_SELECTORS -# Load environment variables -load_dotenv(override=True) +# Load environment variables, first trying .env.local +load_dotenv(dotenv_path=".env.local", override=True) +# If .env.local doesn't exist, fall back to .env +if os.getenv("JIRA_URL") is None and os.path.exists(".env"): + load_dotenv(dotenv_path=".env", override=True) logger = logging.getLogger(__name__) diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index bb9f3c1..02d20bb 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -14,8 +14,8 @@ from dotenv import load_dotenv from app.jira_agent import JiraAgent -# Load environment variables -load_dotenv(override=True) +# Load environment variables from .env.local file +load_dotenv(dotenv_path=".env.local", override=True) # Define your API endpoint # This can be any API that takes JIRA ticket data and returns an analysis diff --git a/examples/jira_example.py b/examples/jira_example.py index b128d6a..d0bf07c 100644 --- a/examples/jira_example.py +++ b/examples/jira_example.py @@ -13,8 +13,8 @@ from dotenv import load_dotenv from app.jira_agent import JiraAgent -# Load environment variables -load_dotenv(override=True) +# Load environment variables from .env.local file +load_dotenv(dotenv_path=".env.local", override=True) def main(): """Run the JIRA example.""" diff --git a/examples/jira_prod_support.py b/examples/jira_prod_support.py deleted file mode 100644 index a375ae9..0000000 --- a/examples/jira_prod_support.py +++ /dev/null @@ -1,611 +0,0 @@ -""" -Example demonstrating how to use the browser automation framework to interact with JIRA tickets. - -This example shows how to: -1. Initialize a local browser instance using the LocalPlaywrightBrowser -2. Navigate to a JIRA ticket -3. Log in (if necessary) -4. Extract the ticket summary, description, and other fields -5. Debug selector issues with element info -""" - -import os -import json -from app.browser_agent.local_playwright import LocalPlaywrightBrowser -from dotenv import load_dotenv - -# Load environment variables -load_dotenv(override=True) - - -def read_jira_ticket(ticket_id, jira_url=None, use_sso=True, prefer_google=True, extract_fields=None): - """ - Opens a JIRA ticket and extracts its information. - - Args: - ticket_id (str): The JIRA ticket ID (e.g., "PROJ-123") - jira_url (str, optional): The JIRA instance URL. Defaults to environment variable. - use_sso (bool): Whether to try SSO login flow (default: True) - prefer_google (bool): Whether to prefer Google SSO if available (default: True) - extract_fields (list, optional): List of additional fields to extract (e.g., ["status", "assignee"]) - - Returns: - dict: Ticket information including summary, description, and other fields - """ - # Default fields to extract if not specified - if extract_fields is None: - extract_fields = ["status", "assignee", "priority", "type"] - - # Get JIRA URL from environment variable if not provided - jira_url = jira_url or os.getenv("JIRA_URL", "https://mydomain.atlassian.net") - - # Credentials from environment variables - username = os.getenv("JIRA_USERNAME") - password = os.getenv("JIRA_PASSWORD") - - print(f"Opening JIRA ticket {ticket_id}...") - - # Create a browser instance (set headless=False to see the browser) - with LocalPlaywrightBrowser(headless=False) as browser: - # Navigate to the JIRA ticket - ticket_url = f"{jira_url}/browse/{ticket_id}" - browser.goto(ticket_url) - - # Wait for the page to load - browser.wait(3000) # 3 seconds - - # Check if login is required - if _is_login_page(browser): - if not username or not password: - print("Login required but no credentials provided in environment variables.") - return {"error": "Login required but no credentials provided"} - - print("Login required. Attempting to log in...") - _login(browser, username, password, use_sso=use_sso, prefer_google=prefer_google) - - # Wait for redirect after login - browser.wait(5000) - - # Navigate to ticket again if needed - current_url = browser.get_current_url() - if ticket_id not in current_url: - browser.goto(ticket_url) - browser.wait(3000) - - # Initialize ticket information - ticket_info = { - "id": ticket_id, - "url": browser.get_current_url(), - } - - # Extract ticket fields - _extract_ticket_fields(browser, ticket_info, extract_fields) - - return ticket_info - - -def _is_login_page(browser): - """Check if we're on a login page.""" - login_selectors = [ - "input[name='username']", - "input[id='login-submit']", - "input[name='password']", - "button:contains('Continue with Google')", - "button:contains('Log in with SSO')", - "a:contains('Single Sign-on')" - ] - - for selector in login_selectors: - # Use get_element_info for better detection - element_info = browser.get_element_info(selector) - if element_info: - print(f"Login element detected: {element_info['tag']}") - return True - - # Fallback to wait_for_selector for complex selectors - if browser.wait_for_selector(selector, timeout=1000): - return True - - return False - - -def _extract_ticket_fields(browser, ticket_info, extract_fields): - """Extract all requested fields from the JIRA ticket. - - Args: - browser: Browser instance - ticket_info: Dictionary to update with extracted data - extract_fields: List of fields to extract - """ - # Standard fields with their selectors - field_selectors = { - "summary": "[data-test-id='issue.views.issue-base.foundation.summary.heading']", - "description": "[data-test-id='issue.views.field.rich-text.description']", - "status": "[data-test-id='issue.views.issue-base.foundation.status.status-field-wrapper']", - "assignee": "[data-test-id='issue.views.field.user.assignee']", - "priority": "[data-test-id='issue.views.field.select.priority']", - "type": "[data-test-id='issue.views.issue-base.foundation.issue-type.issue-type-field-wrapper']", - "reporter": "[data-test-id='issue.views.field.user.reporter']", - "created": "[data-test-id='issue.views.field.date.created']", - "updated": "[data-test-id='issue.views.field.date.updated']", - "comments": "[data-test-id='issue.views.comments.comment-container']", - "labels": "[data-test-id='issue.views.field.labels']", - "components": "[data-test-id='issue.views.field.select.components']", - "fix_versions": "[data-test-id='issue.views.field.select.fixversions']" - } - - # Always extract summary and description - fields_to_extract = ["summary", "description"] + extract_fields - - # Remove duplicates - fields_to_extract = list(dict.fromkeys(fields_to_extract)) - - for field in fields_to_extract: - if field in field_selectors: - selector = field_selectors[field] - try: - # First check if the element exists and is visible - element_info = browser.get_element_info(selector) - - if element_info and element_info.get('isVisible', False): - text = browser.extract_text(selector) - ticket_info[field] = text - print(f"Extracted {field}: {text[:50]}..." if len(text) > 50 else f"Extracted {field}: {text}") - else: - # Log debug info - if element_info: - print(f"{field} element found but not visible: {element_info}") - else: - print(f"{field} element not found") - - # Try alternative selectors - alt_selector = f"[data-testid*='{field}'], [id*='{field}'], [class*='{field}']" - if browser.wait_for_selector(alt_selector, timeout=1000): - text = browser.extract_text(alt_selector) - ticket_info[field] = text - print(f"Extracted {field} (alt): {text[:50]}..." if len( - text) > 50 else f"Extracted {field} (alt): {text}") - else: - ticket_info[field] = "Not found" - except Exception as e: - print(f"Error extracting {field}: {e}") - ticket_info[field] = "Error extracting" - - # Extract comments as a list if present - if "comments" in fields_to_extract: - try: - comments_selector = field_selectors["comments"] - if browser.wait_for_selector(comments_selector, timeout=1000): - # Get all comment elements - comment_elements = browser._page.query_selector_all(comments_selector) - comments = [] - for i, element in enumerate(comment_elements, 1): - comment_text = element.text_content() - if comment_text: - comments.append({ - "number": i, - "text": comment_text.strip() - }) - ticket_info["comments"] = comments - print(f"Extracted {len(comments)} comments") - else: - ticket_info["comments"] = [] - except Exception as e: - print(f"Error extracting comments: {e}") - ticket_info["comments"] = [] - - # As a fallback, get the entire HTML if extraction fails - if "summary" not in ticket_info or ticket_info["summary"] == "Not found": - try: - # Save HTML for debugging - ticket_info["_html"] = browser.get_page_html() - print("Saved page HTML as fallback") - except Exception as e: - print(f"Error getting page HTML: {e}") - - -def _login(browser, username, password, use_sso=True, prefer_google=True): - """Handle JIRA login with support for SSO. - - Args: - browser: Browser instance - username: JIRA username/email - password: JIRA password/API token - use_sso: Whether to attempt SSO login (default: True) - prefer_google: Whether to prefer Google SSO if available (default: True) - """ - try: - # First check for SSO options if requested - if use_sso: - # Prioritize Google SSO if preferred - if prefer_google: - google_sso_selectors = [ - "button:contains('Continue with Google')", - "button:contains('Sign in with Google')", - "a:contains('Google')", - "button:contains('Google')", - "button[data-provider='google']", - "div:contains('Google') button" - ] - - for selector in google_sso_selectors: - # Better element detection - element_info = browser.get_element_info(selector) - if element_info: - print(f"Found Google SSO option: {element_info['tag']} (visible: {element_info['isVisible']})") - if element_info['isVisible']: - browser.click_selector(selector) - browser.wait(5000) # Wait for redirect - - # Handle Google SSO provider page - return _handle_google_sso(browser, username, password) - - # Fallback - if browser.wait_for_selector(selector, timeout=1000): - print(f"Found Google SSO option: {selector}") - browser.click_selector(selector) - browser.wait(5000) # Wait for redirect - - # Handle Google SSO provider page - return _handle_google_sso(browser, username, password) - - # If Google not found or not preferred, try other SSO options - sso_selectors = [ - "button:contains('Log in with SSO')", - "a:contains('Single Sign-on')", - "button:contains('SSO')", - "button:contains('Continue with Microsoft')", - "button:contains('Continue with Okta')" - ] - - for selector in sso_selectors: - if browser.wait_for_selector(selector, timeout=1000): - print(f"Found SSO option: {selector}") - browser.click_selector(selector) - browser.wait(5000) # Wait for redirect - - # Handle SSO provider page - return _handle_sso_provider(browser, username, password) - - # Regular username/password flow if no SSO or SSO not requested - # Enter username - username_selector = "input[name='username'], input[id='username'], input[name='email']" - if browser.wait_for_selector(username_selector, timeout=3000): - browser.click_selector(username_selector) - browser.type(username) - - # Look for Continue or Submit button - continue_selector = "button[id='login-submit'], button[type='submit'], button:contains('Continue')" - if browser.wait_for_selector(continue_selector, timeout=2000): - browser.click_selector(continue_selector) - browser.wait(3000) - - # Enter password (might be on a second screen after username) - password_selector = "input[name='password'], input[id='password']" - if browser.wait_for_selector(password_selector, timeout=3000): - browser.click_selector(password_selector) - browser.type(password) - - # Click login button - login_selector = "button[id='login-submit'], button[type='submit'], button:contains('Log in')" - if browser.wait_for_selector(login_selector, timeout=2000): - browser.click_selector(login_selector) - browser.wait(5000) # Wait longer for login to complete - - return True - except Exception as e: - print(f"Login error: {e}") - return False - - -def _handle_sso_provider(browser, username, password): - """Handle SSO provider authentication flow. - - This function attempts to detect and handle various SSO providers like - Google, Microsoft, Okta, etc. - """ - # Wait for SSO page to load - browser.wait(3000) - - # Get current URL to determine provider - current_url = browser.get_current_url() - print(f"SSO redirect URL: {current_url}") - - # Google SSO - if "google" in current_url.lower(): - return _handle_google_sso(browser, username, password) - # Microsoft/Azure SSO - elif any(provider in current_url.lower() for provider in ["microsoft", "azure", "live"]): - return _handle_microsoft_sso(browser, username, password) - # Okta SSO - elif "okta" in current_url.lower(): - return _handle_okta_sso(browser, username, password) - # Generic SSO as fallback - else: - return _handle_generic_sso(browser, username, password) - - -def _handle_google_sso(browser, username, password): - """Handle Google SSO login flow.""" - try: - # Enter email - email_selector = "input[type='email']" - if browser.wait_for_selector(email_selector, timeout=5000): - # Check element state - element_info = browser.get_element_info(email_selector) - if element_info: - print(f"Found email input: {element_info.get('attributes', {})}") - - browser.click_selector(email_selector) - browser.type(username) - - # Click next - next_selector = "button:contains('Next'), button[id='identifierNext']" - if browser.wait_for_selector(next_selector, timeout=2000): - browser.click_selector(next_selector) - browser.wait(3000) - - # Enter password - password_selector = "input[type='password']" - if browser.wait_for_selector(password_selector, timeout=5000): - browser.click_selector(password_selector) - browser.type(password) - - # Click next/sign in - signin_selector = "button:contains('Next'), button[id='passwordNext']" - if browser.wait_for_selector(signin_selector, timeout=2000): - browser.click_selector(signin_selector) - browser.wait(5000) - - return True - except Exception as e: - print(f"Google SSO error: {e}") - return False - - -def _handle_microsoft_sso(browser, username, password): - """Handle Microsoft/Azure SSO login flow.""" - try: - # Enter email - email_selector = "input[type='email'], input[name='loginfmt']" - if browser.wait_for_selector(email_selector, timeout=5000): - element_info = browser.get_element_info(email_selector) - if element_info: - print(f"Found Microsoft email input: {element_info.get('attributes', {})}") - - browser.click_selector(email_selector) - browser.type(username) - - # Click next - next_selector = "input[type='submit'], button:contains('Next')" - if browser.wait_for_selector(next_selector, timeout=2000): - browser.click_selector(next_selector) - browser.wait(3000) - - # Enter password - password_selector = "input[type='password'], input[name='passwd']" - if browser.wait_for_selector(password_selector, timeout=5000): - browser.click_selector(password_selector) - browser.type(password) - - # Click sign in - signin_selector = "input[type='submit'], button:contains('Sign in')" - if browser.wait_for_selector(signin_selector, timeout=2000): - browser.click_selector(signin_selector) - browser.wait(3000) - - # Handle "Stay signed in?" if it appears - stay_selector = "input[type='submit'][value='Yes'], button:contains('Yes')" - if browser.wait_for_selector(stay_selector, timeout=3000): - browser.click_selector(stay_selector) - browser.wait(3000) - - return True - except Exception as e: - print(f"Microsoft SSO error: {e}") - return False - - -def _handle_okta_sso(browser, username, password): - """Handle Okta SSO login flow.""" - try: - # Enter username/email - username_selector = "input[name='username'], input[id='okta-signin-username']" - if browser.wait_for_selector(username_selector, timeout=5000): - element_info = browser.get_element_info(username_selector) - if element_info: - print(f"Found Okta username input: {element_info.get('attributes', {})}") - - browser.click_selector(username_selector) - browser.type(username) - - # Enter password - password_selector = "input[name='password'], input[id='okta-signin-password']" - if browser.wait_for_selector(password_selector, timeout=3000): - browser.click_selector(password_selector) - browser.type(password) - - # Click sign in - submit_selector = "input[type='submit'], button[type='submit'], button:contains('Sign in')" - if browser.wait_for_selector(submit_selector, timeout=2000): - browser.click_selector(submit_selector) - browser.wait(5000) - - # Handle MFA if present (this is very org-specific) - # This is a simplified example - real MFA handling would need to be customized - push_selector = "button:contains('Send Push'), a:contains('Send Push')" - if browser.wait_for_selector(push_selector, timeout=3000): - browser.click_selector(push_selector) - print("MFA push notification sent - please approve on your device") - browser.wait(20000) # Wait longer for MFA approval - - return True - except Exception as e: - print(f"Okta SSO error: {e}") - return False - - -def _handle_generic_sso(browser, username, password): - """Handle a generic SSO login flow for unknown providers.""" - try: - # Look for common username/email fields - username_selectors = [ - "input[type='email']", - "input[name='username']", - "input[id='username']", - "input[name='email']" - ] - - for selector in username_selectors: - element_info = browser.get_element_info(selector) - if element_info and element_info.get('isVisible', False): - print( - f"Found username input: {element_info['tag']} with attributes: {element_info.get('attributes', {})}") - browser.click_selector(selector) - browser.type(username) - browser.wait(1000) - - # Look for a next/continue button - next_selectors = [ - "button:contains('Next')", - "button:contains('Continue')", - "input[type='submit']", - "button[type='submit']" - ] - - for next_selector in next_selectors: - if browser.wait_for_selector(next_selector, timeout=1000): - browser.click_selector(next_selector) - browser.wait(3000) - break - break - elif browser.wait_for_selector(selector, timeout=1000): - browser.click_selector(selector) - browser.type(username) - browser.wait(1000) - - # Look for a next/continue button - for next_selector in ["button:contains('Next')", "button:contains('Continue')", "input[type='submit']"]: - if browser.wait_for_selector(next_selector, timeout=1000): - browser.click_selector(next_selector) - browser.wait(3000) - break - break - - # Look for common password fields - password_selectors = [ - "input[type='password']", - "input[name='password']", - "input[id='password']" - ] - - for selector in password_selectors: - element_info = browser.get_element_info(selector) - if element_info and element_info.get('isVisible', False): - browser.click_selector(selector) - browser.type(password) - browser.wait(1000) - - # Look for a login/submit button - submit_selectors = [ - "button:contains('Sign in')", - "button:contains('Log in')", - "input[type='submit']", - "button[type='submit']" - ] - - for submit_selector in submit_selectors: - if browser.wait_for_selector(submit_selector, timeout=1000): - browser.click_selector(submit_selector) - browser.wait(5000) - break - break - elif browser.wait_for_selector(selector, timeout=3000): - browser.click_selector(selector) - browser.type(password) - browser.wait(1000) - - # Look for a login/submit button - for submit_selector in ["button:contains('Sign in')", "button:contains('Log in')", - "input[type='submit']"]: - if browser.wait_for_selector(submit_selector, timeout=1000): - browser.click_selector(submit_selector) - browser.wait(5000) - break - break - - return True - except Exception as e: - print(f"Generic SSO error: {e}") - return False - - -def save_ticket_data(ticket_info, output_dir=None): - """Save ticket data to a JSON file. - - Args: - ticket_info: Dictionary of ticket data - output_dir: Directory to save file (defaults to ./ticket_data) - - Returns: - Path to saved file - """ - if output_dir is None: - output_dir = os.path.join(os.getcwd(), "ticket_data") - - # Create directory if it doesn't exist - os.makedirs(output_dir, exist_ok=True) - - # Filename from ticket ID - filename = f"{ticket_info['id'].replace('-', '_').lower()}.json" - filepath = os.path.join(output_dir, filename) - - # Save data - with open(filepath, 'w') as f: - json.dump(ticket_info, f, indent=2) - - print(f"Ticket data saved to: {filepath}") - return filepath - - -if __name__ == "__main__": - # Example usage - ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") - - # Additional fields to extract - fields = input("Enter comma-separated list of additional fields to extract (leave blank for defaults): ") - if fields.strip(): - extract_fields = [f.strip() for f in fields.split(",")] - else: - extract_fields = None - - # Automatically use Google SSO by default - ticket_info = read_jira_ticket(ticket_id, use_sso=True, prefer_google=True, extract_fields=extract_fields) - - # Save to file option - save_option = input("Save ticket data to file? (y/n): ").lower() - if save_option == 'y': - save_path = save_ticket_data(ticket_info) - - print("\nTicket Information:") - for key, value in ticket_info.items(): - # Skip HTML content in the output - if key == "_html": - print(f" {key}: [HTML content saved]") - # Format comments - elif key == "comments" and isinstance(value, list): - print(f" {key}: {len(value)} comments") - if value and len(value) > 0: - print(f" First comment: {value[0]['text'][:100]}..." if len( - value[0]['text']) > 100 else f" First comment: {value[0]['text']}") - # Format long strings - elif isinstance(value, str) and len(value) > 150: - print(f" {key}: {value[:150]}...") - else: - print(f" {key}: {value}") - - print("\nNote: This example demonstrates JIRA ticket retrieval.") - print("To use with your JIRA instance, set the following environment variables:") - print(" - JIRA_URL: Your JIRA instance URL (e.g., https://your-company.atlassian.net)") - print(" - JIRA_USERNAME: Your JIRA username/email") - print(" - JIRA_PASSWORD: Your JIRA password or API token") From bcb8e3a758ed7ae0510688078ba3d315d5db0db3 Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 13:19:12 -0400 Subject: [PATCH 04/13] Attempt login via SSO --- app/browser_agent/base_playwright_browser.py | 69 ++- app/jira_agent/auth.py | 504 +++++++++++++++++-- app/jira_agent/jira.py | 174 ++++--- app/jira_agent/selectors.py | 12 +- examples/jira_example.py | 34 +- 5 files changed, 645 insertions(+), 148 deletions(-) diff --git a/app/browser_agent/base_playwright_browser.py b/app/browser_agent/base_playwright_browser.py index 9054ff4..313e5ae 100644 --- a/app/browser_agent/base_playwright_browser.py +++ b/app/browser_agent/base_playwright_browser.py @@ -245,26 +245,35 @@ def get_element_info(self, selector: str) -> dict: Returns a dictionary with element properties like tag, text, attributes, etc. """ - return self._page.evaluate(""" - selector => { - const el = document.querySelector(selector); - if (!el) return null; - - const attributes = {}; - for (const attr of el.attributes) { - attributes[attr.name] = attr.value; + try: + # Check if page exists and is not null + if not self._page or self._page.is_closed(): + print("Warning: Page is closed or null") + return None + + return self._page.evaluate(""" + selector => { + const el = document.querySelector(selector); + if (!el) return null; + + const attributes = {}; + for (const attr of el.attributes) { + attributes[attr.name] = attr.value; + } + + return { + tag: el.tagName.toLowerCase(), + text: el.innerText, + html: el.innerHTML, + attributes: attributes, + isVisible: el.offsetWidth > 0 && el.offsetHeight > 0, + boundingBox: el.getBoundingClientRect().toJSON() + }; } - - return { - tag: el.tagName.toLowerCase(), - text: el.innerText, - html: el.innerHTML, - attributes: attributes, - isVisible: el.offsetWidth > 0 && el.offsetHeight > 0, - boundingBox: el.getBoundingClientRect().toJSON() - }; - } - """, selector) + """, selector) + except Exception as e: + print(f"Error getting element info: {e}") + return None def fill_form(self, selector: str, value: str) -> None: """Fill a form field with the given value.""" @@ -293,4 +302,24 @@ def switch_tab(self, tab_index: int) -> None: # --- Subclass hook --- def _get_browser_and_page(self) -> tuple[PlaywrightBrowser, Page]: """Subclasses must implement, returning (PlaywrightBrowser, Page).""" - raise NotImplementedError \ No newline at end of file + raise NotImplementedError + + def execute_script(self, script: str, *args): + """Execute JavaScript in the current page. + + Args: + script: JavaScript code to execute + *args: Arguments to pass to the script + + Returns: + Result of the JavaScript execution + """ + try: + if not self._page or self._page.is_closed(): + print("Warning: Page is closed or null") + return None + + return self._page.evaluate(script, *args) + except Exception as e: + print(f"Error executing script: {e}") + return None \ No newline at end of file diff --git a/app/jira_agent/auth.py b/app/jira_agent/auth.py index 9f0e2d7..bce7682 100644 --- a/app/jira_agent/auth.py +++ b/app/jira_agent/auth.py @@ -23,28 +23,44 @@ def is_login_page(browser: Browser) -> bool: Returns: True if this appears to be a login page """ - login_selectors = [ - DEFAULT_SELECTORS["login_page"]["username_field"], - DEFAULT_SELECTORS["login_page"]["password_field"], - DEFAULT_SELECTORS["login_page"]["login_button"] - ] - - # Add SSO selectors - for provider_selectors in SSO_SELECTORS.values(): - login_selectors.extend(provider_selectors) - - for selector in login_selectors: - # Use get_element_info for better detection - element_info = browser.get_element_info(selector) - if element_info: - logger.info(f"Login element detected: {element_info['tag']}") - return True - - # Fallback to wait_for_selector for complex selectors - if browser.wait_for_selector(selector, timeout=1000): - return True - - return False + try: + # First wait to ensure page is fully loaded + browser.wait(2000) + + login_selectors = [ + DEFAULT_SELECTORS["login_page"]["username_field"], + DEFAULT_SELECTORS["login_page"]["password_field"], + DEFAULT_SELECTORS["login_page"]["login_button"] + ] + + # Add SSO selectors + for provider_selectors in SSO_SELECTORS.values(): + login_selectors.extend(provider_selectors) + + for selector in login_selectors: + try: + # Use get_element_info for better detection + element_info = browser.get_element_info(selector) + if element_info: + logger.info(f"Login element detected: {element_info['tag']}") + return True + except Exception as e: + logger.debug(f"Error checking selector {selector}: {e}") + continue + + # Fallback to wait_for_selector for complex selectors + try: + if browser.wait_for_selector(selector, timeout=1000): + return True + except Exception as e: + logger.debug(f"Error waiting for selector {selector}: {e}") + continue + + return False + except Exception as e: + logger.warning(f"Error checking if on login page: {e}") + # If we can't determine, assume we're not on a login page + return False def login(browser: Browser, username: str, password: str, use_sso: bool = True, @@ -64,24 +80,119 @@ def login(browser: Browser, username: str, password: str, use_sso: bool = True, try: # First check for SSO options if requested if use_sso: - # Prioritize Google SSO if preferred - if prefer_google: - return _try_sso_provider(browser, username, password, provider="google") + logger.info("Attempting SSO login") + + # First check what SSO options are available + available_sso = [] + + # Check for Google + google_present = browser.execute_script(""" + const googleTexts = ['google', 'continue with google', 'sign in with google']; + const elements = Array.from(document.querySelectorAll('button, a, div[role="button"]')); + + for (const el of elements) { + const text = el.innerText ? el.innerText.toLowerCase() : ''; + if (googleTexts.some(gt => text.includes(gt))) { + return true; + } + + // Check for Google images + const images = el.querySelectorAll('img'); + for (const img of images) { + if ((img.alt && img.alt.toLowerCase().includes('google')) || + (img.src && img.src.toLowerCase().includes('google'))) { + return true; + } + } + } + return false; + """) + + # Check for Microsoft + microsoft_present = browser.execute_script(""" + const microsoftTexts = ['microsoft', 'continue with microsoft', 'sign in with microsoft', 'azure', 'office 365']; + const elements = Array.from(document.querySelectorAll('button, a, div[role="button"]')); + + for (const el of elements) { + const text = el.innerText ? el.innerText.toLowerCase() : ''; + if (microsoftTexts.some(mt => text.includes(mt))) { + return true; + } + + // Check for Microsoft images + const images = el.querySelectorAll('img'); + for (const img of images) { + if ((img.alt && img.alt.toLowerCase().includes('microsoft')) || + (img.src && img.src.toLowerCase().includes('microsoft'))) { + return true; + } + } + } + return false; + """) + + if google_present: + available_sso.append("google") + logger.info("Google SSO option detected") + + if microsoft_present: + available_sso.append("microsoft") + logger.info("Microsoft SSO option detected") + + # Prioritize Google SSO if preferred and available + if prefer_google and "google" in available_sso: + logger.info("Attempting Google SSO login (preferred)") + if _try_sso_provider(browser, username, password, provider="google", avoid_providers=["microsoft"]): + logger.info("Google SSO login successful") + + # Wait to make sure we're fully logged in + browser.wait(10000) + + # Check if we're still on a login page + if not is_login_page(browser): + return True # Otherwise try each SSO option in order for provider in ["generic_sso", "google", "microsoft", "okta"]: - if _try_sso_provider(browser, username, password, provider): - return True + if provider in available_sso or provider == "generic_sso": + logger.info(f"Trying {provider} SSO") + avoid = [] + if provider != "microsoft": + avoid = ["microsoft"] # Avoid clicking Microsoft when trying other providers + + if _try_sso_provider(browser, username, password, provider=provider, avoid_providers=avoid): + logger.info(f"{provider} SSO login successful") + + # Wait to make sure we're fully logged in + browser.wait(10000) + + # Check if we're still on a login page + if not is_login_page(browser): + return True # Regular username/password flow if no SSO or SSO not requested - return _standard_login(browser, username, password) + logger.info("Attempting standard login") + if _standard_login(browser, username, password): + # Wait to make sure we're fully logged in + browser.wait(10000) + + # Check if we're still on a login page + if not is_login_page(browser): + logger.info("Standard login successful") + return True + else: + logger.warning("Still on login page after login attempt") + return False except Exception as e: logger.error(f"Login error: {e}") return False + + logger.warning("Login process completed but result unclear") + return True # Default to True as we want to continue trying -def _try_sso_provider(browser: Browser, username: str, password: str, provider: str) -> bool: +def _try_sso_provider(browser: Browser, username: str, password: str, provider: str, avoid_providers: list = None) -> bool: """Try to log in using a specific SSO provider. Args: @@ -89,10 +200,14 @@ def _try_sso_provider(browser: Browser, username: str, password: str, provider: username: SSO username/email password: SSO password provider: Provider name ("google", "microsoft", "okta", or "generic_sso") + avoid_providers: List of providers to avoid clicking (e.g., ["microsoft"]) Returns: True if login appears successful """ + if avoid_providers is None: + avoid_providers = [] + if provider not in SSO_SELECTORS: logger.warning(f"Unknown SSO provider: {provider}") return False @@ -100,27 +215,279 @@ def _try_sso_provider(browser: Browser, username: str, password: str, provider: # Get selectors for the provider provider_selectors = SSO_SELECTORS[provider] - # Look for any SSO buttons for this provider - for selector in provider_selectors: - # Check if element exists and is visible - element_info = browser.get_element_info(selector) - if element_info and element_info.get('isVisible', False): - logger.info(f"Found {provider} SSO option: {element_info['tag']} (visible: {element_info['isVisible']})") - browser.click_selector(selector) - browser.wait(5000) # Wait for redirect + # Output debug info about the current page + try: + html = browser.get_page_html() + logger.info(f"Current page HTML length: {len(html)}") + logger.info(f"Current URL: {browser.get_current_url()}") + + # Look for provider-related content in the HTML + if provider.lower() in html.lower(): + logger.info(f"Page contains '{provider}' references") + except Exception as e: + logger.warning(f"Error getting page debug info: {e}") + + # JavaScript fallback for finding and clicking login buttons + try: + logger.info(f"Trying JavaScript fallback to find {provider} button") + found = browser.execute_script(f""" + // Look for buttons/links containing the text {provider} + function findElement() {{ + // Convert provider to lowercase for case-insensitive matching + const providerLower = "{provider}".toLowerCase(); + + // List of providers to avoid + const avoidProviders = {avoid_providers}; + + // First look for buttons/links with text containing the provider name + const elements = Array.from(document.querySelectorAll('button, a, div[role="button"], span[role="button"]')); + + for (const el of elements) {{ + // Skip any privacy policy links + if (el.href && ( + el.href.includes("policies.google.com/privacy") || + el.href.includes("privacy") || + el.href.includes("terms") + )) {{ + console.log("Skipping privacy/terms link:", el); + continue; + }} + + // Skip elements that are likely not login buttons + if (el.innerText && ( + el.innerText.toLowerCase().includes("privacy") || + el.innerText.toLowerCase().includes("policy") || + el.innerText.toLowerCase().includes("terms") || + el.innerText.toLowerCase().includes("cookie") + )) {{ + console.log("Skipping policy/terms text element:", el); + continue; + }} + + // Skip elements for providers we want to avoid + let shouldAvoid = false; + for (const avoidProvider of avoidProviders) {{ + if (el.innerText && el.innerText.toLowerCase().includes(avoidProvider.toLowerCase())) {{ + console.log(`Skipping element containing avoided provider '${avoidProvider}':`, el); + shouldAvoid = true; + break; + }} + + // Check if any images contain the avoid provider + const images = el.querySelectorAll('img'); + for (const img of images) {{ + if ((img.alt && img.alt.toLowerCase().includes(avoidProvider.toLowerCase())) || + (img.src && img.src.toLowerCase().includes(avoidProvider.toLowerCase()))) {{ + console.log(`Skipping element with image of avoided provider '${avoidProvider}':`, el); + shouldAvoid = true; + break; + }} + }} + + if (shouldAvoid) {{ + break; + }} + }} + + if (shouldAvoid) {{ + continue; + }} + + // Check the text content for login-related text + if (el.innerText && el.innerText.toLowerCase().includes(providerLower)) {{ + // Make sure it's a login button by checking for login-related text + if ( + el.innerText.toLowerCase().includes("sign in") || + el.innerText.toLowerCase().includes("login") || + el.innerText.toLowerCase().includes("log in") || + el.innerText.toLowerCase().includes("continue with") + ) {{ + console.log("Found login button by text content:", el); + return el; + }} + + // If it mentions the provider prominently, it's likely a login button + if (el.tagName === "BUTTON" || el.role === "button") {{ + console.log("Found button with provider mention:", el); + return el; + }} + }} + + // For Google specific detection + if (providerLower === "google" && el.innerText && + (el.innerText.toLowerCase().includes("google") || + el.innerText.toLowerCase() === "g")) {{ + console.log("Found Google-specific button:", el); + return el; + }} + + // For Microsoft specific detection + if (providerLower === "microsoft" && el.innerText && + (el.innerText.toLowerCase().includes("microsoft") || + el.innerText.toLowerCase().includes("azure") || + el.innerText.toLowerCase().includes("office 365"))) {{ + console.log("Found Microsoft-specific button:", el); + return el; + }} + + // Check aria-label for login-related text + if (el.getAttribute('aria-label') && + el.getAttribute('aria-label').toLowerCase().includes(providerLower)) {{ + if ( + el.getAttribute('aria-label').toLowerCase().includes("sign in") || + el.getAttribute('aria-label').toLowerCase().includes("login") || + el.getAttribute('aria-label').toLowerCase().includes("log in") + ) {{ + console.log("Found by aria-label:", el); + return el; + }} + }} + + // Check for nested images with alt text or src containing provider + const images = el.querySelectorAll('img'); + for (const img of images) {{ + if ((img.alt && img.alt.toLowerCase().includes(providerLower)) || + (img.src && img.src.toLowerCase().includes(providerLower))) {{ + // Skip if the parent has href to privacy + if (el.href && ( + el.href.includes("policies.google.com") || + el.href.includes("privacy") || + el.href.includes("terms") + )) {{ + console.log("Skipping privacy link with provider image:", el); + continue; + }} + console.log("Found via nested image:", el); + return el; + }} + }} + + // Check for class or id containing provider and looks like a login button + if ((el.id && el.id.toLowerCase().includes(providerLower)) || + (el.className && el.className.toLowerCase().includes(providerLower))) {{ + // Skip if looks like a privacy element + if ( + (el.id && (el.id.toLowerCase().includes("privacy") || el.id.toLowerCase().includes("term"))) || + (el.className && (el.className.toLowerCase().includes("privacy") || el.className.toLowerCase().includes("term"))) + ) {{ + console.log("Skipping privacy/terms element:", el); + continue; + }} + console.log("Found by class/id:", el); + return el; + }} + }} + + return null; + }} - # Handle provider-specific login + const element = findElement(); + if (element) {{ + // Get element details for logging + const details = {{ + tag: element.tagName, + text: element.innerText, + className: element.className, + id: element.id, + href: element.href || null + }}; + + console.log("Clicking element:", details); + element.click(); + return details; + }} + return null; + """) + + if found: + logger.info(f"JavaScript found and clicked {provider} button: {found}") + browser.wait(10000) # Wait for redirect return _handle_sso_flow(browser, username, password, provider) + except Exception as e: + logger.error(f"Error using JavaScript fallback: {e}") - # Fallback to simple selector check - if browser.wait_for_selector(selector, timeout=1000): - logger.info(f"Found {provider} SSO option: {selector}") - browser.click_selector(selector) - browser.wait(5000) # Wait for redirect + # Now try the regular selectors as fallback + logger.info(f"Trying {len(provider_selectors)} {provider} selectors: {provider_selectors}") + + # Look for any SSO buttons for this provider + for selector in provider_selectors: + try: + # Check if element exists and is visible + element_info = browser.get_element_info(selector) + if element_info: + # Skip if it looks like a privacy policy link + href = element_info.get('attributes', {}).get('href', '') + if href and ('privacy' in href or 'policies.google.com' in href or 'terms' in href): + logger.info(f"Skipping privacy/terms link: {href}") + continue + + # Check if this element contains text of a provider we want to avoid + should_avoid = False + for avoid_provider in avoid_providers: + text = element_info.get('text', '').lower() + if avoid_provider.lower() in text: + logger.info(f"Skipping element containing avoided provider '{avoid_provider}': {text}") + should_avoid = True + break + + if should_avoid: + continue + + logger.info(f"Found {provider} element with selector '{selector}': {element_info}") + if element_info.get('isVisible', False): + logger.info(f"VISIBLE {provider} SSO option: {element_info['tag']} with selector '{selector}'") + browser.click_selector(selector) + browser.wait(8000) # Increased wait for redirect + + # Handle provider-specific login + return _handle_sso_flow(browser, username, password, provider) + else: + logger.info(f"Element found but NOT VISIBLE: {element_info}") + else: + logger.debug(f"No element found with selector: {selector}") - # Handle provider-specific login - return _handle_sso_flow(browser, username, password, provider) + # Fallback to simple selector check + if browser.wait_for_selector(selector, timeout=2000): # Increased timeout + # Check if it's a privacy policy link before clicking + avoid_check = browser.execute_script(f""" + const el = document.querySelector("{selector}"); + if (!el) return null; + + // Check for privacy policy + if (el.href && ( + el.href.includes("privacy") || + el.href.includes("policies.google.com") || + el.href.includes("terms") + )) {{ + return "privacy"; + }} + + // Check for avoided providers + const avoidProviders = {avoid_providers}; + for (const avoid of avoidProviders) {{ + if (el.innerText && el.innerText.toLowerCase().includes(avoid.toLowerCase())) {{ + return avoid; + }} + }} + + return null; + """) + + if avoid_check: + logger.info(f"Skipping element with '{avoid_check}' content: {selector}") + continue + + logger.info(f"Successfully waited for {provider} SSO option: {selector}") + browser.click_selector(selector) + browser.wait(8000) # Increased wait for redirect + + # Handle provider-specific login + return _handle_sso_flow(browser, username, password, provider) + except Exception as e: + logger.debug(f"Error trying SSO selector {selector}: {e}") + continue + logger.warning(f"Could not find any {provider} SSO options on the page") return False @@ -137,7 +504,7 @@ def _handle_sso_flow(browser: Browser, username: str, password: str, provider: s True if login appears successful """ # Wait for SSO page to load - browser.wait(3000) + browser.wait(5000) # Increased wait time # Get current URL to determine provider if not explicitly specified if provider == "generic_sso": @@ -215,7 +582,7 @@ def _handle_google_sso(browser: Browser, username: str, password: str) -> bool: try: # Enter email email_selector = "input[type='email']" - if browser.wait_for_selector(email_selector, timeout=5000): + if browser.wait_for_selector(email_selector, timeout=10000): # Increased timeout # Check element state element_info = browser.get_element_info(email_selector) if element_info: @@ -226,23 +593,50 @@ def _handle_google_sso(browser: Browser, username: str, password: str) -> bool: # Click next next_selector = "button:contains('Next'), button[id='identifierNext']" - if browser.wait_for_selector(next_selector, timeout=2000): + if browser.wait_for_selector(next_selector, timeout=5000): # Increased timeout browser.click_selector(next_selector) - browser.wait(3000) + browser.wait(5000) # Increased wait time # Enter password password_selector = "input[type='password']" - if browser.wait_for_selector(password_selector, timeout=5000): + if browser.wait_for_selector(password_selector, timeout=10000): # Increased timeout browser.click_selector(password_selector) browser.type(password) - # Click next/sign in - signin_selector = "button:contains('Next'), button[id='passwordNext']" - if browser.wait_for_selector(signin_selector, timeout=2000): + # Click sign in + signin_selector = "button:contains('Sign in'), button[id='passwordNext']" + if browser.wait_for_selector(signin_selector, timeout=5000): # Increased timeout browser.click_selector(signin_selector) - browser.wait(5000) - return True + # Wait longer for Google authentication to complete and redirect back + browser.wait(15000) # Substantially increased wait time + + # Check if we're redirected back to JIRA + current_url = browser.get_current_url() + logger.info(f"Current URL after Google auth: {current_url}") + + # Check if we successfully returned to JIRA + if "atlassian" in current_url or "jira" in current_url: + # Wait for JIRA UI to fully load + browser.wait(5000) + return True + + # Handle potential 2FA challenge or other authentication steps + browser.wait(5000) + verify_selectors = [ + "input[id='totpPin']", # TOTP verification code + "button:contains('Try another way')", + "button:contains('Verify')" + ] + + for selector in verify_selectors: + if browser.wait_for_selector(selector, timeout=2000): + logger.warning("Additional verification detected - may require manual intervention") + # Wait longer for manual intervention + browser.wait(30000) + return True # Hope the user has completed manual verification + + return True # Assume success if we got this far except Exception as e: logger.error(f"Google SSO error: {e}") return False diff --git a/app/jira_agent/jira.py b/app/jira_agent/jira.py index 7c1da13..e787629 100644 --- a/app/jira_agent/jira.py +++ b/app/jira_agent/jira.py @@ -75,40 +75,69 @@ def get_ticket(self, ticket_id: str, extract_fields: Optional[List[str]] = None) """ # Default fields to extract if not specified if extract_fields is None: - extract_fields = ["status", "assignee", "priority", "type"] + extract_fields = ["summary", "description"] logger.info(f"Getting information for ticket {ticket_id}") - with LocalPlaywrightBrowser(headless=self.headless) as browser: - # Navigate to the JIRA ticket - ticket_url = f"{self.jira_url}/browse/{ticket_id}" - browser.goto(ticket_url) - browser.wait(3000) # Wait for page to load - - # Check if login is required - if is_login_page(browser): - logger.info("Login required") - login(browser, self.username, self.password, - use_sso=self.use_sso, prefer_google=self.prefer_google) + # Initialize with basic info in case of errors + ticket_info = { + "id": ticket_id, + "url": f"{self.jira_url}/browse/{ticket_id}", + } + + try: + with LocalPlaywrightBrowser(headless=self.headless) as browser: + # Navigate to the JIRA ticket + ticket_url = f"{self.jira_url}/browse/{ticket_id}" + logger.info(f"Navigating to {ticket_url}") + browser.goto(ticket_url) - # Wait for redirect after login - browser.wait(5000) + # Extra wait to ensure page is fully loaded + browser.wait(5000) # Increased wait time - # Navigate to ticket again if needed - current_url = browser.get_current_url() - if ticket_id not in current_url: - browser.goto(ticket_url) - browser.wait(3000) - - # Initialize ticket information - ticket_info = { - "id": ticket_id, - "url": browser.get_current_url(), - } - - # Extract ticket fields - self._extract_ticket_fields(browser, ticket_info, extract_fields) - + # Update URL after navigation + ticket_info["url"] = browser.get_current_url() + + # Check if login is required + try: + if is_login_page(browser): + logger.info("Login required") + login_success = login(browser, self.username, self.password, + use_sso=self.use_sso, prefer_google=self.prefer_google) + + if not login_success: + logger.error("Login failed") + ticket_info["error"] = "Login failed" + return ticket_info + + # Wait for redirect after login + browser.wait(5000) + + # Navigate to ticket again if needed + current_url = browser.get_current_url() + if ticket_id not in current_url: + logger.info(f"Navigating back to ticket {ticket_id} after login") + browser.goto(ticket_url) + browser.wait(5000) + + # Update URL after navigation + ticket_info["url"] = browser.get_current_url() + except Exception as e: + logger.error(f"Error during login check: {e}") + ticket_info["error"] = f"Login error: {str(e)}" + return ticket_info + + # Extract ticket fields + try: + self._extract_ticket_fields(browser, ticket_info, extract_fields) + except Exception as e: + logger.error(f"Error extracting ticket fields: {e}") + ticket_info["error"] = f"Field extraction error: {str(e)}" + + return ticket_info + except Exception as e: + logger.error(f"Unexpected error accessing ticket {ticket_id}: {e}") + ticket_info["error"] = f"Browser error: {str(e)}" return ticket_info def add_comment(self, ticket_id: str, comment_text: str) -> bool: @@ -345,6 +374,7 @@ def _extract_ticket_fields(self, browser, ticket_info: Dict[str, Any], extract_f for field in fields_to_extract: if field in FIELD_SELECTORS: + logger.info(f"Extracting {field}") selector = FIELD_SELECTORS[field] try: # First check if the element exists and is visible @@ -361,46 +391,68 @@ def _extract_ticket_fields(self, browser, ticket_info: Dict[str, Any], extract_f else: logger.debug(f"{field} element not found") - # Try alternative selectors - alt_selector = f"[data-testid*='{field}'], [id*='{field}'], [class*='{field}']" - if browser.wait_for_selector(alt_selector, timeout=1000): - text = browser.extract_text(alt_selector) - ticket_info[field] = text - logger.info(f"Extracted {field} (alt): {text[:50]}..." if len(text) > 50 else f"Extracted {field} (alt): {text}") + # Extended selectors for common fields + if field == "summary": + alt_selectors = [ + "h1", + "[data-testid*='summary']", + "[id*='summary']", + "[class*='summary']", + ".issue-header-content h1", + "h1.entry-title", + "#summary-val", + ".ghx-summary" + ] + elif field == "description": + alt_selectors = [ + "[data-testid*='description']", + "[id*='description']", + "[class*='description']", + "#description-val", + ".user-content-block" + ] else: + alt_selectors = [ + f"[data-testid*='{field}']", + f"[id*='{field}']", + f"[class*='{field}']" + ] + + # Try each alternative selector + field_found = False + for alt_selector in alt_selectors: + try: + if browser.wait_for_selector(alt_selector, timeout=3000): # Increased timeout + text = browser.extract_text(alt_selector) + ticket_info[field] = text + logger.info(f"Extracted {field} using {alt_selector}: {text[:50]}..." if len(text) > 50 else f"Extracted {field} using {alt_selector}: {text}") + field_found = True + break + except Exception as e: + logger.debug(f"Error with alt selector {alt_selector}: {e}") + continue + + if not field_found: + logger.warning(f"Could not find {field} using any selector") ticket_info[field] = "Not found" except Exception as e: logger.error(f"Error extracting {field}: {e}") ticket_info[field] = "Error extracting" - # Extract comments as a list if present - if "comments" in fields_to_extract: - try: - comments_selector = FIELD_SELECTORS["comments"] - if browser.wait_for_selector(comments_selector, timeout=1000): - # Get all comment elements - comment_elements = browser._page.query_selector_all(comments_selector) - comments = [] - for i, element in enumerate(comment_elements, 1): - comment_text = element.text_content() - if comment_text: - comments.append({ - "number": i, - "text": comment_text.strip() - }) - ticket_info["comments"] = comments - logger.info(f"Extracted {len(comments)} comments") - else: - ticket_info["comments"] = [] - except Exception as e: - logger.error(f"Error extracting comments: {e}") - ticket_info["comments"] = [] - # As a fallback, get the entire HTML if extraction fails if "summary" not in ticket_info or ticket_info["summary"] == "Not found": try: - # Save HTML for debugging - ticket_info["_html"] = browser.get_page_html() - logger.info("Saved page HTML as fallback") + # Try a broader approach by getting all headings + headings = browser.execute_script(""" + return Array.from(document.querySelectorAll('h1,h2')).map(el => el.innerText).join('\\n'); + """) + + if headings: + logger.info("Found headings as fallback for summary") + ticket_info["summary"] = headings.split('\n')[0] # Use the first heading + else: + # Save HTML for debugging + ticket_info["_html"] = browser.get_page_html() + logger.info("Saved page HTML as fallback") except Exception as e: logger.error(f"Error getting page HTML: {e}") \ No newline at end of file diff --git a/app/jira_agent/selectors.py b/app/jira_agent/selectors.py index 9bcc726..96a7ded 100644 --- a/app/jira_agent/selectors.py +++ b/app/jira_agent/selectors.py @@ -17,7 +17,17 @@ "a:contains('Google')", "button:contains('Google')", "button[data-provider='google']", - "div:contains('Google') button" + "div:contains('Google') button", + "a[data-provider='google']", + "a.google", + "a[href*='google']", + ".google-button", + "[id*='google']", + "[class*='google']", + "button[data-testid*='google']", + "img[alt*='Google']", + "img[src*='google']", + "*[aria-label*='Google']" ], "microsoft": [ "button:contains('Continue with Microsoft')", diff --git a/examples/jira_example.py b/examples/jira_example.py index d0bf07c..8022df0 100644 --- a/examples/jira_example.py +++ b/examples/jira_example.py @@ -23,11 +23,23 @@ def main(): # Initialize the JIRA agent # You can customize these parameters or set them in your .env file - agent = JiraAgent( - headless=False, # Set to True to hide the browser - jira_url=os.getenv("JIRA_URL"), - use_sso=True, - prefer_google=True + load_dotenv(dotenv_path='.env.local') # Try local env first + if not os.getenv("JIRA_URL"): # If not found, try .env + load_dotenv() + + # Get JIRA configuration from environment + jira_url = os.getenv("JIRA_URL") + jira_username = os.getenv("JIRA_USERNAME") + jira_password = os.getenv("JIRA_PASSWORD") + use_sso = os.getenv("JIRA_USE_SSO", "true").lower() == "true" + + # Initialize the JIRA agent with headless=False for debugging + jira_agent = JiraAgent( + jira_url=jira_url, + username=jira_username, + password=jira_password, + use_sso=use_sso, + headless=False # Set to False for debugging ) # Display menu of options @@ -42,7 +54,7 @@ def main(): if choice in ("1", "5"): # Get ticket information print(f"\nGetting information for ticket {ticket_id}...") - ticket_info = agent.get_ticket(ticket_id) + ticket_info = jira_agent.get_ticket(ticket_id) # Display ticket information print("\nTicket Information:") @@ -61,7 +73,7 @@ def main(): # Save ticket data save_option = input("\nSave ticket data to file? (y/n): ").lower() if save_option == 'y': - file_path = agent.save_ticket_data(ticket_info) + file_path = jira_agent.save_ticket_data(ticket_info) print(f"Ticket data saved to: {file_path}") if choice in ("2", "5"): @@ -69,7 +81,7 @@ def main(): comment_text = input("\nEnter comment text (leave empty to skip): ") if comment_text: print(f"Adding comment to ticket {ticket_id}...") - result = agent.add_comment(ticket_id, comment_text) + result = jira_agent.add_comment(ticket_id, comment_text) if result: print("Comment added successfully!") else: @@ -90,7 +102,7 @@ def main(): if new_status: print(f"Changing status of ticket {ticket_id} to '{new_status}'...") - result = agent.change_status(ticket_id, new_status) + result = jira_agent.change_status(ticket_id, new_status) if result: print("Status changed successfully!") else: @@ -104,9 +116,9 @@ def main(): analysis_endpoint = input("Enter API endpoint for analysis (leave empty to skip): ") if analysis_endpoint: - analysis_results = agent.analyze_ticket(ticket_id, analysis_endpoint=analysis_endpoint) + analysis_results = jira_agent.analyze_ticket(ticket_id, analysis_endpoint=analysis_endpoint) else: - analysis_results = agent.analyze_ticket(ticket_id) + analysis_results = jira_agent.analyze_ticket(ticket_id) print("\nAnalysis Results:") for key, value in analysis_results.items(): From 2c43f09909948897eb5d70cedd508f22c710113d Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 14:27:19 -0400 Subject: [PATCH 05/13] Implement API integration with JIRA --- app/jira_agent/jira.py | 528 ++++++++++++++++++++++++++++++++++----- examples/jira_example.py | 21 +- 2 files changed, 491 insertions(+), 58 deletions(-) diff --git a/app/jira_agent/jira.py b/app/jira_agent/jira.py index e787629..06bd3c4 100644 --- a/app/jira_agent/jira.py +++ b/app/jira_agent/jira.py @@ -20,6 +20,14 @@ from app.jira_agent.auth import is_login_page, login from app.jira_agent.selectors import DEFAULT_SELECTORS, FIELD_SELECTORS +# Conditionally import jira for API mode +try: + from jira import JIRA + JIRA_API_AVAILABLE = True +except ImportError: + JIRA_API_AVAILABLE = False + logging.warning("JIRA API package not installed. To use API mode, run: pip install jira") + # Load environment variables, first trying .env.local load_dotenv(dotenv_path=".env.local", override=True) # If .env.local doesn't exist, fall back to .env @@ -38,7 +46,8 @@ def __init__(self, password: Optional[str] = None, use_sso: bool = True, prefer_google: bool = True, - cache_dir: str = "./cache"): + cache_dir: str = "./cache", + mode: str = "browser"): """Initialize the JIRA agent. Args: @@ -49,6 +58,7 @@ def __init__(self, use_sso: Whether to use SSO for authentication prefer_google: Whether to prefer Google SSO if available cache_dir: Directory for caching memory + mode: Interaction mode - 'browser' (Playwright) or 'api' (JIRA API) """ self.headless = headless self.jira_url = jira_url or os.getenv("JIRA_URL", "https://mydomain.atlassian.net") @@ -56,6 +66,7 @@ def __init__(self, self.password = password or os.getenv("JIRA_PASSWORD") self.use_sso = use_sso self.prefer_google = prefer_google + self.mode = mode if not self.username or not self.password: raise ValueError("JIRA credentials are required. Set them in .env file or pass them to the constructor.") @@ -85,6 +96,140 @@ def get_ticket(self, ticket_id: str, extract_fields: Optional[List[str]] = None) "url": f"{self.jira_url}/browse/{ticket_id}", } + # Use different implementation based on mode + if self.mode == "api": + return self._get_ticket_api(ticket_id, extract_fields) + else: + return self._get_ticket_browser(ticket_id, extract_fields, ticket_info) + + def _get_ticket_api(self, ticket_id: str, extract_fields: List[str]) -> Dict[str, Any]: + """Get ticket information using JIRA API. + + Args: + ticket_id: The JIRA ticket ID + extract_fields: List of fields to extract + + Returns: + Dictionary with ticket information + """ + if not JIRA_API_AVAILABLE: + logger.warning("JIRA API not available. Install with: pip install jira") + return { + "id": ticket_id, + "url": f"{self.jira_url}/browse/{ticket_id}", + "error": "JIRA API package not installed. Run: pip install jira" + } + + logger.info(f"Using API mode to get ticket {ticket_id}") + + # Initialize with basic info + ticket_info = { + "id": ticket_id, + "url": f"{self.jira_url}/browse/{ticket_id}", + "mode": "api" + } + + try: + # Connect to JIRA + auth_method = None + + # Determine authentication method based on URL and credentials + if self.use_sso: + logger.warning("SSO not supported in API mode. Using basic auth instead.") + + # Use basic auth with username/password or token + auth = (self.username, self.password) + + # Connect to JIRA + jira = JIRA( + server=self.jira_url, + basic_auth=auth + ) + + # Get issue + issue = jira.issue(ticket_id) + + # Extract common fields + if "summary" in extract_fields or not extract_fields: + ticket_info["summary"] = issue.fields.summary + + if "description" in extract_fields or not extract_fields: + ticket_info["description"] = issue.fields.description or "" + + if "status" in extract_fields: + ticket_info["status"] = issue.fields.status.name + + if "assignee" in extract_fields: + if issue.fields.assignee: + ticket_info["assignee"] = issue.fields.assignee.displayName + else: + ticket_info["assignee"] = "Unassigned" + + if "priority" in extract_fields: + if issue.fields.priority: + ticket_info["priority"] = issue.fields.priority.name + else: + ticket_info["priority"] = "None" + + if "type" in extract_fields: + if issue.fields.issuetype: + ticket_info["type"] = issue.fields.issuetype.name + else: + ticket_info["type"] = "Unknown" + + if "reporter" in extract_fields: + if issue.fields.reporter: + ticket_info["reporter"] = issue.fields.reporter.displayName + else: + ticket_info["reporter"] = "Unknown" + + if "created" in extract_fields: + ticket_info["created"] = issue.fields.created + + if "updated" in extract_fields: + ticket_info["updated"] = issue.fields.updated + + if "comments" in extract_fields: + comments = [] + for comment in issue.fields.comment.comments: + comments.append({ + "author": comment.author.displayName, + "text": comment.body, + "created": comment.created + }) + ticket_info["comments"] = comments + + if "labels" in extract_fields: + ticket_info["labels"] = issue.fields.labels if issue.fields.labels else [] + + # Extract any custom fields that were requested + field_map = {field['name'].lower(): field['id'] for field in jira.fields()} + for field in extract_fields: + if field not in ticket_info and field.lower() in field_map: + field_id = field_map[field.lower()] + if hasattr(issue.fields, field_id): + value = getattr(issue.fields, field_id) + if value is not None: + ticket_info[field] = value + + return ticket_info + + except Exception as e: + logger.error(f"JIRA API error: {e}") + ticket_info["error"] = f"JIRA API error: {str(e)}" + return ticket_info + + def _get_ticket_browser(self, ticket_id: str, extract_fields: List[str], ticket_info: Dict[str, Any]) -> Dict[str, Any]: + """Get ticket information using browser automation. + + Args: + ticket_id: The JIRA ticket ID + extract_fields: List of fields to extract + ticket_info: Dictionary with basic ticket information + + Returns: + Dictionary with ticket information + """ try: with LocalPlaywrightBrowser(headless=self.headless) as browser: # Navigate to the JIRA ticket @@ -152,6 +297,55 @@ def add_comment(self, ticket_id: str, comment_text: str) -> bool: """ logger.info(f"Adding comment to ticket {ticket_id}") + # Use different implementation based on mode + if self.mode == "api": + return self._add_comment_api(ticket_id, comment_text) + else: + return self._add_comment_browser(ticket_id, comment_text) + + def _add_comment_api(self, ticket_id: str, comment_text: str) -> bool: + """Add a comment to a ticket using JIRA API. + + Args: + ticket_id: The JIRA ticket ID + comment_text: The text of the comment to add + + Returns: + True if comment was added successfully + """ + if not JIRA_API_AVAILABLE: + logger.warning("JIRA API not available. Install with: pip install jira") + return False + + logger.info(f"Using API mode to add comment to {ticket_id}") + + try: + # Connect to JIRA + jira = JIRA( + server=self.jira_url, + basic_auth=(self.username, self.password) + ) + + # Add comment to the issue + jira.add_comment(ticket_id, comment_text) + + logger.info(f"Comment added to {ticket_id} via API") + return True + + except Exception as e: + logger.error(f"Error adding comment via API: {e}") + return False + + def _add_comment_browser(self, ticket_id: str, comment_text: str) -> bool: + """Add a comment to a ticket using browser automation. + + Args: + ticket_id: The JIRA ticket ID + comment_text: The text of the comment to add + + Returns: + True if comment was added successfully + """ with LocalPlaywrightBrowser(headless=self.headless) as browser: # Navigate to the JIRA ticket ticket_url = f"{self.jira_url}/browse/{ticket_id}" @@ -217,13 +411,81 @@ def change_status(self, ticket_id: str, new_status: str) -> bool: Args: ticket_id: The JIRA ticket ID (e.g., "PROJ-123") - new_status: The new status (e.g., "In Progress", "Done", etc.) + new_status: The new status to set (e.g., "In Progress", "Done") Returns: True if status was changed successfully """ logger.info(f"Changing status of ticket {ticket_id} to {new_status}") + # Use different implementation based on mode + if self.mode == "api": + return self._change_status_api(ticket_id, new_status) + else: + return self._change_status_browser(ticket_id, new_status) + + def _change_status_api(self, ticket_id: str, new_status: str) -> bool: + """Change ticket status using JIRA API. + + Args: + ticket_id: The JIRA ticket ID + new_status: The new status to set + + Returns: + True if status was changed successfully + """ + if not JIRA_API_AVAILABLE: + logger.warning("JIRA API not available. Install with: pip install jira") + return False + + logger.info(f"Using API mode to change status of {ticket_id} to {new_status}") + + try: + # Connect to JIRA + jira = JIRA( + server=self.jira_url, + basic_auth=(self.username, self.password) + ) + + # Get the issue + issue = jira.issue(ticket_id) + + # Get available transitions + transitions = jira.transitions(issue) + + # Find the transition ID for the requested status + transition_id = None + for t in transitions: + if t['name'].lower() == new_status.lower() or t['to']['name'].lower() == new_status.lower(): + transition_id = t['id'] + break + + if not transition_id: + logger.error(f"No transition found for status: {new_status}") + available_statuses = [t['to']['name'] for t in transitions] + logger.info(f"Available statuses: {available_statuses}") + return False + + # Perform the transition + jira.transition_issue(issue, transition_id) + + logger.info(f"Changed status of {ticket_id} to {new_status} via API") + return True + + except Exception as e: + logger.error(f"Error changing status via API: {e}") + return False + + def _change_status_browser(self, ticket_id: str, new_status: str) -> bool: + """Change ticket status using browser automation. + + Args: + ticket_id: The JIRA ticket ID + new_status: The new status to set + + Returns: + True if status was changed successfully + """ with LocalPlaywrightBrowser(headless=self.headless) as browser: # Navigate to the JIRA ticket ticket_url = f"{self.jira_url}/browse/{ticket_id}" @@ -235,8 +497,6 @@ def change_status(self, ticket_id: str, new_status: str) -> bool: logger.info("Login required") login(browser, self.username, self.password, use_sso=self.use_sso, prefer_google=self.prefer_google) - - # Wait for redirect after login browser.wait(5000) # Navigate to ticket again if needed @@ -245,40 +505,36 @@ def change_status(self, ticket_id: str, new_status: str) -> bool: browser.goto(ticket_url) browser.wait(3000) - # Click the status dropdown + # Find and click the status dropdown status_dropdown = DEFAULT_SELECTORS["ticket_page"]["status_transition"]["dropdown"] if not browser.wait_for_selector(status_dropdown, timeout=5000): logger.error("Status dropdown not found") return False browser.click_selector(status_dropdown) - browser.wait(1000) + browser.wait(2000) # Wait for dropdown to open - # Click the new status option - # First try to find a specific selector for the requested status - status_key = new_status.lower().replace(" ", "_") - if status_key in DEFAULT_SELECTORS["ticket_page"]["status_transition"]["options"]: - status_option = DEFAULT_SELECTORS["ticket_page"]["status_transition"]["options"][status_key] - else: - # Otherwise, try a generic selector with the status text - status_option = f"button:contains('{new_status}'), [role='option']:contains('{new_status}')" - - if not browser.wait_for_selector(status_option, timeout=5000): + # Custom status selector based on the provided status name + status_selector = f"button:contains('{new_status}')" + + # Try to find the status option + if not browser.wait_for_selector(status_selector, timeout=5000): logger.error(f"Status option '{new_status}' not found") return False - browser.click_selector(status_option) - browser.wait(5000) # Wait for status to change + # Click the status option + browser.click_selector(status_selector) + browser.wait(3000) # Wait for status change to take effect - # Verify status was changed - status_text = browser.extract_text(DEFAULT_SELECTORS["ticket_page"]["status"]) - if new_status.lower() in status_text.lower(): - logger.info("Status changed successfully") + # Optional: Verify status changed + current_status_text = browser.extract_text(DEFAULT_SELECTORS["ticket_page"]["status"]) + if new_status.lower() in current_status_text.lower(): + logger.info(f"Status successfully changed to {new_status}") return True - logger.warning("Could not verify status was changed") - return False - + logger.warning("Could not verify status change") + return True # Return True anyway as the click was successful + def save_ticket_data(self, ticket_info: Dict[str, Any], output_dir: Optional[str] = None) -> str: """Save ticket data to a JSON file. @@ -308,53 +564,213 @@ def save_ticket_data(self, ticket_info: Dict[str, Any], output_dir: Optional[str def analyze_ticket(self, ticket_id: str, analysis_endpoint: Optional[str] = None, analysis_params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """Analyze a JIRA ticket and optionally call an external API. + """Analyze a JIRA ticket using an external service or local processing. Args: ticket_id: The JIRA ticket ID (e.g., "PROJ-123") - analysis_endpoint: Optional API endpoint URL for external analysis - analysis_params: Optional additional parameters for the API call + analysis_endpoint: Optional endpoint for analysis service + analysis_params: Additional parameters for analysis + + Returns: + Analysis results as a dictionary + """ + # Use different implementation based on mode + if self.mode == "api": + return self._analyze_ticket_api(ticket_id, analysis_endpoint, analysis_params) + else: + return self._analyze_ticket_browser(ticket_id, analysis_endpoint, analysis_params) + + def _analyze_ticket_api(self, ticket_id: str, analysis_endpoint: Optional[str], + analysis_params: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Analyze ticket using JIRA API. + + Args: + ticket_id: The JIRA ticket ID + analysis_endpoint: Optional endpoint for analysis service + analysis_params: Additional parameters for analysis + + Returns: + Analysis results as a dictionary + """ + if not JIRA_API_AVAILABLE: + logger.warning("JIRA API not available. Install with: pip install jira") + return { + "ticket_id": ticket_id, + "error": "JIRA API package not installed. Run: pip install jira" + } + + logger.info(f"Using API mode to analyze ticket {ticket_id}") + + try: + # Connect to JIRA + jira = JIRA( + server=self.jira_url, + basic_auth=(self.username, self.password) + ) + + # Get the issue + issue = jira.issue(ticket_id) + + # Get ticket data + ticket_data = { + "id": ticket_id, + "summary": issue.fields.summary, + "description": issue.fields.description or "", + "status": issue.fields.status.name, + "assignee": issue.fields.assignee.displayName if issue.fields.assignee else "Unassigned", + "reporter": issue.fields.reporter.displayName if issue.fields.reporter else "Unknown", + "type": issue.fields.issuetype.name if issue.fields.issuetype else "Unknown", + "priority": issue.fields.priority.name if issue.fields.priority else "None", + "created": issue.fields.created, + "updated": issue.fields.updated + } + + # Get comments + comments = [] + for comment in issue.fields.comment.comments: + comments.append({ + "author": comment.author.displayName, + "text": comment.body, + "created": comment.created + }) + ticket_data["comments"] = comments + + # Basic analysis + analysis_results = { + "ticket_id": ticket_id, + "data": ticket_data, + "analysis": { + "word_count": len(ticket_data["description"].split()), + "comment_count": len(comments), + "age_days": self._days_since(ticket_data["created"]), + "last_updated_days": self._days_since(ticket_data["updated"]) + } + } + + # If there's an external analysis endpoint, use it + if analysis_endpoint: + try: + import requests + + # Prepare payload + payload = { + "ticket_id": ticket_id, + "ticket_data": ticket_data + } + + # Add any additional parameters + if analysis_params: + payload.update(analysis_params) + + # Call the analysis service + response = requests.post(analysis_endpoint, json=payload) + + if response.status_code == 200: + external_analysis = response.json() + analysis_results["external_analysis"] = external_analysis + logger.info("External analysis completed successfully") + else: + analysis_results["external_analysis_error"] = f"Error: {response.status_code}" + logger.error(f"External analysis failed: {response.status_code}") + except Exception as e: + analysis_results["external_analysis_error"] = str(e) + logger.error(f"Error calling external analysis service: {e}") + + return analysis_results + + except Exception as e: + logger.error(f"Error analyzing ticket via API: {e}") + return { + "ticket_id": ticket_id, + "error": f"JIRA API error: {str(e)}" + } + + def _days_since(self, date_string: str) -> int: + """Calculate days between a date string and now. + + Args: + date_string: Date string in JIRA format Returns: - Dictionary with analysis results + Number of days """ - # Get ticket information - ticket_info = self.get_ticket(ticket_id, extract_fields=["status", "assignee", "priority", "type", - "reporter", "comments", "labels"]) + from datetime import datetime + import dateutil.parser - # Basic analysis + try: + # Parse the date string + issue_date = dateutil.parser.parse(date_string) + + # Calculate difference from now + now = datetime.now(issue_date.tzinfo) + delta = now - issue_date + + return delta.days + except Exception: + return 0 + + def _analyze_ticket_browser(self, ticket_id: str, analysis_endpoint: Optional[str], + analysis_params: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Analyze ticket using browser automation. + + Args: + ticket_id: The JIRA ticket ID + analysis_endpoint: Optional endpoint for analysis service + analysis_params: Additional parameters for analysis + + Returns: + Analysis results as a dictionary + """ + logger.info(f"Analyzing ticket {ticket_id}") + + # Get ticket data first + ticket_info = self.get_ticket( + ticket_id, + extract_fields=["summary", "description", "status", "priority", "type"] + ) + + # Simple analysis based on ticket data analysis_results = { "ticket_id": ticket_id, - "summary": ticket_info.get("summary", ""), - "status": ticket_info.get("status", ""), - "has_description": bool(ticket_info.get("description")), - "comment_count": len(ticket_info.get("comments", [])), - "timestamp": time.strftime("%Y-%m-%d %H:%M:%S") + "data": { + "summary": ticket_info.get("summary", ""), + "status": ticket_info.get("status", ""), + "type": ticket_info.get("type", "") + }, + "analysis": { + "word_count": len(ticket_info.get("description", "").split()), + "priority": ticket_info.get("priority", "Unknown") + } } - # Call external API if provided + # If an external analysis endpoint is provided, use it if analysis_endpoint: - import requests - - params = analysis_params or {} - params.update({ - "ticket_id": ticket_id, - "summary": ticket_info.get("summary", ""), - "description": ticket_info.get("description", "") - }) - try: - response = requests.post(analysis_endpoint, json=params) + import requests + + # Prepare request payload + payload = { + "ticket_id": ticket_id, + "ticket_data": ticket_info + } + + # Add any additional parameters + if analysis_params: + payload.update(analysis_params) + + # Send request to analysis service + response = requests.post(analysis_endpoint, json=payload) + if response.status_code == 200: - api_results = response.json() - analysis_results["api_results"] = api_results - logger.info(f"API analysis completed for ticket {ticket_id}") + external_analysis = response.json() + analysis_results["external_analysis"] = external_analysis + logger.info("External analysis completed successfully") else: - logger.error(f"API call failed with status {response.status_code}") - analysis_results["api_error"] = f"Status code: {response.status_code}" + analysis_results["external_analysis_error"] = f"Error: {response.status_code}" + logger.error(f"External analysis failed: {response.status_code}") except Exception as e: - logger.error(f"API call error: {e}") - analysis_results["api_error"] = str(e) + analysis_results["external_analysis_error"] = str(e) + logger.error(f"Error calling external analysis service: {e}") return analysis_results diff --git a/examples/jira_example.py b/examples/jira_example.py index 8022df0..b2804d0 100644 --- a/examples/jira_example.py +++ b/examples/jira_example.py @@ -21,6 +21,19 @@ def main(): # Get ticket ID from user ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") + # Ask user for mode preference + print("\nChoose mode:") + print("1. Browser mode (uses Playwright for automation)") + print("2. API mode (uses JIRA API - currently placeholder)") + + mode_choice = input("Select mode (1-2) [default: 1]: ") + if mode_choice == "2": + mode = "api" + print("\nUsing API mode (note: this is currently a placeholder implementation)") + else: + mode = "browser" + print("\nUsing Browser mode with Playwright") + # Initialize the JIRA agent # You can customize these parameters or set them in your .env file load_dotenv(dotenv_path='.env.local') # Try local env first @@ -33,13 +46,17 @@ def main(): jira_password = os.getenv("JIRA_PASSWORD") use_sso = os.getenv("JIRA_USE_SSO", "true").lower() == "true" - # Initialize the JIRA agent with headless=False for debugging + # Set headless mode based on the selected mode (visible browser for debugging in browser mode) + headless = (mode == "api") + + # Initialize the JIRA agent with the selected mode jira_agent = JiraAgent( jira_url=jira_url, username=jira_username, password=jira_password, use_sso=use_sso, - headless=False # Set to False for debugging + headless=headless, # Set headless=True for API mode + mode=mode ) # Display menu of options From fa4ba79fecb71ec73df79ae531dfc80df063261c Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 14:46:32 -0400 Subject: [PATCH 06/13] Implement API integration with JIRA --- app/jira_agent/jira.py | 465 +-------------------------------------- examples/jira_example.py | 30 +-- 2 files changed, 15 insertions(+), 480 deletions(-) diff --git a/app/jira_agent/jira.py b/app/jira_agent/jira.py index 06bd3c4..c4eb862 100644 --- a/app/jira_agent/jira.py +++ b/app/jira_agent/jira.py @@ -1,7 +1,6 @@ -"""JIRA agent for browser automation with JIRA tickets. +"""JIRA agent for interacting with JIRA tickets. This module provides the JiraAgent class for interacting with JIRA: -- Logging in (with various authentication methods) - Reading ticket information - Adding comments - Changing ticket status @@ -15,10 +14,6 @@ from typing import Dict, List, Optional, Any from pathlib import Path from dotenv import load_dotenv -from app.browser_agent.local_playwright import LocalPlaywrightBrowser -from app.memory.selector_memory import SelectorMemory -from app.jira_agent.auth import is_login_page, login -from app.jira_agent.selectors import DEFAULT_SELECTORS, FIELD_SELECTORS # Conditionally import jira for API mode try: @@ -40,39 +35,27 @@ class JiraAgent: """Agent for interacting with JIRA to read and manipulate tickets.""" def __init__(self, - headless: bool = False, jira_url: str = None, username: Optional[str] = None, password: Optional[str] = None, - use_sso: bool = True, - prefer_google: bool = True, - cache_dir: str = "./cache", - mode: str = "browser"): + cache_dir: str = "./cache"): """Initialize the JIRA agent. Args: - headless: Whether to run the browser in headless mode jira_url: URL of the JIRA instance username: JIRA username (if None, reads from JIRA_USERNAME env var) password: JIRA password (if None, reads from JIRA_PASSWORD env var) - use_sso: Whether to use SSO for authentication - prefer_google: Whether to prefer Google SSO if available cache_dir: Directory for caching memory - mode: Interaction mode - 'browser' (Playwright) or 'api' (JIRA API) """ - self.headless = headless self.jira_url = jira_url or os.getenv("JIRA_URL", "https://mydomain.atlassian.net") self.username = username or os.getenv("JIRA_USERNAME") self.password = password or os.getenv("JIRA_PASSWORD") - self.use_sso = use_sso - self.prefer_google = prefer_google - self.mode = mode if not self.username or not self.password: raise ValueError("JIRA credentials are required. Set them in .env file or pass them to the constructor.") - # Initialize selector memory - self.memory = SelectorMemory("jira", cache_dir) + if not JIRA_API_AVAILABLE: + logger.warning("JIRA API package not installed. Install with: pip install jira") def get_ticket(self, ticket_id: str, extract_fields: Optional[List[str]] = None) -> Dict[str, Any]: """Get information about a JIRA ticket. @@ -96,11 +79,7 @@ def get_ticket(self, ticket_id: str, extract_fields: Optional[List[str]] = None) "url": f"{self.jira_url}/browse/{ticket_id}", } - # Use different implementation based on mode - if self.mode == "api": - return self._get_ticket_api(ticket_id, extract_fields) - else: - return self._get_ticket_browser(ticket_id, extract_fields, ticket_info) + return self._get_ticket_api(ticket_id, extract_fields) def _get_ticket_api(self, ticket_id: str, extract_fields: List[str]) -> Dict[str, Any]: """Get ticket information using JIRA API. @@ -120,30 +99,19 @@ def _get_ticket_api(self, ticket_id: str, extract_fields: List[str]) -> Dict[str "error": "JIRA API package not installed. Run: pip install jira" } - logger.info(f"Using API mode to get ticket {ticket_id}") + logger.info(f"Using API to get ticket {ticket_id}") # Initialize with basic info ticket_info = { "id": ticket_id, - "url": f"{self.jira_url}/browse/{ticket_id}", - "mode": "api" + "url": f"{self.jira_url}/browse/{ticket_id}" } try: - # Connect to JIRA - auth_method = None - - # Determine authentication method based on URL and credentials - if self.use_sso: - logger.warning("SSO not supported in API mode. Using basic auth instead.") - - # Use basic auth with username/password or token - auth = (self.username, self.password) - # Connect to JIRA jira = JIRA( server=self.jira_url, - basic_auth=auth + basic_auth=(self.username, self.password) ) # Get issue @@ -218,72 +186,6 @@ def _get_ticket_api(self, ticket_id: str, extract_fields: List[str]) -> Dict[str logger.error(f"JIRA API error: {e}") ticket_info["error"] = f"JIRA API error: {str(e)}" return ticket_info - - def _get_ticket_browser(self, ticket_id: str, extract_fields: List[str], ticket_info: Dict[str, Any]) -> Dict[str, Any]: - """Get ticket information using browser automation. - - Args: - ticket_id: The JIRA ticket ID - extract_fields: List of fields to extract - ticket_info: Dictionary with basic ticket information - - Returns: - Dictionary with ticket information - """ - try: - with LocalPlaywrightBrowser(headless=self.headless) as browser: - # Navigate to the JIRA ticket - ticket_url = f"{self.jira_url}/browse/{ticket_id}" - logger.info(f"Navigating to {ticket_url}") - browser.goto(ticket_url) - - # Extra wait to ensure page is fully loaded - browser.wait(5000) # Increased wait time - - # Update URL after navigation - ticket_info["url"] = browser.get_current_url() - - # Check if login is required - try: - if is_login_page(browser): - logger.info("Login required") - login_success = login(browser, self.username, self.password, - use_sso=self.use_sso, prefer_google=self.prefer_google) - - if not login_success: - logger.error("Login failed") - ticket_info["error"] = "Login failed" - return ticket_info - - # Wait for redirect after login - browser.wait(5000) - - # Navigate to ticket again if needed - current_url = browser.get_current_url() - if ticket_id not in current_url: - logger.info(f"Navigating back to ticket {ticket_id} after login") - browser.goto(ticket_url) - browser.wait(5000) - - # Update URL after navigation - ticket_info["url"] = browser.get_current_url() - except Exception as e: - logger.error(f"Error during login check: {e}") - ticket_info["error"] = f"Login error: {str(e)}" - return ticket_info - - # Extract ticket fields - try: - self._extract_ticket_fields(browser, ticket_info, extract_fields) - except Exception as e: - logger.error(f"Error extracting ticket fields: {e}") - ticket_info["error"] = f"Field extraction error: {str(e)}" - - return ticket_info - except Exception as e: - logger.error(f"Unexpected error accessing ticket {ticket_id}: {e}") - ticket_info["error"] = f"Browser error: {str(e)}" - return ticket_info def add_comment(self, ticket_id: str, comment_text: str) -> bool: """Add a comment to a JIRA ticket. @@ -297,28 +199,10 @@ def add_comment(self, ticket_id: str, comment_text: str) -> bool: """ logger.info(f"Adding comment to ticket {ticket_id}") - # Use different implementation based on mode - if self.mode == "api": - return self._add_comment_api(ticket_id, comment_text) - else: - return self._add_comment_browser(ticket_id, comment_text) - - def _add_comment_api(self, ticket_id: str, comment_text: str) -> bool: - """Add a comment to a ticket using JIRA API. - - Args: - ticket_id: The JIRA ticket ID - comment_text: The text of the comment to add - - Returns: - True if comment was added successfully - """ if not JIRA_API_AVAILABLE: logger.warning("JIRA API not available. Install with: pip install jira") return False - logger.info(f"Using API mode to add comment to {ticket_id}") - try: # Connect to JIRA jira = JIRA( @@ -336,76 +220,6 @@ def _add_comment_api(self, ticket_id: str, comment_text: str) -> bool: logger.error(f"Error adding comment via API: {e}") return False - def _add_comment_browser(self, ticket_id: str, comment_text: str) -> bool: - """Add a comment to a ticket using browser automation. - - Args: - ticket_id: The JIRA ticket ID - comment_text: The text of the comment to add - - Returns: - True if comment was added successfully - """ - with LocalPlaywrightBrowser(headless=self.headless) as browser: - # Navigate to the JIRA ticket - ticket_url = f"{self.jira_url}/browse/{ticket_id}" - browser.goto(ticket_url) - browser.wait(3000) # Wait for page to load - - # Check if login is required - if is_login_page(browser): - logger.info("Login required") - login(browser, self.username, self.password, - use_sso=self.use_sso, prefer_google=self.prefer_google) - - # Wait for redirect after login - browser.wait(5000) - - # Navigate to ticket again if needed - current_url = browser.get_current_url() - if ticket_id not in current_url: - browser.goto(ticket_url) - browser.wait(3000) - - # Click the comment button - comment_button = DEFAULT_SELECTORS["ticket_page"]["add_comment"]["button"] - if not browser.wait_for_selector(comment_button, timeout=5000): - logger.error("Comment button not found") - return False - - browser.click_selector(comment_button) - browser.wait(1000) - - # Fill in the comment field - comment_field = DEFAULT_SELECTORS["ticket_page"]["add_comment"]["field"] - if not browser.wait_for_selector(comment_field, timeout=3000): - logger.error("Comment field not found") - return False - - browser.click_selector(comment_field) - browser.type(comment_text) - browser.wait(1000) - - # Click the submit button - submit_button = DEFAULT_SELECTORS["ticket_page"]["add_comment"]["submit"] - if not browser.wait_for_selector(submit_button, timeout=3000): - logger.error("Submit button not found") - return False - - browser.click_selector(submit_button) - browser.wait(5000) # Wait for comment to be added - - # Verify comment was added - latest_comment_selector = f"{DEFAULT_SELECTORS['ticket_page']['comments']}:nth-last-child(1)" - if browser.wait_for_selector(latest_comment_selector, timeout=5000): - comment_text_content = browser.extract_text(latest_comment_selector) - if comment_text in comment_text_content: - logger.info("Comment added successfully") - return True - - logger.warning("Could not verify comment was added") - return False - def change_status(self, ticket_id: str, new_status: str) -> bool: """Change the status of a JIRA ticket. @@ -418,28 +232,10 @@ def change_status(self, ticket_id: str, new_status: str) -> bool: """ logger.info(f"Changing status of ticket {ticket_id} to {new_status}") - # Use different implementation based on mode - if self.mode == "api": - return self._change_status_api(ticket_id, new_status) - else: - return self._change_status_browser(ticket_id, new_status) - - def _change_status_api(self, ticket_id: str, new_status: str) -> bool: - """Change ticket status using JIRA API. - - Args: - ticket_id: The JIRA ticket ID - new_status: The new status to set - - Returns: - True if status was changed successfully - """ if not JIRA_API_AVAILABLE: logger.warning("JIRA API not available. Install with: pip install jira") return False - logger.info(f"Using API mode to change status of {ticket_id} to {new_status}") - try: # Connect to JIRA jira = JIRA( @@ -475,65 +271,6 @@ def _change_status_api(self, ticket_id: str, new_status: str) -> bool: except Exception as e: logger.error(f"Error changing status via API: {e}") return False - - def _change_status_browser(self, ticket_id: str, new_status: str) -> bool: - """Change ticket status using browser automation. - - Args: - ticket_id: The JIRA ticket ID - new_status: The new status to set - - Returns: - True if status was changed successfully - """ - with LocalPlaywrightBrowser(headless=self.headless) as browser: - # Navigate to the JIRA ticket - ticket_url = f"{self.jira_url}/browse/{ticket_id}" - browser.goto(ticket_url) - browser.wait(3000) # Wait for page to load - - # Check if login is required - if is_login_page(browser): - logger.info("Login required") - login(browser, self.username, self.password, - use_sso=self.use_sso, prefer_google=self.prefer_google) - browser.wait(5000) - - # Navigate to ticket again if needed - current_url = browser.get_current_url() - if ticket_id not in current_url: - browser.goto(ticket_url) - browser.wait(3000) - - # Find and click the status dropdown - status_dropdown = DEFAULT_SELECTORS["ticket_page"]["status_transition"]["dropdown"] - if not browser.wait_for_selector(status_dropdown, timeout=5000): - logger.error("Status dropdown not found") - return False - - browser.click_selector(status_dropdown) - browser.wait(2000) # Wait for dropdown to open - - # Custom status selector based on the provided status name - status_selector = f"button:contains('{new_status}')" - - # Try to find the status option - if not browser.wait_for_selector(status_selector, timeout=5000): - logger.error(f"Status option '{new_status}' not found") - return False - - # Click the status option - browser.click_selector(status_selector) - browser.wait(3000) # Wait for status change to take effect - - # Optional: Verify status changed - current_status_text = browser.extract_text(DEFAULT_SELECTORS["ticket_page"]["status"]) - if new_status.lower() in current_status_text.lower(): - logger.info(f"Status successfully changed to {new_status}") - return True - - logger.warning("Could not verify status change") - return True # Return True anyway as the click was successful def save_ticket_data(self, ticket_info: Dict[str, Any], output_dir: Optional[str] = None) -> str: """Save ticket data to a JSON file. @@ -571,24 +308,6 @@ def analyze_ticket(self, ticket_id: str, analysis_endpoint: Optional[str] = None analysis_endpoint: Optional endpoint for analysis service analysis_params: Additional parameters for analysis - Returns: - Analysis results as a dictionary - """ - # Use different implementation based on mode - if self.mode == "api": - return self._analyze_ticket_api(ticket_id, analysis_endpoint, analysis_params) - else: - return self._analyze_ticket_browser(ticket_id, analysis_endpoint, analysis_params) - - def _analyze_ticket_api(self, ticket_id: str, analysis_endpoint: Optional[str], - analysis_params: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """Analyze ticket using JIRA API. - - Args: - ticket_id: The JIRA ticket ID - analysis_endpoint: Optional endpoint for analysis service - analysis_params: Additional parameters for analysis - Returns: Analysis results as a dictionary """ @@ -599,7 +318,7 @@ def _analyze_ticket_api(self, ticket_id: str, analysis_endpoint: Optional[str], "error": "JIRA API package not installed. Run: pip install jira" } - logger.info(f"Using API mode to analyze ticket {ticket_id}") + logger.info(f"Analyzing ticket {ticket_id}") try: # Connect to JIRA @@ -707,168 +426,4 @@ def _days_since(self, date_string: str) -> int: return delta.days except Exception: - return 0 - - def _analyze_ticket_browser(self, ticket_id: str, analysis_endpoint: Optional[str], - analysis_params: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """Analyze ticket using browser automation. - - Args: - ticket_id: The JIRA ticket ID - analysis_endpoint: Optional endpoint for analysis service - analysis_params: Additional parameters for analysis - - Returns: - Analysis results as a dictionary - """ - logger.info(f"Analyzing ticket {ticket_id}") - - # Get ticket data first - ticket_info = self.get_ticket( - ticket_id, - extract_fields=["summary", "description", "status", "priority", "type"] - ) - - # Simple analysis based on ticket data - analysis_results = { - "ticket_id": ticket_id, - "data": { - "summary": ticket_info.get("summary", ""), - "status": ticket_info.get("status", ""), - "type": ticket_info.get("type", "") - }, - "analysis": { - "word_count": len(ticket_info.get("description", "").split()), - "priority": ticket_info.get("priority", "Unknown") - } - } - - # If an external analysis endpoint is provided, use it - if analysis_endpoint: - try: - import requests - - # Prepare request payload - payload = { - "ticket_id": ticket_id, - "ticket_data": ticket_info - } - - # Add any additional parameters - if analysis_params: - payload.update(analysis_params) - - # Send request to analysis service - response = requests.post(analysis_endpoint, json=payload) - - if response.status_code == 200: - external_analysis = response.json() - analysis_results["external_analysis"] = external_analysis - logger.info("External analysis completed successfully") - else: - analysis_results["external_analysis_error"] = f"Error: {response.status_code}" - logger.error(f"External analysis failed: {response.status_code}") - except Exception as e: - analysis_results["external_analysis_error"] = str(e) - logger.error(f"Error calling external analysis service: {e}") - - return analysis_results - - def _extract_ticket_fields(self, browser, ticket_info: Dict[str, Any], extract_fields: List[str]) -> None: - """Extract all requested fields from the JIRA ticket. - - Args: - browser: Browser instance - ticket_info: Dictionary to update with extracted data - extract_fields: List of fields to extract - """ - # Always extract summary and description - fields_to_extract = ["summary", "description"] + extract_fields - - # Remove duplicates - fields_to_extract = list(dict.fromkeys(fields_to_extract)) - - for field in fields_to_extract: - if field in FIELD_SELECTORS: - logger.info(f"Extracting {field}") - selector = FIELD_SELECTORS[field] - try: - # First check if the element exists and is visible - element_info = browser.get_element_info(selector) - - if element_info and element_info.get('isVisible', False): - text = browser.extract_text(selector) - ticket_info[field] = text - logger.info(f"Extracted {field}: {text[:50]}..." if len(text) > 50 else f"Extracted {field}: {text}") - else: - # Log debug info - if element_info: - logger.debug(f"{field} element found but not visible: {element_info}") - else: - logger.debug(f"{field} element not found") - - # Extended selectors for common fields - if field == "summary": - alt_selectors = [ - "h1", - "[data-testid*='summary']", - "[id*='summary']", - "[class*='summary']", - ".issue-header-content h1", - "h1.entry-title", - "#summary-val", - ".ghx-summary" - ] - elif field == "description": - alt_selectors = [ - "[data-testid*='description']", - "[id*='description']", - "[class*='description']", - "#description-val", - ".user-content-block" - ] - else: - alt_selectors = [ - f"[data-testid*='{field}']", - f"[id*='{field}']", - f"[class*='{field}']" - ] - - # Try each alternative selector - field_found = False - for alt_selector in alt_selectors: - try: - if browser.wait_for_selector(alt_selector, timeout=3000): # Increased timeout - text = browser.extract_text(alt_selector) - ticket_info[field] = text - logger.info(f"Extracted {field} using {alt_selector}: {text[:50]}..." if len(text) > 50 else f"Extracted {field} using {alt_selector}: {text}") - field_found = True - break - except Exception as e: - logger.debug(f"Error with alt selector {alt_selector}: {e}") - continue - - if not field_found: - logger.warning(f"Could not find {field} using any selector") - ticket_info[field] = "Not found" - except Exception as e: - logger.error(f"Error extracting {field}: {e}") - ticket_info[field] = "Error extracting" - - # As a fallback, get the entire HTML if extraction fails - if "summary" not in ticket_info or ticket_info["summary"] == "Not found": - try: - # Try a broader approach by getting all headings - headings = browser.execute_script(""" - return Array.from(document.querySelectorAll('h1,h2')).map(el => el.innerText).join('\\n'); - """) - - if headings: - logger.info("Found headings as fallback for summary") - ticket_info["summary"] = headings.split('\n')[0] # Use the first heading - else: - # Save HTML for debugging - ticket_info["_html"] = browser.get_page_html() - logger.info("Saved page HTML as fallback") - except Exception as e: - logger.error(f"Error getting page HTML: {e}") \ No newline at end of file + return 0 \ No newline at end of file diff --git a/examples/jira_example.py b/examples/jira_example.py index b2804d0..b3835bc 100644 --- a/examples/jira_example.py +++ b/examples/jira_example.py @@ -21,21 +21,8 @@ def main(): # Get ticket ID from user ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") - # Ask user for mode preference - print("\nChoose mode:") - print("1. Browser mode (uses Playwright for automation)") - print("2. API mode (uses JIRA API - currently placeholder)") - - mode_choice = input("Select mode (1-2) [default: 1]: ") - if mode_choice == "2": - mode = "api" - print("\nUsing API mode (note: this is currently a placeholder implementation)") - else: - mode = "browser" - print("\nUsing Browser mode with Playwright") - # Initialize the JIRA agent - # You can customize these parameters or set them in your .env file + # Load environment variables, first trying .env.local load_dotenv(dotenv_path='.env.local') # Try local env first if not os.getenv("JIRA_URL"): # If not found, try .env load_dotenv() @@ -44,19 +31,14 @@ def main(): jira_url = os.getenv("JIRA_URL") jira_username = os.getenv("JIRA_USERNAME") jira_password = os.getenv("JIRA_PASSWORD") - use_sso = os.getenv("JIRA_USE_SSO", "true").lower() == "true" - # Set headless mode based on the selected mode (visible browser for debugging in browser mode) - headless = (mode == "api") + print("\nUsing JIRA API to interact with tickets") - # Initialize the JIRA agent with the selected mode + # Initialize the JIRA agent jira_agent = JiraAgent( jira_url=jira_url, username=jira_username, - password=jira_password, - use_sso=use_sso, - headless=headless, # Set headless=True for API mode - mode=mode + password=jira_password ) # Display menu of options @@ -76,9 +58,7 @@ def main(): # Display ticket information print("\nTicket Information:") for key, value in ticket_info.items(): - if key == "_html": - print(f" {key}: [HTML content]") - elif key == "comments" and isinstance(value, list): + if key == "comments" and isinstance(value, list): print(f" {key}: {len(value)} comments") if value and len(value) > 0: print(f" First comment: {value[0]['text'][:100]}..." if len(value[0]['text']) > 100 else f" First comment: {value[0]['text']}") From be665cba4b20d0b82a33ac9e3fd5233a3a0a4faf Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 20:28:47 -0400 Subject: [PATCH 07/13] Extract email address from the description --- examples/jira_api_integration.py | 51 ++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index 02d20bb..e1a7bb7 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -10,6 +10,7 @@ import os import json +import re import requests from dotenv import load_dotenv from app.jira_agent import JiraAgent @@ -19,10 +20,30 @@ # Define your API endpoint # This can be any API that takes JIRA ticket data and returns an analysis -API_ENDPOINT = os.getenv("ANALYSIS_API_ENDPOINT", "https://your-analysis-api.com/analyze") +API_ENDPOINT = os.getenv("PROD_SUPPORT_API_URL", "http://localhost:8000/") API_KEY = os.getenv("ANALYSIS_API_KEY", "") +def extract_email_from_text(text): + """ + Extract email address from text using regex. + + Args: + text: String to search for email addresses + + Returns: + First email address found or None + """ + if not text: + return None + + # Regex to match email addresses + email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' + matches = re.findall(email_pattern, text) + + return matches[0] if matches else None + + def analyze_with_api(ticket_data): """ Send ticket data to an external API for analysis. @@ -59,6 +80,10 @@ def analyze_with_api(ticket_data): "timestamp": "2023-06-01T12:34:56Z" } + # Add email to response if available + if "user_email" in ticket_data and ticket_data["user_email"]: + simulation_response["analysis"]["user_email"] = ticket_data["user_email"] + return simulation_response @@ -78,7 +103,13 @@ def format_analysis_comment(analysis_results): analysis = analysis_results.get("analysis", {}) comment += f"**Ticket Type**: {analysis.get('ticket_type', 'Unknown')}\n" comment += f"**Recommended Priority**: {analysis.get('priority_recommendation', 'Unknown')}\n" - comment += f"**Estimated Effort**: {analysis.get('estimated_effort', 'Unknown')}\n\n" + comment += f"**Estimated Effort**: {analysis.get('estimated_effort', 'Unknown')}\n" + + # Add user email if available + if "user_email" in analysis: + comment += f"**User Email**: {analysis['user_email']}\n" + + comment += "\n" # Add recommended action if "recommended_action" in analysis: @@ -113,19 +144,27 @@ def main(): # Initialize the JIRA agent agent = JiraAgent( - headless=False, # Set to True to hide the browser jira_url=os.getenv("JIRA_URL"), - use_sso=True, - prefer_google=True + username=os.getenv("JIRA_USERNAME"), + password=os.getenv("JIRA_PASSWORD") ) # Get ticket information with additional fields print(f"\nGetting information for ticket {ticket_id}...") ticket_info = agent.get_ticket( ticket_id, - extract_fields=["status", "assignee", "priority", "type", "reporter", "labels", "comments"] + extract_fields=["summary", "description"] ) + # Extract email from description if present + description = ticket_info.get("description", "") + user_email = extract_email_from_text(description) + if user_email: + ticket_info["user_email"] = user_email + print(f"Extracted email: {user_email}") + else: + print("No email found in description") + # Display basic ticket information print(f"\nTicket: {ticket_info.get('id')}") print(f"Summary: {ticket_info.get('summary', 'Unknown')}") From b145e2f8bb9fa7a4797d85d80d857e1842e0cee0 Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 21:29:28 -0400 Subject: [PATCH 08/13] Use LLM to review API guide and refactor --- app/jira_agent/__init__.py | 15 +- app/jira_agent/api_utils.py | 247 +++++++++++++++++++++++++++++++ examples/jira_api_integration.py | 173 ++++++++++++++-------- 3 files changed, 376 insertions(+), 59 deletions(-) create mode 100644 app/jira_agent/api_utils.py diff --git a/app/jira_agent/__init__.py b/app/jira_agent/__init__.py index 2b91bdf..414c1e2 100644 --- a/app/jira_agent/__init__.py +++ b/app/jira_agent/__init__.py @@ -4,8 +4,21 @@ 1. Authenticating with JIRA (including SSO) 2. Navigating to and extracting data from JIRA tickets 3. Performing actions on tickets (commenting, status updates, etc.) +4. Integrating with external APIs and parsing API documentation """ from app.jira_agent.jira import JiraAgent +from app.jira_agent.api_utils import ( + parse_api_docs_with_llm, + extract_endpoints_rule_based, + get_api_documentation, + determine_headers +) -__all__ = ["JiraAgent"] \ No newline at end of file +__all__ = [ + "JiraAgent", + "parse_api_docs_with_llm", + "extract_endpoints_rule_based", + "get_api_documentation", + "determine_headers" +] \ No newline at end of file diff --git a/app/jira_agent/api_utils.py b/app/jira_agent/api_utils.py new file mode 100644 index 0000000..1092fa2 --- /dev/null +++ b/app/jira_agent/api_utils.py @@ -0,0 +1,247 @@ +"""Utilities for interacting with APIs and parsing documentation. + +This module provides functions for: +1. Parsing API documentation using LLMs +2. Extracting endpoints and authentication requirements +3. Fallback methods using rule-based parsing +""" + +import json +import re +import requests +import logging +import os +from typing import Dict, List, Optional, Any, Union + +# Set up logging +logger = logging.getLogger(__name__) + +# Default LLM settings +DEFAULT_LLM_MODEL = "gpt-4" # For OpenAI +DEFAULT_ANTHROPIC_MODEL = "claude-3-opus-20240229" # For Anthropic + + +def parse_api_docs_with_llm( + documentation: Union[Dict, str], + llm_api_key: Optional[str] = None, + llm_api_url: Optional[str] = None +) -> Dict[str, Any]: + """ + Use an LLM to parse API documentation and extract endpoints intelligently. + + Args: + documentation: API documentation as text or JSON + llm_api_key: API key for the LLM service (OpenAI, Anthropic, etc.) + llm_api_url: URL for the LLM API endpoint + + Returns: + Dictionary with extracted endpoints and usage information + """ + if not documentation: + logger.warning("No documentation provided for parsing") + return {"endpoints": [], "analysis_endpoint": None} + + # Convert documentation to string if it's a dictionary + doc_text = json.dumps(documentation) if isinstance(documentation, dict) else str(documentation) + + # Check if we have an LLM API key (from args or environment) + llm_api_key = llm_api_key or os.getenv("LLM_API_KEY", "") + llm_api_url = llm_api_url or os.getenv("LLM_API_URL", "https://api.openai.com/v1/chat/completions") + + if not llm_api_key: + logger.warning("No LLM API key provided. Using rule-based parsing as fallback.") + return extract_endpoints_rule_based(documentation) + + try: + logger.info("Using LLM to intelligently parse API documentation...") + + # Prepare prompt for the LLM + prompt = f""" + You are an AI assistant helping to parse API documentation and extract useful information. + Please analyze this API documentation and extract: + 1. A list of all available endpoints + 2. Which endpoint would be best for sending JIRA ticket data for analysis + 3. Any required headers or authentication methods + 4. Any specific request format requirements + + Here's the documentation: + {doc_text[:4000]} # Truncate if too large + + Response format: + {{ + "endpoints": ["list", "of", "endpoints"], + "analysis_endpoint": "recommended_endpoint_for_analysis", + "auth_method": "authentication method if specified", + "required_headers": {{"header_name": "description"}}, + "request_format": "description of request format if specified" + }} + """ + + # Make request to LLM API + headers = { + "Authorization": f"Bearer {llm_api_key}", + "Content-Type": "application/json" + } + + # Determine which model to use based on the API URL + is_openai = "openai" in llm_api_url.lower() + model = DEFAULT_LLM_MODEL if is_openai else DEFAULT_ANTHROPIC_MODEL + + payload = { + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.2, # Low temperature for more deterministic results + "max_tokens": 2000 + } + + # Make the actual API call to the LLM + response = requests.post(llm_api_url, headers=headers, json=payload) + + if response.status_code == 200: + llm_response = response.json() + + # Extract content based on API format (different for OpenAI vs Anthropic vs others) + content = "" + if is_openai and "choices" in llm_response: # OpenAI format + content = llm_response["choices"][0]["message"]["content"] + elif not is_openai and "content" in llm_response: # Anthropic format + content = llm_response["content"][0]["text"] + else: + content = str(llm_response) + logger.warning(f"Unexpected LLM response format: {content[:100]}...") + + try: + # Parse the JSON response + extracted_data = json.loads(content) + logger.info(f"LLM successfully parsed documentation and found {len(extracted_data.get('endpoints', []))} endpoints") + return extracted_data + except json.JSONDecodeError: + logger.error(f"Failed to parse LLM response as JSON: {content[:100]}...") + # Try to extract JSON from the content if it's embedded in other text + json_match = re.search(r'({.*})', content.replace('\n', '')) + if json_match: + try: + extracted_data = json.loads(json_match.group(1)) + logger.info(f"Extracted JSON from LLM response and found {len(extracted_data.get('endpoints', []))} endpoints") + return extracted_data + except: + pass + else: + logger.error(f"LLM API call failed with status {response.status_code}: {response.text}") + + # If we get here, something went wrong with the LLM parsing + logger.warning("Falling back to rule-based parsing") + return extract_endpoints_rule_based(documentation) + + except Exception as e: + logger.exception(f"Error using LLM to parse documentation: {e}") + logger.warning("Falling back to rule-based parsing") + return extract_endpoints_rule_based(documentation) + + +def extract_endpoints_rule_based(documentation: Union[Dict, str]) -> Dict[str, Any]: + """ + Extract endpoints from documentation using simple rules. + Used as a fallback when LLM parsing fails. + + Args: + documentation: Dictionary or string of API documentation + + Returns: + Dictionary with extracted endpoints and analysis endpoint + """ + endpoints = [] + + if isinstance(documentation, dict): + # Look for endpoints in various common documentation formats + if "paths" in documentation: # OpenAPI/Swagger format + endpoints = list(documentation["paths"].keys()) + elif "endpoints" in documentation: + endpoints = documentation["endpoints"] + elif "resources" in documentation: + endpoints = documentation["resources"] + else: + # Try to find endpoints by looking at all keys + for key, value in documentation.items(): + if isinstance(value, dict) and ("url" in value or "path" in value or "endpoint" in value): + endpoints.append(key) + else: + # For text documentation, try to extract paths using regex + doc_text = str(documentation) + endpoints = re.findall(r'["\']?(/[a-zA-Z0-9/_-]+)', doc_text) + endpoints = list(set(endpoints)) # Remove duplicates + + # Determine which endpoint to use for analysis + analysis_endpoint = None + for endpoint in endpoints: + if "analy" in endpoint.lower(): + analysis_endpoint = endpoint + break + + logger.info(f"Rule-based parsing found {len(endpoints)} endpoints") + return { + "endpoints": endpoints, + "analysis_endpoint": analysis_endpoint + } + + +def get_api_documentation(api_url: str, guide_path: str = "/docs/guide") -> Dict[str, Any]: + """ + Fetch API documentation from the given URL. + + Args: + api_url: Base URL of the API + guide_path: Path to the API documentation or guide + + Returns: + Tuple of (documentation_content, content_type) + """ + guide_url = f"{api_url.rstrip('/')}{guide_path}" + logger.info(f"Fetching API documentation from {guide_url}") + + try: + response = requests.get(guide_url, timeout=5) + + if response.status_code == 200: + content_type = response.headers.get('Content-Type', '') + + # Return the appropriate format based on content type + if 'application/json' in content_type: + return response.json(), 'json' + else: + return response.text, 'text' + else: + logger.warning(f"Failed to get documentation: HTTP {response.status_code}") + return None, None + + except Exception as e: + logger.exception(f"Error fetching API documentation: {e}") + return None, None + + +def determine_headers(parsed_docs: Dict[str, Any], api_key: Optional[str] = None) -> Dict[str, str]: + """ + Determine the headers to use for API requests based on parsed documentation. + + Args: + parsed_docs: Documentation parsed by LLM or rule-based methods + api_key: API key to use for authentication (if required) + + Returns: + Dictionary of headers to use for requests + """ + headers = {} + + # If we have information about required headers from the documentation + if "required_headers" in parsed_docs: + for header, _ in parsed_docs.get("required_headers", {}).items(): + if header.lower() == "authorization" and api_key: + auth_method = parsed_docs.get("auth_method", "Bearer").split()[0] + headers["Authorization"] = f"{auth_method} {api_key}" + logger.info(f"Added authentication header: {header}") + # Default to Bearer token if no specific auth method is specified + elif api_key: + headers["Authorization"] = f"Bearer {api_key}" + logger.info("Added default Bearer token authentication header") + + return headers \ No newline at end of file diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index e1a7bb7..33dd76b 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -13,15 +13,24 @@ import re import requests from dotenv import load_dotenv -from app.jira_agent import JiraAgent +from app.jira_agent import ( + JiraAgent, + parse_api_docs_with_llm, + extract_endpoints_rule_based, + get_api_documentation, + determine_headers +) # Load environment variables from .env.local file load_dotenv(dotenv_path=".env.local", override=True) # Define your API endpoint # This can be any API that takes JIRA ticket data and returns an analysis -API_ENDPOINT = os.getenv("PROD_SUPPORT_API_URL", "http://localhost:8000/") +API_ENDPOINT = os.getenv("PROD_SUPPORT_API_URL", "http://localhost:8080/") API_KEY = os.getenv("ANALYSIS_API_KEY", "") +# Optional: Add LLM API key for documentation parsing +LLM_API_KEY = os.getenv("LLM_API_KEY", "") +LLM_API_URL = os.getenv("LLM_API_URL", "https://api.openai.com/v1/chat/completions") def extract_email_from_text(text): @@ -54,37 +63,90 @@ def analyze_with_api(ticket_data): Returns: Dictionary with analysis results or error message """ - # This is a simulation - in a real example, you would call your actual API - # For demonstration, we'll simulate an API response - - print(f"Sending data to API: {API_ENDPOINT}") - - # In a real implementation, you would do: - # headers = {"Authorization": f"Bearer {API_KEY}"} - # response = requests.post(API_ENDPOINT, json=ticket_data, headers=headers) - # return response.json() - - # For this example, we'll simulate a response - simulation_response = { - "analysis": { - "ticket_type": "Bug Report", - "priority_recommendation": "High" if "urgent" in ticket_data.get("summary", "").lower() else "Medium", - "estimated_effort": "4 hours", - "similar_tickets": ["PROJ-100", "PROJ-212", "PROJ-345"], - "recommended_action": "Assign to backend team", - "automated_checks": [ - {"name": "Security scan", "result": "Passed"}, - {"name": "Code quality", "result": "Failed", "details": "Insufficient test coverage"} - ] - }, - "timestamp": "2023-06-01T12:34:56Z" - } - - # Add email to response if available - if "user_email" in ticket_data and ticket_data["user_email"]: - simulation_response["analysis"]["user_email"] = ticket_data["user_email"] - - return simulation_response + print(f"Connecting to API at: {API_ENDPOINT}") + + # Get API documentation using the refactored utility + api_documentation, content_type = get_api_documentation(API_ENDPOINT, "/docs/guide") + + if not api_documentation: + print("Could not retrieve API documentation") + parsed_docs = {"endpoints": [], "analysis_endpoint": None} + else: + # Use the refactored LLM parser + parsed_docs = parse_api_docs_with_llm( + api_documentation, + llm_api_key=LLM_API_KEY, + llm_api_url=LLM_API_URL + ) + + # Display the endpoints found + available_endpoints = parsed_docs.get("endpoints", []) + print(f"Found {len(available_endpoints)} available endpoints") + for endpoint in available_endpoints[:5]: # Show first 5 to avoid overwhelming output + print(f" - {endpoint}") + if len(available_endpoints) > 5: + print(f" ... and {len(available_endpoints) - 5} more") + + # Display additional information if provided by the LLM + if "auth_method" in parsed_docs: + print(f"Authentication method: {parsed_docs['auth_method']}") + if "request_format" in parsed_docs: + print(f"Request format: {parsed_docs['request_format']}") + + # Now proceed with the actual analysis + print(f"\nSending ticket data to API for analysis...") + + # Get the analysis endpoint from the parsed documentation + analysis_endpoint = parsed_docs.get("analysis_endpoint") + + # Use the default endpoint if we didn't find one in the documentation + if not analysis_endpoint: + analysis_endpoint = "/api/v1/analyze" # Default fallback + print(f"No analysis endpoint found in documentation, using default: {analysis_endpoint}") + else: + print(f"Using analysis endpoint from documentation: {analysis_endpoint}") + + try: + # In a real implementation, you would do: + full_url = f"{API_ENDPOINT.rstrip('/')}{analysis_endpoint}" + print(f"Using endpoint: {full_url}") + + # Use the utility to determine headers based on documentation + headers = determine_headers(parsed_docs, API_KEY) + + # Try to make the actual API call + # Uncomment this in real implementation: + # response = requests.post(full_url, json=ticket_data, headers=headers) + # if response.status_code == 200: + # return response.json() + + # For this example, we'll simulate a response with simplified content + simulation_response = { + "analysis": { + # Only include necessary fields + "user_account_status": "Active" + }, + "api_info": { + "documentation_available": bool(api_documentation), + "endpoints_found": parsed_docs.get("endpoints", []), + "endpoint_used": analysis_endpoint, + "llm_parsed": True, + "auth_method": parsed_docs.get("auth_method", "Not specified") + } + } + + # Add email to response if available + if "user_email" in ticket_data and ticket_data["user_email"]: + simulation_response["analysis"]["user_email"] = ticket_data["user_email"] + + return simulation_response + + except Exception as e: + print(f"Error during API analysis: {e}") + return { + "error": str(e), + "message": "Failed to analyze ticket" + } def format_analysis_comment(analysis_results): @@ -99,39 +161,34 @@ def format_analysis_comment(analysis_results): """ comment = "**Automated Analysis Results**\n\n" - # Add ticket type and priority + # Get analysis section analysis = analysis_results.get("analysis", {}) - comment += f"**Ticket Type**: {analysis.get('ticket_type', 'Unknown')}\n" - comment += f"**Recommended Priority**: {analysis.get('priority_recommendation', 'Unknown')}\n" - comment += f"**Estimated Effort**: {analysis.get('estimated_effort', 'Unknown')}\n" # Add user email if available if "user_email" in analysis: comment += f"**User Email**: {analysis['user_email']}\n" + # Add account status if available + if "user_account_status" in analysis: + comment += f"**Account Status**: {analysis['user_account_status']}\n" + comment += "\n" - # Add recommended action - if "recommended_action" in analysis: - comment += f"**Recommended Action**: {analysis['recommended_action']}\n\n" - - # Add similar tickets - similar_tickets = analysis.get("similar_tickets", []) - if similar_tickets: - comment += "**Similar Tickets**:\n" - for ticket in similar_tickets: - comment += f"- {ticket}\n" - comment += "\n" - - # Add automated checks - automated_checks = analysis.get("automated_checks", []) - if automated_checks: - comment += "**Automated Checks**:\n" - for check in automated_checks: - result_icon = "✅" if check["result"] == "Passed" else "❌" - comment += f"- {result_icon} {check['name']}: {check['result']}" - if "details" in check: - comment += f" - {check['details']}" + # Add API information if available + api_info = analysis_results.get("api_info", {}) + if api_info: + comment += "**API Information**:\n" + comment += f"- Documentation Available: {api_info.get('documentation_available', False)}\n" + comment += f"- Endpoint Used: {api_info.get('endpoint_used', 'Unknown')}\n" + + # Add list of available endpoints (first 3 only to keep comment concise) + endpoints = api_info.get("endpoints_found", []) + if endpoints: + comment += f"- Available Endpoints ({len(endpoints)} total): " + if len(endpoints) <= 3: + comment += ", ".join(endpoints) + else: + comment += ", ".join(endpoints[:3]) + f", ... ({len(endpoints) - 3} more)" comment += "\n" return comment From 0ddbad1e6aa1a8be31bc621eee546aeb9f5b0667 Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 23:04:22 -0400 Subject: [PATCH 09/13] Use LLM to select the correct endpoint --- app/jira_agent/__init__.py | 4 ++-- app/jira_agent/api_utils.py | 23 +++++++++--------- examples/jira_api_integration.py | 41 ++++++++++---------------------- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/app/jira_agent/__init__.py b/app/jira_agent/__init__.py index 414c1e2..c58d752 100644 --- a/app/jira_agent/__init__.py +++ b/app/jira_agent/__init__.py @@ -9,7 +9,7 @@ from app.jira_agent.jira import JiraAgent from app.jira_agent.api_utils import ( - parse_api_docs_with_llm, + select_api_with_llm, extract_endpoints_rule_based, get_api_documentation, determine_headers @@ -17,7 +17,7 @@ __all__ = [ "JiraAgent", - "parse_api_docs_with_llm", + "select_api_with_llm", "extract_endpoints_rule_based", "get_api_documentation", "determine_headers" diff --git a/app/jira_agent/api_utils.py b/app/jira_agent/api_utils.py index 1092fa2..af4a025 100644 --- a/app/jira_agent/api_utils.py +++ b/app/jira_agent/api_utils.py @@ -21,7 +21,8 @@ DEFAULT_ANTHROPIC_MODEL = "claude-3-opus-20240229" # For Anthropic -def parse_api_docs_with_llm( +def select_api_with_llm( + ticket_data: str, documentation: Union[Dict, str], llm_api_key: Optional[str] = None, llm_api_url: Optional[str] = None @@ -58,22 +59,22 @@ def parse_api_docs_with_llm( # Prepare prompt for the LLM prompt = f""" You are an AI assistant helping to parse API documentation and extract useful information. - Please analyze this API documentation and extract: - 1. A list of all available endpoints - 2. Which endpoint would be best for sending JIRA ticket data for analysis - 3. Any required headers or authentication methods - 4. Any specific request format requirements + You are also given the description of the JIRA ticket that the user has submitted. + In the API documentation you are given there are infomration about the endpoints, the intent recogintion, and a few examples of when to use each endpoint. + Please analyze this API documentation along with the JIRA ticket description and extract the following information: + 1. Which endpoint would be best for handling the JIRA ticket request + 2. Which information from the JIRA ticket description is relevant to the endpoint Here's the documentation: {doc_text[:4000]} # Truncate if too large + + Here's the JIRA ticket description: + {ticket_data} Response format: {{ - "endpoints": ["list", "of", "endpoints"], - "analysis_endpoint": "recommended_endpoint_for_analysis", - "auth_method": "authentication method if specified", - "required_headers": {{"header_name": "description"}}, - "request_format": "description of request format if specified" + "endpoint": Result from 1., + "relevant_info": Result from 2. }} """ diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index 33dd76b..9e0abc4 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -15,7 +15,7 @@ from dotenv import load_dotenv from app.jira_agent import ( JiraAgent, - parse_api_docs_with_llm, + select_api_with_llm, extract_endpoints_rule_based, get_api_documentation, determine_headers @@ -70,49 +70,34 @@ def analyze_with_api(ticket_data): if not api_documentation: print("Could not retrieve API documentation") - parsed_docs = {"endpoints": [], "analysis_endpoint": None} + api_analysis_result = {"endpoints": [], "analysis_endpoint": None} else: # Use the refactored LLM parser - parsed_docs = parse_api_docs_with_llm( + api_analysis_result = select_api_with_llm( + ticket_data, api_documentation, llm_api_key=LLM_API_KEY, llm_api_url=LLM_API_URL ) # Display the endpoints found - available_endpoints = parsed_docs.get("endpoints", []) - print(f"Found {len(available_endpoints)} available endpoints") - for endpoint in available_endpoints[:5]: # Show first 5 to avoid overwhelming output - print(f" - {endpoint}") - if len(available_endpoints) > 5: - print(f" ... and {len(available_endpoints) - 5} more") + selected_endpoint = api_analysis_result.get("endpoint", []) + print(f"Selected endpoint: {selected_endpoint}") # Display additional information if provided by the LLM - if "auth_method" in parsed_docs: - print(f"Authentication method: {parsed_docs['auth_method']}") - if "request_format" in parsed_docs: - print(f"Request format: {parsed_docs['request_format']}") + if "relevant_info" in api_analysis_result: + print(f"Relevant information from ticket: {api_analysis_result['relevant_info']}") # Now proceed with the actual analysis print(f"\nSending ticket data to API for analysis...") - # Get the analysis endpoint from the parsed documentation - analysis_endpoint = parsed_docs.get("analysis_endpoint") - - # Use the default endpoint if we didn't find one in the documentation - if not analysis_endpoint: - analysis_endpoint = "/api/v1/analyze" # Default fallback - print(f"No analysis endpoint found in documentation, using default: {analysis_endpoint}") - else: - print(f"Using analysis endpoint from documentation: {analysis_endpoint}") - try: # In a real implementation, you would do: - full_url = f"{API_ENDPOINT.rstrip('/')}{analysis_endpoint}" + full_url = f"{API_ENDPOINT.rstrip('/')}{selected_endpoint}" print(f"Using endpoint: {full_url}") # Use the utility to determine headers based on documentation - headers = determine_headers(parsed_docs, API_KEY) + headers = determine_headers(api_analysis_result, API_KEY) # Try to make the actual API call # Uncomment this in real implementation: @@ -128,10 +113,10 @@ def analyze_with_api(ticket_data): }, "api_info": { "documentation_available": bool(api_documentation), - "endpoints_found": parsed_docs.get("endpoints", []), - "endpoint_used": analysis_endpoint, + "endpoints_found": api_analysis_result.get("endpoints", []), + "endpoint_used": selected_endpoint, "llm_parsed": True, - "auth_method": parsed_docs.get("auth_method", "Not specified") + "auth_method": api_analysis_result.get("auth_method", "Not specified") } } From a631096a7e2fdf6f8f35f32638748a4b0483d07c Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Mon, 31 Mar 2025 23:09:41 -0400 Subject: [PATCH 10/13] Remove deprecated code --- examples/jira_api_integration.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index 9e0abc4..cf587e2 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -33,26 +33,6 @@ LLM_API_URL = os.getenv("LLM_API_URL", "https://api.openai.com/v1/chat/completions") -def extract_email_from_text(text): - """ - Extract email address from text using regex. - - Args: - text: String to search for email addresses - - Returns: - First email address found or None - """ - if not text: - return None - - # Regex to match email addresses - email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' - matches = re.findall(email_pattern, text) - - return matches[0] if matches else None - - def analyze_with_api(ticket_data): """ Send ticket data to an external API for analysis. @@ -200,12 +180,6 @@ def main(): # Extract email from description if present description = ticket_info.get("description", "") - user_email = extract_email_from_text(description) - if user_email: - ticket_info["user_email"] = user_email - print(f"Extracted email: {user_email}") - else: - print("No email found in description") # Display basic ticket information print(f"\nTicket: {ticket_info.get('id')}") From 41d97d4c858305c77b5b81f0e5c8431623d7d997 Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Tue, 1 Apr 2025 12:42:35 -0400 Subject: [PATCH 11/13] Build and call selected endpoint --- app/jira_agent/api_utils.py | 149 +++++++++++++++---- examples/jira_api_integration.py | 239 +++++++++++++------------------ 2 files changed, 225 insertions(+), 163 deletions(-) diff --git a/app/jira_agent/api_utils.py b/app/jira_agent/api_utils.py index af4a025..0d08fdb 100644 --- a/app/jira_agent/api_utils.py +++ b/app/jira_agent/api_utils.py @@ -22,60 +22,90 @@ def select_api_with_llm( - ticket_data: str, + ticket_data: Dict[str, Any], documentation: Union[Dict, str], llm_api_key: Optional[str] = None, llm_api_url: Optional[str] = None ) -> Dict[str, Any]: """ - Use an LLM to parse API documentation and extract endpoints intelligently. + Use an LLM to analyze API documentation and ticket data to select the best endpoint. Args: + ticket_data: Dictionary containing ticket information documentation: API documentation as text or JSON llm_api_key: API key for the LLM service (OpenAI, Anthropic, etc.) llm_api_url: URL for the LLM API endpoint Returns: - Dictionary with extracted endpoints and usage information + Dictionary with selected endpoint, parameters, and relevant ticket information """ if not documentation: logger.warning("No documentation provided for parsing") - return {"endpoints": [], "analysis_endpoint": None} + return {"endpoint": None, "relevant_info": None, "endpoint_params": {}} # Convert documentation to string if it's a dictionary doc_text = json.dumps(documentation) if isinstance(documentation, dict) else str(documentation) + # Extract key ticket information for the prompt + ticket_id = ticket_data.get("id", "") + summary = ticket_data.get("summary", "") + description = ticket_data.get("description", "") + assignee = ticket_data.get("assignee", {}) + assignee_email = "" + if isinstance(assignee, dict): + assignee_email = assignee.get("emailAddress", "") + elif isinstance(assignee, str): + assignee_email = assignee + # Check if we have an LLM API key (from args or environment) llm_api_key = llm_api_key or os.getenv("LLM_API_KEY", "") llm_api_url = llm_api_url or os.getenv("LLM_API_URL", "https://api.openai.com/v1/chat/completions") if not llm_api_key: logger.warning("No LLM API key provided. Using rule-based parsing as fallback.") - return extract_endpoints_rule_based(documentation) + endpoints = extract_endpoints_rule_based(documentation) + return { + "endpoint": endpoints.get("analysis_endpoint"), + "relevant_info": None, + "endpoint_params": {} + } try: - logger.info("Using LLM to intelligently parse API documentation...") + logger.info("Using LLM to intelligently select API endpoint based on ticket data") # Prepare prompt for the LLM prompt = f""" - You are an AI assistant helping to parse API documentation and extract useful information. - You are also given the description of the JIRA ticket that the user has submitted. - In the API documentation you are given there are infomration about the endpoints, the intent recogintion, and a few examples of when to use each endpoint. - Please analyze this API documentation along with the JIRA ticket description and extract the following information: - 1. Which endpoint would be best for handling the JIRA ticket request - 2. Which information from the JIRA ticket description is relevant to the endpoint - - Here's the documentation: + You are an AI assistant helping to analyze a JIRA ticket and determine the appropriate API endpoint to call. + + JIRA Ticket Information: + ID: {ticket_id} + Summary: {summary} + Description: {description} + Assignee Email: {assignee_email} + + API Documentation: {doc_text[:4000]} # Truncate if too large - - Here's the JIRA ticket description: - {ticket_data} + + Task: + 1. Analyze the JIRA ticket to identify what action needs to be performed + 2. Determine the most appropriate API endpoint to call based on the documentation + 3. Extract specific information from the ticket that would be needed as parameters for the API call + 4. Identify the required parameters for the selected endpoint Response format: {{ - "endpoint": Result from 1., - "relevant_info": Result from 2. + "endpoint": "The most appropriate endpoint path", + "relevant_info": {{ + "extracted_values_from_ticket": "that_are_relevant", + "can_include_multiple": "key_value_pairs" + }}, + "endpoint_params": {{ + "param_name": "description of what this parameter requires", + "another_param": "another description" + }} }} + + Please return ONLY valid JSON in exactly the format specified. No additional text. """ # Make request to LLM API @@ -114,8 +144,18 @@ def select_api_with_llm( try: # Parse the JSON response extracted_data = json.loads(content) - logger.info(f"LLM successfully parsed documentation and found {len(extracted_data.get('endpoints', []))} endpoints") + logger.info(f"LLM successfully analyzed ticket and selected endpoint: {extracted_data.get('endpoint')}") + + # Ensure the response has the expected structure + if "endpoint" not in extracted_data: + extracted_data["endpoint"] = None + if "relevant_info" not in extracted_data: + extracted_data["relevant_info"] = {} + if "endpoint_params" not in extracted_data: + extracted_data["endpoint_params"] = {} + return extracted_data + except json.JSONDecodeError: logger.error(f"Failed to parse LLM response as JSON: {content[:100]}...") # Try to extract JSON from the content if it's embedded in other text @@ -123,7 +163,16 @@ def select_api_with_llm( if json_match: try: extracted_data = json.loads(json_match.group(1)) - logger.info(f"Extracted JSON from LLM response and found {len(extracted_data.get('endpoints', []))} endpoints") + logger.info(f"Extracted JSON from LLM response with endpoint: {extracted_data.get('endpoint')}") + + # Ensure the response has the expected structure + if "endpoint" not in extracted_data: + extracted_data["endpoint"] = None + if "relevant_info" not in extracted_data: + extracted_data["relevant_info"] = {} + if "endpoint_params" not in extracted_data: + extracted_data["endpoint_params"] = {} + return extracted_data except: pass @@ -132,12 +181,64 @@ def select_api_with_llm( # If we get here, something went wrong with the LLM parsing logger.warning("Falling back to rule-based parsing") - return extract_endpoints_rule_based(documentation) + endpoints = extract_endpoints_rule_based(documentation) + return { + "endpoint": endpoints.get("analysis_endpoint"), + "relevant_info": extract_ticket_info(ticket_data), + "endpoint_params": {} + } except Exception as e: - logger.exception(f"Error using LLM to parse documentation: {e}") + logger.exception(f"Error using LLM to select API endpoint: {e}") logger.warning("Falling back to rule-based parsing") - return extract_endpoints_rule_based(documentation) + endpoints = extract_endpoints_rule_based(documentation) + return { + "endpoint": endpoints.get("analysis_endpoint"), + "relevant_info": extract_ticket_info(ticket_data), + "endpoint_params": {} + } + + +def extract_ticket_info(ticket_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract relevant information from a ticket for API calls. + Used as a fallback when LLM parsing fails. + + Args: + ticket_data: Dictionary containing ticket information + + Returns: + Dictionary with extracted information + """ + info = {} + + # Extract email from description or summary + description = ticket_data.get("description", "") + summary = ticket_data.get("summary", "") + + # Try to find an email in the description or summary + email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' + email_matches = [] + + if isinstance(description, str): + email_matches = re.findall(email_pattern, description) + if not email_matches and isinstance(summary, str): + email_matches = re.findall(email_pattern, summary) + + if email_matches: + info["email"] = email_matches[0] + + # Add ticket ID and assignee + info["ticket_id"] = ticket_data.get("id", "") + + assignee = ticket_data.get("assignee", {}) + if isinstance(assignee, dict): + info["assignee"] = assignee.get("displayName", "") + info["assignee_email"] = assignee.get("emailAddress", "") + elif isinstance(assignee, str): + info["assignee"] = assignee + + return info def extract_endpoints_rule_based(documentation: Union[Dict, str]) -> Dict[str, Any]: diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index cf587e2..89ecbfc 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -5,20 +5,18 @@ 1. Initialize the JIRA agent 2. Extract data from JIRA tickets 3. Send ticket data to an external API for analysis -4. Post the analysis results back as a comment +4. Display the API response """ import os import json -import re import requests from dotenv import load_dotenv from app.jira_agent import ( JiraAgent, select_api_with_llm, extract_endpoints_rule_based, - get_api_documentation, - determine_headers + get_api_documentation ) # Load environment variables from .env.local file @@ -41,7 +39,7 @@ def analyze_with_api(ticket_data): ticket_data: Dictionary containing ticket information Returns: - Dictionary with analysis results or error message + API response text """ print(f"Connecting to API at: {API_ENDPOINT}") @@ -50,113 +48,104 @@ def analyze_with_api(ticket_data): if not api_documentation: print("Could not retrieve API documentation") - api_analysis_result = {"endpoints": [], "analysis_endpoint": None} + api_analysis_result = {"endpoint": None, "relevant_info": None, "endpoint_params": {}} else: - # Use the refactored LLM parser - api_analysis_result = select_api_with_llm( - ticket_data, - api_documentation, - llm_api_key=LLM_API_KEY, - llm_api_url=LLM_API_URL - ) - - # Display the endpoints found - selected_endpoint = api_analysis_result.get("endpoint", []) - print(f"Selected endpoint: {selected_endpoint}") + try: + # Use the refactored LLM parser + api_analysis_result = select_api_with_llm( + ticket_data, + api_documentation, + llm_api_key=LLM_API_KEY, + llm_api_url=LLM_API_URL + ) + + # Make sure api_analysis_result is a dictionary + if not isinstance(api_analysis_result, dict): + print(f"Warning: Expected dictionary from select_api_with_llm but got {type(api_analysis_result)}") + api_analysis_result = {"endpoint": None, "relevant_info": str(api_analysis_result), "endpoint_params": {}} + except Exception as e: + print(f"Error during API documentation analysis: {e}") + api_analysis_result = {"endpoint": None, "relevant_info": None, "endpoint_params": {}} + + # Safely get values from api_analysis_result + selected_endpoint = None + relevant_info = None + endpoint_params = {} + + if isinstance(api_analysis_result, dict): + selected_endpoint = api_analysis_result.get("endpoint") + relevant_info = api_analysis_result.get("relevant_info") + endpoint_params = api_analysis_result.get("endpoint_params", {}) + + # Display the endpoints found + print(f"Selected endpoint: {selected_endpoint}") + + # Display additional information if provided by the LLM + if relevant_info: + print(f"Relevant information from ticket: {relevant_info}") - # Display additional information if provided by the LLM - if "relevant_info" in api_analysis_result: - print(f"Relevant information from ticket: {api_analysis_result['relevant_info']}") + if endpoint_params: + print(f"Required parameters for endpoint: {endpoint_params}") # Now proceed with the actual analysis print(f"\nSending ticket data to API for analysis...") try: - # In a real implementation, you would do: - full_url = f"{API_ENDPOINT.rstrip('/')}{selected_endpoint}" - print(f"Using endpoint: {full_url}") + # Use default endpoint if none was selected + if not selected_endpoint: + selected_endpoint = "/api/v1/analyze" + print(f"No endpoint selected, using default: {selected_endpoint}") + + # Build the request URL + base_url = f"{API_ENDPOINT.rstrip('/')}{selected_endpoint}" - # Use the utility to determine headers based on documentation - headers = determine_headers(api_analysis_result, API_KEY) + # Create headers for API request + headers = {} + if API_KEY: + headers["Authorization"] = f"Bearer {API_KEY}" + + # Prepare request parameters based on relevant_info and endpoint_params + request_params = {} + if isinstance(relevant_info, dict): + # For each parameter required by the endpoint, try to find it in relevant_info + if endpoint_params: + for param_name in endpoint_params.keys(): + if param_name in relevant_info: + request_params[param_name] = relevant_info[param_name] + else: + # If we don't have endpoint_params, just use all relevant_info + request_params = relevant_info - # Try to make the actual API call - # Uncomment this in real implementation: - # response = requests.post(full_url, json=ticket_data, headers=headers) - # if response.status_code == 200: - # return response.json() + # Include some ticket information if not already in request_params + if "ticket_id" not in request_params and "id" in ticket_data: + request_params["ticket_id"] = ticket_data["id"] - # For this example, we'll simulate a response with simplified content - simulation_response = { - "analysis": { - # Only include necessary fields - "user_account_status": "Active" - }, - "api_info": { - "documentation_available": bool(api_documentation), - "endpoints_found": api_analysis_result.get("endpoints", []), - "endpoint_used": selected_endpoint, - "llm_parsed": True, - "auth_method": api_analysis_result.get("auth_method", "Not specified") - } - } + if "email" not in request_params and isinstance(relevant_info, dict) and "email" in relevant_info: + request_params["email"] = relevant_info["email"] - # Add email to response if available - if "user_email" in ticket_data and ticket_data["user_email"]: - simulation_response["analysis"]["user_email"] = ticket_data["user_email"] + # Build the final URL with parameters + url_params = "&".join([f"{k}={v}" for k, v in request_params.items()]) + if "?" not in base_url: + full_url = f"{base_url}?{url_params}" if url_params else base_url + else: + full_url = f"{base_url}&{url_params}" if url_params else base_url + + print(f"Making GET request to: {full_url}") - return simulation_response + # Make the actual API call (GET method only) + response = requests.get(full_url, headers=headers) - except Exception as e: - print(f"Error during API analysis: {e}") - return { - "error": str(e), - "message": "Failed to analyze ticket" - } - - -def format_analysis_comment(analysis_results): - """ - Format analysis results as a markdown comment for JIRA. - - Args: - analysis_results: Dictionary with analysis results - - Returns: - Formatted comment text - """ - comment = "**Automated Analysis Results**\n\n" - - # Get analysis section - analysis = analysis_results.get("analysis", {}) - - # Add user email if available - if "user_email" in analysis: - comment += f"**User Email**: {analysis['user_email']}\n" - - # Add account status if available - if "user_account_status" in analysis: - comment += f"**Account Status**: {analysis['user_account_status']}\n" - - comment += "\n" - - # Add API information if available - api_info = analysis_results.get("api_info", {}) - if api_info: - comment += "**API Information**:\n" - comment += f"- Documentation Available: {api_info.get('documentation_available', False)}\n" - comment += f"- Endpoint Used: {api_info.get('endpoint_used', 'Unknown')}\n" + if response.status_code == 200: + return response.text + else: + error_message = f"API call failed with status {response.status_code}: {response.text[:100]}" + print(error_message) + return error_message - # Add list of available endpoints (first 3 only to keep comment concise) - endpoints = api_info.get("endpoints_found", []) - if endpoints: - comment += f"- Available Endpoints ({len(endpoints)} total): " - if len(endpoints) <= 3: - comment += ", ".join(endpoints) - else: - comment += ", ".join(endpoints[:3]) + f", ... ({len(endpoints) - 3} more)" - comment += "\n" - - return comment + except Exception as e: + error_message = f"Error during API analysis: {e}" + print(error_message) + return error_message def main(): @@ -175,59 +164,31 @@ def main(): print(f"\nGetting information for ticket {ticket_id}...") ticket_info = agent.get_ticket( ticket_id, - extract_fields=["summary", "description"] + extract_fields=["summary", "description", "assignee"] ) - # Extract email from description if present - description = ticket_info.get("description", "") - # Display basic ticket information print(f"\nTicket: {ticket_info.get('id')}") print(f"Summary: {ticket_info.get('summary', 'Unknown')}") - print(f"Status: {ticket_info.get('status', 'Unknown')}") + print(f"Assignee: {ticket_info.get('assignee', 'Unknown')}") - # Analyze with external API (or simulation) + # Analyze with external API print("\nSending to API for analysis...") - analysis_results = analyze_with_api(ticket_info) - - # Display analysis results - print("\nAnalysis Results:") - print(json.dumps(analysis_results, indent=2)) + api_response = analyze_with_api(ticket_info) - # Format as comment - comment_text = format_analysis_comment(analysis_results) - print("\nFormatted Comment:") - print(comment_text) + # Display raw API response + print("\nAPI Response:") + print(api_response) - # Ask if we should post the analysis as a comment - post_comment = input("\nPost analysis as comment to JIRA ticket? (y/n): ").lower() == 'y' + # Ask if we should post the API response as a comment to JIRA + post_comment = input("\nPost API response as comment to JIRA ticket? (y/n): ").lower() == 'y' if post_comment: - print(f"Adding analysis as comment to ticket {ticket_id}...") - result = agent.add_comment(ticket_id, comment_text) + print(f"Adding API response as comment to ticket {ticket_id}...") + result = agent.add_comment(ticket_id, api_response) if result: - print("Analysis comment added successfully!") + print("API response comment added successfully!") else: - print("Failed to add analysis comment.") - - # Save analysis to file - save_option = input("\nSave full analysis to file? (y/n): ").lower() - if save_option == 'y': - # Create results dict with both ticket info and analysis - combined_results = { - "ticket_info": ticket_info, - "analysis": analysis_results - } - - # Save file - output_dir = os.path.join(os.getcwd(), "analysis_results") - os.makedirs(output_dir, exist_ok=True) - filename = f"{ticket_id.replace('-', '_').lower()}_analysis.json" - filepath = os.path.join(output_dir, filename) - - with open(filepath, 'w') as f: - json.dump(combined_results, f, indent=2) - - print(f"Analysis saved to: {filepath}") + print("Failed to add comment.") print("\nJIRA API integration example completed!") From 3058fb8651654f45dd60433949ebf6f99382a00a Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Tue, 1 Apr 2025 13:40:47 -0400 Subject: [PATCH 12/13] UI Support --- examples/jira_api_integration.py | 179 ++++++++++++++++++++++++++++--- 1 file changed, 166 insertions(+), 13 deletions(-) diff --git a/examples/jira_api_integration.py b/examples/jira_api_integration.py index 89ecbfc..f94c72e 100644 --- a/examples/jira_api_integration.py +++ b/examples/jira_api_integration.py @@ -11,6 +11,9 @@ import os import json import requests +import tkinter as tk +from tkinter import scrolledtext +import threading from dotenv import load_dotenv from app.jira_agent import ( JiraAgent, @@ -18,6 +21,7 @@ extract_endpoints_rule_based, get_api_documentation ) +import sys # Load environment variables from .env.local file load_dotenv(dotenv_path=".env.local", override=True) @@ -31,6 +35,28 @@ LLM_API_URL = os.getenv("LLM_API_URL", "https://api.openai.com/v1/chat/completions") +class RedirectText: + """ + A class that redirects print statements to both the console and a tkinter text widget. + """ + def __init__(self, text_widget): + self.text_widget = text_widget + self.buffer = "" + self.original_stdout = sys.__stdout__ + + def write(self, string): + self.buffer += string + self.text_widget.config(state=tk.NORMAL) + self.text_widget.insert(tk.END, string) + self.text_widget.see(tk.END) + self.text_widget.config(state=tk.DISABLED) + # Write to original stdout to avoid recursion + self.original_stdout.write(string) + + def flush(self): + pass + + def analyze_with_api(ticket_data): """ Send ticket data to an external API for analysis. @@ -148,10 +174,15 @@ def analyze_with_api(ticket_data): return error_message -def main(): - """Run the JIRA API integration example.""" - # Get ticket ID from user - ticket_id = input("Enter JIRA ticket ID (e.g., PROJ-123): ") +def run_analysis(ticket_id, submit_button, status_label, comment_frame, yes_button, no_button): + """Process JIRA ticket and display results in GUI""" + + # Update status + status_label.config(text="Working on your request...") + submit_button.config(state=tk.DISABLED) + + # Hide comment buttons initially + comment_frame.pack_forget() # Initialize the JIRA agent agent = JiraAgent( @@ -162,6 +193,9 @@ def main(): # Get ticket information with additional fields print(f"\nGetting information for ticket {ticket_id}...") + import time + time.sleep(2) # Add a 2-second delay + ticket_info = agent.get_ticket( ticket_id, extract_fields=["summary", "description", "assignee"] @@ -169,28 +203,147 @@ def main(): # Display basic ticket information print(f"\nTicket: {ticket_info.get('id')}") + time.sleep(2) # Add a 2-second delay + print(f"Summary: {ticket_info.get('summary', 'Unknown')}") + time.sleep(2) # Add a 2-second delay + print(f"Assignee: {ticket_info.get('assignee', 'Unknown')}") + time.sleep(2) # Add a 2-second delay # Analyze with external API print("\nSending to API for analysis...") + time.sleep(2) # Add a 2-second delay + api_response = analyze_with_api(ticket_info) # Display raw API response print("\nAPI Response:") + time.sleep(2) # Add a 2-second delay + print(api_response) - # Ask if we should post the API response as a comment to JIRA - post_comment = input("\nPost API response as comment to JIRA ticket? (y/n): ").lower() == 'y' - if post_comment: - print(f"Adding API response as comment to ticket {ticket_id}...") - result = agent.add_comment(ticket_id, api_response) - if result: - print("API response comment added successfully!") - else: - print("Failed to add comment.") + # Store the current ticket_id and api_response for the comment buttons + yes_button.config(command=lambda: post_comment_to_jira(agent, ticket_id, api_response, status_label, comment_frame)) + no_button.config(command=lambda: skip_comment(status_label, comment_frame)) + + # Show the comment option in the UI + comment_frame.pack(fill=tk.X, pady=10) + + # Update status + status_label.config(text="Analysis completed! Post as comment?") + + +def post_comment_to_jira(agent, ticket_id, api_response, status_label, comment_frame): + """Post the API response as a comment to the JIRA ticket""" + status_label.config(text="Posting comment...") + + # Hide comment buttons + comment_frame.pack_forget() + + print(f"\nAdding API response as comment to ticket {ticket_id}...") + result = agent.add_comment(ticket_id, api_response) + if result: + print("API response comment added successfully!") + status_label.config(text="Comment added successfully!") + else: + print("Failed to add comment.") + status_label.config(text="Failed to add comment.") + + print("\nJIRA API integration example completed!") + + +def skip_comment(status_label, comment_frame): + """Skip posting the comment""" + # Hide comment buttons + comment_frame.pack_forget() + print("\nSkipped posting comment.") print("\nJIRA API integration example completed!") + status_label.config(text="Analysis completed!") + + +def create_gui(): + """Create a GUI window for JIRA ticket analysis""" + root = tk.Tk() + root.title("JIRA API Integration") + + # Set window size + window_width = 800 + window_height = 600 + root.geometry(f"{window_width}x{window_height}") + + # Center the window on the screen + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + x_position = int((screen_width - window_width) / 2) + y_position = int((screen_height - window_height) / 2) + root.geometry(f"{window_width}x{window_height}+{x_position}+{y_position}") + + # Create frames + input_frame = tk.Frame(root, padx=10, pady=10) + input_frame.pack(fill=tk.X) + + output_frame = tk.Frame(root, padx=10, pady=10) + output_frame.pack(fill=tk.BOTH, expand=True) + + # Create comment frame (initially hidden) + comment_frame = tk.Frame(root, padx=10, pady=10) + + # Add comment question and buttons + tk.Label(comment_frame, text="Post API response as comment to JIRA ticket?").pack(side=tk.LEFT) + yes_button = tk.Button(comment_frame, text="Yes", width=8) + yes_button.pack(side=tk.LEFT, padx=5) + no_button = tk.Button(comment_frame, text="No", width=8) + no_button.pack(side=tk.LEFT, padx=5) + + # Ticket ID input + tk.Label(input_frame, text="Enter JIRA Ticket ID:").pack(side=tk.LEFT) + ticket_entry = tk.Entry(input_frame, width=20) + ticket_entry.pack(side=tk.LEFT, padx=5) + + # Status label + status_label = tk.Label(input_frame, text="Ready") + status_label.pack(side=tk.RIGHT) + + # Output text area + output_text = scrolledtext.ScrolledText(output_frame, wrap=tk.WORD, state=tk.DISABLED) + output_text.pack(fill=tk.BOTH, expand=True) + + # Submit button + def on_submit(): + ticket_id = ticket_entry.get().strip() + if not ticket_id: + status_label.config(text="Please enter a ticket ID") + return + + # Clear output + output_text.config(state=tk.NORMAL) + output_text.delete(1.0, tk.END) + output_text.config(state=tk.DISABLED) + + # Run analysis in a separate thread to keep UI responsive + thread = threading.Thread( + target=run_analysis, + args=(ticket_id, submit_button, status_label, comment_frame, yes_button, no_button) + ) + thread.daemon = True + thread.start() + + submit_button = tk.Button(input_frame, text="Analyze Ticket", command=on_submit) + submit_button.pack(side=tk.LEFT, padx=5) + + # Redirect stdout to the text widget + redirect = RedirectText(output_text) + sys.stdout = redirect + + return root + + +def main(): + """Run the JIRA API integration example with GUI.""" + root = create_gui() + root.mainloop() if __name__ == "__main__": main() \ No newline at end of file From b5742e4ba4a85c2c740a329bb5ab9e557ab6b81c Mon Sep 17 00:00:00 2001 From: wor-thongthai Date: Wed, 2 Apr 2025 17:58:09 -0400 Subject: [PATCH 13/13] Revert changes to base_playwright_browser.py as it's not used in JIRA integration --- app/browser_agent/base_playwright_browser.py | 69 ++++++-------------- 1 file changed, 20 insertions(+), 49 deletions(-) diff --git a/app/browser_agent/base_playwright_browser.py b/app/browser_agent/base_playwright_browser.py index 313e5ae..9054ff4 100644 --- a/app/browser_agent/base_playwright_browser.py +++ b/app/browser_agent/base_playwright_browser.py @@ -245,35 +245,26 @@ def get_element_info(self, selector: str) -> dict: Returns a dictionary with element properties like tag, text, attributes, etc. """ - try: - # Check if page exists and is not null - if not self._page or self._page.is_closed(): - print("Warning: Page is closed or null") - return None - - return self._page.evaluate(""" - selector => { - const el = document.querySelector(selector); - if (!el) return null; - - const attributes = {}; - for (const attr of el.attributes) { - attributes[attr.name] = attr.value; - } - - return { - tag: el.tagName.toLowerCase(), - text: el.innerText, - html: el.innerHTML, - attributes: attributes, - isVisible: el.offsetWidth > 0 && el.offsetHeight > 0, - boundingBox: el.getBoundingClientRect().toJSON() - }; + return self._page.evaluate(""" + selector => { + const el = document.querySelector(selector); + if (!el) return null; + + const attributes = {}; + for (const attr of el.attributes) { + attributes[attr.name] = attr.value; } - """, selector) - except Exception as e: - print(f"Error getting element info: {e}") - return None + + return { + tag: el.tagName.toLowerCase(), + text: el.innerText, + html: el.innerHTML, + attributes: attributes, + isVisible: el.offsetWidth > 0 && el.offsetHeight > 0, + boundingBox: el.getBoundingClientRect().toJSON() + }; + } + """, selector) def fill_form(self, selector: str, value: str) -> None: """Fill a form field with the given value.""" @@ -302,24 +293,4 @@ def switch_tab(self, tab_index: int) -> None: # --- Subclass hook --- def _get_browser_and_page(self) -> tuple[PlaywrightBrowser, Page]: """Subclasses must implement, returning (PlaywrightBrowser, Page).""" - raise NotImplementedError - - def execute_script(self, script: str, *args): - """Execute JavaScript in the current page. - - Args: - script: JavaScript code to execute - *args: Arguments to pass to the script - - Returns: - Result of the JavaScript execution - """ - try: - if not self._page or self._page.is_closed(): - print("Warning: Page is closed or null") - return None - - return self._page.evaluate(script, *args) - except Exception as e: - print(f"Error executing script: {e}") - return None \ No newline at end of file + raise NotImplementedError \ No newline at end of file