Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
65a8984
feat: Migrate from PyGithub REST API to GitHub GraphQL API v4
myakove Oct 20, 2025
3346d6a
feat: Migrate from PyGithub REST API to GitHub GraphQL API v4
myakove Oct 20, 2025
d86c41b
test: Add enforcement test for asyncio.to_thread isolation
myakove Oct 20, 2025
a28f5c9
fix: Fix ALL 20 failing tests
myakove Oct 20, 2025
21eb6bc
fix: Fix remaining test failures
myakove Oct 20, 2025
2a93e41
fix: Set repository.full_name in conftest
myakove Oct 20, 2025
3188cf6
fix: Set repository.full_name in ALL test fixtures
myakove Oct 20, 2025
13d6fb1
fix: Add get_check_runs to CommitWrapper
myakove Oct 20, 2025
d259dfd
fix: Proper implementation of CommitWrapper.get_check_runs
myakove Oct 20, 2025
6f9729a
fix: Remove get_check_runs from CommitWrapper - not needed
myakove Oct 20, 2025
0580c78
fix: Add merge_commit_sha property to PullRequestWrapper
myakove Oct 20, 2025
ab38325
fix: Support assignee in create_issue and handle CommitWrapper in get…
myakove Oct 20, 2025
c2f8850
fix: Properly handle CommitWrapper in get_commit_check_runs
myakove Oct 20, 2025
694052a
fix: Update all get_commit_check_runs callers with owner/repo params
myakove Oct 20, 2025
bec4684
fix: Add assignee parameter to create_issue
myakove Oct 20, 2025
244ace1
fix: Log label names instead of LabelWrapper objects
myakove Oct 20, 2025
d3a38af
fix: Close and recreate GraphQL client before each query
myakove Oct 20, 2025
784b3f6
fix: Add comprehensive error logging to add_comment
myakove Oct 20, 2025
2f320bd
fix: Revert problematic client recreation on every query
myakove Oct 20, 2025
3e66ecb
fix: Recreate GraphQL client/transport for EVERY query
myakove Oct 20, 2025
3bcbfdc
fix: Check if issue exists before creating for new PR
myakove Oct 20, 2025
61eb671
fix: Add comprehensive logging to welcome message flow
myakove Oct 20, 2025
ad4ba16
fix: Add granular logging to track GraphQL addComment mutation
myakove Oct 20, 2025
0c94446
fix: Increase GraphQL timeout from 30s to 90s
myakove Oct 20, 2025
13438c9
fix: Add explicit timeout and comprehensive error handling
myakove Oct 20, 2025
832d1aa
fix: Remove emoji from comment success log
myakove Oct 20, 2025
89c17b6
fix: Remove emojis from docstrings
myakove Oct 20, 2025
a3838ea
fix: Remove retries and add asyncio.wait_for to enforce timeout
myakove Oct 20, 2025
69d4ae9
fix: Add proper rate limit handling with sleep until reset
myakove Oct 20, 2025
2b8cfa6
fix: Wrap entire GraphQL session in asyncio.wait_for to catch connect…
myakove Oct 20, 2025
d82100e
fix: Remove duplicate methods and fix all mypy/ruff errors
myakove Oct 20, 2025
46d5e50
fix: Add UnifiedGitHubAPI import and proper type annotation
myakove Oct 20, 2025
3ddca48
fix: Add explicit client cleanup on GraphQL timeout
myakove Oct 20, 2025
180a00e
warn: Add large comment body warning
myakove Oct 20, 2025
b9974b4
test: Temporarily replace welcome message with short text
myakove Oct 20, 2025
bc5246e
Revert "test: Temporarily replace welcome message with short text"
myakove Oct 20, 2025
7fe86bd
Revert "warn: Add large comment body warning"
myakove Oct 20, 2025
86cb58b
test: Add dummy file to test welcome message flow
myakove Oct 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TEST_PR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Test PR for welcome message debugging
12 changes: 4 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ warn_redundant_casts = true
[tool.hatch.build.targets.wheel]
packages = ["webhook_server"]

[tool.uv]
dev-dependencies = [
[dependency-groups]
dev = [
"ipdb>=0.13.13",
"ipython>=8.12.3",
"types-colorama>=0.4.15.20240311",
Expand All @@ -58,6 +58,7 @@ dependencies = [
"colorama>=0.4.6",
"colorlog>=6.8.2",
"fastapi>=0.115.0",
"gql[aiohttp]>=3.5.0",
"pygithub>=2.4.0",
"pyhelper-utils>=0.0.42",
"pytest-cov>=6.0.0",
Expand Down Expand Up @@ -94,13 +95,8 @@ repository = "https://github.com/myakove/github-webhook-server"
"Bug Tracker" = "https://github.com/myakove/github-webhook-server/issues"

[project.optional-dependencies]
tests = ["pytest-asyncio>=0.26.0", "pytest-xdist>=3.7.0"]
tests = ["pytest-asyncio>=0.24.0", "pytest-xdist>=3.7.0"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[dependency-groups]
tests = [
"psutil>=7.0.0",
]
531 changes: 520 additions & 11 deletions uv.lock

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions webhook_server/libs/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ def __init__(self, err: dict[str, str]):

class NoApiTokenError(Exception):
pass


class UnifiedAPINotInitializedError(Exception):
"""Raised when UnifiedGitHubAPI is accessed before initialization."""

pass
121 changes: 96 additions & 25 deletions webhook_server/libs/github_api.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
from typing import Any

import requests
from github import GithubException
from github.Commit import Commit
from github.PullRequest import PullRequest
from github.Repository import Repository

# GraphQL wrappers provide PyGithub-compatible interface
from webhook_server.libs.graphql.graphql_wrappers import CommitWrapper, PullRequestWrapper
from webhook_server.libs.graphql.unified_api import UnifiedGitHubAPI
from starlette.datastructures import Headers

from webhook_server.libs.check_run_handler import CheckRunHandler
from webhook_server.libs.config import Config
from webhook_server.libs.handlers.check_run_handler import CheckRunHandler
from webhook_server.libs.exceptions import RepositoryNotFoundInConfigError
from webhook_server.libs.issue_comment_handler import IssueCommentHandler
from webhook_server.libs.owners_files_handler import OwnersFileHandler
from webhook_server.libs.pull_request_handler import PullRequestHandler
from webhook_server.libs.pull_request_review_handler import PullRequestReviewHandler
from webhook_server.libs.push_handler import PushHandler
from webhook_server.libs.handlers.issue_comment_handler import IssueCommentHandler
from webhook_server.libs.handlers.owners_files_handler import OwnersFileHandler
from webhook_server.libs.handlers.pull_request_handler import PullRequestHandler
from webhook_server.libs.handlers.pull_request_review_handler import PullRequestReviewHandler
from webhook_server.libs.handlers.push_handler import PushHandler
from webhook_server.utils.constants import (
BUILD_CONTAINER_STR,
CAN_BE_MERGED_STR,
Expand Down Expand Up @@ -61,7 +63,6 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging.
self.token: str
self.api_user: str
self.current_pull_request_supported_retest: list[str] = []

if not self.config.repository_data:
raise RepositoryNotFoundInConfigError(f"Repository {self.repository_name} not found in config file")

Expand All @@ -73,6 +74,8 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging.

if github_api and self.token:
self.repository = get_github_repo_api(github_app_api=github_api, repository=self.repository_full_name)
# Initialize UnifiedGitHubAPI for GraphQL operations
self.unified_api: UnifiedGitHubAPI = UnifiedGitHubAPI(token=self.token, logger=self.logger)
# Once we have a repository, we can get the config from .github-webhook-server.yaml
local_repository_config = self.config.repository_local_data(
github_api=github_api, repository_full_name=self.repository_full_name
Expand Down Expand Up @@ -200,7 +203,7 @@ def add_api_users_to_auto_verified_and_merged_users(self) -> None:

self.auto_verified_and_merged_users.append(_api.get_user().login)

def prepare_log_prefix(self, pull_request: PullRequest | None = None) -> str:
def prepare_log_prefix(self, pull_request: PullRequestWrapper | None = None) -> str:
return prepare_log_prefix(
event_type=self.github_event,
delivery_id=self.x_github_delivery,
Expand Down Expand Up @@ -264,25 +267,43 @@ def _repo_data_from_config(self, repository_config: dict[str, Any]) -> None:
value="create-issue-for-new-pr", return_on_none=global_create_issue_for_new_pr, extra_dict=repository_config
)

async def get_pull_request(self, number: int | None = None) -> PullRequest | None:
if number:
return await asyncio.to_thread(self.repository.get_pull, number)

for _number in extract_key_from_dict(key="number", _dict=self.hook_data):
try:
return await asyncio.to_thread(self.repository.get_pull, _number)
except GithubException:
continue
async def get_pull_request(self, number: int | None = None) -> PullRequestWrapper | None:
"""Get pull request using GraphQL."""
if not self.unified_api:
self.logger.error(f"{self.log_prefix} UnifiedAPI not initialized")
return None

# Extract owner and repo name from repository_full_name
owner, repo_name = self.repository_full_name.split("/")

# Try to get PR number from various sources
pr_number = number
if not pr_number:
for _number in extract_key_from_dict(key="number", _dict=self.hook_data):
pr_number = _number
break

# If we have a PR number, use GraphQL
if pr_number:
# Fetch PR with commits and labels (commonly needed data)
pr_data = await self.unified_api.get_pull_request(
owner, repo_name, pr_number, include_commits=True, include_labels=True
)
return PullRequestWrapper(pr_data)

# For commit-based lookups or check_run events, use REST via unified_api
# (GraphQL doesn't have efficient commit->PR lookup)
commit: dict[str, Any] = self.hook_data.get("commit", {})
if commit:
commit_obj = await asyncio.to_thread(self.repository.get_commit, commit["sha"])
owner, repo_name = self.repository.full_name.split("/")
commit_obj = await self.unified_api.get_commit(owner, repo_name, commit["sha"])
with contextlib.suppress(Exception):
_pulls = await asyncio.to_thread(commit_obj.get_pulls)
_pulls = await self.unified_api.get_pulls_from_commit(commit_obj)
return _pulls[0]

if self.github_event == "check_run":
for _pull_request in await asyncio.to_thread(self.repository.get_pulls, state="open"):
owner, repo_name = self.repository.full_name.split("/")
for _pull_request in await self.unified_api.get_open_pull_requests(owner, repo_name):
if _pull_request.head.sha == self.hook_data["check_run"]["head_sha"]:
self.logger.debug(
f"{self.log_prefix} Found pull request {_pull_request.title} [{_pull_request.number}] for check run {self.hook_data['check_run']['name']}"
Expand All @@ -291,9 +312,59 @@ async def get_pull_request(self, number: int | None = None) -> PullRequest | Non

return None

async def _get_last_commit(self, pull_request: PullRequest) -> Commit:
_commits = await asyncio.to_thread(pull_request.get_commits)
return list(_commits)[-1]
async def _get_last_commit(self, pull_request: PullRequestWrapper) -> Commit | CommitWrapper:
"""Get last commit from PullRequestWrapper."""
commits = pull_request.get_commits()
if commits:
return commits[-1]
# If no commits in wrapper, fallback to REST via unified_api
self.logger.warning(f"{self.log_prefix} No commits in GraphQL response, using REST fallback")
owner, repo_name = self.repository.full_name.split("/")
commits = await self.unified_api.get_pr_commits(owner, repo_name, pull_request.number)
return commits[-1]

async def add_pr_comment(self, pull_request: PullRequestWrapper, body: str) -> None:
"""Add comment to PR via unified_api."""
try:
pr_id = pull_request.id
self.logger.debug(f"{self.log_prefix} Adding PR comment with pr_id={pr_id}, body length={len(body)}")
await self.unified_api.add_comment(pr_id, body)
self.logger.info(f"{self.log_prefix} Successfully added PR comment")
except Exception as ex:
self.logger.error(f"{self.log_prefix} Failed to add PR comment: {ex}", exc_info=True)
raise

async def update_pr_title(self, pull_request: PullRequestWrapper, title: str) -> None:
"""Update PR title via unified_api."""
pr_id = pull_request.id
await self.unified_api.update_pull_request(pr_id, title=title)

async def enable_pr_automerge(self, pull_request: PullRequestWrapper, merge_method: str = "SQUASH") -> None:
"""Enable automerge on PR via unified_api."""
pr_id = pull_request.id
await self.unified_api.enable_pull_request_automerge(pr_id, merge_method)

async def request_pr_reviews(self, pull_request: PullRequestWrapper, reviewers: list[str]) -> None:
"""Request reviews on PR via unified_api."""
pr_id = pull_request.id
reviewer_ids = []
for reviewer in reviewers:
try:
user_id = await self.unified_api.get_user_id(reviewer)
reviewer_ids.append(user_id)
except Exception as ex:
self.logger.warning(f"{self.log_prefix} Failed to get ID for {reviewer}: {ex}")
if reviewer_ids:
await self.unified_api.request_reviews(pr_id, reviewer_ids)

async def add_pr_assignee(self, pull_request: PullRequestWrapper, assignee: str) -> None:
"""Add assignee to PR via unified_api."""
pr_id = pull_request.id
try:
user_id = await self.unified_api.get_user_id(assignee)
await self.unified_api.add_assignees(pr_id, [user_id])
except Exception as ex:
self.logger.warning(f"{self.log_prefix} Failed to add assignee {assignee}: {ex}")

@staticmethod
def _comment_with_details(title: str, body: str) -> str:
Expand Down
Empty file.
Loading