Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4d5135f
support virtual packages on generic git hosts (Gitea)
GanesanRengasamy Apr 6, 2026
cc79114
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 7, 2026
3dedd94
Addressed reviewed corrections
GanesanRengasamy Apr 7, 2026
8cfcd22
Merge branch 'main' into feat/genric-host-gitea-private
danielmeppiel Apr 9, 2026
13dbf73
Update src/apm_cli/deps/github_downloader.py
ganesanviji Apr 9, 2026
c386e89
Merge branch 'main' into feat/genric-host-gitea-private
sergio-sisternes-epam Apr 10, 2026
e455776
Review comments addressed
ganesanviji Apr 15, 2026
ab09b7a
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 15, 2026
4fb6dcd
Merge branch 'feat/genric-host-gitea-private' of https://github.com/g…
ganesanviji Apr 16, 2026
0b3460c
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 16, 2026
8cc38d8
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 17, 2026
47b8051
Merge branch 'main' into feat/genric-host-gitea-private
danielmeppiel Apr 18, 2026
22cc4e7
Merge branch 'main' into feat/genric-host-gitea-private
danielmeppiel Apr 19, 2026
ea3fd0c
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 24, 2026
00f88e7
Update reference.py
ganesanviji Apr 24, 2026
fb75924
Merge branch 'feat/genric-host-gitea-private' of https://github.com/g…
ganesanviji Apr 24, 2026
77c077d
restore: re-add TestVirtualFilePackageYamlGeneration and TestSCPPortD…
ganesanviji Apr 24, 2026
dbaa48b
Merge branch 'main' into feat/genric-host-gitea-private
danielmeppiel Apr 24, 2026
8b56ffa
Review concerns addressed
ganesanviji Apr 25, 2026
d55b754
Merge branch 'feat/genric-host-gitea-private' of https://github.com/g…
ganesanviji Apr 25, 2026
8e3e078
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 25, 2026
179ce53
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 25, 2026
1d74243
Merge branch 'main' into feat/genric-host-gitea-private
danielmeppiel Apr 25, 2026
d183107
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 26, 2026
b4af12d
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 26, 2026
c1f4d49
Merge branch 'main' into feat/genric-host-gitea-private
ganesanviji Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
91 changes: 70 additions & 21 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Comment thread
ganesanviji marked this conversation as resolved.
]
api_url = api_url_candidates[0]

# Set up authentication headers
headers = {
Expand All @@ -1617,33 +1641,58 @@ 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}")

# 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
Expand Down
2 changes: 1 addition & 1 deletion src/apm_cli/models/dependency/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
152 changes: 149 additions & 3 deletions tests/test_github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down Expand Up @@ -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"
Expand All @@ -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."

Expand Down Expand Up @@ -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__])
Loading
Loading