-
Notifications
You must be signed in to change notification settings - Fork 96
support virtual packages on generic git hosts (Gitea) #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4d5135f
cc79114
3dedd94
8cfcd22
13dbf73
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1629,5 +1629,147 @@ def test_try_raw_download_returns_content_on_200(self): | |
| assert result == b'hello world' | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Generic host (Gitea / GitLab) download tests | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _make_resp(status_code: int, content: bytes = b"") -> Mock: | ||
| """Build a minimal mock requests.Response.""" | ||
| resp = Mock() | ||
| resp.status_code = status_code | ||
| resp.content = content | ||
| if status_code >= 400: | ||
| resp.raise_for_status = Mock( | ||
| side_effect=requests_lib.exceptions.HTTPError(response=resp) | ||
| ) | ||
| else: | ||
| resp.raise_for_status = Mock() | ||
| return resp | ||
|
|
||
|
|
||
| class TestGiteaRawUrlDownload: | ||
| """Gitea raw URL path: /{owner}/{repo}/raw/{ref}/{file}.""" | ||
|
|
||
| def setup_method(self): | ||
| with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH: | ||
| self.downloader = GitHubPackageDownloader() | ||
|
|
||
| def test_raw_url_succeeds_on_first_attempt(self): | ||
| """Raw URL returns 200 -- content returned without calling the API.""" | ||
| dep_ref = DependencyReference.parse("gitea.myorg.com/owner/repo") | ||
| expected = b"# README content" | ||
| raw_ok = _make_resp(200, expected) | ||
|
|
||
| with patch.object(self.downloader, "_resilient_get", return_value=raw_ok) as mock_get: | ||
| result = self.downloader.download_raw_file(dep_ref, "README.md", "main") | ||
|
|
||
| assert result == expected | ||
| first_url = mock_get.call_args_list[0][0][0] | ||
| assert first_url == "https://gitea.myorg.com/owner/repo/raw/main/README.md" | ||
| assert mock_get.call_count == 1 | ||
|
|
||
| def test_raw_url_with_token_adds_auth_header(self): | ||
| """Token is forwarded as Authorization header in the raw URL request. | ||
|
|
||
| Token resolution is lazy, so the env patch must stay active for the | ||
| duration of the download call. | ||
| """ | ||
| dep_ref = DependencyReference.parse("gitea.myorg.com/owner/repo") | ||
| raw_ok = _make_resp(200, b"data") | ||
|
|
||
| with patch.dict(os.environ, {"GITHUB_APM_PAT": "gta-tok"}, clear=True): | ||
| with _CRED_FILL_PATCH: | ||
| downloader = GitHubPackageDownloader() | ||
| with patch.object(downloader, "_resilient_get", return_value=raw_ok) as mock_get: | ||
| downloader.download_raw_file(dep_ref, "README.md", "main") | ||
|
|
||
| raw_headers = mock_get.call_args_list[0][1].get("headers", {}) | ||
| assert "Authorization" in raw_headers | ||
|
|
||
| def test_falls_back_to_api_v1_when_raw_returns_non_200(self): | ||
| """When the raw URL returns 404, the API v1 path is tried next.""" | ||
| dep_ref = DependencyReference.parse("gitea.myorg.com/owner/repo") | ||
| expected = b"file via API" | ||
|
|
||
| with patch.object( | ||
| self.downloader, "_resilient_get", | ||
| side_effect=[_make_resp(404), _make_resp(200, expected)] | ||
| ) as mock_get: | ||
| result = self.downloader.download_raw_file(dep_ref, "README.md", "main") | ||
|
|
||
| assert result == expected | ||
| urls = [c[0][0] for c in mock_get.call_args_list] | ||
| assert urls[0] == "https://gitea.myorg.com/owner/repo/raw/main/README.md" | ||
| assert "/api/v1/" in urls[1] | ||
|
|
||
|
|
||
| class TestGitLabApiVersionNegotiation: | ||
| """API version negotiation: v1 -> v3 -> v4 for generic hosts.""" | ||
|
|
||
| def setup_method(self): | ||
| with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH: | ||
| self.downloader = GitHubPackageDownloader() | ||
|
|
||
| def test_gitlab_v4_reached_after_v1_and_v3_return_404(self): | ||
| """GitLab uses /api/v4/ -- negotiation must try v1, v3, then v4.""" | ||
| dep_ref = DependencyReference.parse("gitlab.myorg.com/owner/repo") | ||
| expected = b"gitlab file content" | ||
|
|
||
| side_effects = [ | ||
| _make_resp(404), # raw URL | ||
| _make_resp(404), # v1 | ||
| _make_resp(404), # v3 | ||
| _make_resp(200, expected), # v4 | ||
| ] | ||
| with patch.object(self.downloader, "_resilient_get", side_effect=side_effects) as mock_get: | ||
| result = self.downloader.download_raw_file(dep_ref, "skill.md", "main") | ||
|
|
||
| assert result == expected | ||
| urls = [c[0][0] for c in mock_get.call_args_list] | ||
| assert "/api/v1/" in urls[1] | ||
| assert "/api/v3/" in urls[2] | ||
| assert "/api/v4/" in urls[3] | ||
|
|
||
|
Comment on lines
+1706
to
+1732
|
||
| def test_gitea_v1_succeeds_without_trying_v3_or_v4(self): | ||
| """When v1 returns 200, v3 and v4 must never be called.""" | ||
| dep_ref = DependencyReference.parse("gitea.example.com/owner/repo") | ||
| expected = b"gitea content" | ||
|
|
||
| with patch.object( | ||
| self.downloader, "_resilient_get", | ||
| side_effect=[_make_resp(404), _make_resp(200, expected)] | ||
| ) as mock_get: | ||
| result = self.downloader.download_raw_file(dep_ref, "file.md", "main") | ||
|
|
||
| assert result == expected | ||
| urls = [c[0][0] for c in mock_get.call_args_list] | ||
| assert all("/api/v3/" not in u and "/api/v4/" not in u for u in urls) | ||
|
|
||
| def test_all_api_versions_404_raises_runtime_error(self): | ||
| """When every API version returns 404 for both refs, a clear error is raised.""" | ||
| dep_ref = DependencyReference.parse("git.example.com/owner/repo") | ||
| # raw + v1 + v3 + v4 for 'main', then v1 + v3 + v4 for 'master' fallback | ||
| side_effects = [_make_resp(404)] * 8 | ||
|
|
||
| with patch.object(self.downloader, "_resilient_get", side_effect=side_effects): | ||
| with pytest.raises(RuntimeError, match="File not found"): | ||
| self.downloader.download_raw_file(dep_ref, "missing.md", "main") | ||
|
|
||
| def test_github_com_uses_api_github_com_not_api_v4(self): | ||
| """github.com must still use api.github.com, never /api/v4/.""" | ||
| dep_ref = DependencyReference.parse("owner/repo") | ||
| expected = b"github content" | ||
| api_ok = _make_resp(200, expected) | ||
|
|
||
| with patch.object(self.downloader, "_try_raw_download", return_value=None): | ||
| with patch.object(self.downloader, "_resilient_get", return_value=api_ok) as mock_get: | ||
| result = self.downloader.download_raw_file(dep_ref, "README.md", "main") | ||
|
|
||
| assert result == expected | ||
| url_called = mock_get.call_args_list[0][0][0] | ||
| assert url_called.startswith("https://api.github.com/") | ||
| assert "/api/v4/" not in url_called | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| pytest.main([__file__]) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This PR changes how virtual packages are detected/handled for generic FQDN hosts (and introduces GitLab-specific nested-group behavior). The Starlight docs and the apm-guide usage doc currently document virtual package rules and the "dict form required when shorthand is ambiguous" note, but they don't describe the generic-host behavior being introduced here (e.g., whether subdirectory virtual packages are supported via shorthand on non-GitHub hosts, or require object form). Please update the relevant docs pages so users of Gitea/self-hosted hosts know which syntax is supported and when they must use the object form.