Skip to content
Closed
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
51 changes: 46 additions & 5 deletions src/agentready/assessors/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ..models.attribute import Attribute
from ..models.finding import Citation, Finding, Remediation
from ..models.repository import Repository
from ..utils.github_templates import GitHubTemplatesFetcher
from .base import BaseAssessor


Expand Down Expand Up @@ -807,11 +808,17 @@ def assess(self, repository: Repository) -> Finding:
Scoring:
- PR template exists (50%)
- Issue templates exist (50%, requires ≥2 templates)

Checks local .github/ directory first, then falls back to
organization-level templates from the org's .github repo if the
repository has no local templates and the GITHUB_TOKEN is
available.
"""
score = 0
evidence = []
template_count = 0

# Check for PR template (50%)
# Check for PR template (50%) - local first
pr_template_paths = [
repository.path / ".github" / "PULL_REQUEST_TEMPLATE.md",
repository.path / "PULL_REQUEST_TEMPLATE.md",
Expand All @@ -824,9 +831,23 @@ def assess(self, repository: Repository) -> Finding:
score += 50
evidence.append("PR template found")
else:
evidence.append("No PR template found")
# Check org-level fallback for PR templates
fetcher = GitHubTemplatesFetcher()
owner = fetcher.extract_owner(repository.url)
if owner:
org_pr_templates = fetcher.fetch_pr_templates(owner)
if org_pr_templates:
score += 50
pr_template_found = True
evidence.append(
"PR template found (inherited from org-level .github repo)"
)
else:
evidence.append("No PR template found")
else:
Comment on lines +835 to +847
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle GitHub API failures in the assessor instead of propagating exceptions.

fetch_pr_templates() / fetch_issue_templates() can raise GitHubTemplatesError (e.g., 5xx/network), and assess() currently does not catch it. A transient GitHub outage can therefore fail this assessor run instead of degrading gracefully.

Suggested guard pattern
 from ..utils.github_templates import GitHubTemplatesFetcher
+from ..utils.github_templates import GitHubTemplatesError
...
-            if owner:
-                org_pr_templates = fetcher.fetch_pr_templates(owner)
+            if owner:
+                try:
+                    org_pr_templates = fetcher.fetch_pr_templates(owner)
+                except GitHubTemplatesError:
+                    org_pr_templates = []
+                    evidence.append("Org-level PR template check unavailable (GitHub API error)")
                 if org_pr_templates:
...
-            if owner:
-                org_issue_templates = fetcher.fetch_issue_templates(owner)
+            if owner:
+                try:
+                    org_issue_templates = fetcher.fetch_issue_templates(owner)
+                except GitHubTemplatesError:
+                    org_issue_templates = []
+                    evidence.append("Org-level issue template check unavailable (GitHub API error)")
                 if len(org_issue_templates) >= 2:

As per coding guidelines, "Check for proper error handling (return skipped/error Finding, don't crash)."

Also applies to: 878-896

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agentready/assessors/structure.py` around lines 835 - 847, The assessor's
assess() currently lets GitHubTemplatesFetcher.fetch_pr_templates and
fetch_issue_templates raise GitHubTemplatesError and crash the run; wrap calls
to GitHubTemplatesFetcher.extract_owner + fetch_pr_templates and
fetch_issue_templates in try/except catching GitHubTemplatesError, and on
exception degrade gracefully by leaving score unchanged, setting
pr_template_found/issue_template_found false (or whatever local flag is used),
appending evidence like "Skipped: GitHub API error fetching templates" (and
optionally processLogger.warn the error), and continue; apply the same guard
around both fetch_pr_templates and fetch_issue_templates invocations so
transient GitHub failures do not propagate.

evidence.append("No PR template found")

# Check for issue templates (50%)
# Check for issue templates (50%) - local first
issue_template_dir = repository.path / ".github" / "ISSUE_TEMPLATE"

if issue_template_dir.exists() and issue_template_dir.is_dir():
Expand All @@ -853,15 +874,35 @@ def assess(self, repository: Repository) -> Finding:
except OSError:
evidence.append("Could not read issue template directory")
else:
evidence.append("No issue template directory found")
# Check org-level fallback for issue templates
fetcher = GitHubTemplatesFetcher()
owner = fetcher.extract_owner(repository.url)
if owner:
org_issue_templates = fetcher.fetch_issue_templates(owner)
if len(org_issue_templates) >= 2:
score += 50
template_count = len(org_issue_templates)
evidence.append(
f"Issue templates found: {template_count} templates (inherited from org-level .github repo)"
)
elif len(org_issue_templates) == 1:
score += 25
template_count = 1
evidence.append(
"Issue template directory exists with 1 template (need ≥2, inherited from org-level .github repo)"
)
else:
evidence.append("No issue template directory found")
else:
evidence.append("No issue template directory found")

status = "pass" if score >= 75 else "fail"

return 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,
Expand Down
175 changes: 175 additions & 0 deletions src/agentready/utils/github_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""GitHub organization-level template fetching.

Checks for issue/PR templates at the organization level via the .github repo.
"""

import logging
import os
import re
from typing import List, Optional

import requests

logger = logging.getLogger(__name__)


class GitHubTemplatesError(Exception):
"""GitHub template fetch errors."""

pass


class GitHubTemplatesFetcher:
"""Fetches organization-level issue/PR templates from .github repos.

GitHub organizations can define default community health files (issue
templates, PR templates, CODEOWNERS, etc.) in a dedicated `.github`
repository that is inherited by all repositories in the organization.
"""

def __init__(self, token: Optional[str] = None):
"""Initialize the fetcher.

Args:
token: GitHub personal access token (optional, defaults to
GITHUB_TOKEN environment variable)
"""
self.token = token or os.getenv("GITHUB_TOKEN")
self._api_base = "https://api.github.com"

@property
def configured(self) -> bool:
"""Check if a GitHub token is available."""
return bool(self.token)

def _request_headers(self) -> dict:
"""Get default headers for GitHub API requests."""
return {
"Authorization": f"Bearer {self.token}" if self.token else None,
"Accept": "application/vnd.github.v3+json",
"User-Agent": "agentready",
}

def _fetch_contents(self, owner: str, repo: str, path: str = "") -> List[dict]:
"""Fetch directory contents from a GitHub repository.

Args:
owner: Repository owner/organization name
repo: Repository name
path: Path within the repository (defaults to root)

Returns:
List of file metadata dicts from GitHub API
"""
url = f"{self._api_base}/repos/{owner}/{repo}/contents/{path.lstrip('/')}"

try:
response = requests.get(
url,
headers={k: v for k, v in self._request_headers().items() if v},
timeout=30,
)
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
if response.status_code == 404:
logger.debug("Path not found: %s/%s/%s", owner, repo, path)
return []
elif response.status_code in (401, 403):
logger.debug("Unauthorized to access %s/%s/%s", owner, repo, path)
return []
raise GitHubTemplatesError(
f"Failed to fetch contents: {owner}/{repo}/{path}"
) from e
except requests.RequestException as e:
raise GitHubTemplatesError(
f"Request failed for {owner}/{repo}/{path}: {e}"
) from e

def fetch_pr_templates(self, owner: str, repo_name: str = ".github") -> List[str]:
"""Check for PR templates in an organization-level .github repo.

Args:
owner: GitHub organization name
repo_name: Repository name (defaults to '.github')

Returns:
List of PR template file paths found (e.g.
['PULL_REQUEST_TEMPLATE.md'])
"""
if not self.configured:
return []

contents = self._fetch_contents(owner, repo_name, ".github")
if not contents:
return []

pr_filenames = {
"PULL_REQUEST_TEMPLATE.md": "PULL_REQUEST_TEMPLATE.md",
"pull_request_template.md": "PULL_REQUEST_TEMPLATE.md",
}

found = []
for item in contents:
if item.get("type") == "file" and item.get("name") in pr_filenames:
found.append(pr_filenames[item["name"]])

return sorted(set(found))

def fetch_issue_templates(
self, owner: str, repo_name: str = ".github"
) -> List[str]:
"""Check for issue templates in an organization-level .github repo.

Args:
owner: GitHub organization name
repo_name: Repository name (defaults to '.github')

Returns:
List of issue template file paths found (e.g.
['ISSUE_TEMPLATE/bug_report.md', 'ISSUE_TEMPLATE/feature_request.md'])
"""
if not self.configured:
return []

contents = self._fetch_contents(owner, repo_name, ".github/ISSUE_TEMPLATE")
if not contents:
return []

extensions = {".md", ".yml", ".yaml"}
found = []
for item in contents:
name = item.get("name", "")
if item.get("type") == "file" and any(
name.endswith(ext) for ext in extensions
):
found.append(f"ISSUE_TEMPLATE/{name}")

return sorted(found)

def extract_owner(self, url: str) -> str | None:
"""Extract the owner/organization from a GitHub repository URL.

Supports HTTPS (https://github.com/owner/repo) and SSH
(git@github.com:owner/repo.git) formats.

Args:
url: Repository URL string

Returns:
Owner/organization name or None if not parseable
"""
if not url:
return None

# SSH format: git@github.com:owner/repo.git
ssh_match = re.match(r"git@github\.com:([^/]+)/.+?$", url)
if ssh_match:
return ssh_match.group(1)

# HTTPS: https://github.com/owner/repo.git or .gitignore etc
https_match = re.match(r"https://github\.com/([^/]+)/.+?$", url)
if https_match:
return https_match.group(1)

return None
Loading
Loading