원클릭으로
platforms-github-api
// GitHub REST API v3 and GraphQL v4 integration patterns for ticket management and automation
// GitHub REST API v3 and GraphQL v4 integration patterns for ticket management and automation
Jira REST API v3 integration patterns for issue tracking, sprint management, and JQL query optimization. Production-ready patterns for 2025 rate limiting.
Linear GraphQL API integration patterns for modern issue tracking with cycles, projects, and team-scoped workflows
| name | platforms-github-api |
| description | GitHub REST API v3 and GraphQL v4 integration patterns for ticket management and automation |
GitHub REST API v3 and GraphQL v4 integration patterns for ticket management and automation.
# Personal Access Token (PAT)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
"X-GitHub-Api-Version": "2022-11-28",
}
# Required scopes: repo, read:org
💡 For full patterns and best practices, continue reading below.
Current Implementation (from mcp-ticketer):
class GitHubAdapter:
def __init__(self, config: dict[str, Any]):
self.token = config.get("api_key") or config.get("token")
self.owner = config.get("owner")
self.repo = config.get("repo")
self.headers = {
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github.v3+json",
"X-GitHub-Api-Version": "2022-11-28",
}
self.client = httpx.AsyncClient(
base_url="https://api.github.com",
headers=self.headers,
timeout=30.0,
)
Required Scopes:
repo - Full control of private repositoriespublic_repo - Access public repositoriesread:org - Read org and team membershipwrite:discussion - Read/write team discussions (optional)Security Best Practices:
import os
# ✅ GOOD: Environment variables
token = os.getenv("GITHUB_TOKEN")
# ❌ BAD: Hardcoded in code
token = "ghp_xxxxxxxxxxxx" # NEVER DO THIS
# ✅ GOOD: Validate before use
def validate_credentials(self) -> tuple[bool, str]:
if not self.token:
return False, "GITHUB_TOKEN is required"
if not self.owner:
return False, "GitHub owner is required"
if not self.repo:
return False, "GitHub repo is required"
return True, ""
Benefits:
Example Setup:
permissions:
contents: write
issues: write
pull_requests: write
metadata: read
repository_access: only_selected
repositories: ["mcp-ticketer"]
Benefits:
Not Currently Implemented - Planned for mcp-ticketer v3.0
| API Type | Authenticated | Unauthenticated |
|---|---|---|
| REST API | 5,000/hour | 60/hour |
| Search API | 30/minute | 10/minute |
| GraphQL | 5,000 points/hour | - |
| GitHub Apps | 15,000/hour | - |
# Store rate limit info from headers
self._rate_limit = {
"limit": response.headers.get("X-RateLimit-Limit"),
"remaining": response.headers.get("X-RateLimit-Remaining"),
"reset": response.headers.get("X-RateLimit-Reset"),
}
# Check before batch operations
async def get_rate_limit(self) -> dict[str, Any]:
response = await self.client.get("/rate_limit")
response.raise_for_status()
return response.json()
# Usage
rate_limit = await adapter.get_rate_limit()
if rate_limit["rate"]["remaining"] < 100:
reset_time = datetime.fromtimestamp(rate_limit["rate"]["reset"])
wait_seconds = (reset_time - datetime.now()).total_seconds()
await asyncio.sleep(wait_seconds)
class RetryConfig:
def __init__(
self,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 60.0,
exponential_base: float = 2.0,
retry_on_status: list[int] = [429, 502, 503, 504],
):
self.max_retries = max_retries
self.initial_delay = initial_delay
self.max_delay = max_delay
self.exponential_base = exponential_base
self.retry_on_status = retry_on_status
# Retry delays: 1s, 2s, 4s, 8s (capped at max_delay)
ETag Conditional Requests (Not Currently Implemented):
# First request
response = await client.get("/repos/owner/repo/issues/123")
etag = response.headers.get("ETag") # "W/\"abc123\""
# Subsequent request with ETag
headers = {"If-None-Match": etag}
response = await client.get("/repos/owner/repo/issues/123", headers=headers)
if response.status_code == 304:
# Not modified, use cached version (doesn't count against rate limit!)
return cached_issue
Benefits:
GitHub natively supports only two states:
openclosedThis is insufficient for modern workflows requiring:
in_progressreadytestedwaitingblockedclass GitHubStateMapping:
# Native states
OPEN = "open"
CLOSED = "closed"
# Extended states via labels
STATE_LABELS = {
TicketState.IN_PROGRESS: "in-progress",
TicketState.READY: "ready",
TicketState.TESTED: "tested",
TicketState.WAITING: "waiting",
TicketState.BLOCKED: "blocked",
}
# Priority labels
PRIORITY_LABELS = {
Priority.CRITICAL: ["P0", "critical", "urgent"],
Priority.HIGH: ["P1", "high"],
Priority.MEDIUM: ["P2", "medium"],
Priority.LOW: ["P3", "low"],
}
async def update(self, ticket_id: str, updates: dict[str, Any]) -> Task | None:
# Get current issue
current_issue = await self.client.get(
f"/repos/{self.owner}/{self.repo}/issues/{issue_number}"
)
current_labels = [label["name"] for label in current_issue.get("labels", [])]
# Remove old state labels
labels_to_update = [
label for label in current_labels
if label.lower() not in [
sl.lower() for sl in GitHubStateMapping.STATE_LABELS.values()
]
]
# Add new state label
new_state = updates["state"]
state_label = self._get_state_label(new_state)
if state_label:
await self._ensure_label_exists(state_label, "fbca04")
labels_to_update.append(state_label)
# Update GitHub native state
if new_state in [TicketState.DONE, TicketState.CLOSED]:
update_data["state"] = "closed"
else:
update_data["state"] = "open"
update_data["labels"] = labels_to_update
async def _ensure_label_exists(self, label_name: str, color: str = "0366d6") -> None:
# Cache labels to reduce API calls
if not self._labels_cache:
response = await self.client.get(f"/repos/{self.owner}/{self.repo}/labels")
self._labels_cache = response.json()
# Check if label exists
existing_labels = [label["name"].lower() for label in self._labels_cache]
if label_name.lower() not in existing_labels:
# Create the label
response = await self.client.post(
f"/repos/{self.owner}/{self.repo}/labels",
json={"name": label_name, "color": color},
)
if response.status_code == 201:
self._labels_cache.append(response.json())
⚠️ Pitfall: Cache never expires. Manual refresh needed if labels created outside adapter.
GitHub Milestones API provides:
async def milestone_create(
self,
name: str,
target_date: date | None = None,
labels: list[str] | None = None,
description: str = "",
) -> Milestone:
# Create via GitHub API (title, description, due_on)
milestone_data = {
"title": name,
"description": description,
"state": "open",
"due_on": target_date.isoformat() + "Z" if target_date else None,
}
response = await self.client.post(
f"/repos/{self.owner}/{self.repo}/milestones",
json=milestone_data,
)
gh_milestone = response.json()
# Store labels locally (GitHub limitation workaround)
milestone = self._github_milestone_to_milestone(gh_milestone, labels)
config_dir = Path.home() / ".mcp-ticketer"
manager = MilestoneManager(config_dir)
manager.save_milestone(milestone) # Saves to ~/.mcp-ticketer/milestones.json
return milestone
def _github_milestone_to_milestone(
self, gh_milestone: dict[str, Any], labels: list[str] | None = None
) -> Milestone:
# GitHub calculates progress automatically
total = gh_milestone.get("open_issues", 0) + gh_milestone.get("closed_issues", 0)
closed = gh_milestone.get("closed_issues", 0)
progress_pct = (closed / total * 100) if total > 0 else 0.0
return Milestone(
id=str(gh_milestone["number"]),
name=gh_milestone["title"],
total_issues=total,
closed_issues=closed,
progress_pct=progress_pct,
labels=labels or [], # From local storage
)
# Determine milestone state
state = "closed" if gh_milestone["state"] == "closed" else "open"
# Compute based on due date
if state == "open" and target_date:
if target_date < date.today():
state = "closed" # Past due
else:
state = "active" # In progress
| Operation | Use | Reason |
|---|---|---|
| Create issue | REST | Simpler, mutations well-supported |
| Update issue | REST | Direct updates, label management |
| List issues | REST | Simple pagination, filtering |
| Search issues | GraphQL | Advanced search, nested data |
| Get issue (nested) | GraphQL | Fetch comments, reactions, projects in one call |
| Projects V2 | GraphQL | Only available via GraphQL |
| Milestones | REST | Full CRUD support |
ISSUE_FRAGMENT = """
fragment IssueFields on Issue {
id
number
title
body
state
createdAt
updatedAt
url
author { login }
assignees(first: 10) { nodes { login email } }
labels(first: 20) { nodes { name color } }
milestone { id number title state }
comments(first: 100) { nodes { id body author { login } } }
}
"""
# Use in queries
SEARCH_ISSUES = """
query SearchIssues($query: String!, $first: Int!, $after: String) {
search(query: $query, type: ISSUE, first: $first, after: $after) {
nodes {
... on Issue {
...IssueFields
}
}
}
}
"""
full_query = ISSUE_FRAGMENT + SEARCH_ISSUES
async def search(self, query: SearchQuery) -> list[Task]:
# Build GitHub search query
search_parts = [f"repo:{self.owner}/{self.repo}", "is:issue"]
if query.query:
escaped = query.query.replace('"', '\\"')
search_parts.append(f'"{escaped}"')
if query.state:
if query.state in [TicketState.DONE, TicketState.CLOSED]:
search_parts.append("is:closed")
else:
search_parts.append("is:open")
state_label = self._get_state_label(query.state)
if state_label:
search_parts.append(f'label:"{state_label}"')
if query.priority:
priority_label = self._get_priority_label(query.priority)
search_parts.append(f'label:"{priority_label}"')
if query.assignee:
search_parts.append(f"assignee:{query.assignee}")
if query.tags:
for tag in query.tags:
search_parts.append(f'label:"{tag}"')
github_query = " ".join(search_parts)
# Result: repo:owner/repo is:issue "authentication" label:"P1" assignee:user
Search Query Examples:
repo:owner/repo is:issue is:open label:"bug"
repo:owner/repo is:issue assignee:username created:>2025-01-01
repo:owner/repo is:issue "authentication" label:"P0" is:closed
async def create_pull_request(
self,
ticket_id: str,
base_branch: str = "main",
head_branch: str | None = None,
title: str | None = None,
body: str | None = None,
draft: bool = False,
) -> dict[str, Any]:
# Get issue details
issue = await self.read(ticket_id)
# Auto-generate branch name from issue
if not head_branch:
safe_title = "-".join(
issue.title.lower()
.replace("[", "").replace("]", "")
.split()[:5] # Limit to 5 words
)
head_branch = f"{issue_number}-{safe_title}"
# Result: "123-fix-authentication-bug"
# Auto-generate PR title
if not title:
title = f"[#{issue_number}] {issue.title}"
# Auto-generate PR body with checklist
if not body:
body = f"""## Summary
This PR addresses issue #{issue_number}.
**Issue:** #{issue_number} - {issue.title}
## Description
{issue.description or 'No description provided.'}
## Changes
- [ ] Implementation details to be added
## Testing
- [ ] Tests have been added/updated
- [ ] All tests pass
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
Fixes #{issue_number}
"""
# Create branch if doesn't exist
if not branch_exists:
base_sha = await self._get_branch_sha(base_branch)
await self.client.post(
f"/repos/{self.owner}/{self.repo}/git/refs",
json={"ref": f"refs/heads/{head_branch}", "sha": base_sha},
)
# Create pull request
pr_response = await self.client.post(
f"/repos/{self.owner}/{self.repo}/pulls",
json={
"title": title,
"body": body,
"head": head_branch,
"base": base_branch,
"draft": draft,
},
)
pr = pr_response.json()
# Add comment to issue
await self.add_comment(Comment(
ticket_id=ticket_id,
content=f"Pull request #{pr['number']} created: {pr['html_url']}",
))
return {
"number": pr["number"],
"url": pr["html_url"],
"branch": head_branch,
"state": pr["state"],
"draft": pr.get("draft", False),
"linked_issue": issue_number,
}
async def link_existing_pull_request(
self, ticket_id: str, pr_url: str
) -> dict[str, Any]:
# Parse PR URL: https://github.com/owner/repo/pull/123
pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
match = re.search(pr_pattern, pr_url)
pr_owner, pr_repo, pr_number = match.groups()
# Verify same repository
if pr_owner != self.owner or pr_repo != self.repo:
raise ValueError("PR must be from same repository")
# Get PR details
pr = await self.client.get(
f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}"
).json()
# Update PR body to include issue reference
current_body = pr.get("body", "")
issue_ref = f"#{issue_number}"
if issue_ref not in current_body:
updated_body = current_body + f"\n\nRelated to #{issue_number}"
await self.client.patch(
f"/repos/{self.owner}/{self.repo}/pulls/{pr_number}",
json={"body": updated_body},
)
# Add comment to issue
await self.add_comment(Comment(
ticket_id=ticket_id,
content=f"Linked to pull request #{pr_number}: {pr_url}",
))
return {
"success": True,
"pr_number": pr["number"],
"pr_url": pr["html_url"],
"linked_issue": issue_number,
}
| Qualifier | Example | Description |
|---|---|---|
author: | author:username | Issue creator |
assignee: | assignee:username | Assigned user |
mentions: | mentions:username | User mentioned |
commenter: | commenter:username | User commented |
involves: | involves:username | Combined: author, assignee, mentions, commenter |
label: | label:"bug" | Has label |
created: | created:>2025-01-01 | Created after date |
updated: | updated:<2025-12-31 | Updated before date |
is: | is:open, is:closed | State filter |
no: | no:assignee, no:label | Missing field |
milestone: | milestone:"v2.0" | In milestone |
project: | project:repo/1 | In project board |
# Find all high-priority authentication bugs
query = SearchQuery(
query="authentication",
state=TicketState.OPEN,
priority=Priority.HIGH,
tags=["bug", "security"],
)
# → repo:owner/repo is:issue is:open "authentication" label:"P1" label:"bug" label:"security"
# Find stale issues (no activity in 90 days)
github_query = "repo:owner/repo is:issue is:open updated:<2024-10-01"
# Find issues involving specific user
github_query = "repo:owner/repo is:issue involves:username"
# Find unassigned critical issues
github_query = "repo:owner/repo is:issue is:open label:P0 no:assignee"
# ✅ GOOD: Specific query
"repo:owner/repo is:issue label:bug assignee:user"
# ❌ BAD: Broad text search
"repo:owner/repo bug user"
# Response headers
Link: <https://api.github.com/repos/owner/repo/issues?page=2>; rel="next",
<https://api.github.com/repos/owner/repo/issues?page=5>; rel="last"
# Implementation
params = {
"per_page": min(limit, 100), # GitHub max: 100
"page": (offset // limit) + 1 if limit > 0 else 1,
}
response = await self.client.get(
f"/repos/{self.owner}/{self.repo}/issues",
params=params,
)
⚠️ Limitation: Offset-based pagination inefficient for large datasets.
variables = {
"query": github_query,
"first": min(query.limit, 100),
"after": None, # Cursor from previous page
}
result = await self._graphql_request(SEARCH_ISSUES, variables)
issues = result["search"]["nodes"]
page_info = result["search"]["pageInfo"]
if page_info["hasNextPage"]:
next_cursor = page_info["endCursor"]
# Use next_cursor in next request
✅ Efficient: Only fetches needed data, no wasted API calls.
# ❌ BAD: Large offset emulation (wasteful)
issues = await adapter.list(limit=10, offset=5000)
# GraphQL: Fetches 5000 issues, returns 10
# ✅ GOOD: Cursor-based pagination
cursor = None
all_issues = []
while len(all_issues) < desired_count:
page = await adapter.search(SearchQuery(
query="",
limit=100,
# Store cursor between requests
))
all_issues.extend(page)
if len(page) < 100:
break # No more results
| Code | Meaning | Action |
|---|---|---|
| 200 | OK | Success |
| 201 | Created | Resource created |
| 204 | No Content | Delete success |
| 304 | Not Modified | Use cached version |
| 400 | Bad Request | Fix request format |
| 401 | Unauthorized | Check authentication |
| 403 | Forbidden | Check permissions |
| 404 | Not Found | Resource doesn't exist |
| 422 | Unprocessable Entity | Validation error |
| 429 | Too Many Requests | Rate limited (retry) |
| 500 | Server Error | GitHub issue (retry) |
| 502 | Bad Gateway | GitHub overloaded (retry) |
| 503 | Service Unavailable | Maintenance (retry) |
class RetryConfig:
retry_on_status = [429, 502, 503, 504, 522, 524]
retry_on_exceptions = [
TimeoutException,
httpx.ConnectTimeout,
httpx.ReadTimeout,
]
# Exponential backoff: 1s, 2s, 4s, 8s (max 60s)
def _calculate_backoff(attempt: int) -> float:
delay = initial_delay * (exponential_base ** attempt)
delay = min(delay, max_delay)
if jitter:
delay *= random.uniform(0.5, 1.5)
return delay
try:
issue = await adapter.create(task)
except ValueError as e:
# Invalid credentials or configuration
logger.error(f"Auth error: {e}")
except httpx.HTTPStatusError as e:
if e.response.status_code == 422:
# Validation error (e.g., duplicate label)
logger.warning(f"Validation failed: {e}")
elif e.response.status_code == 429:
# Rate limited (auto-retried by adapter)
logger.info("Rate limited, retrying...")
elif e.response.status_code == 404:
# Resource not found
logger.error(f"Issue not found: {e}")
else:
# Other HTTP errors
logger.error(f"HTTP {e.response.status_code}: {e}")
except httpx.TimeoutException:
# Network timeout
logger.error("Request timed out")
except Exception as e:
# Unexpected errors
logger.exception(f"Unexpected error: {e}")
get_rate_limit()update() method for state transitions, not raw APIasyncio.gather()Problem:
# ❌ BAD: No rate limit awareness
for i in range(10000):
await adapter.create(task) # Will hit 429 after 5000
Solution:
# ✅ GOOD: Check rate limit before batch operations
rate_limit = await adapter.get_rate_limit()
if rate_limit["rate"]["remaining"] < 100:
reset_time = datetime.fromtimestamp(rate_limit["rate"]["reset"])
wait_seconds = (reset_time - datetime.now()).total_seconds()
logger.info(f"Rate limit low, waiting {wait_seconds}s...")
await asyncio.sleep(wait_seconds)
# Throttle batch operations
for i in range(len(tasks)):
await adapter.create(tasks[i])
if (i + 1) % 100 == 0:
await asyncio.sleep(1) # Throttle every 100 requests
Problem:
# ❌ BAD: Label created outside adapter, cache stale
# User manually creates "critical" label in GitHub UI
await adapter.create(Task(tags=["critical"])) # Tries to create duplicate
Solution:
# ✅ GOOD: Clear cache before operation
adapter._labels_cache = None # Clear cache
await adapter.create(Task(tags=["critical"])) # Re-fetches labels
# OR: Pre-create labels
await adapter._ensure_label_exists("critical", "d73a4a")
Problem:
# ❌ BAD: Fetching all issues with large offset
issues = await adapter.list(limit=10, offset=5000)
# GraphQL emulation: Fetches 5000 issues, returns 10 (wasteful!)
Solution:
# ✅ GOOD: Use cursor-based pagination
cursor = None
all_issues = []
while len(all_issues) < desired_count:
page = await adapter.search(SearchQuery(
query="",
limit=100,
# GraphQL uses cursor internally
))
all_issues.extend(page)
if len(page) < 100:
break # No more results
Problem:
# ❌ BAD: Expecting labels stored in GitHub milestone
milestone = await adapter.milestone_get("5")
# milestone.labels fetched from local storage, NOT GitHub
Solution:
# ✅ GOOD: Understand hybrid storage
# GitHub stores: title, description, due_on, state, progress
# Local storage: labels (not supported by GitHub API)
# To sync labels, always use milestone methods
await adapter.milestone_update("5", labels=["new-label"])
Problem:
# ❌ BAD: Direct state change without label update
await adapter.client.patch(
f"/repos/{owner}/{repo}/issues/{number}",
json={"state": "open"}, # Missing state label!
)
Solution:
# ✅ GOOD: Use adapter methods for state transitions
await adapter.update(issue_id, {
"state": TicketState.IN_PROGRESS,
# Automatically:
# 1. Adds "in-progress" label
# 2. Removes old state labels
# 3. Sets GitHub state to "open"
})
Problem:
# ❌ BAD: Creating PR without checking branch exists
pr = await adapter.create_pull_request(
ticket_id="123",
head_branch="feature-branch", # Might not exist!
)
Solution:
# ✅ GOOD: Adapter auto-creates branch if needed
pr = await adapter.create_pull_request(
ticket_id="123",
# Omit head_branch: auto-generates from issue title
# Creates branch from base if doesn't exist
)
# 1. Create issue with extended state and priority
task = Task(
title="Implement OAuth2 authentication",
description="""
## Overview
Add OAuth2 authentication support to API
## Requirements
- Support authorization code flow
- Token refresh mechanism
- Revocation endpoint
## Acceptance Criteria
- [ ] OAuth2 endpoints implemented
- [ ] Token storage secure
- [ ] Tests passing
""",
state=TicketState.IN_PROGRESS,
priority=Priority.HIGH,
tags=["feature", "authentication", "security"],
assignee="developer",
)
created_issue = await adapter.create(task)
print(f"Created issue #{created_issue.id}: {created_issue.title}")
# 2. Create milestone for sprint
milestone = await adapter.milestone_create(
name="Sprint 24 - Authentication",
target_date=date(2025, 12, 31),
labels=["sprint-24", "Q4"],
description="Focus: OAuth2 and security improvements",
)
# 3. Link issue to milestone
await adapter.update(created_issue.id, {
"parent_epic": milestone.id,
})
# 4. Add progress comment
await adapter.add_comment(Comment(
ticket_id=created_issue.id,
content="""
## Progress Update
✅ OAuth2 endpoints implemented
✅ Token storage secure
⏳ Working on tests
**Next:** Complete test coverage by EOD
""",
))
# 5. Create PR from issue
pr = await adapter.create_pull_request(
ticket_id=created_issue.id,
base_branch="main",
draft=True, # WIP
)
print(f"Created draft PR #{pr['number']}: {pr['url']}")
# 6. Transition state to ready
await adapter.update(created_issue.id, {
"state": TicketState.READY, # Adds "ready" label
})
# 7. Close issue when merged
await adapter.update(created_issue.id, {
"state": TicketState.DONE, # Closes issue
})
# Find all high-priority authentication bugs assigned to team
query = SearchQuery(
query="authentication",
state=TicketState.OPEN,
priority=Priority.HIGH,
tags=["bug", "security"],
)
issues = await adapter.search(query)
for issue in issues:
print(f"#{issue.id}: {issue.title}")
print(f" Priority: {issue.priority}")
print(f" State: {issue.state}")
print(f" Labels: {', '.join(issue.tags)}")
print()
# Filter issues in milestone
milestone_issues = await adapter.list(
filters={"parent_epic": "5", "state": "open"}
)
print(f"Found {len(milestone_issues)} open issues in milestone")
# Get milestone with progress
milestone = await adapter.milestone_get("5")
print(f"Milestone: {milestone.name}")
print(f"Target Date: {milestone.target_date}")
print(f"Progress: {milestone.progress_pct:.1f}%")
print(f"Total Issues: {milestone.total_issues}")
print(f"Closed Issues: {milestone.closed_issues}")
print(f"Open Issues: {milestone.total_issues - milestone.closed_issues}")
# Check if on track
if milestone.target_date < date.today():
print("⚠️ OVERDUE")
elif milestone.progress_pct >= 80:
print("✅ ON TRACK")
else:
print("⚠️ AT RISK")
# Get issues in milestone
issues = await adapter.milestone_get_issues(
milestone_id=milestone.id,
state="open",
)
print("\nOpen Issues:")
for issue in issues:
print(f"- #{issue['id']}: {issue['title']}")
# List sprints/cycles for project
# Note: Requires Projects V2 node ID (not numeric ID)
project_id = "PVT_kwDOABCD1234" # From GraphQL
iterations = await adapter.list_cycles(
project_id=project_id,
limit=10,
)
print("Active Sprints:")
for iteration in iterations:
print(f"\n{iteration['title']}")
print(f" Duration: {iteration['duration']} days")
print(f" Start: {iteration['startDate']}")
print(f" End: {iteration['endDate']}")
Error: 401 Unauthorized
Solution: Check GITHUB_TOKEN is valid and not expired
Error: 403 Forbidden
Solution: Check token has required scopes (repo, read:org)
Error: 404 Not Found
Solution: Verify owner/repo configuration is correct
Error: 422 Unprocessable Entity
Solution: Validation error - check request body format
Common: Duplicate label, invalid milestone number
Error: 429 Too Many Requests
Solution: Rate limited - wait for reset or use exponential backoff
Check: X-RateLimit-Reset header for reset time
Error: 502/503 Bad Gateway
Solution: GitHub server overloaded - retry with exponential backoff
import logging
# Enable debug logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("mcp_ticketer.adapters.github")
# Log all API requests
async def _request(self, method: str, endpoint: str, **kwargs):
logger.debug(f"{method} {endpoint}")
start = time.time()
response = await self.client.request(method, endpoint, **kwargs)
duration = time.time() - start
logger.debug(f"{method} {endpoint} - {response.status_code} ({duration:.2f}s)")
return response
Official Documentation:
mcp-ticketer Implementation:
/src/mcp_ticketer/adapters/github.py (2,568 lines)/docs/adapters/github.md/docs/adapters/github-milestones.mdResearch:
/docs/research/github-api-skill-research-2025-12-04.md