diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca2ba3d..a85a3c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/scripts/github_app.py b/scripts/github_app.py index f143a64..44141c8 100644 --- a/scripts/github_app.py +++ b/scripts/github_app.py @@ -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) @@ -15,6 +19,7 @@ webhook_handler.plan([ AutoCreatePullRequest(github), LabelsFromCommits(github), + UndraftPR(github), ]) diff --git a/src/fastgithub/recipes/github/__init__.py b/src/fastgithub/recipes/github/__init__.py index 7a7b7f9..2e93c7f 100644 --- a/src/fastgithub/recipes/github/__init__.py +++ b/src/fastgithub/recipes/github/__init__.py @@ -2,3 +2,4 @@ from .autocreate_pr import AutoCreatePullRequest from .labels_from_commits import LabelsFromCommits +from .undraft_pr import UndraftPR diff --git a/src/fastgithub/recipes/github/undraft_pr.py b/src/fastgithub/recipes/github/undraft_pr.py new file mode 100644 index 0000000..114a889 --- /dev/null +++ b/src/fastgithub/recipes/github/undraft_pr.py @@ -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() diff --git a/tests/recipes/conftest.py b/tests/recipes/conftest.py index 7303e65..51fcb59 100644 --- a/tests/recipes/conftest.py +++ b/tests/recipes/conftest.py @@ -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}" @@ -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.""" diff --git a/tests/recipes/test_undraft_pr.py b/tests/recipes/test_undraft_pr.py new file mode 100644 index 0000000..5a195a1 --- /dev/null +++ b/tests/recipes/test_undraft_pr.py @@ -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()