Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@
"build": true,
"coverage.xml": true,
"node_modules": true
},
"python.languageServer": "None",
"cursorpyright.analysis.diagnosticSeverityOverrides": {
"reportGeneralTypeIssues": "warning",
"reportArgumentType": "warning"
}
}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dev = [
"pytest>=8.3.3",
"pytest-cov>=6.0.0",
"ruff>=0.12.8",
"httpx>=0.28.1",
]

[project.urls]
Expand Down
12 changes: 6 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@


@pytest.fixture
def payload() -> bytes:
def signature_payload() -> bytes:
return b"foo"


@pytest.fixture
def secret() -> str:
def signature_secret() -> str:
return "mysecret"


@pytest.fixture
def signature_sha256(payload: bytes, secret: str) -> str:
digest = hmac.new(secret.encode(), payload, "sha256").hexdigest()
def signature_sha256(signature_payload: bytes, signature_secret: str) -> str:
digest = hmac.new(signature_secret.encode(), signature_payload, "sha256").hexdigest()
return f"sha256={digest}"


@pytest.fixture
def signature_sha1(payload: bytes, secret: str) -> str:
digest = hmac.new(secret.encode(), payload, "sha1").hexdigest()
def signature_sha1(signature_payload: bytes, signature_secret: str) -> str:
digest = hmac.new(signature_secret.encode(), signature_payload, "sha1").hexdigest()
return f"sha1={digest}"
Empty file added tests/recipes/__init__.py
Empty file.
111 changes: 111 additions & 0 deletions tests/recipes/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import json
from unittest.mock import MagicMock

import httpx
import pytest

from fastgithub.helpers.github import Label
from fastgithub.recipes.github.autocreate_pr import AutoCreatePullRequest

# see https://github.com/octokit/webhooks/tree/main/payload-examples/api.github.com
_BASE_URL_GITHUB_PAYLOAD = "https://raw.githubusercontent.com/octokit/webhooks/refs/heads/main/payload-examples/api.github.com/{event}/{action}"


@pytest.fixture
def mock_github():
"""Mock GitHub instance."""
return MagicMock()


@pytest.fixture
def mock_github_helper():
"""Mock GithubHelper instance."""
helper = MagicMock()
helper.repo = MagicMock()
helper.raise_for_rate_excess = MagicMock()
return helper


def fetch_github_payload(event: str, action: str, base_url: str = _BASE_URL_GITHUB_PAYLOAD):
response = httpx.get(base_url.format(event=event, action=action))
return json.loads(response.content.decode("utf8"))


def fetch_all_pull_request_payloads():
"""
Fetch all available pull_request event payloads from GitHub webhooks repository.

Returns:
dict: Dictionary mapping action names to their corresponding payloads
"""

# All available pull_request actions from the GitHub webhooks repository
# Based on actual files available at: https://github.com/octokit/webhooks/tree/main/payload-examples/api.github.com/pull_request
actions = [
"assigned",
"closed",
"converted_to_draft",
"labeled",
"locked",
"opened",
"ready_for_review",
"reopened",
"review_request_removed",
"review_requested",
"synchronize",
"unassigned",
"unlabeled",
"unlocked",
]

return {
action: fetch_github_payload("pull_request", action + ".payload.json")
for action in actions
}


@pytest.fixture
def all_pull_request_payloads():
"""Fixture that provides all available pull_request payloads."""
return fetch_all_pull_request_payloads()


@pytest.fixture
def push_payload():
"""Get a valid GitHub push payload."""
return fetch_github_payload("push", "payload.json")


@pytest.fixture
def sample_pull_request_payload():
return {
"action": "opened",
"number": 123,
"pull_request": {
"number": 123,
"title": "Test PR",
"body": "Test PR body",
"head": {"ref": "feature-branch"},
"base": {"ref": "main"},
},
"repository": {"full_name": "owner/repo", "name": "repo", "owner": {"login": "owner"}},
}


@pytest.fixture
def custom_labels_config():
return {
"#bug": [Label(name="bug", color="d73a4a", description="Something isn't working")],
"#feature": [Label(name="feature", color="a2eeef", description="New feature")],
}


@pytest.fixture
def autocreate_pr_recipe(mock_github):
return AutoCreatePullRequest(mock_github)


@pytest.fixture
def custom_draft_label():
"""Custom draft label for testing."""
return Label(name="custom-draft", color="cccccc", description="Custom draft label")
162 changes: 162 additions & 0 deletions tests/recipes/test_autocreate_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from unittest.mock import MagicMock, patch

import pytest
from github.GithubException import GithubException


def test_autocreate_pr_events_property(autocreate_pr_recipe):
events = autocreate_pr_recipe.events
assert "push" in events
assert events["push"] == autocreate_pr_recipe._process_push


@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper")
def test_autocreate_pr_successful_creation(
mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper
):
mock_github_helper_class.return_value = mock_github_helper
mock_github_helper.repo.default_branch = "main"
mock_github_helper.repo.get_commits.return_value = [
MagicMock(commit=MagicMock(message="Test commit"))
]
mock_github_helper.repo.create_pull.return_value = MagicMock()

autocreate_pr_recipe._process_push(push_payload)

mock_github_helper_class.assert_called_once_with(
autocreate_pr_recipe.github, repo_fullname=push_payload["repository"]["full_name"]
)
mock_github_helper.raise_for_rate_excess.assert_called_once()
mock_github_helper.repo.create_pull.assert_called_once_with(
base="main",
head=push_payload["ref"],
title="Test commit",
body="Created by FastGitHub",
draft=True,
)


@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper")
def test_autocreate_pr_with_custom_parameters(
mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper
):
"""Test PR creation with custom parameters."""
# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_github_helper.repo.create_pull.return_value = MagicMock()

# Call with custom parameters
autocreate_pr_recipe._process_push(
push_payload,
base_branch="develop",
title="Custom Title",
body="Custom body",
as_draft=False,
)

# Verify PR creation with custom parameters
mock_github_helper.repo.create_pull.assert_called_once_with(
base="develop",
head=push_payload["ref"],
title="Custom Title",
body="Custom body",
draft=False,
)


@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper")
def test_autocreate_pr_github_exception_422_ignored(
mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper
):
"""Test that GithubException with status 422 is ignored (PR already exists)."""
# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_github_helper.repo.default_branch = "main"
mock_github_helper.repo.get_commits.return_value = [
MagicMock(commit=MagicMock(message="Test commit"))
]

# Mock GithubException with status 422
mock_github_helper.repo.create_pull.side_effect = GithubException(422, {}, {})

# Should not raise an exception
autocreate_pr_recipe._process_push(push_payload)

# Verify create_pull was called
mock_github_helper.repo.create_pull.assert_called_once()


@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper")
def test_autocreate_pr_github_exception_other_status_raised(
mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper
):
"""Test that GithubException with status other than 422 is raised."""
# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_github_helper.repo.default_branch = "main"
mock_github_helper.repo.get_commits.return_value = [
MagicMock(commit=MagicMock(message="Test commit"))
]

# Mock GithubException with status 500
mock_github_helper.repo.create_pull.side_effect = GithubException(500, {}, {})

# Should raise the exception
with pytest.raises(GithubException) as exc_info:
autocreate_pr_recipe._process_push(push_payload)

assert exc_info.value.status == 500


@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper")
def test_autocreate_pr_uses_commit_message_as_title_when_no_title_provided(
mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper
):
"""Test that commit message is used as title when no title is provided."""
# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_github_helper.repo.default_branch = "main"
mock_commit = MagicMock(commit=MagicMock(message="Amazing feature commit"))
mock_github_helper.repo.get_commits.return_value = [mock_commit]
mock_github_helper.repo.create_pull.return_value = MagicMock()

# Call without title parameter
autocreate_pr_recipe._process_push(push_payload)

# Verify get_commits was called to get the commit message
mock_github_helper.repo.get_commits.assert_called_once_with(sha=push_payload["ref"])

# Verify PR creation uses commit message as title
mock_github_helper.repo.create_pull.assert_called_once_with(
base="main",
head=push_payload["ref"],
title="Amazing feature commit",
body="Created by FastGitHub",
draft=True,
)


@patch("fastgithub.recipes.github.autocreate_pr.GithubHelper")
def test_autocreate_pr_uses_custom_base_branch(
mock_github_helper_class, autocreate_pr_recipe, push_payload, mock_github_helper
):
"""Test that custom base_branch is used instead of default branch."""
# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_github_helper.repo.default_branch = "main"
mock_github_helper.repo.get_commits.return_value = [
MagicMock(commit=MagicMock(message="Test commit"))
]
mock_github_helper.repo.create_pull.return_value = MagicMock()

# Call with custom base_branch
autocreate_pr_recipe._process_push(push_payload, base_branch="custom-branch")

# Verify PR creation uses custom base branch
mock_github_helper.repo.create_pull.assert_called_once_with(
base="custom-branch",
head=push_payload["ref"],
title="Test commit",
body="Created by FastGitHub",
draft=True,
)
65 changes: 65 additions & 0 deletions tests/recipes/test_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import pytest


@pytest.mark.parametrize(
"action",
[
"assigned",
"closed",
"converted_to_draft",
"labeled",
"locked",
"opened",
"ready_for_review",
"reopened",
"review_request_removed",
"review_requested",
"synchronize",
"unassigned",
"unlabeled",
"unlocked",
],
)
def test_pull_request_payload_structure(all_pull_request_payloads, action):
required_fields = ["action", "number", "repository"]
payload = all_pull_request_payloads[action]

for field in required_fields:
assert field in payload, f"Payload for action '{action}' missing required field '{field}'"
assert "full_name" in payload["repository"], (
f"Repository missing 'full_name' in action '{action}'"
)
assert payload["action"] == action, (
f"Payload action '{payload['action']}' doesn't match key '{action}'"
)
assert isinstance(payload["number"], int), f"PR number should be integer in action '{action}'"


def test_all_pull_request_payloads_actions_coverage(all_pull_request_payloads):
expected_actions = [
"assigned",
"closed",
"converted_to_draft",
"labeled",
"locked",
"opened",
"ready_for_review",
"reopened",
"review_request_removed",
"review_requested",
"synchronize",
"unassigned",
"unlabeled",
"unlocked",
]

found_actions = set(all_pull_request_payloads.keys())
common_actions = {"opened", "closed", "labeled", "unlabeled"}
assert common_actions.issubset(found_actions), (
f"Missing common actions. Found: {found_actions}"
)

print(f"Successfully fetched payloads for actions: {sorted(found_actions)}")
missing_actions = set(expected_actions) - found_actions
if missing_actions:
print(f"Missing payloads for actions: {sorted(missing_actions)}")
Loading
Loading