diff --git a/CHANGELOG.md b/CHANGELOG.md index d007d98bc..c90639107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Gemini CLI** as a supported APM target (`--target gemini`). APM auto-detects `.gemini/` directories and writes MCP server configuration to `.gemini/settings.json`. Includes `apm runtime setup gemini` / `apm runtime remove gemini` support. (#917) +- New `pr-description-skill` skill bundle: enforces a 10-section PR body shape (TL;DR / Problem / Approach / Implementation / Diagrams / Trade-offs / Benefits / Validation / How to test, plus the `Co-authored-by` trailer) with a cite-or-omit rule for every WHY-claim, GFM-rendered output, ASCII-only template source, and validated mermaid diagrams. Captures the meta-pattern from PR #882 as a reusable scaffold so future PR bodies meet the same bar without per-PR specialist subagent intervention. (#884) +- `apm experimental` command group -- a feature-flag registry with `list` / `enable` / `disable` / `reset` subcommands. Opt in to new behaviour before it graduates to default. Ships with one built-in flag (`verbose-version`) and a contributor recipe for proposing new flags. (#849) +- `includes:` manifest field (auto | list) for explicit governance of local `.apm/` content. Closes audit-blindness gap (#887). +- `apm audit --ci` now verifies hash integrity of locally deployed files, detecting hand-edits and config drift. (#887) +- `policy.manifest.require_explicit_includes` policy field enforces explicit `includes` lists (rejects `auto` + undeclared). (#887) +- `includes-consent` advisory appears in `apm audit` CLI/JSON output when local content is deployed without an explicit `includes:` declaration (#887) +- `apm-primitives-architect` agent: reusable persona for designing and critiquing `.apm/` skill bundles. (#882) +- `apm-triage-panel` skill: three-persona panel (DevX UX, Supply Chain Security, APM CEO; conditional OSS Growth Hacker) for issue triage producing single labelled-decision comment with structured JSON tail. Mirrors `apm-review-panel` orchestration model. (#915) +- CI: add `APM Self-Check` to `ci.yml` for `apm audit --ci`, regeneration-drift validation, and `merge-gate.yml` `EXPECTED_CHECKS` coverage. (#885) +- Virtual package support for self-hosted Git services (Gitea, Gogs): `apm install` now resolves subdirectory packages and raw file downloads from generic Git hosts via raw URL and API version negotiation (v1/v3). GitLab nested-group paths (`group/subgroup/repo`) are treated as full repo URLs (dict form required for virtual packages). -- by @ganesanviji (#587) - **Gemini CLI** as a supported APM target (`--target gemini`): auto-detects `.gemini/`, writes MCP config to `.gemini/settings.json`, and adds `apm runtime setup|remove gemini`. (#917) - Experimental `cowork` target for Microsoft 365 Copilot Cowork custom-skill deployment via OneDrive (`apm experimental enable cowork`; `apm install --target cowork --global`; persisted via `apm config set cowork-skills-dir`). (#913) - `apm experimental` command group (`list` / `enable` / `disable` / `reset`) lets you opt into new behaviour before it graduates to default. Ships with the `verbose-version` flag. (#849) diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index ed7bab90f..9de5ddd90 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -1591,15 +1591,39 @@ def _download_github_file(self, dep_ref: DependencyReference, file_path: str, re return content # All raw attempts failed — fall through to API path which # handles private repos, rate-limit messaging, and SAML errors. - + + # Try raw URL for generic hosts (Gitea, GitLab, etc.) + if host.lower() not in ("github.com",) and not host.lower().endswith(".ghe.com"): + raw_url = f"https://{host}/{owner}/{repo}/raw/{ref}/{file_path}" + raw_headers = {} + if token: + raw_headers['Authorization'] = f'token {token}' + try: + response = self._resilient_get(raw_url, headers=raw_headers, timeout=30) + if response.status_code == 200: + if verbose_callback: + verbose_callback(f"Downloaded file: {host}/{dep_ref.repo_url}/{file_path}") + return response.content + except (requests.RequestException, OSError): + pass + # --- Contents API path (authenticated, enterprise, or raw fallback) --- - # Build GitHub API URL - format differs by host type + # Build API URL candidates - format differs by host type if host == "github.com": - api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={ref}" + api_url_candidates = [ + f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={ref}" + ] elif host.lower().endswith(".ghe.com"): - api_url = f"https://api.{host}/repos/{owner}/{repo}/contents/{file_path}?ref={ref}" + api_url_candidates = [ + f"https://api.{host}/repos/{owner}/{repo}/contents/{file_path}?ref={ref}" + ] else: - api_url = f"https://{host}/api/v3/repos/{owner}/{repo}/contents/{file_path}?ref={ref}" + # Generic host: negotiate Gitea/Gogs-style contents API versions. + api_url_candidates = [ + f"https://{host}/api/v1/repos/{owner}/{repo}/contents/{file_path}?ref={ref}", + f"https://{host}/api/v3/repos/{owner}/{repo}/contents/{file_path}?ref={ref}", + ] + api_url = api_url_candidates[0] # Set up authentication headers headers = { @@ -1617,7 +1641,22 @@ def _download_github_file(self, dep_ref: DependencyReference, file_path: str, re return response.content except requests.exceptions.HTTPError as e: if e.response.status_code == 404: - # Try fallback branches if the specified ref fails + # For generic hosts, try remaining API version candidates before ref fallback + for candidate_url in api_url_candidates[1:]: + try: + candidate_resp = self._resilient_get(candidate_url, headers=headers, timeout=30) + candidate_resp.raise_for_status() + if verbose_callback: + verbose_callback(f"Downloaded file: {host}/{dep_ref.repo_url}/{file_path}") + return candidate_resp.content + except requests.exceptions.HTTPError as ce: + if ce.response.status_code != 404: + raise RuntimeError( + f"Failed to download {file_path}: HTTP {ce.response.status_code}" + ) + # 404 on this version too -- try next + + # All API versions returned 404 -- try fallback ref if ref not in ["main", "master"]: # If original ref failed, don't try fallbacks - it might be a specific version raise RuntimeError(f"File not found: {file_path} at ref '{ref}' in {dep_ref.repo_url}") @@ -1625,25 +1664,35 @@ def _download_github_file(self, dep_ref: DependencyReference, file_path: str, re # Try the other default branch fallback_ref = "master" if ref == "main" else "main" - # Build fallback API URL + # Build fallback URL candidates if host == "github.com": - fallback_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}" + fallback_url_candidates = [ + f"https://api.github.com/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}" + ] elif host.lower().endswith(".ghe.com"): - fallback_url = f"https://api.{host}/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}" + fallback_url_candidates = [ + f"https://api.{host}/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}" + ] else: - fallback_url = f"https://{host}/api/v3/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}" + fallback_url_candidates = [ + f"https://{host}/api/v1/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}", + f"https://{host}/api/v3/repos/{owner}/{repo}/contents/{file_path}?ref={fallback_ref}", + ] - try: - response = self._resilient_get(fallback_url, headers=headers, timeout=30) - response.raise_for_status() - if verbose_callback: - verbose_callback(f"Downloaded file: {host}/{dep_ref.repo_url}/{file_path}") - return response.content - except requests.exceptions.HTTPError: - raise RuntimeError( - f"File not found: {file_path} in {dep_ref.repo_url} " - f"(tried refs: {ref}, {fallback_ref})" - ) + for fallback_url in fallback_url_candidates: + try: + response = self._resilient_get(fallback_url, headers=headers, timeout=30) + response.raise_for_status() + if verbose_callback: + verbose_callback(f"Downloaded file: {host}/{dep_ref.repo_url}/{file_path}") + return response.content + except requests.exceptions.HTTPError: + pass # Try next version or ref + + raise RuntimeError( + f"File not found: {file_path} in {dep_ref.repo_url} " + f"(tried refs: {ref}, {fallback_ref})" + ) elif e.response.status_code == 401 or e.response.status_code == 403: # Distinguish rate limiting from auth failure. # GitHub returns 403 with X-RateLimit-Remaining: 0 when the diff --git a/src/apm_cli/models/dependency/reference.py b/src/apm_cli/models/dependency/reference.py index b5a6f1d96..804a724a8 100644 --- a/src/apm_cli/models/dependency/reference.py +++ b/src/apm_cli/models/dependency/reference.py @@ -836,7 +836,7 @@ def _parse_standard_url( user_repo = "/".join(parts[1:]) else: user_repo = "/".join(parts[1:3]) - elif len(parts) >= 2 and "." not in parts[0]: + elif len(parts) >= 2 and ("." not in parts[0] or validated_host is not None): if not host: host = default_host() if is_azure_devops_hostname(host) and len(parts) >= 3: diff --git a/tests/test_github_downloader.py b/tests/test_github_downloader.py index 850ef83c7..585c85215 100644 --- a/tests/test_github_downloader.py +++ b/tests/test_github_downloader.py @@ -1711,7 +1711,7 @@ def test_yaml_with_colon_in_description(self, tmp_path): assert apm_yml_path.exists(), "apm.yml was not created" content = apm_yml_path.read_text(encoding="utf-8") - parsed = yaml.safe_load(content) # must not raise + parsed = yaml.safe_load(content) # must not raise expected = ( "Senior software engineer subagent for implementation tasks:" @@ -1765,7 +1765,6 @@ def test_collection_yaml_with_colon_in_description(self, tmp_path): """apm.yml for collection packages must be valid when description contains a colon.""" import yaml - # A minimal .collection.yml whose description contains ":" collection_manifest = ( b"id: my-collection\n" b"name: My Collection\n" @@ -1791,7 +1790,7 @@ def _fake_download(dep_ref_arg, path, ref): downloader.download_collection_package(dep_ref, target_path) content = (target_path / "apm.yml").read_text(encoding="utf-8") - parsed = yaml.safe_load(content) # must not raise + parsed = yaml.safe_load(content) # must not raise assert parsed["description"] == "A collection for tasks: feature development, debugging." @@ -1832,5 +1831,152 @@ def _fake_download(dep_ref_arg, path, ref): assert parsed["tags"] == ["scope: engineering", "plain-tag"] +# --------------------------------------------------------------------------- +# 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 TestGiteaGogsApiVersionNegotiation: + """API version negotiation: raw URL -> v1 -> v3 for Gitea/Gogs generic hosts. + + The implementation intentionally stops at v3. GitLab uses a completely + different API shape (/api/v4/projects/:id/repository/files/...) that is + not compatible with the GitHub Contents-style endpoint negotiated here; + GitLab support is limited to git-clone operations only. + """ + + def setup_method(self): + with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH: + self.downloader = GitHubPackageDownloader() + + def test_v1_falls_back_to_v3_for_generic_hosts(self): + """When Gitea raw URL and v1 both return 404, v3 is tried and succeeds.""" + dep_ref = DependencyReference.parse("gitea.myorg.com/owner/repo") + expected = b"gitea v3 file content" + + side_effects = [ + _make_resp(404), # raw URL + _make_resp(404), # v1 + _make_resp(200, expected), # v3 + ] + 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 len(mock_get.call_args_list) == 3 + + def test_gitea_v1_succeeds_without_trying_v3(self): + """When v1 returns 200, v3 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 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(main) + v1(main) + v3(main) = 3 calls, then v1(master) + v3(master) = 2 calls + side_effects = [_make_resp(404)] * 5 + + 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__]) diff --git a/tests/unit/test_generic_git_urls.py b/tests/unit/test_generic_git_urls.py index 0fca7d9f9..8a9538661 100644 --- a/tests/unit/test_generic_git_urls.py +++ b/tests/unit/test_generic_git_urls.py @@ -822,7 +822,7 @@ def test_scp_port_only_no_path_raises(self): DependencyReference.parse("git@host.example.com:7999") def test_scp_port_trailing_slash_no_path_raises(self): - """git@host:7999/ — trailing slash but empty remaining path.""" + """git@host:7999/ -- trailing slash but empty remaining path.""" with pytest.raises(ValueError, match="no repository path follows"): DependencyReference.parse("git@host.example.com:7999/") @@ -896,3 +896,62 @@ def test_ssh_protocol_with_port_still_works(self): assert dep.host == "bitbucket.example.com" assert dep.port == 7999 assert dep.repo_url == "project/repo" + + +class TestGiteaVirtualPackageDetection: + """Gitea-specific virtual package detection -- supplements TestFQDNVirtualPaths + and TestNestedGroupSupport with Gitea host fixtures and regression guards + for the len(path_segments) > 2 over-trigger.""" + + # --- Must NOT be virtual (nested-group repo, no virtual indicators) --- + + def test_three_segment_gitea_path_is_not_virtual(self): + """group/subgroup/repo on Gitea is a nested-group repo, not virtual.""" + dep = DependencyReference.parse("gitea.myorg.com/group/subgroup/repo") + assert dep.host == "gitea.myorg.com" + assert dep.repo_url == "group/subgroup/repo" + assert dep.is_virtual is False + + def test_two_segment_gitea_path_is_not_virtual(self): + """Simple owner/repo on a Gitea host is never virtual.""" + dep = DependencyReference.parse("gitea.myorg.com/owner/repo") + assert dep.host == "gitea.myorg.com" + assert dep.repo_url == "owner/repo" + assert dep.is_virtual is False + + def test_four_segment_generic_path_without_indicators_is_not_virtual(self): + """Deep nested groups without file extensions or /collections/ are not virtual.""" + dep = DependencyReference.parse("git.company.internal/team/skills/brand-guidelines") + assert dep.is_virtual is False + assert dep.repo_url == "team/skills/brand-guidelines" + + # --- Must be virtual (explicit virtual indicators) --- + + def test_gitea_virtual_file_extension(self): + """Path with virtual file extension on Gitea is detected as virtual.""" + dep = DependencyReference.parse("gitea.myorg.com/owner/repo/file.prompt.md") + assert dep.host == "gitea.myorg.com" + assert dep.repo_url == "owner/repo" + assert dep.virtual_path == "file.prompt.md" + assert dep.is_virtual is True + assert dep.is_virtual_file() is True + + def test_gitea_collections_path_is_virtual(self): + """Path with /collections/ on Gitea is detected as a virtual collection.""" + dep = DependencyReference.parse("gitea.myorg.com/owner/repo/collections/security") + assert dep.host == "gitea.myorg.com" + assert dep.repo_url == "owner/repo" + assert dep.virtual_path == "collections/security" + assert dep.is_virtual is True + assert dep.is_virtual_collection() is True + + def test_dict_format_virtual_on_gitea(self): + """Dict format with path= on Gitea host yields a virtual package.""" + dep = DependencyReference.parse_from_dict({ + "git": "gitea.myorg.com/owner/repo", + "path": "prompts/review.prompt.md", + }) + assert dep.host == "gitea.myorg.com" + assert dep.repo_url == "owner/repo" + assert dep.virtual_path == "prompts/review.prompt.md" + assert dep.is_virtual is True