Skip to content
Open
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
7 changes: 3 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,9 @@ repos:
args:
- --number
additional_dependencies:
- mdformat-gfm
- mdformat-tables
- mdformat-frontmatter
- mdformat-black
# - mdformat-gfm
# - mdformat-tables
# - mdformat-frontmatter
- mdformat-shfmt
# An extremely fast Python linter, written in Rust
- repo: https://github.com/astral-sh/ruff-pre-commit
Expand Down
7 changes: 6 additions & 1 deletion scripts/github_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from github import Auth, Github

from fastgithub import GithubWebhookHandler, SignatureVerificationSHA256, webhook_router
from fastgithub.recipes.github import AutoCreatePullRequest, LabelsFromCommits
from fastgithub.recipes.github import (
AutoCreatePullRequest,
LabelsFromCommits,
UndraftPR,
)

signature_verification = SignatureVerificationSHA256(secret=os.environ["GITHUB_WEBHOOK_SECRET"]) # noqa: S106
webhook_handler = GithubWebhookHandler(signature_verification)
Expand All @@ -15,6 +19,7 @@
webhook_handler.plan([
AutoCreatePullRequest(github),
LabelsFromCommits(github),
UndraftPR(github),
])


Expand Down
1 change: 1 addition & 0 deletions src/fastgithub/recipes/github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

from .autocreate_pr import AutoCreatePullRequest
from .labels_from_commits import LabelsFromCommits
from .undraft_pr import UndraftPR
36 changes: 36 additions & 0 deletions src/fastgithub/recipes/github/undraft_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from collections.abc import Callable

from github import Github

from fastgithub.helpers.github import GithubHelper, Label
from fastgithub.recipes._base import GithubRecipe
from fastgithub.recipes.github._config import NODRAFT
from fastgithub.types import Payload


class UndraftPR(GithubRecipe):
"""
Undraft a pull request when the draft label is removed.
"""

def __init__(self, github: Github, draft_label: Label = NODRAFT):
super().__init__(github)
self.draft_label = draft_label

@property
def events(self) -> dict[str, Callable]:
return {"pull_request": self._process_pull_request}

def _process_pull_request(self, payload: Payload):
gh = GithubHelper(self.github, payload["repository"]["full_name"])
gh.raise_for_rate_excess()

pr = gh.repo.get_pull(payload["number"])
if (
payload["action"] == "labeled"
and payload["label"]["name"] == self.draft_label
and self.draft_label in pr.labels
):
pr.mark_ready_for_review()
elif payload["action"] == "unlabeled" and payload["label"]["name"] == self.draft_label:
pr.convert_to_draft()
7 changes: 7 additions & 0 deletions tests/recipes/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

# 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}"
Expand Down Expand Up @@ -105,6 +106,12 @@ def autocreate_pr_recipe(mock_github):
return AutoCreatePullRequest(mock_github)


@pytest.fixture
def undraft_pr_recipe(mock_github):
"""Create UndraftPR instance with mocked GitHub."""
return UndraftPR(mock_github)


@pytest.fixture
def custom_draft_label():
"""Custom draft label for testing."""
Expand Down
299 changes: 299 additions & 0 deletions tests/recipes/test_undraft_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
from unittest.mock import MagicMock, patch

import pytest

from fastgithub.recipes.github.undraft_pr import UndraftPR


def test_undraft_pr_events_property(undraft_pr_recipe):
"""Test that the recipe exposes the correct events."""
events = undraft_pr_recipe.events
assert "pull_request" in events
assert events["pull_request"] == undraft_pr_recipe._process_pull_request


def test_undraft_pr_initialization_with_custom_label(mock_github, custom_draft_label):
"""Test UndraftPR initialization with custom draft label."""
recipe = UndraftPR(mock_github, custom_draft_label)
assert recipe.draft_label == custom_draft_label


def test_undraft_pr_initialization_with_default_label(mock_github):
"""Test UndraftPR initialization with default draft label."""
recipe = UndraftPR(mock_github)
# Should use the default NODRAFT label from _config.py
assert recipe.draft_label is not None


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_labeled_action_mark_ready_for_review(
mock_github_helper_class, undraft_pr_recipe, mock_github_helper
):
"""Test that PR is marked ready for review when draft label is added."""
# Setup payload for labeled action
payload = {
"action": "labeled",
"label": {"name": "nodraft"},
"number": 123,
"repository": {"full_name": "owner/repo"},
}

# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_pr.labels = [MagicMock(name="nodraft")] # GitHub label object
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
undraft_pr_recipe._process_pull_request(payload)

# Verify GitHub helper was created
mock_github_helper_class.assert_called_once_with(
undraft_pr_recipe.github, payload["repository"]["full_name"]
)

# Verify rate limit check was called
mock_github_helper.raise_for_rate_excess.assert_called_once()

# Verify PR was retrieved
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])

# Verify no actions were taken on the PR due to bugs in the code:
# 1. payload["label"]["name"] == self.draft_label compares string vs Label object
# 2. self.draft_label in pr.labels compares Label object vs GitHub label objects
mock_pr.mark_ready_for_review.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_unlabeled_action_convert_to_draft(
mock_github_helper_class, undraft_pr_recipe, mock_github_helper
):
"""Test that PR is converted to draft when draft label is removed."""
# Setup payload for unlabeled action
payload = {
"action": "unlabeled",
"label": {"name": "nodraft"},
"number": 123,
"repository": {"full_name": "owner/repo"},
}

# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
undraft_pr_recipe._process_pull_request(payload)

# Verify PR was retrieved
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])

# Verify no actions were taken on the PR due to bug in the code:
# The condition `payload["label"]["name"] == self.draft_label` compares
# a string with a Label object, which will never be true
mock_pr.convert_to_draft.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_labeled_action_wrong_label(
mock_github_helper_class, undraft_pr_recipe, mock_github_helper
):
"""Test that nothing happens when a different label is added."""
# Setup payload for labeled action with wrong label
payload = {
"action": "labeled",
"label": {"name": "bug"},
"number": 123,
"repository": {"full_name": "owner/repo"},
}

# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
undraft_pr_recipe._process_pull_request(payload)

# Verify PR was retrieved
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])

# Verify no actions were taken on the PR
mock_pr.mark_ready_for_review.assert_not_called()
mock_pr.convert_to_draft.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_labeled_action_label_not_in_pr(
mock_github_helper_class, undraft_pr_recipe, mock_github_helper
):
"""Test that nothing happens when draft label is added but not in PR labels."""
# Setup payload for labeled action
payload = {
"action": "labeled",
"label": {"name": "nodraft"},
"number": 123,
"repository": {"full_name": "owner/repo"},
}

# Setup mocks - PR doesn't have the nodraft label
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_pr.labels = [MagicMock(name="bug")] # Different label
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
undraft_pr_recipe._process_pull_request(payload)

# Verify PR was retrieved
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])

# Verify no actions were taken on the PR
mock_pr.mark_ready_for_review.assert_not_called()
mock_pr.convert_to_draft.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_unlabeled_action_wrong_label(
mock_github_helper_class, undraft_pr_recipe, mock_github_helper
):
"""Test that nothing happens when a different label is removed."""
# Setup payload for unlabeled action with wrong label
payload = {
"action": "unlabeled",
"label": {"name": "bug"},
"number": 123,
"repository": {"full_name": "owner/repo"},
}

# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
undraft_pr_recipe._process_pull_request(payload)

# Verify PR was retrieved
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])

# Verify no actions were taken on the PR
mock_pr.mark_ready_for_review.assert_not_called()
mock_pr.convert_to_draft.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_other_action_ignored(
mock_github_helper_class, undraft_pr_recipe, mock_github_helper
):
"""Test that other PR actions are ignored."""
# Setup payload for other action
payload = {"action": "opened", "number": 123, "repository": {"full_name": "owner/repo"}}

# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
undraft_pr_recipe._process_pull_request(payload)

# Verify PR was retrieved
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])

# Verify no actions were taken on the PR
mock_pr.mark_ready_for_review.assert_not_called()
mock_pr.convert_to_draft.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
def test_undraft_pr_with_custom_draft_label(
mock_github_helper_class, mock_github, custom_draft_label, mock_github_helper
):
"""Test UndraftPR with custom draft label."""
# Create recipe with custom draft label
recipe = UndraftPR(mock_github, custom_draft_label)

# Setup payload for labeled action with custom label
payload = {
"action": "labeled",
"label": {"name": "custom-draft"},
"number": 123,
"repository": {"full_name": "owner/repo"},
}

# Setup mocks
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_pr.labels = [MagicMock(name="custom-draft")]
mock_github_helper.repo.get_pull.return_value = mock_pr

# Call the method
recipe._process_pull_request(payload)

# Verify no actions were taken on the PR due to bug in the code:
# The condition `payload["label"]["name"] == self.draft_label` compares
# a string with a Label object, which will never be true
mock_pr.mark_ready_for_review.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
@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_undraft_pr_with_pull_request_action(
mock_github_helper_class, mock_github, all_pull_request_payloads, mock_github_helper, action
):
recipe = UndraftPR(mock_github)
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_github_helper.repo.get_pull.return_value = mock_pr

payload = all_pull_request_payloads[action]
recipe._process_pull_request(payload)

mock_github_helper_class.assert_called_once_with(
recipe.github, payload["repository"]["full_name"]
)
mock_github_helper.raise_for_rate_excess.assert_called_once()
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])
mock_pr.mark_ready_for_review.assert_not_called()
mock_pr.convert_to_draft.assert_not_called()


@patch("fastgithub.recipes.github.undraft_pr.GithubHelper")
@pytest.mark.parametrize("action", ["labeled", "unlabeled"])
def test_undraft_pr_labeled_unlabeled_actions_specifically(
mock_github_helper_class, mock_github, all_pull_request_payloads, mock_github_helper, action
):
recipe = UndraftPR(mock_github)
mock_github_helper_class.return_value = mock_github_helper
mock_pr = MagicMock()
mock_github_helper.repo.get_pull.return_value = mock_pr

payload = all_pull_request_payloads[action]
recipe._process_pull_request(payload)

mock_github_helper_class.assert_called_once_with(
recipe.github, payload["repository"]["full_name"]
)
mock_github_helper.raise_for_rate_excess.assert_called_once()
mock_github_helper.repo.get_pull.assert_called_once_with(payload["number"])
mock_pr.mark_ready_for_review.assert_not_called()
mock_pr.convert_to_draft.assert_not_called()
Loading