Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 16 additions & 15 deletions src/apm_cli/deps/github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
is_azure_devops_hostname,
is_github_hostname
)
from ..utils.yaml_io import yaml_to_str


def normalize_collection_path(virtual_path: str) -> str:
Expand Down Expand Up @@ -1515,11 +1516,13 @@ def download_virtual_file_package(self, dep_ref: DependencyReference, target_pat
# If frontmatter parsing fails, use default description
pass

apm_yml_content = f"""name: {package_name}
version: 1.0.0
description: {description}
author: {dep_ref.repo_url.split('/')[0]}
"""
apm_yml_data = {
"name": package_name,
"version": "1.0.0",
"description": description,
"author": dep_ref.repo_url.split('/')[0],
}
apm_yml_content = yaml_to_str(apm_yml_data)

apm_yml_path = target_path / "apm.yml"
apm_yml_path.write_text(apm_yml_content, encoding='utf-8')
Expand Down Expand Up @@ -1655,17 +1658,15 @@ def download_collection_package(self, dep_ref: DependencyReference, target_path:
# Generate apm.yml with collection metadata
package_name = dep_ref.get_virtual_package_name()

apm_yml_content = f"""name: {package_name}
version: 1.0.0
description: {manifest.description}
author: {dep_ref.repo_url.split('/')[0]}
"""

# Add tags if present
apm_yml_data = {
"name": package_name,
"version": "1.0.0",
"description": manifest.description,
"author": dep_ref.repo_url.split('/')[0],
}
if manifest.tags:
apm_yml_content += f"\ntags:\n"
for tag in manifest.tags:
apm_yml_content += f" - {tag}\n"
apm_yml_data["tags"] = list(manifest.tags)
apm_yml_content = yaml_to_str(apm_yml_data)

apm_yml_path = target_path / "apm.yml"
apm_yml_path.write_text(apm_yml_content, encoding='utf-8')
Expand Down
176 changes: 176 additions & 0 deletions tests/test_github_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1656,5 +1656,181 @@ def test_try_raw_download_returns_content_on_200(self):
assert result == b'hello world'


class TestVirtualFilePackageYamlGeneration:
"""Tests that apm.yml for virtual packages is always valid YAML."""

def _make_dep_ref(self, virtual_path):
"""Helper: build a minimal DependencyReference for a virtual file."""
from apm_cli.models.apm_package import DependencyReference
dep_ref = Mock(spec=DependencyReference)
dep_ref.is_virtual = True
dep_ref.virtual_path = virtual_path
dep_ref.reference = "main"
dep_ref.repo_url = "github/awesome-copilot"
dep_ref.get_virtual_package_name.return_value = "awesome-copilot-swe-subagent"
dep_ref.to_github_url.return_value = f"https://github.com/github/awesome-copilot/blob/main/{virtual_path}"
dep_ref.is_virtual_file.return_value = True
dep_ref.VIRTUAL_FILE_EXTENSIONS = [".prompt.md", ".instructions.md", ".chatmode.md", ".agent.md"]
return dep_ref

def _make_collection_dep_ref(self, virtual_path):
"""Helper: build a minimal DependencyReference for a virtual collection."""
from apm_cli.models.apm_package import DependencyReference
dep_ref = Mock(spec=DependencyReference)
dep_ref.is_virtual = True
dep_ref.virtual_path = virtual_path
dep_ref.reference = "main"
dep_ref.repo_url = "github/my-org"
dep_ref.get_virtual_package_name.return_value = "my-org-my-collection"
dep_ref.to_github_url.return_value = f"https://github.com/github/my-org/blob/main/{virtual_path}"
dep_ref.is_virtual_collection.return_value = True
return dep_ref

def test_yaml_with_colon_in_description(self, tmp_path):
"""apm.yml must be valid when the agent description contains a colon."""
import yaml

agent_content = (
b"---\n"
b"name: 'SWE'\n"
b"description: 'Senior software engineer subagent for implementation tasks:"
b" feature development, debugging, refactoring, and testing.'\n"
b"tools: ['vscode']\n"
b"---\n\n## Body\n"
)

dep_ref = self._make_dep_ref("agents/swe-subagent.agent.md")
target_path = tmp_path / "pkg"

downloader = GitHubPackageDownloader()
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
with patch.object(downloader, "download_raw_file", return_value=agent_content):
downloader.download_virtual_file_package(dep_ref, target_path)

apm_yml_path = target_path / "apm.yml"
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

expected = (
"Senior software engineer subagent for implementation tasks:"
" feature development, debugging, refactoring, and testing."
)
assert parsed["description"] == expected

def test_yaml_with_colon_in_name(self, tmp_path):
"""apm.yml must be valid even when the package name contains a colon."""
import yaml

dep_ref = self._make_dep_ref("agents/my-agent.agent.md")
dep_ref.get_virtual_package_name.return_value = "org-name: special"

agent_content = b"---\nname: 'plain'\ndescription: 'plain'\n---\n"
target_path = tmp_path / "pkg"

downloader = GitHubPackageDownloader()
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
with patch.object(downloader, "download_raw_file", return_value=agent_content):
downloader.download_virtual_file_package(dep_ref, target_path)

content = (target_path / "apm.yml").read_text(encoding="utf-8")
parsed = yaml.safe_load(content)
assert parsed["name"] == "org-name: special"

def test_yaml_without_special_characters_still_valid(self, tmp_path):
"""apm.yml generation must still work for ordinary descriptions."""
import yaml

agent_content = (
b"---\n"
b"name: 'Simple Agent'\n"
b"description: 'A simple agent without special chars'\n"
b"---\n"
)

dep_ref = self._make_dep_ref("agents/simple.agent.md")
target_path = tmp_path / "pkg"

downloader = GitHubPackageDownloader()
with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
with patch.object(downloader, "download_raw_file", return_value=agent_content):
downloader.download_virtual_file_package(dep_ref, target_path)

content = (target_path / "apm.yml").read_text(encoding="utf-8")
parsed = yaml.safe_load(content)
assert parsed["description"] == "A simple agent without special chars"

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"
b"description: 'A collection for tasks: feature development, debugging.'\n"
b"items:\n"
b" - path: agents/my-agent.agent.md\n"
b" kind: agent\n"
)
agent_file = b"---\nname: My Agent\n---\n## Body\n"

dep_ref = self._make_collection_dep_ref("collections/my-collection")
target_path = tmp_path / "pkg"

downloader = GitHubPackageDownloader()

def _fake_download(dep_ref_arg, path, ref):
if "collection" in path:
return collection_manifest
return agent_file

with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
with patch.object(downloader, "download_raw_file", side_effect=_fake_download):
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

assert parsed["description"] == "A collection for tasks: feature development, debugging."

def test_collection_yaml_with_colon_in_tags(self, tmp_path):
"""apm.yml for collection packages must be valid when tags contain a colon."""
import yaml

collection_manifest = (
b"id: tagged-collection\n"
b"name: Tagged\n"
b"description: Normal description\n"
b"tags:\n"
b" - 'scope: engineering'\n"
b" - plain-tag\n"
b"items:\n"
b" - path: agents/my-agent.agent.md\n"
b" kind: agent\n"
)
agent_file = b"---\nname: My Agent\n---\n## Body\n"

dep_ref = self._make_collection_dep_ref("collections/tagged-collection")
target_path = tmp_path / "pkg"

downloader = GitHubPackageDownloader()

def _fake_download(dep_ref_arg, path, ref):
if "collection" in path:
return collection_manifest
return agent_file

with patch.dict(os.environ, {}, clear=True), _CRED_FILL_PATCH:
with patch.object(downloader, "download_raw_file", side_effect=_fake_download):
downloader.download_collection_package(dep_ref, target_path)

content = (target_path / "apm.yml").read_text(encoding="utf-8")
parsed = yaml.safe_load(content)

assert parsed["tags"] == ["scope: engineering", "plain-tag"]


if __name__ == '__main__':
pytest.main([__file__])
Loading