From a68df7d785dfb8b512077ba0a3b7df75a55cea68 Mon Sep 17 00:00:00 2001 From: kireto Date: Sat, 19 Sep 2026 19:06:07 +0000 Subject: [PATCH] DEVX-168: fix(ci): handle list-shaped ref response in create_dependency_pr --- docs/specs/DEVX-168.md | 35 +++++++++++++ src/devx/ci/create_dependency_pr.py | 6 +++ tests/unit/test_create_dependency_pr.py | 69 +++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 docs/specs/DEVX-168.md diff --git a/docs/specs/DEVX-168.md b/docs/specs/DEVX-168.md new file mode 100644 index 0000000..fbd1f06 --- /dev/null +++ b/docs/specs/DEVX-168.md @@ -0,0 +1,35 @@ +# DEVX-168: Fix create_dependency_pr master-ref parsing + +## Problem + +`devx.ci.create_dependency_pr` crashes with +`AttributeError: 'list' object has no attribute 'get'` when creating the +dependency branch. Gitea's `GET /repos/{o}/{r}/git/refs/heads/master` +returns a JSON **array** of matching refs, not a single object. Observed +in production when sso-bridge v0.4.1's post-merge dep-PR step ran. + +## Approach + +REQ-1: Normalize the ref response — if it is a list, select the entry +whose `ref` field equals `refs/heads/master` or fall back to the first +entry; proceed to extract `object.sha` as before. An empty or absent +SHA still fails closed. + +## Test Plan + +- Unit test: list-shaped response resolves SHA and creates the PR. +- Unit test: empty list fails with the master-SHA error message. + +## Deploy Plan + +devx releases as a version tag; sso-bridge/infra pin bumps pick it up via +their normal dep-PR flow. + +## Rollback Plan + +Revert the commit; dep-PR creation stays broken on Gitea (status quo). + +## Acceptance Criteria + +- [x] REQ-1: list-shaped ref response is handled; SHA extraction works; + empty list still errors. 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 9efbe08..3abead9 100644 --- a/src/devx/ci/create_dependency_pr.py +++ b/src/devx/ci/create_dependency_pr.py @@ -345,6 +345,12 @@ 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") diff --git a/tests/unit/test_create_dependency_pr.py b/tests/unit/test_create_dependency_pr.py index 5c4525a..5682c33 100644 --- a/tests/unit/test_create_dependency_pr.py +++ b/tests/unit/test_create_dependency_pr.py @@ -460,3 +460,72 @@ class TestCliManifestMode: assert result.exit_code == 0 args = mock_digest.call_args[0] assert args[1:4] == ("oblachno", "sso-bridge", "0.2.4") + + +class TestMasterRefParsing: + """Gitea's /git/refs/heads/ returns a list, not an object.""" + + def _invoke(self, client: MagicMock) -> object: + runner = CliRunner() + return runner.invoke( + cli, + [ + "--package", + "sso_bridge", + "--new-version", + "0.9.1", + "--source-repo", + "oblachno/sso-bridge", + "--manifest", + "deploy/m.json", + ], + ) + + @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: + 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" + + @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_empty_ref_list_fails( + self, + _token: MagicMock, + mock_client_cls: MagicMock, + _find: MagicMock, + _read: MagicMock, + _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) + assert result.exit_code != 0 + assert "master SHA" in str(result.exception) or "master SHA" in result.output