diff --git a/src/agentready/assessors/structure.py b/src/agentready/assessors/structure.py index b7b8c2ef..d46d249d 100644 --- a/src/agentready/assessors/structure.py +++ b/src/agentready/assessors/structure.py @@ -1,5 +1,7 @@ """Structure assessors for project layout and separation of concerns.""" +import logging +import os import re import tomllib import warnings @@ -7,11 +9,15 @@ from pathlib import Path from typing import Literal, TypedDict +import requests + from ..models.attribute import Attribute from ..models.finding import Citation, Finding, Remediation from ..models.repository import Repository from .base import BaseAssessor +logger = logging.getLogger(__name__) + class SourceDirectoryInfo(TypedDict): """Type-safe return value for _find_source_directory method.""" @@ -801,15 +807,93 @@ def attribute(self) -> Attribute: default_weight=0.01, ) + @staticmethod + def _parse_github_owner(url: str | None) -> str | None: + """Extract the GitHub owner (org or user) from a remote URL. + + Returns None if the URL is missing or not a GitHub URL. + """ + if not url: + return None + + # HTTPS: https://github.com/owner/repo.git + match = re.match(r"https?://github\.com/([^/]+)/", url) + if match: + return match.group(1) + + # SSH: git@github.com:owner/repo.git + match = re.match(r"git@github\.com:([^/]+)/", url) + if match: + return match.group(1) + + return None + + @staticmethod + def _check_org_templates(owner: str) -> dict: + """Check an org's .github repo for default issue/PR templates. + + Uses the GitHub REST API to check {owner}/.github for templates. + Falls back gracefully on any error (returns empty results). + """ + result = {"pr_template": False, "issue_template_count": 0} + + headers = {"Accept": "application/vnd.github.v3+json"} + token = os.getenv("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + api_base = f"https://api.github.com/repos/{owner}/.github/contents/.github" + + # Check for issue templates + try: + resp = requests.get( + f"{api_base}/ISSUE_TEMPLATE", headers=headers, timeout=5 + ) + if resp.status_code == 200: + items = resp.json() + if isinstance(items, list): + result["issue_template_count"] = sum( + 1 + for item in items + if item.get("name", "").endswith((".md", ".yml", ".yaml")) + ) + except (requests.RequestException, ValueError): + logger.debug("Could not check org-level issue templates for %s", owner) + + # Check for PR template + try: + resp = requests.get( + f"{api_base}/PULL_REQUEST_TEMPLATE.md", headers=headers, timeout=5 + ) + if resp.status_code == 200: + result["pr_template"] = True + else: + # Also check lowercase variant + resp = requests.get( + f"{api_base}/pull_request_template.md", + headers=headers, + timeout=5, + ) + if resp.status_code == 200: + result["pr_template"] = True + except (requests.RequestException, ValueError): + logger.debug("Could not check org-level PR template for %s", owner) + + return result + def assess(self, repository: Repository) -> Finding: """Check for GitHub issue and PR templates. Scoring: - PR template exists (50%) - Issue templates exist (50%, requires ≥2 templates) + + Falls back to org-level templates in the owner's .github repo + when templates are not found locally. """ score = 0 evidence = [] + template_count = 0 # Check for PR template (50%) pr_template_paths = [ @@ -823,15 +907,13 @@ def assess(self, repository: Repository) -> Finding: if pr_template_found: score += 50 evidence.append("PR template found") - else: - evidence.append("No PR template found") # Check for issue templates (50%) issue_template_dir = repository.path / ".github" / "ISSUE_TEMPLATE" + issue_templates_found = False if issue_template_dir.exists() and issue_template_dir.is_dir(): try: - # Count .md and .yml files (both formats supported) md_templates = list(issue_template_dir.glob("*.md")) yml_templates = list(issue_template_dir.glob("*.yml")) + list( issue_template_dir.glob("*.yaml") @@ -843,16 +925,47 @@ def assess(self, repository: Repository) -> Finding: evidence.append( f"Issue templates found: {template_count} templates" ) + issue_templates_found = True elif template_count == 1: score += 25 evidence.append( "Issue template directory exists with 1 template (need ≥2)" ) + issue_templates_found = True else: evidence.append("Issue template directory exists but is empty") except OSError: evidence.append("Could not read issue template directory") - else: + + # Fall back to org-level .github repo if anything is still missing + if not pr_template_found or not issue_templates_found: + owner = self._parse_github_owner(repository.url) + if owner: + org_templates = self._check_org_templates(owner) + + if not pr_template_found and org_templates["pr_template"]: + pr_template_found = True + score += 50 + evidence.append("PR template found (org-level)") + + if not issue_templates_found: + org_count = org_templates["issue_template_count"] + if org_count >= 2: + template_count = org_count + score += 50 + evidence.append( + f"Issue templates found (org-level): {org_count} templates" + ) + elif org_count == 1: + template_count = org_count + score += 25 + evidence.append( + "Issue template found (org-level): 1 template (need ≥2)" + ) + + if not pr_template_found: + evidence.append("No PR template found") + if not issue_templates_found and template_count == 0: evidence.append("No issue template directory found") status = "pass" if score >= 75 else "fail" @@ -861,7 +974,7 @@ def assess(self, repository: Repository) -> Finding: attribute=self.attribute, status=status, score=score, - measured_value=f"PR:{pr_template_found}, Issues:{template_count if issue_template_dir.exists() else 0}", + measured_value=f"PR:{pr_template_found}, Issues:{template_count}", threshold="PR template + ≥2 issue templates", evidence=evidence, remediation=self._create_remediation() if status == "fail" else None, @@ -879,6 +992,7 @@ def _create_remediation(self) -> Remediation: "Add bug_report.md for bug reports", "Add feature_request.md for feature requests", "Optionally add config.yml to configure template chooser", + "Note: org-level templates in /.github are also recognized", ], tools=["gh"], commands=[ diff --git a/tests/unit/test_assessors_structure.py b/tests/unit/test_assessors_structure.py index 08e6c0ae..91c99fe1 100644 --- a/tests/unit/test_assessors_structure.py +++ b/tests/unit/test_assessors_structure.py @@ -1,6 +1,13 @@ """Tests for structure assessors.""" -from agentready.assessors.structure import StandardLayoutAssessor +from unittest.mock import MagicMock, patch + +import pytest + +from agentready.assessors.structure import ( + IssuePRTemplatesAssessor, + StandardLayoutAssessor, +) from agentready.models.repository import Repository @@ -825,3 +832,250 @@ def test_python_arsrc_bundled_with_package(self): content = arsrc_path.read_text() assert len(content) > 0, "Python.arsrc is empty" assert "tests" in content, "Python.arsrc missing expected entry 'tests'" + + +class TestIssuePRTemplatesAssessor: + """Test IssuePRTemplatesAssessor.""" + + @pytest.fixture + def assessor(self): + return IssuePRTemplatesAssessor() + + def _make_repo(self, tmp_path, url=None): + (tmp_path / ".git").mkdir(exist_ok=True) + return Repository( + path=tmp_path, + name="test-repo", + url=url, + branch="main", + commit_hash="abc123", + languages={"Python": 10}, + total_files=5, + total_lines=50, + ) + + # --- _parse_github_owner tests --- + + def test_parse_owner_https(self, assessor): + assert ( + assessor._parse_github_owner("https://github.com/myorg/myrepo.git") + == "myorg" + ) + + def test_parse_owner_https_no_git_suffix(self, assessor): + assert ( + assessor._parse_github_owner("https://github.com/myorg/myrepo") == "myorg" + ) + + def test_parse_owner_ssh(self, assessor): + assert ( + assessor._parse_github_owner("git@github.com:myorg/myrepo.git") == "myorg" + ) + + def test_parse_owner_non_github(self, assessor): + assert ( + assessor._parse_github_owner("https://gitlab.com/myorg/myrepo.git") is None + ) + + def test_parse_owner_none(self, assessor): + assert assessor._parse_github_owner(None) is None + + def test_parse_owner_empty(self, assessor): + assert assessor._parse_github_owner("") is None + + # --- Local template detection (no API call) --- + + def test_local_templates_pass(self, assessor, tmp_path): + """Full local templates should pass without any API call.""" + (tmp_path / ".git").mkdir() + (tmp_path / ".github").mkdir() + (tmp_path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_text("PR") + tpl_dir = tmp_path / ".github" / "ISSUE_TEMPLATE" + tpl_dir.mkdir() + (tpl_dir / "bug_report.md").write_text("bug") + (tpl_dir / "feature_request.yml").write_text("feature") + + repo = self._make_repo(tmp_path, url="https://github.com/org/repo.git") + with patch("agentready.assessors.structure.requests.get") as mock_get: + finding = assessor.assess(repo) + mock_get.assert_not_called() + + assert finding.status == "pass" + assert finding.score == 100 + + def test_no_templates_no_url(self, assessor, tmp_path): + """No templates and no URL should fail without API call.""" + repo = self._make_repo(tmp_path, url=None) + with patch("agentready.assessors.structure.requests.get") as mock_get: + finding = assessor.assess(repo) + mock_get.assert_not_called() + + assert finding.status == "fail" + assert finding.score == 0 + + # --- Org-level fallback tests --- + + def test_org_fallback_issue_templates(self, assessor, tmp_path): + """Org-level issue templates should be found when local ones are missing.""" + (tmp_path / ".git").mkdir() + (tmp_path / ".github").mkdir() + (tmp_path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_text("PR") + + repo = self._make_repo(tmp_path, url="https://github.com/kagenti/kagenti.git") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [ + {"name": "bug_report.md"}, + {"name": "feature_request.yml"}, + {"name": "config.yml"}, + ] + + def side_effect(url, **kwargs): + if "ISSUE_TEMPLATE" in url: + return mock_response + r = MagicMock() + r.status_code = 404 + return r + + with patch( + "agentready.assessors.structure.requests.get", side_effect=side_effect + ): + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100 + evidence_str = " ".join(finding.evidence) + assert "org-level" in evidence_str + + def test_org_fallback_pr_template(self, assessor, tmp_path): + """Org-level PR template should be found when local one is missing.""" + (tmp_path / ".git").mkdir() + tpl_dir = tmp_path / ".github" / "ISSUE_TEMPLATE" + tpl_dir.mkdir(parents=True) + (tpl_dir / "bug.md").write_text("bug") + (tpl_dir / "feature.md").write_text("feature") + + repo = self._make_repo(tmp_path, url="https://github.com/myorg/myrepo.git") + + def side_effect(url, **kwargs): + r = MagicMock() + if "PULL_REQUEST_TEMPLATE.md" in url: + r.status_code = 200 + return r + r.status_code = 404 + return r + + with patch( + "agentready.assessors.structure.requests.get", side_effect=side_effect + ): + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100 + evidence_str = " ".join(finding.evidence) + assert "PR template found (org-level)" in evidence_str + + def test_org_fallback_both_templates(self, assessor, tmp_path): + """Org-level fallback should find both PR and issue templates.""" + repo = self._make_repo(tmp_path, url="https://github.com/myorg/myrepo.git") + + issue_resp = MagicMock() + issue_resp.status_code = 200 + issue_resp.json.return_value = [ + {"name": "bug.md"}, + {"name": "feature.yml"}, + ] + + pr_resp = MagicMock() + pr_resp.status_code = 200 + + def side_effect(url, **kwargs): + if "ISSUE_TEMPLATE" in url: + return issue_resp + if "PULL_REQUEST_TEMPLATE" in url: + return pr_resp + r = MagicMock() + r.status_code = 404 + return r + + with patch( + "agentready.assessors.structure.requests.get", side_effect=side_effect + ): + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 100 + + def test_org_fallback_one_issue_template(self, assessor, tmp_path): + """Org-level with only 1 issue template should give partial score.""" + repo = self._make_repo(tmp_path, url="https://github.com/myorg/myrepo.git") + + issue_resp = MagicMock() + issue_resp.status_code = 200 + issue_resp.json.return_value = [{"name": "bug.md"}] + + pr_resp = MagicMock() + pr_resp.status_code = 200 + + def side_effect(url, **kwargs): + if "ISSUE_TEMPLATE" in url: + return issue_resp + if "PULL_REQUEST_TEMPLATE" in url: + return pr_resp + r = MagicMock() + r.status_code = 404 + return r + + with patch( + "agentready.assessors.structure.requests.get", side_effect=side_effect + ): + finding = assessor.assess(repo) + + assert finding.status == "pass" + assert finding.score == 75 + + # --- Graceful failure --- + + def test_org_fallback_api_404(self, assessor, tmp_path): + """API returning 404 should not change the score.""" + repo = self._make_repo(tmp_path, url="https://github.com/myorg/myrepo.git") + + def side_effect(url, **kwargs): + r = MagicMock() + r.status_code = 404 + return r + + with patch( + "agentready.assessors.structure.requests.get", side_effect=side_effect + ): + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0 + + def test_org_fallback_api_timeout(self, assessor, tmp_path): + """API timeout should not change the score.""" + import requests + + repo = self._make_repo(tmp_path, url="https://github.com/myorg/myrepo.git") + + with patch( + "agentready.assessors.structure.requests.get", + side_effect=requests.Timeout("timeout"), + ): + finding = assessor.assess(repo) + + assert finding.status == "fail" + assert finding.score == 0 + + def test_org_fallback_non_github_url(self, assessor, tmp_path): + """Non-GitHub URL should skip org-level check entirely.""" + repo = self._make_repo(tmp_path, url="https://gitlab.com/myorg/myrepo.git") + + with patch("agentready.assessors.structure.requests.get") as mock_get: + finding = assessor.assess(repo) + mock_get.assert_not_called() + + assert finding.status == "fail" + assert finding.score == 0