#!/usr/bin/env python3 # /// script # dependencies = [ # "httpx", # "PyGithub", # "markdown", # ] # /// """ GitHub Action script to sync GitHub issues and discussions with Pylon. Creates Pylon issues for new GitHub issues/discussions and syncs updates. """ import json import os import re from enum import Enum from typing import Any, Optional import httpx import markdown from github import Auth, Github from github.Repository import Repository # Configuration GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") PYLON_API_TOKEN = os.getenv("PYLON_API_TOKEN") PYLON_API_BASE = "https://api.usepylon.com" GITHUB_REPO = os.getenv("GITHUB_REPOSITORY") GITHUB_EVENT_PATH = os.getenv("GITHUB_EVENT_PATH") GITHUB_EVENT_NAME = os.getenv("GITHUB_EVENT_NAME") # Headers for API requests PYLON_HEADERS = { "Authorization": f"Bearer {PYLON_API_TOKEN}", "Content-Type": "application/json", } GITHUB_HEADERS = { "Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json", } def handle_http_response(response: httpx.Response) -> None: """Handle HTTP response with comprehensive error logging.""" if response.status_code >= 400: print(f"HTTP Error {response.status_code}: {response.reason_phrase}") print(f"Response headers: {dict(response.headers)}") print(f"Response content: {response.text}") try: error_data = response.json() print(f"Error details: {json.dumps(error_data, indent=2)}") except (ValueError, TypeError): print("Could not parse error response as JSON") else: print(f"Success response: {response.json()}") response.raise_for_status() class PylonIssueType(Enum): """Pylon issue types.""" CONVERSATION = "Conversation" BUG = "Bug" QUESTION = "Question" FEATURE_REQUEST = "Feature Request" INCIDENT = "Incident" TASK = "Task" COMPLAINT = "Complaint" FEEDBACK = "Feedback" class PylonIssueState(Enum): """Pylon issue states.""" NEW = "new" OPEN = "open" CLOSED = "closed" PENDING = "pending" RESOLVED = "resolved" class GitHubAction(Enum): """GitHub event actions.""" # Issue actions OPENED = "opened" EDITED = "edited" REOPENED = "reopened" CLOSED = "closed" # Discussion actions CREATED = "created" ANSWERED = "answered" LOCKED = "locked" UNLOCKED = "unlocked" class ExternalSource(Enum): """External source types for linking issues.""" GITHUB = "github" SLACK = "slack" EMAIL = "email" WEB = "web" API = "api" class ItemType(Enum): """GitHub item types for comment extraction.""" ISSUE = "issue" DISCUSSION = "discussion" def load_github_event() -> dict[str, Any]: """Load the GitHub event payload.""" with open(GITHUB_EVENT_PATH) as f: return json.load(f) def extract_or_create_pylon_issue_id_from_body( repo: Repository, issue_number: int, item_type: ItemType = ItemType.ISSUE, title: Optional[str] = None, body: Optional[str] = None, external_url: Optional[str] = None, author: Optional[dict] = None, issue_type: Optional[PylonIssueType] = None, ) -> Optional[str]: """Extract Pylon issue ID from GitHub issue or discussion body, or create one if not found.""" # Updated pattern to match the actual format: **Pylon Issue ID:** `uuid` pylon_id_pattern = r"\*\*Pylon Issue ID:\*\*\s*`([a-zA-Z0-9\-]+)`" # Get the issue or discussion body based on item type if item_type == ItemType.ISSUE: item = repo.get_issue(issue_number) current_body = item.body or "" current_title = item.title current_url = item.html_url current_author = item.user else: # ItemType.DISCUSSION item = repo.get_discussion(issue_number) current_body = item.body or "" current_title = item.title current_url = item.html_url current_author = item.user # Use provided values or fall back to current item values search_body = body if body is not None else current_body item_title = title if title is not None else current_title item_url = external_url if external_url is not None else current_url item_author = author if author is not None else current_author # Search for Pylon issue ID in the body match = re.search(pylon_id_pattern, search_body) if match: return match.group(1) # If no Pylon issue ID found and we have the required parameters, create one if item_title and item_url and item_author: # Set default issue type based on item type if issue_type is None: issue_type = ( PylonIssueType.BUG if item_type == ItemType.ISSUE else PylonIssueType.QUESTION ) # Create external ID external_id = f"github-{item_type.value}-{issue_number}" # Extract requester information requester_email, requester_name = extract_requester_info(item_author) # Create Pylon issue pylon_issue = create_pylon_issue( title=item_title, body=search_body, external_id=external_id, external_url=item_url, requester_email=requester_email, requester_name=requester_name, issue_type=issue_type, ) pylon_issue_id = pylon_issue["data"]["id"] pylon_issue_url = pylon_issue["data"]["link"] # Add Pylon info to GitHub issue/discussion body append_pylon_info_to_body(repo, issue_number, pylon_issue_id, pylon_issue_url, item_type) print(f"Created Pylon issue {pylon_issue_id} for GitHub {item_type.value} #{issue_number}") return pylon_issue_id return None def extract_requester_info(author: dict[str, Any]) -> tuple[str, str]: """Extract requester email and name from GitHub author data.""" requester_name = author["login"] # GitHub username requester_email = author.get("email") or f"{author['login']}@users.noreply.github.com" return requester_email, requester_name def create_pylon_issue( title: str, body: str, external_id: str, external_url: str, requester_email: str, requester_name: str, issue_type: PylonIssueType = PylonIssueType.CONVERSATION, ) -> dict[str, Any]: """Create a new Pylon issue.""" url = f"{PYLON_API_BASE}/issues" # Convert GitHub markdown to HTML for Pylon body_html = convert_markdown_to_html(body) data = { "title": title, "body_html": body_html, "external_issues": [ { "external_id": external_id, "source": ExternalSource.GITHUB.value, "link": external_url, } ], "type": issue_type.value, # Configurable issue type "state": PylonIssueState.NEW.value, # Initial state "requester_email": requester_email, "requester_name": requester_name, } print(f"Creating Pylon issue with data: {data} to url: {url}") with httpx.Client() as client: response = client.post(url, headers=PYLON_HEADERS, json=data) handle_http_response(response) return response.json() def update_pylon_issue(issue_id: str, title: str, body: str) -> dict[str, Any]: """Update an existing Pylon issue.""" url = f"{PYLON_API_BASE}/issues/{issue_id}" # Convert GitHub markdown to HTML for Pylon body_html = convert_markdown_to_html(body) data = {"title": title, "body_html": body_html} with httpx.Client() as client: response = client.patch(url, headers=PYLON_HEADERS, json=data) handle_http_response(response) return response.json() def close_pylon_issue(issue_id: str) -> dict[str, Any]: """Close a Pylon issue.""" url = f"{PYLON_API_BASE}/issues/{issue_id}" data = {"state": PylonIssueState.CLOSED.value} with httpx.Client() as client: response = client.patch(url, headers=PYLON_HEADERS, json=data) handle_http_response(response) return response.json() def get_pylon_issue(issue_id: str) -> dict[str, Any]: """Get Pylon issue details to extract message_id.""" url = f"{PYLON_API_BASE}/issues/{issue_id}" with httpx.Client() as client: response = client.get(url, headers=PYLON_HEADERS) handle_http_response(response) return response.json() def get_pylon_issue_threads(issue_id: str) -> dict[str, Any]: """Get Pylon issue threads to find internal thread_id.""" url = f"{PYLON_API_BASE}/issues/{issue_id}/threads" with httpx.Client() as client: response = client.get(url, headers=PYLON_HEADERS) handle_http_response(response) return response.json() def post_pylon_note( issue_id: str, body_html: str, thread_id: str, message_id: str ) -> dict[str, Any]: """Post an internal note to a Pylon issue.""" url = f"{PYLON_API_BASE}/issues/{issue_id}/note" data = {"thread_id": thread_id, "body_html": body_html, "message_id": message_id} print(f"Posting internal note to Pylon with data: {data}") with httpx.Client() as client: response = client.post(url, headers=PYLON_HEADERS, json=data) handle_http_response(response) return response.json() def get_pylon_issue_messages(issue_id: str) -> dict[str, Any]: """Get Pylon issue messages to find the latest message_id.""" url = f"{PYLON_API_BASE}/issues/{issue_id}/messages" with httpx.Client() as client: response = client.get(url, headers=PYLON_HEADERS) handle_http_response(response) return response.json() def post_pylon_message(issue_id: str, content: str, author: dict[str, Any]) -> dict[str, Any]: """Post an internal note to a Pylon issue using GitHub author info.""" # Get messages to find the actual message_id messages_data = get_pylon_issue_messages(issue_id) # Extract the latest message ID messages = messages_data.get("data", []) if not messages: print(f"Warning: No messages found for Pylon issue {issue_id}") return {} # Get the latest message (assuming they're ordered by creation time) latest_message = messages[-1] message_id = latest_message.get("id") if not message_id: print("Warning: Could not extract message_id from latest message") return {} # Get threads to find internal thread_id threads_data = get_pylon_issue_threads(issue_id) # Extract the first internal thread ID threads = threads_data.get("data", []) if not threads: print(f"Warning: No threads found for Pylon issue {issue_id}") return {} # Find an internal thread (assuming they have a type field or similar) # For now, use the first thread thread_id = threads[0].get("id") if not thread_id: print("Warning: Could not extract thread_id from threads") return {} body_html = convert_markdown_to_html(content) return post_pylon_note(issue_id, body_html, thread_id, message_id) def convert_markdown_to_html(markdown_text: str) -> str: """Convert GitHub markdown to HTML for Pylon using the markdown library.""" if not markdown_text: return "" # Configure markdown with GitHub-style extensions md = markdown.Markdown( extensions=[ "markdown.extensions.tables", "markdown.extensions.fenced_code", "markdown.extensions.codehilite", "markdown.extensions.toc", "markdown.extensions.nl2br", # Convert newlines to
], extension_configs={"markdown.extensions.codehilite": {"css_class": "highlight"}}, ) return md.convert(markdown_text) def append_pylon_info_to_body( repo: Repository, item_number: int, pylon_issue_id: str, pylon_issue_url: str, item_type: ItemType, ) -> None: """Append Pylon issue info to GitHub issue/discussion body.""" pylon_info = f""" --- > #### 🔗 Pylon Integration > > **Pylon Issue ID:** `{pylon_issue_id}` > **Pylon Issue URL:** {pylon_issue_url} > > This {item_type.value} has been synced with Pylon for tracking and management. DO NOT REMOVE THIS COMMENT """ if item_type == ItemType.ISSUE: issue = repo.get_issue(item_number) current_body = issue.body or "" updated_body = current_body + pylon_info issue.edit(body=updated_body) else: # ItemType.DISCUSSION discussion = repo.get_discussion(item_number) current_body = discussion.body or "" updated_body = current_body + pylon_info discussion.edit(body=updated_body) def handle_github_issue_created(event: dict[str, Any], g: Github) -> None: """Handle GitHub issue creation events.""" issue = event["issue"] issue_number = issue["number"] issue_title = issue["title"] issue_body = issue["body"] or "" issue_url = issue["html_url"] repo = g.get_repo(GITHUB_REPO) # Extract or create Pylon issue pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, issue_number, ItemType.ISSUE, title=issue_title, body=issue_body, external_url=issue_url, author=issue["user"], issue_type=PylonIssueType.BUG, ) if pylon_issue_id: print( f"Pylon issue {pylon_issue_id} exists or was created for GitHub issue #{issue_number}" ) else: print(f"Could not create Pylon issue for GitHub issue #{issue_number}") def handle_github_issue_updated(event: dict[str, Any], g: Github) -> None: """Handle GitHub issue update events (edited, reopened).""" issue = event["issue"] action = event["action"] issue_number = issue["number"] issue_title = issue["title"] issue_body = issue["body"] or "" issue_url = issue["html_url"] repo = g.get_repo(GITHUB_REPO) # Check if Pylon issue exists pylon_issue_id = extract_or_create_pylon_issue_id_from_body(repo, issue_number, ItemType.ISSUE) if pylon_issue_id: # Update Pylon issue update_pylon_issue(pylon_issue_id, issue_title, issue_body) # Post update message message = f"""GitHub issue #{issue_number} has been {action}. **Title:** {issue_title} **URL:** {issue_url}""" post_pylon_message(pylon_issue_id, message, issue["user"]) print(f"Updated Pylon issue {pylon_issue_id} for GitHub issue #{issue_number}") else: print(f"No Pylon issue found for GitHub issue #{issue_number}") def handle_github_issue_closed(event: dict[str, Any], g: Github) -> None: """Handle GitHub issue closure events.""" issue = event["issue"] issue_number = issue["number"] issue_title = issue["title"] issue_url = issue["html_url"] repo = g.get_repo(GITHUB_REPO) # Check if Pylon issue exists pylon_issue_id = extract_or_create_pylon_issue_id_from_body(repo, issue_number, ItemType.ISSUE) if pylon_issue_id: # Close Pylon issue close_pylon_issue(pylon_issue_id) # Post closure message message = f"""GitHub issue #{issue_number} has been closed. **Title:** {issue_title} **URL:** {issue_url}""" post_pylon_message(pylon_issue_id, message, issue["user"]) print(f"Closed Pylon issue {pylon_issue_id} for GitHub issue #{issue_number}") else: print(f"No Pylon issue found for GitHub issue #{issue_number}") def handle_github_issue_comment(event: dict[str, Any], g: Github) -> None: """Handle GitHub issue comment events.""" comment = event["comment"] issue = event["issue"] issue_number = issue["number"] comment_body = comment["body"] comment_author = comment["user"]["login"] repo = g.get_repo(GITHUB_REPO) # Extract or create Pylon issue pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, issue_number, ItemType.ISSUE, title=issue["title"], body=issue["body"] or "", external_url=issue["html_url"], author=issue["user"], issue_type=PylonIssueType.BUG, ) if pylon_issue_id: # Post comment to Pylon issue message = f"""New comment on GitHub issue #{issue_number} by @{comment_author}: {comment_body} """ post_pylon_message(pylon_issue_id, message, comment["user"]) print(f"Posted comment to Pylon issue {pylon_issue_id} for GitHub issue #{issue_number}") else: print(f"Could not create or find Pylon issue for GitHub issue #{issue_number}") def handle_github_issue(event: dict[str, Any], g: Github) -> None: """Handle GitHub issue events - routes to specific handlers.""" action = event["action"] if action == GitHubAction.OPENED.value: handle_github_issue_created(event, g) elif action == GitHubAction.CLOSED.value: handle_github_issue_closed(event, g) elif action in [GitHubAction.EDITED.value, GitHubAction.REOPENED.value]: handle_github_issue_updated(event, g) else: print(f"Unhandled issue action: {action}") def handle_github_discussion_created(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion creation events.""" discussion = event["discussion"] discussion_number = discussion["number"] discussion_title = discussion["title"] discussion_body = discussion["body"] or "" discussion_url = discussion["html_url"] repo = g.get_repo(GITHUB_REPO) # Extract or create Pylon issue pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, discussion_number, ItemType.DISCUSSION, title=discussion_title, body=discussion_body, external_url=discussion_url, author=discussion["user"], issue_type=PylonIssueType.QUESTION, ) if pylon_issue_id: print( f"Pylon issue {pylon_issue_id} exists or was created for GitHub discussion #{discussion_number}" ) else: print(f"Could not create Pylon issue for GitHub discussion #{discussion_number}") def handle_github_discussion_updated(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion update events (edited).""" discussion = event["discussion"] action = event["action"] discussion_number = discussion["number"] discussion_title = discussion["title"] discussion_body = discussion["body"] or "" discussion_url = discussion["html_url"] repo = g.get_repo(GITHUB_REPO) # Check if Pylon issue exists pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, discussion_number, ItemType.DISCUSSION ) if pylon_issue_id: # Update Pylon issue update_pylon_issue(pylon_issue_id, discussion_title, discussion_body) # Post update message message = f"""GitHub discussion #{discussion_number} has been {action}. **Title:** {discussion_title} **URL:** {discussion_url}""" post_pylon_message(pylon_issue_id, message, discussion["user"]) print(f"Updated Pylon issue {pylon_issue_id} for GitHub discussion #{discussion_number}") else: print(f"No Pylon issue found for GitHub discussion #{discussion_number}") def handle_github_discussion_answered(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion answered events.""" discussion = event["discussion"] discussion_number = discussion["number"] discussion_title = discussion["title"] discussion_url = discussion["html_url"] repo = g.get_repo(GITHUB_REPO) # Check if Pylon issue exists pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, discussion_number, ItemType.DISCUSSION ) if pylon_issue_id: # Close Pylon issue when discussion is answered close_pylon_issue(pylon_issue_id) # Post answered message message = f"""GitHub discussion #{discussion_number} has been answered. **Title:** {discussion_title} **URL:** {discussion_url}""" post_pylon_message(pylon_issue_id, message, discussion["user"]) print( f"Closed Pylon issue {pylon_issue_id} for answered GitHub discussion #{discussion_number}" ) else: print(f"No Pylon issue found for GitHub discussion #{discussion_number}") def handle_github_discussion_locked(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion locked events.""" discussion = event["discussion"] discussion_number = discussion["number"] discussion_title = discussion["title"] discussion_url = discussion["html_url"] repo = g.get_repo(GITHUB_REPO) # Check if Pylon issue exists pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, discussion_number, ItemType.DISCUSSION ) if pylon_issue_id: # Close Pylon issue when discussion is locked close_pylon_issue(pylon_issue_id) # Post lock message message = f"""GitHub discussion #{discussion_number} has been locked. **Title:** {discussion_title} **URL:** {discussion_url}""" post_pylon_message(pylon_issue_id, message, discussion["user"]) print( f"Closed Pylon issue {pylon_issue_id} for locked GitHub discussion #{discussion_number}" ) else: print(f"No Pylon issue found for GitHub discussion #{discussion_number}") def handle_github_discussion_unlocked(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion unlocked events.""" discussion = event["discussion"] discussion_number = discussion["number"] discussion_title = discussion["title"] discussion_url = discussion["html_url"] repo = g.get_repo(GITHUB_REPO) # Check if Pylon issue exists pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, discussion_number, ItemType.DISCUSSION ) if pylon_issue_id: # Note: Pylon doesn't have a direct "reopen" API, so we'll just post a message message = f"""GitHub discussion #{discussion_number} has been unlocked. **Title:** {discussion_title} **URL:** {discussion_url}""" post_pylon_message(pylon_issue_id, message, discussion["user"]) print( f"Posted unlock message to Pylon issue {pylon_issue_id} for unlocked GitHub discussion #{discussion_number}" ) else: print(f"No Pylon issue found for GitHub discussion #{discussion_number}") def handle_github_discussion_comment(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion comment events.""" comment = event["comment"] discussion = event["discussion"] discussion_number = discussion["number"] comment_body = comment["body"] comment_url = comment["html_url"] comment_author = comment["user"]["login"] repo = g.get_repo(GITHUB_REPO) # Extract or create Pylon issue pylon_issue_id = extract_or_create_pylon_issue_id_from_body( repo, discussion_number, ItemType.DISCUSSION, title=discussion["title"], body=discussion["body"] or "", external_url=discussion["html_url"], author=discussion["user"], issue_type=PylonIssueType.QUESTION, ) if pylon_issue_id: # Post comment to Pylon issue message = f"""New comment on GitHub discussion #{discussion_number} by @{comment_author}: {comment_body} **Comment URL:** {comment_url}""" post_pylon_message(pylon_issue_id, message, comment["user"]) print( f"Posted comment to Pylon issue {pylon_issue_id} for GitHub discussion #{discussion_number}" ) else: print(f"Could not create or find Pylon issue for GitHub discussion #{discussion_number}") def handle_github_discussion(event: dict[str, Any], g: Github) -> None: """Handle GitHub discussion events - routes to specific handlers.""" action = event["action"] if action == GitHubAction.CREATED.value: handle_github_discussion_created(event, g) elif action == GitHubAction.ANSWERED.value: handle_github_discussion_answered(event, g) elif action == GitHubAction.LOCKED.value: handle_github_discussion_locked(event, g) elif action == GitHubAction.UNLOCKED.value: handle_github_discussion_unlocked(event, g) elif action == GitHubAction.EDITED.value: handle_github_discussion_updated(event, g) else: print(f"Unhandled discussion action: {action}") def validate_environment() -> int: """Validate required environment variables.""" required_vars = { "GITHUB_TOKEN": GITHUB_TOKEN, "PYLON_API_TOKEN": PYLON_API_TOKEN, "GITHUB_REPO": GITHUB_REPO, "GITHUB_EVENT_PATH": GITHUB_EVENT_PATH, "GITHUB_EVENT_NAME": GITHUB_EVENT_NAME, } missing_vars = [var for var, value in required_vars.items() if not value] if missing_vars: print(f"Error: Missing required environment variables: {', '.join(missing_vars)}") return 1 return 0 def main(): """Main function to handle GitHub events and sync with Pylon.""" if validate_environment() != 0: return 1 # Load GitHub event event = load_github_event() g = Github(auth=Auth.Token(GITHUB_TOKEN)) # Determine event type from the event payload if "issue" in event and "comment" in event: event_type = "issue_comment" elif "discussion" in event and "comment" in event: event_type = "discussion_comment" elif "issue" in event: event_type = "issue" elif "discussion" in event: event_type = "discussion" else: print(f"Unsupported event type. Event keys: {list(event.keys())}") return 1 # Handle different event types if event_type == "issue": handle_github_issue(event, g) print("Successfully synced issue with Pylon") return 0 elif event_type == "issue_comment": handle_github_issue_comment(event, g) print("Successfully synced issue comment with Pylon") return 0 elif event_type == "discussion": handle_github_discussion(event, g) print("Successfully synced discussion with Pylon") return 0 elif event_type == "discussion_comment": handle_github_discussion_comment(event, g) print("Successfully synced discussion comment with Pylon") return 0 else: print(f"Unsupported event type: {event_type}") return 1 if __name__ == "__main__": exit(main())