diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml index 1b1b11f..aa1b3ea 100644 --- a/.gitea/workflows/auto-merge.yml +++ b/.gitea/workflows/auto-merge.yml @@ -6,18 +6,19 @@ on: jobs: merge: - if: github.event.label.name == 'ready-to-merge' runs-on: docker steps: - uses: actions/checkout@v4 - name: Install dependencies - run: python3 -m pip install requests + run: python3 -m pip install --break-system-packages requests python-dotenv click - name: Squash merge with task ID env: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + PYTHONPATH: src run: | python3 scripts/auto_merge.py \ "${{ github.head_ref }}" \ "${{ github.event.pull_request.title }}" \ "${{ github.repository }}" \ - "${{ github.event.number }}" + "${{ github.event.number }}" \ + "${{ github.event.label.name }}" diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index d7a5173..8f70484 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -12,10 +12,11 @@ jobs: with: fetch-depth: 0 - name: Install dependencies - run: python3 -m pip install requests + run: python3 -m pip install --break-system-packages requests python-dotenv click - name: Update Vikunja task env: VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} + PYTHONPATH: src run: | python3 scripts/post_merge.py \ "$(git log -1 --pretty=%B)" \ diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 5ef5585..91d7bd4 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -12,11 +12,12 @@ jobs: - uses: actions/checkout@v4 - name: Install build tools run: | - python3 -m pip install build twine requests + python3 -m pip install --break-system-packages build twine requests python-dotenv click - name: Build and publish release env: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + PYTHONPATH: src run: | python3 scripts/publish.py \ "${{ github.ref_name }}" \ diff --git a/scripts/auto_merge.py b/scripts/auto_merge.py index ac55780..fa6c44a 100644 --- a/scripts/auto_merge.py +++ b/scripts/auto_merge.py @@ -2,7 +2,7 @@ """Auto-merge PR by extracting task ID from branch and validating PR title. Usage: - REPO_TOKEN= python3 scripts/auto_merge.py + REPO_TOKEN= python3 scripts/auto_merge.py [label_name] """ import os @@ -15,6 +15,8 @@ from gitea_runner_manager.config import CONVENTIONAL_RE, GITEA_API_URL, TASK_ID_ from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ +READY_TO_MERGE = "ready-to-merge" + load_dotenv(override=True) @@ -37,16 +39,31 @@ def validate_pr_title(pr_title: str) -> None: ) +def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool: + """Check whether the PR has the ready-to-merge label via the API.""" + labels = client.get_pr_labels(pr_number) + return any(label.get("name") == READY_TO_MERGE for label in labels) + + @click.command() @click.argument("branch") @click.argument("pr_title") @click.argument("repo") @click.argument("pr_number") -def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: +@click.argument("label_name", required=False, default="") +def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str) -> None: token = os.environ.get("REPO_TOKEN", "") if not token: raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) + owner, repo_name = repo.split("/") + client = GiteaClient(GITEA_API_URL, token, owner, repo_name) + + # Gitea Actions may not populate github.event.label.name; fall back to API check. + if label_name != READY_TO_MERGE and not has_ready_to_merge_label(client, pr_number): + click.echo(_("Label '{label}' is not '{rtm}', skipping.", label=label_name, rtm=READY_TO_MERGE)) + return + task_id = extract_task_id(branch) if not task_id: raise click.ClickException( @@ -59,8 +76,6 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: validate_pr_title(pr_title) merge_title = f"{task_id}: {pr_title}" - owner, repo_name = repo.split("/") - client = GiteaClient(GITEA_API_URL, token, owner, repo_name) try: client.merge_pr(pr_number, merge_title) diff --git a/src/gitea_runner_manager/api_clients.py b/src/gitea_runner_manager/api_clients.py index 8497eae..eaa423a 100644 --- a/src/gitea_runner_manager/api_clients.py +++ b/src/gitea_runner_manager/api_clients.py @@ -107,6 +107,11 @@ class GiteaClient: # -- pulls / releases -- + def get_pr_labels(self, pr_number: str | int) -> list[dict[str, Any]]: + """Fetch labels currently attached to a pull request.""" + r = self._request("GET", f"/issues/{pr_number}/labels") + return r.json() + def merge_pr(self, pr_number: str | int, merge_title: str) -> None: payload = {"Do": "squash", "MergeTitleField": merge_title} self._request("POST", f"/pulls/{pr_number}/merge", json=payload) diff --git a/tests/unit/test_api_clients.py b/tests/unit/test_api_clients.py index 85af19a..53137d9 100644 --- a/tests/unit/test_api_clients.py +++ b/tests/unit/test_api_clients.py @@ -189,6 +189,18 @@ class TestGiteaClient: json={"Do": "squash", "MergeTitleField": "fix: bug"}, ) + def test_get_pr_labels(self) -> None: + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}])) + + result = client.get_pr_labels(5) + assert result == [{"name": "ready-to-merge"}] + client._session.request.assert_called_once_with( + "GET", + "https://git.example.com/repos/owner/repo/issues/5/labels", + timeout=DEFAULT_TIMEOUT, + ) + def test_update_repo_settings(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client._session.request = MagicMock(return_value=_mock_response({"default_delete_branch_after_merge": True})) diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 4a4e80c..8bdbf0a 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -9,7 +9,7 @@ from click.testing import CliRunner from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE from gitea_runner_manager.exceptions import APIError -from scripts.auto_merge import extract_task_id, main, validate_pr_title +from scripts.auto_merge import extract_task_id, has_ready_to_merge_label, main, validate_pr_title class TestRegexes: @@ -53,40 +53,111 @@ class TestValidatePrTitle: assert "conventional" in str(exc.value) +class TestHasReadyToMergeLabel: + def test_label_present(self) -> None: + client = MagicMock() + client.get_pr_labels.return_value = [{"name": "bug"}, {"name": "ready-to-merge"}] + assert has_ready_to_merge_label(client, "5") is True + + def test_label_absent(self) -> None: + client = MagicMock() + client.get_pr_labels.return_value = [{"name": "bug"}] + assert has_ready_to_merge_label(client, "5") is False + + def test_no_labels(self) -> None: + client = MagicMock() + client.get_pr_labels.return_value = [] + assert has_ready_to_merge_label(client, "5") is False + + class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch("scripts.auto_merge.GiteaClient") - def test_successful_flow(self, mock_client_cls: MagicMock) -> None: + def test_successful_flow_with_label_arg(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client runner = CliRunner() + result = runner.invoke( + main, + ["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "ready-to-merge"], + ) + assert result.exit_code == 0 + assert "squash-merged" in result.output + mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout") + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("scripts.auto_merge.GiteaClient") + def test_successful_flow_label_fallback(self, mock_client_cls: MagicMock) -> None: + """Label not passed via arg, but PR has ready-to-merge via API.""" + mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] + mock_client_cls.return_value = mock_client + runner = CliRunner() result = runner.invoke( main, ["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7"], ) assert result.exit_code == 0 assert "squash-merged" in result.output - mock_client_cls.assert_called_once() mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout") + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("scripts.auto_merge.GiteaClient") + def test_wrong_label_skips_merge(self, mock_client_cls: MagicMock) -> None: + """Label is not ready-to-merge and PR doesn't have it via API either.""" + mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "bug"}] + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke( + main, + ["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", "bug"], + ) + assert result.exit_code == 0 + assert "skipping" in result.output + mock_client.merge_pr.assert_not_called() + + @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) + @patch("scripts.auto_merge.GiteaClient") + def test_empty_label_falls_back_to_api(self, mock_client_cls: MagicMock) -> None: + """Gitea Actions doesn't populate label name, but API shows ready-to-merge.""" + mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] + mock_client_cls.return_value = mock_client + runner = CliRunner() + result = runner.invoke( + main, + ["GRM-19-fix-bug", "fix: resolve timeout", "owner/repo", "7", ""], + ) + assert result.exit_code == 0 + assert "squash-merged" in result.output + @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) def test_missing_token_exits(self) -> None: runner = CliRunner() - result = runner.invoke(main, ["branch", "title", "repo", "1"]) + result = runner.invoke(main, ["branch", "title", "owner/repo", "1"]) assert result.exit_code == 1 assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - def test_missing_task_id_exits(self) -> None: + @patch("scripts.auto_merge.GiteaClient") + def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] + mock_client_cls.return_value = mock_client runner = CliRunner() - result = runner.invoke(main, ["feature-no-id", "fix: bug", "repo", "1"]) + result = runner.invoke(main, ["feature-no-id", "fix: bug", "owner/repo", "1"]) assert result.exit_code == 1 assert "task ID" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - def test_invalid_pr_title_exits(self) -> None: + @patch("scripts.auto_merge.GiteaClient") + def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None: + mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] + mock_client_cls.return_value = mock_client runner = CliRunner() - result = runner.invoke(main, ["GRM-19-fix", "random title", "repo", "1"]) + result = runner.invoke(main, ["GRM-19-fix", "random title", "owner/repo", "1"]) assert result.exit_code == 1 assert "conventional" in result.output @@ -94,6 +165,7 @@ class TestMain: @patch("scripts.auto_merge.GiteaClient") def test_merge_pr_failure_raises_click(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client_cls.return_value = mock_client runner = CliRunner() @@ -105,6 +177,7 @@ class TestMain: @patch("scripts.auto_merge.GiteaClient") def test_merge_pr_json_parse_failure(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() + mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway") mock_client_cls.return_value = mock_client runner = CliRunner()