From 872d757bb5f2acf24723f7fa1e93df188a8ae565 Mon Sep 17 00:00:00 2001 From: Emil Simeonov Date: Sat, 19 Sep 2026 21:10:42 +0200 Subject: [PATCH] fix(ci): create dep-PR branches via POST /branches Gitea lacks the POST /git/refs endpoint (HTTP 405). Use the branches API with new_branch_name/old_branch_name; tolerate already-exists. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/specs/DEVX-169.md | 33 +++++++++ src/devx/ci/create_dependency_pr.py | 18 ++--- tests/unit/test_create_dependency_pr.py | 94 ++++++++++++++++--------- 3 files changed, 101 insertions(+), 44 deletions(-) create mode 100644 docs/specs/DEVX-169.md diff --git a/docs/specs/DEVX-169.md b/docs/specs/DEVX-169.md new file mode 100644 index 0000000..6541ff1 --- /dev/null +++ b/docs/specs/DEVX-169.md @@ -0,0 +1,33 @@ +# DEVX-169: create_dependency_pr uses branches API + +## Problem + +After DEVX-168, dep-PR creation fails at `POST /git/refs` with +`HTTP 405 Method Not Allowed` — this Gitea version does not implement the +createRef endpoint. Observed creating the sso_bridge 0.4.1 infra +dependency PR. + +## Approach + +REQ-1: Create the dependency branch via `POST /branches` with +`new_branch_name`/`old_branch_name` (from master). An already-exists +error is tolerated; other API errors fail closed. + +## Test Plan + +- Branch creation calls `POST /branches` with the expected payload. +- 422 already-exists is tolerated and the PR is still created. +- Other API errors abort with a branch-creation failure. + +## Deploy Plan + +devx release tag; producers pick it up via pin bumps. + +## Rollback Plan + +Revert; dep-PR creation stays broken (status quo). + +## Acceptance Criteria + +- [x] REQ-1: branch creation uses the branches API; already-exists + tolerated; failures surface. Covered by unit tests at 100% coverage. diff --git a/src/devx/ci/create_dependency_pr.py b/src/devx/ci/create_dependency_pr.py index 3abead9..c84195a 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -344,17 +344,13 @@ def cli( # Create branch via API try: - master_ref = client._request("GET", "/git/refs/heads/master").json() - # Implements: REQ-1 — Gitea returns a list of matching refs - if isinstance(master_ref, list): - master_ref = next( - (r for r in master_ref if r.get("ref") == "refs/heads/master"), - master_ref[0] if master_ref else {}, - ) - master_sha = master_ref.get("object", {}).get("sha", "") - if not master_sha: - raise click.ClickException("Could not get master SHA") - client._request("POST", "/git/refs", json={"ref": f"refs/heads/{branch_name}", "sha": master_sha}) + # Implements: REQ-1 — Gitea lacks POST /git/refs; create the branch + # from master via the branches API. + client._request( + "POST", + "/branches", + json={"new_branch_name": branch_name, "old_branch_name": base_branch}, + ) except APIError as e: if "already exists" in str(e).lower(): click.echo(f"[dep-pr] Branch {branch_name} already exists") diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index 5682c33..af84b09 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -462,10 +462,10 @@ class TestCliManifestMode: assert args[1:4] == ("oblachno", "sso-bridge", "0.2.4") -class TestMasterRefParsing: - """Gitea's /git/refs/heads/ returns a list, not an object.""" +class TestBranchCreation: + """Branch creation uses the branches API (Gitea lacks POST /git/refs).""" - def _invoke(self, client: MagicMock) -> object: + def _invoke(self) -> object: runner = CliRunner() return runner.invoke( cli, @@ -481,31 +481,10 @@ class TestMasterRefParsing: ], ) - @patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None) - @patch("devx.ci.create_dependency_pr.update_manifest", return_value=True) - @patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0") - @patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None) - @patch("devx.ci.create_dependency_pr.GiteaClient") - @patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok") - def test_list_shaped_ref_response( - self, - _token: MagicMock, - mock_client_cls: MagicMock, - _find: MagicMock, - _read: MagicMock, - _update: MagicMock, - _task: MagicMock, - ) -> None: + def _client(self, mock_client_cls: MagicMock) -> MagicMock: client = mock_client_cls.return_value - resp = MagicMock() - resp.json.return_value = [{"ref": "refs/heads/master", "object": {"sha": "abc123"}}] - client._request.return_value = resp client.create_pr.return_value = {"number": 1} - result = self._invoke(client) - assert result.exit_code == 0 - assert "Created PR" in result.output - post = client._request.call_args_list[-1] - assert post.kwargs["json"]["sha"] == "abc123" + return client @patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None) @patch("devx.ci.create_dependency_pr.update_manifest", return_value=True) @@ -513,7 +492,7 @@ class TestMasterRefParsing: @patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None) @patch("devx.ci.create_dependency_pr.GiteaClient") @patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok") - def test_empty_ref_list_fails( + def test_creates_branch_via_branches_api( self, _token: MagicMock, mock_client_cls: MagicMock, @@ -522,10 +501,59 @@ class TestMasterRefParsing: _update: MagicMock, _task: MagicMock, ) -> None: - client = mock_client_cls.return_value - resp = MagicMock() - resp.json.return_value = [] - client._request.return_value = resp - result = self._invoke(client) + client = self._client(mock_client_cls) + result = self._invoke() + assert result.exit_code == 0 + assert "Created PR" in result.output + post = client._request.call_args + assert post.args[:2] == ("POST", "/branches") + assert post.kwargs["json"] == { + "new_branch_name": "deps/sso_bridge-0.9.1", + "old_branch_name": "master", + } + + @patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None) + @patch("devx.ci.create_dependency_pr.update_manifest", return_value=True) + @patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0") + @patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None) + @patch("devx.ci.create_dependency_pr.GiteaClient") + @patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok") + def test_existing_branch_tolerated( + self, + _token: MagicMock, + mock_client_cls: MagicMock, + _find: MagicMock, + _read: MagicMock, + _update: MagicMock, + _task: MagicMock, + ) -> None: + from devx.exceptions import APIError + + client = self._client(mock_client_cls) + client._request.side_effect = APIError(422, "branch already exists") + result = self._invoke() + assert result.exit_code == 0 + assert "already exists" in result.output.lower() + + @patch("devx.ci.create_dependency_pr.create_vikunja_task", return_value=None) + @patch("devx.ci.create_dependency_pr.update_manifest", return_value=True) + @patch("devx.ci.create_dependency_pr.read_manifest_version", return_value="0.9.0") + @patch("devx.ci.create_dependency_pr.find_existing_pr", return_value=None) + @patch("devx.ci.create_dependency_pr.GiteaClient") + @patch("devx.ci.create_dependency_pr.get_ci_token", return_value="tok") + def test_branch_api_error_fails( + self, + _token: MagicMock, + mock_client_cls: MagicMock, + _find: MagicMock, + _read: MagicMock, + _update: MagicMock, + _task: MagicMock, + ) -> None: + from devx.exceptions import APIError + + client = self._client(mock_client_cls) + client._request.side_effect = APIError(500, "boom") + result = self._invoke() assert result.exit_code != 0 - assert "master SHA" in str(result.exception) or "master SHA" in result.output + assert "Failed to create branch" in result.output -- 2.54.0