From d143e62918da59148f9d2822b8a22b168d7a72a7 Mon Sep 17 00:00:00 2001 From: a2a-platform Date: Thu, 4 Jun 2026 00:33:54 +0000 Subject: [PATCH] deploy --- README.md | 36 + a2a.yaml | 11 + agent.py | 1209 ++++++++++++++++++++++++ frontend/index.html | 12 + frontend/package.json | 19 + frontend/src/App.jsx | 283 ++++++ frontend/src/a2a.js | 55 ++ frontend/src/main.jsx | 10 + frontend/src/style.css | 72 ++ frontend/vite.config.js | 18 + requirements.txt | 8 + skills/repo-inspection-review/SKILL.md | 18 + skills/repository-inspection/SKILL.md | 32 + tests/test_analysis.py | 67 ++ tests/test_inspector.py | 72 ++ 15 files changed, 1922 insertions(+) create mode 100644 README.md create mode 100644 a2a.yaml create mode 100644 agent.py create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/a2a.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js create mode 100644 requirements.txt create mode 100644 skills/repo-inspection-review/SKILL.md create mode 100644 skills/repository-inspection/SKILL.md create mode 100644 tests/test_analysis.py create mode 100644 tests/test_inspector.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..c46b465 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# GitHub Repo Inspector + +Public A2A agent + packed React frontend for inspecting GitHub repositories with exact file-level citations. + +## Skills + +- `check_auth(github_token="")`: validates the supplied GitHub token or consumer secret without logging/returning it. +- `list_repositories(github_token="", visibility="all", affiliation="owner,collaborator,organization_member", per_page=100, max_pages=10)`: lists accessible repositories with owner/name/default branch/private flag. +- `inspect_repository(owner, repo, github_token="", ref="", include_hidden=true, max_files=750, max_bytes=4000000, max_file_bytes=250000)`: fetches a recursive tree, includes hidden files by default, classifies every tree entry, fetches supported text files within explicit limits, reports skipped binary/vendor/build/cache/limited files, analyzes line-level risks, and returns structured JSON plus markdown. +- `create_issue(owner, repo, title, body, github_token="", labels=[])`: creates an issue for a confirmed recommendation/risk. + +## GitHub permissions + +Provide `github_token` in the UI/skill call or configure a consumer secret named `github_token` / `GITHUB_TOKEN`. + +Recommended scopes: + +- Public repos: `public_repo` or fine-grained read-only Contents metadata/content access. +- Private repos: `repo` or fine-grained repository Contents read + Metadata read. +- Issue creation: `repo` or fine-grained Issues read/write. + +Tokens are never stored, logged, or returned. Snippets redact likely secrets. + +## Limits and safety + +The scanner inspects all fetched tree metadata. It fetches supported text blobs until `max_files`, `max_bytes`, or `max_file_bytes` limits are reached. Skipped files include an explicit reason such as binary extension, vendor/build/cache folder, too-large file, or aggregate byte limit. + +Findings use conservative deterministic heuristics and should be treated as review prompts unless the cited evidence clearly proves the issue. + +## Local checks + +```bash +pytest tests +``` + +Packed frontend is served at `/app` after deployment. diff --git a/a2a.yaml b/a2a.yaml new file mode 100644 index 0000000..2ed02e0 --- /dev/null +++ b/a2a.yaml @@ -0,0 +1,11 @@ +name: github-repo-inspector +version: 1.0.0 +entrypoint: agent:GithubRepoInspector +expose: + public: true +frontend: + path: frontend + build: npm run build + dist: dist + mount: /app + auth: inherit diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..fc47095 --- /dev/null +++ b/agent.py @@ -0,0 +1,1209 @@ +"""GitHub Repo Inspector A2A agent. + +The agent uses direct GitHub REST calls plus deterministic repository heuristics +so findings are evidence-backed and cite exact files/lines. A platform-scoped +LLM grant is still declared/read through ctx.llm per hosted-agent convention, +but no provider keys or LiteLLM environment variables are read directly. +""" +from __future__ import annotations + +import base64 +import json +import mimetypes +import re +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx +from pydantic import BaseModel, Field + +from a2a_pack import ( + A2AAgent, + EgressPolicy, + LLMProvisioning, + NoAuth, + Pricing, + Resources, + RunContext, + WorkspaceAccess, + WorkspaceMode, + skill, +) +from a2a_pack.context import LLMCreds + + +GITHUB_API = "https://api.github.com" +RUNTIME_SKILLS_DIR = "github-repo-inspector/.deepagents/skills/" +DEEPAGENTS_RECURSION_LIMIT = 500 + +CATEGORY_ORDER = [ + "source", + "config", + "CI/CD", + "Docker/container", + "tests", + "docs", + "package/dependency", + "infra/IaC", + "scripts", + "hidden/dotfiles", + "assets/other", +] + +VENDOR_BUILD_CACHE_DIRS = { + ".git", + ".hg", + ".svn", + ".next", + ".nuxt", + ".parcel-cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "venv", + "env", + "node_modules", + "bower_components", + "vendor", + "dist", + "build", + "target", + "coverage", + ".coverage", + "__pycache__", + ".mypy_cache", + ".gradle", + ".idea", + ".vscode", + "out", + "tmp", + "temp", + "cache", + ".cache", +} + +BINARY_EXTENSIONS = { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip", ".gz", + ".tgz", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war", ".ear", ".class", + ".pyc", ".pyo", ".so", ".dylib", ".dll", ".exe", ".bin", ".wasm", ".woff", + ".woff2", ".ttf", ".otf", ".mp3", ".mp4", ".mov", ".avi", ".mkv", ".wav", + ".flac", ".sqlite", ".db", ".parquet", ".avif", ".heic", +} + +TEXT_EXTENSIONS = { + ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".java", ".go", ".rs", + ".rb", ".php", ".cs", ".cpp", ".cc", ".c", ".h", ".hpp", ".swift", ".kt", + ".kts", ".scala", ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", + ".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".conf", ".xml", ".html", + ".css", ".scss", ".md", ".rst", ".txt", ".dockerfile", ".tf", ".tfvars", ".hcl", + ".sql", ".graphql", ".proto", ".gradle", ".properties", ".env", ".example", + ".lock", ".mod", ".sum", ".mk", ".cmake", +} + +SECRET_REGEXES: list[tuple[str, re.Pattern[str]]] = [ + ("GitHub token", re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}")), + ("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")), + ("Private key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----")), + ("Slack token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{20,}")), + ("Generic assigned secret", re.compile(r"(?i)(api[_-]?key|secret|token|password|passwd|pwd)\s*[:=]\s*['\"]?([^'\"\s]{12,})")), +] + +TODO_RE = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b", re.IGNORECASE) +COMMENTED_BLOCK_RE = re.compile(r"^\s*(//|#|--|/\*|\*)\s*(if |for |while |def |function |class |const |let |var |return |import |from )") +RISKY_PACKAGE_HINTS = {"request", "left-pad", "event-stream", "ua-parser-js", "colors", "faker"} + + +class GithubRepoInspectorConfig(BaseModel): + github_api_base: str = GITHUB_API + + +class GithubRepoInspector(A2AAgent[GithubRepoInspectorConfig, NoAuth]): + name = "github-repo-inspector" + description = ( + "GitHub Repo Inspector lists accessible repositories, inspects complete " + "GitHub repository trees with file-level citations, and can create " + "GitHub issues for selected recommendations." + ) + version = "1.0.0" + + config_model = GithubRepoInspectorConfig + auth_model = NoAuth + + llm_provisioning = LLMProvisioning.PLATFORM + pricing = Pricing( + price_per_call_usd=0.0, + caller_pays_llm=False, + notes="Deterministic GitHub inspection. Reads only ctx.llm platform grant metadata; never reads provider keys directly.", + ) + resources = Resources(cpu="1", memory="1Gi", max_runtime_seconds=900) + egress = EgressPolicy(allow_hosts=("api.github.com",)) + workspace_access = WorkspaceAccess.dynamic( + max_files=128, + allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY), + require_reason=False, + ) + tools_used = ("github-rest-api", "httpx", "deterministic-static-analysis") + + @skill( + description="Check GitHub authentication without logging or returning the token", + timeout_seconds=30, + idempotent=True, + cost_class="cheap", + ) + async def check_auth( + self, + ctx: RunContext[NoAuth], + github_token: str = "", + ) -> dict[str, Any]: + _ = ctx.llm # Read the scoped platform grant object without exposing it. + token = _resolve_github_token(ctx, github_token) + if not token: + return { + "ok": False, + "authenticated": False, + "message": "Provide github_token or configure a consumer secret named github_token. Required scopes: repo/read for private repository inspection; issues:write or repo for issue creation.", + } + async with _github_client(self.config.github_api_base, token) as client: + result = await _github_get(client, "/user") + if not result["ok"]: + return _error_result("github_auth_failed", result) + data = result["json"] + return { + "ok": True, + "authenticated": True, + "login": data.get("login"), + "id": data.get("id"), + "scopes": result.get("scopes", []), + "rate_limit_remaining": result.get("rate_limit_remaining"), + "message": "Authenticated. Tokens are never stored or returned.", + } + + @skill( + description="List GitHub repositories accessible to the supplied or configured GitHub token", + timeout_seconds=60, + idempotent=True, + cost_class="cheap", + ) + async def list_repositories( + self, + ctx: RunContext[NoAuth], + github_token: str = "", + visibility: str = "all", + affiliation: str = "owner,collaborator,organization_member", + per_page: int = 100, + max_pages: int = 10, + ) -> dict[str, Any]: + _ = ctx.llm + await ctx.emit_progress("auth") + token = _resolve_github_token(ctx, github_token) + if not token: + return _missing_token_result() + per_page = max(1, min(int(per_page), 100)) + max_pages = max(1, min(int(max_pages), 50)) + repos: list[dict[str, Any]] = [] + async with _github_client(self.config.github_api_base, token) as client: + for page in range(1, max_pages + 1): + result = await _github_get( + client, + "/user/repos", + params={ + "visibility": visibility, + "affiliation": affiliation, + "sort": "full_name", + "direction": "asc", + "per_page": per_page, + "page": page, + }, + ) + if not result["ok"]: + return _error_result("github_repo_list_failed", result) + batch = result["json"] or [] + for repo in batch: + repos.append( + { + "owner": repo.get("owner", {}).get("login"), + "name": repo.get("name"), + "full_name": repo.get("full_name"), + "default_branch": repo.get("default_branch"), + "private": bool(repo.get("private")), + "archived": bool(repo.get("archived")), + "fork": bool(repo.get("fork")), + "html_url": repo.get("html_url"), + "description": repo.get("description"), + "language": repo.get("language"), + "updated_at": repo.get("updated_at"), + } + ) + await ctx.emit_progress(f"repo list page {page}: {len(repos)} repos") + if len(batch) < per_page: + break + return {"ok": True, "repositories": repos, "count": len(repos)} + + @skill( + description="Inspect a complete GitHub repository tree and return structured JSON plus markdown report with file-level citations", + timeout_seconds=900, + idempotent=True, + cost_class="analysis", + ) + async def inspect_repository( + self, + ctx: RunContext[NoAuth], + owner: str, + repo: str, + github_token: str = "", + ref: str = "", + include_hidden: bool = True, + max_files: int = 750, + max_bytes: int = 4_000_000, + max_file_bytes: int = 250_000, + ) -> dict[str, Any]: + creds = ctx.llm + await ctx.emit_progress("auth") + token = _resolve_github_token(ctx, github_token) + if not token: + return _missing_token_result() + + safe_owner = _clean_repo_part(owner) + safe_repo = _clean_repo_part(repo) + if not safe_owner or not safe_repo: + return {"ok": False, "error": {"code": "invalid_repo", "message": "owner and repo are required"}} + + max_files = max(1, min(int(max_files), 5000)) + max_bytes = max(10_000, min(int(max_bytes), 25_000_000)) + max_file_bytes = max(1_000, min(int(max_file_bytes), 2_000_000)) + + async with _github_client(self.config.github_api_base, token) as client: + repo_result = await _github_get(client, f"/repos/{safe_owner}/{safe_repo}") + if not repo_result["ok"]: + return _error_result("github_repo_metadata_failed", repo_result) + repo_meta = repo_result["json"] + target_ref = ref.strip() or repo_meta.get("default_branch") or "HEAD" + + await ctx.emit_progress("branch/ref") + branch_result = await _github_get(client, f"/repos/{safe_owner}/{safe_repo}/branches") + branches = [] + if branch_result["ok"]: + branches = [ + {"name": item.get("name"), "protected": item.get("protected", False)} + for item in (branch_result["json"] or []) + ] + + await ctx.emit_progress("tree fetch") + tree_result = await _github_get( + client, + f"/repos/{safe_owner}/{safe_repo}/git/trees/{target_ref}", + params={"recursive": "1"}, + ) + if not tree_result["ok"]: + return _error_result("github_tree_fetch_failed", tree_result) + tree_json = tree_result["json"] or {} + tree_entries = tree_json.get("tree") or [] + if not tree_entries: + return { + "ok": True, + "repository": _repo_payload(repo_meta, target_ref, branches), + "report": { + "markdown": f"# Repository inspection: {safe_owner}/{safe_repo}\n\nThe repository tree is empty for ref `{target_ref}`.", + "json": {}, + }, + "warnings": ["Repository tree was empty for the selected ref."], + } + + await ctx.emit_progress("classification") + classified, skipped, candidate_files = _classify_tree(tree_entries, include_hidden) + limited_candidates = [] + total_candidate_bytes = 0 + for item in candidate_files: + if len(limited_candidates) >= max_files: + skipped.append(_skip_record(item, "max_files_limit", f"max_files={max_files} reached")) + continue + size = int(item.get("size") or 0) + if size > max_file_bytes: + skipped.append(_skip_record(item, "too_large", f"file size {size} exceeds max_file_bytes={max_file_bytes}")) + continue + if total_candidate_bytes + size > max_bytes: + skipped.append(_skip_record(item, "max_bytes_limit", f"aggregate text byte budget max_bytes={max_bytes} reached")) + continue + limited_candidates.append(item) + total_candidate_bytes += size + + await ctx.emit_progress(f"file fetch: {len(limited_candidates)} text files") + files: list[RepoFile] = [] + for index, item in enumerate(limited_candidates, start=1): + await ctx.check_cancelled() + if index == 1 or index % 25 == 0: + await ctx.emit_progress(f"file fetch {index}/{len(limited_candidates)}") + blob = await _github_get(client, f"/repos/{safe_owner}/{safe_repo}/git/blobs/{item['sha']}") + if not blob["ok"]: + skipped.append(_skip_record(item, "blob_fetch_failed", _github_error_message(blob))) + continue + repo_file = _decode_blob(item, blob["json"] or {}) + if repo_file is None: + skipped.append(_skip_record(item, "decode_failed", "GitHub blob could not be decoded as supported text")) + continue + if _looks_binary(repo_file.text): + skipped.append(_skip_record(item, "binary_content", "NUL/control-byte heuristic indicates binary content")) + continue + files.append(repo_file) + + await ctx.emit_progress("analysis") + analysis = _analyze_repository( + repo_meta=repo_meta, + ref=target_ref, + branches=branches, + tree_entries=tree_entries, + classified=classified, + files=files, + skipped=skipped, + limits={"max_files": max_files, "max_bytes": max_bytes, "max_file_bytes": max_file_bytes}, + llm_creds=creds, + ) + markdown = _render_markdown(analysis) + await ctx.emit_progress("report") + artifact = await ctx.write_artifact( + f"github-repo-inspection-{safe_owner}-{safe_repo}.md", + markdown.encode("utf-8"), + "text/markdown", + ) + await ctx.emit_artifact(artifact) + analysis["artifacts"] = [{"name": artifact.name, "uri": artifact.uri, "mime_type": artifact.mime_type}] + return {"ok": True, "report": {"json": analysis, "markdown": markdown}, **analysis} + + @skill( + description="Create a GitHub issue for a confirmed repository inspection recommendation or risk", + timeout_seconds=60, + cost_class="write", + ) + async def create_issue( + self, + ctx: RunContext[NoAuth], + owner: str, + repo: str, + title: str, + body: str, + github_token: str = "", + labels: list[str] | None = None, + ) -> dict[str, Any]: + _ = ctx.llm + token = _resolve_github_token(ctx, github_token) + if not token: + return _missing_token_result(action="create issues") + safe_owner = _clean_repo_part(owner) + safe_repo = _clean_repo_part(repo) + clean_title = str(title or "").strip()[:256] + clean_body = _redact_secrets(str(body or ""))[:60_000] + if not clean_title or not clean_body: + return {"ok": False, "error": {"code": "invalid_issue", "message": "title and body are required"}} + payload: dict[str, Any] = {"title": clean_title, "body": clean_body} + if labels: + payload["labels"] = [str(label)[:50] for label in labels[:10]] + async with _github_client(self.config.github_api_base, token) as client: + result = await _github_post(client, f"/repos/{safe_owner}/{safe_repo}/issues", json_payload=payload) + if not result["ok"]: + return _error_result("github_issue_create_failed", result) + issue = result["json"] + return { + "ok": True, + "issue": { + "number": issue.get("number"), + "title": issue.get("title"), + "html_url": issue.get("html_url"), + "state": issue.get("state"), + }, + } + + def _build_deep_agent(self, *, ctx: RunContext[NoAuth], creds: LLMCreds) -> Any: + """Optional inner DeepAgent builder for future narrative enhancement. + + The public scanner is deterministic to guarantee full-tree coverage; + this builder wires packaged DeepAgents skills correctly without using + provider keys directly. + """ + if not creds.api_key: + raise RuntimeError("LLM credentials were not available from ctx.llm.") + from deepagents import create_deep_agent + from langchain_openai import ChatOpenAI + + model = ChatOpenAI(model=creds.model, base_url=creds.base_url, api_key=creds.api_key, temperature=0) + backend = ctx.workspace_backend() + skill_sources = _seed_runtime_skills(backend, ctx) + return create_deep_agent( + model=model, + backend=backend, + skills=skill_sources or None, + system_prompt=( + "You review repository inspection JSON and improve clarity without " + "inventing findings. Preserve file-level citations." + ), + ) + + +@dataclass +class RepoFile: + path: str + sha: str + size: int + category: str + text: str + lines: list[str] + + +def _github_client(api_base: str, token: str) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=api_base.rstrip("/"), + timeout=httpx.Timeout(30.0, connect=10.0), + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "a2a-github-repo-inspector/1.0", + }, + follow_redirects=True, + ) + + +async def _github_get(client: httpx.AsyncClient, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + try: + resp = await client.get(path, params=params) + return _response_payload(resp) + except httpx.HTTPError as exc: + return {"ok": False, "status_code": 0, "message": str(exc), "json": None} + + +async def _github_post(client: httpx.AsyncClient, path: str, json_payload: dict[str, Any]) -> dict[str, Any]: + try: + resp = await client.post(path, json=json_payload) + return _response_payload(resp) + except httpx.HTTPError as exc: + return {"ok": False, "status_code": 0, "message": str(exc), "json": None} + + +def _response_payload(resp: httpx.Response) -> dict[str, Any]: + try: + data = resp.json() if resp.content else None + except ValueError: + data = {"message": resp.text[:500]} + scopes = [s.strip() for s in resp.headers.get("x-oauth-scopes", "").split(",") if s.strip()] + payload = { + "ok": 200 <= resp.status_code < 300, + "status_code": resp.status_code, + "json": data, + "message": (data or {}).get("message") if isinstance(data, dict) else resp.text[:500], + "rate_limit_remaining": resp.headers.get("x-ratelimit-remaining"), + "rate_limit_reset": resp.headers.get("x-ratelimit-reset"), + "scopes": scopes, + } + return payload + + +def _resolve_github_token(ctx: RunContext[NoAuth], github_token: str = "") -> str: + token = str(github_token or "").strip() + if token: + return token + for name in ("github_token", "GITHUB_TOKEN"): + try: + token = ctx.consumer_secret(name).strip() + if token: + return token + except Exception: + pass + return "" + + +def _missing_token_result(action: str = "inspect repositories") -> dict[str, Any]: + return { + "ok": False, + "error": { + "code": "missing_github_token", + "message": f"Provide github_token or configure a consumer secret named github_token to {action}.", + "required_scopes": ["repo/read for private repositories", "issues:write or repo for issue creation"], + }, + } + + +def _error_result(code: str, result: dict[str, Any]) -> dict[str, Any]: + return { + "ok": False, + "error": { + "code": code, + "status_code": result.get("status_code"), + "message": _github_error_message(result), + "rate_limit_remaining": result.get("rate_limit_remaining"), + "rate_limit_reset": result.get("rate_limit_reset"), + }, + } + + +def _github_error_message(result: dict[str, Any]) -> str: + status = result.get("status_code") + msg = str(result.get("message") or "GitHub API request failed") + if status == 401: + return "GitHub authentication failed. Check token validity and scopes." + if status == 403 and result.get("rate_limit_remaining") == "0": + return f"GitHub rate limit exceeded. Reset epoch: {result.get('rate_limit_reset')}" + if status == 404: + return "Repository, ref, or resource was not found, or token lacks access." + return msg[:500] + + +def _clean_repo_part(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_.-]", "", str(value or "").strip())[:100] + + +def _repo_payload(meta: dict[str, Any], ref: str, branches: list[dict[str, Any]]) -> dict[str, Any]: + return { + "owner": meta.get("owner", {}).get("login"), + "name": meta.get("name"), + "full_name": meta.get("full_name"), + "default_branch": meta.get("default_branch"), + "selected_ref": ref, + "private": bool(meta.get("private")), + "html_url": meta.get("html_url"), + "description": meta.get("description"), + "language": meta.get("language"), + "branches": branches, + } + + +def _classify_tree(tree_entries: list[dict[str, Any]], include_hidden: bool) -> tuple[dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]: + categories: dict[str, list[dict[str, Any]]] = {cat: [] for cat in CATEGORY_ORDER} + skipped: list[dict[str, Any]] = [] + candidate_files: list[dict[str, Any]] = [] + for item in sorted(tree_entries, key=lambda x: x.get("path", "")): + path = item.get("path", "") + if not path: + continue + category = classify_path(path) + entry = { + "path": path, + "type": item.get("type"), + "mode": item.get("mode"), + "size": item.get("size"), + "sha": item.get("sha"), + "category": category, + "hidden": _is_hidden_path(path), + } + categories.setdefault(category, []).append(entry) + if item.get("type") != "blob": + continue + if _is_hidden_path(path) and not include_hidden: + skipped.append(_skip_record(entry, "hidden_excluded", "include_hidden=false")) + continue + skip_reason = _skip_reason_for_path(path) + if skip_reason: + skipped.append(_skip_record(entry, skip_reason[0], skip_reason[1])) + continue + candidate_files.append({**item, "category": category}) + return { + "categories": categories, + "tree": _tree_from_paths([item.get("path", "") for item in tree_entries if item.get("path")]), + "counts_by_category": {k: len(v) for k, v in categories.items()}, + "total_entries": len(tree_entries), + "total_files": sum(1 for item in tree_entries if item.get("type") == "blob"), + }, skipped, candidate_files + + +def classify_path(path: str) -> str: + p = path.replace("\\", "/") + lower = p.lower() + name = lower.rsplit("/", 1)[-1] + parts = lower.split("/") + ext = Path(name).suffix.lower() + if _is_hidden_path(p): + if name in {".github", ".gitlab-ci.yml", ".circleci"} or ".github/workflows/" in lower: + return "CI/CD" + if name in {".dockerignore"}: + return "Docker/container" + if name.startswith(".env") or name in {".npmrc", ".pypirc", ".netrc", ".gitignore", ".gitattributes", ".editorconfig", ".prettierrc", ".eslintrc"}: + return "hidden/dotfiles" + if ".github/workflows/" in lower or name in {"jenkinsfile", ".gitlab-ci.yml", "circle.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", "buildkite.yml"} or ".circleci/" in lower: + return "CI/CD" + if name == "dockerfile" or name.startswith("dockerfile") or name in {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", ".dockerignore"}: + return "Docker/container" + if any(part in {"test", "tests", "spec", "specs", "__tests__", "e2e"} for part in parts) or re.search(r"(test|spec)\.(py|js|jsx|ts|tsx|go|rs|rb|php|java)$", lower): + return "tests" + if name in {"readme.md", "readme.rst", "readme.txt", "license", "license.md", "changelog.md", "contributing.md", "code_of_conduct.md", "security.md"} or parts[0] in {"docs", "doc", "documentation", "examples"} or ext in {".md", ".rst"}: + return "docs" + if name in {"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "requirements.txt", "poetry.lock", "pyproject.toml", "pipfile", "pipfile.lock", "go.mod", "go.sum", "cargo.toml", "cargo.lock", "pom.xml", "build.gradle", "gradle.lockfile", "gemfile", "gemfile.lock", "composer.json", "composer.lock", "mix.exs", "deno.lock"}: + return "package/dependency" + if ext in {".tf", ".tfvars", ".hcl"} or any(part in {"terraform", "infra", "infrastructure", "k8s", "kubernetes", "helm", "charts", "ansible", "pulumi", "cloudformation"} for part in parts) or name in {"serverless.yml", "serverless.yaml", "template.yaml", "template.yml"}: + return "infra/IaC" + if parts[0] in {"scripts", "bin", "tools"} or ext in {".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd"}: + return "scripts" + if ext in {".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".conf", ".xml", ".properties"}: + return "config" + if ext in {".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".cs", ".cpp", ".c", ".h", ".swift", ".kt", ".scala", ".sql", ".graphql", ".proto", ".html", ".css", ".scss"}: + return "source" + return "assets/other" + + +def _is_hidden_path(path: str) -> bool: + return any(part.startswith(".") and part not in {".", ".."} for part in path.split("/")) + + +def _skip_reason_for_path(path: str) -> tuple[str, str] | None: + parts = path.replace("\\", "/").split("/") + for part in parts[:-1]: + if part.lower() in VENDOR_BUILD_CACHE_DIRS: + return ("vendor_build_cache", f"Skipped folder '{part}' as vendor/build/cache output") + ext = Path(path).suffix.lower() + if ext in BINARY_EXTENSIONS: + return ("binary_extension", f"Skipped binary/asset extension {ext}") + if ext and ext not in TEXT_EXTENSIONS and classify_path(path) == "assets/other": + guessed, _ = mimetypes.guess_type(path) + if guessed and not guessed.startswith("text/"): + return ("unsupported_asset", f"Skipped unsupported asset MIME guess {guessed}") + return None + + +def _skip_record(item: dict[str, Any], reason: str, detail: str) -> dict[str, Any]: + return { + "path": item.get("path"), + "sha": item.get("sha"), + "size": item.get("size"), + "category": item.get("category") or classify_path(str(item.get("path") or "")), + "reason": reason, + "detail": detail, + } + + +def _tree_from_paths(paths: list[str]) -> list[dict[str, Any]]: + # Compact flat tree is easier for UI filters while still preserving full paths. + return [{"path": path, "depth": path.count("/"), "name": path.rsplit("/", 1)[-1], "hidden": _is_hidden_path(path), "category": classify_path(path)} for path in sorted(paths)] + + +def _decode_blob(item: dict[str, Any], blob_json: dict[str, Any]) -> RepoFile | None: + content = blob_json.get("content") or "" + encoding = blob_json.get("encoding") + if encoding != "base64": + return None + try: + raw = base64.b64decode(content, validate=False) + except Exception: + return None + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + try: + text = raw.decode("latin-1") + except UnicodeDecodeError: + return None + text = text.replace("\r\n", "\n").replace("\r", "\n") + return RepoFile( + path=item.get("path", ""), + sha=item.get("sha", ""), + size=int(item.get("size") or len(raw)), + category=item.get("category") or classify_path(item.get("path", "")), + text=text, + lines=text.split("\n"), + ) + + +def _looks_binary(text: str) -> bool: + if "\x00" in text: + return True + if not text: + return False + sample = text[:4096] + control = sum(1 for ch in sample if ord(ch) < 9 or (13 < ord(ch) < 32)) + return control / max(len(sample), 1) > 0.05 + + +def _analyze_repository( + *, + repo_meta: dict[str, Any], + ref: str, + branches: list[dict[str, Any]], + tree_entries: list[dict[str, Any]], + classified: dict[str, Any], + files: list[RepoFile], + skipped: list[dict[str, Any]], + limits: dict[str, int], + llm_creds: LLMCreds, +) -> dict[str, Any]: + findings: list[dict[str, Any]] = [] + recommendations: list[dict[str, Any]] = [] + file_index = {f.path: f for f in files} + file_paths = {item.get("path", "") for item in tree_entries} + categories = classified["counts_by_category"] + + language_counter = _infer_languages(file_paths) + frameworks = _infer_frameworks(file_index, file_paths) + entrypoints = _infer_entrypoints(file_paths, file_index) + external_services = _infer_external_services(files) + + for repo_file in files: + findings.extend(_security_findings(repo_file)) + findings.extend(_todo_findings(repo_file)) + findings.extend(_commented_code_findings(repo_file)) + findings.extend(_docker_findings(repo_file)) + findings.extend(_ci_findings(repo_file)) + findings.extend(_iac_findings(repo_file)) + findings.extend(_dependency_findings(repo_file)) + findings.extend(_config_findings(repo_file)) + + structural_findings, structural_recs = _structural_findings_and_recs(file_paths, file_index, categories) + findings.extend(structural_findings) + recommendations.extend(structural_recs) + recommendations.extend(_recommendations_from_findings(findings)) + + scores = _score(findings, file_paths, categories, skipped) + architecture = { + "languages": language_counter, + "frameworks": frameworks, + "entrypoints": entrypoints, + "major_components": _major_components(file_paths), + "data_flow": _infer_data_flow(files, external_services), + "build_test_deploy_path": _infer_build_test_deploy(file_paths, file_index), + "external_services": external_services, + } + + skipped_by_reason = Counter(item["reason"] for item in skipped) + analysis = { + "repository": _repo_payload(repo_meta, ref, branches), + "inspection_metadata": { + "tree_entries_seen": len(tree_entries), + "files_seen": sum(1 for item in tree_entries if item.get("type") == "blob"), + "text_files_analyzed": len(files), + "bytes_analyzed": sum(f.size for f in files), + "limits": limits, + "llm_grant_source": getattr(llm_creds, "source", "platform"), + "note": "Findings are deterministic heuristics and evidence-backed; no secrets are stored or returned.", + }, + "repo_map": classified, + "skipped_files": skipped, + "skipped_summary": dict(skipped_by_reason), + "scorecard": scores, + "architecture_summary": architecture, + "findings": findings, + "recommendations": recommendations, + "issue_ready_payloads": [rec.get("issue_payload") for rec in recommendations if rec.get("issue_payload")], + } + return analysis + + +def _citation(repo_file: RepoFile, line_start: int, line_end: int | None = None) -> dict[str, Any]: + line_end = line_start if line_end is None else line_end + start = max(line_start, 1) + end = min(max(line_end, start), len(repo_file.lines)) + snippet = "\n".join(repo_file.lines[start - 1:end]) + return { + "path": repo_file.path, + "line_start": start, + "line_end": end, + "snippet": _redact_secrets(snippet)[:1200], + } + + +def _structure_citation(path: str, note: str) -> dict[str, Any]: + return {"path": path, "line_start": None, "line_end": None, "snippet": note, "evidence_type": "repository-structure"} + + +def _finding(category: str, title: str, severity: str, description: str, citation: dict[str, Any]) -> dict[str, Any]: + return { + "id": _slug(f"{category}-{title}-{citation.get('path')}-{citation.get('line_start')}"), + "category": category, + "title": title, + "severity": severity, + "description": description, + "citations": [citation], + } + + +def _security_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + out = [] + for i, line in enumerate(repo_file.lines, start=1): + for label, pattern in SECRET_REGEXES: + if pattern.search(line): + out.append(_finding("security/config", f"Possible hard-coded {label}", "high", "A secret-like value appears in source/config. Verify whether it is real and rotate if exposed.", _citation(repo_file, i))) + lower = line.lower() + if "cors" in lower and ("*" in line or "origin: true" in lower): + out.append(_finding("security/config", "Permissive CORS configuration", "medium", "CORS appears broadly permissive. Confirm this is intentional and constrained in production.", _citation(repo_file, i))) + if "csrf" in lower and ("false" in lower or "disable" in lower): + out.append(_finding("security/config", "CSRF protection disabled", "medium", "CSRF appears disabled in configuration or code.", _citation(repo_file, i))) + if "verify" in lower and "false" in lower and ("ssl" in lower or "tls" in lower or "cert" in lower): + out.append(_finding("security/config", "TLS/certificate verification disabled", "high", "TLS or certificate verification appears disabled.", _citation(repo_file, i))) + if re.search(r"0\.0\.0\.0|host\s*[:=]\s*['\"]?\*", line, re.I) and repo_file.category in {"config", "source", "Docker/container"}: + out.append(_finding("security/config", "Broad network binding", "low", "Service binds broadly; verify production network exposure and firewalling.", _citation(repo_file, i))) + if re.search(r"chmod\s+777|--privileged|privileged\s*[:=]\s*true", line, re.I): + out.append(_finding("security/config", "Overly broad permissions", "high", "Command or configuration grants broad filesystem/container permissions.", _citation(repo_file, i))) + if re.search(r"eval\(|exec\(|child_process|shell=True|pickle\.loads|yaml\.load\(", line): + out.append(_finding("security/config", "Dangerous execution/deserialization primitive", "medium", "Potentially dangerous execution or deserialization API appears in code. Review input trust boundaries.", _citation(repo_file, i))) + return out + + +def _todo_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + return [_finding("dead-code", "TODO/FIXME/HACK marker", "low", "Maintenance marker found; verify whether it represents unfinished production work.", _citation(repo_file, i)) for i, line in enumerate(repo_file.lines, start=1) if TODO_RE.search(line)] + + +def _commented_code_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + out = [] + for i, line in enumerate(repo_file.lines, start=1): + if COMMENTED_BLOCK_RE.search(line): + out.append(_finding("dead-code", "Commented-out code heuristic", "low", "Line looks like commented-out code. Remove or document if intentionally retained.", _citation(repo_file, i))) + return out[:20] + + +def _docker_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + if repo_file.category != "Docker/container": + return [] + out = [] + has_user = any(re.match(r"\s*USER\s+", line, re.I) for line in repo_file.lines) + for i, line in enumerate(repo_file.lines, start=1): + if re.search(r"FROM\s+[^\s:]+\s*$", line, re.I) or ":latest" in line: + out.append(_finding("ci-docker-infra", "Unpinned or latest Docker base image", "medium", "Docker base image appears unpinned or uses latest, reducing reproducibility.", _citation(repo_file, i))) + if re.search(r"curl .*\|\s*(sh|bash)|wget .*\|\s*(sh|bash)", line, re.I): + out.append(_finding("ci-docker-infra", "Remote script piped to shell", "high", "Docker build pipes remote content into a shell. Pin and verify downloaded artifacts.", _citation(repo_file, i))) + if "--no-check-certificate" in line or "strict-ssl false" in line.lower(): + out.append(_finding("ci-docker-infra", "TLS checks disabled in container build", "high", "Container build disables TLS verification.", _citation(repo_file, i))) + if repo_file.path.lower().endswith("dockerfile") and not has_user: + out.append(_finding("ci-docker-infra", "Dockerfile lacks non-root USER", "medium", "No USER directive was found; container may run as root by default.", _structure_citation(repo_file.path, "Dockerfile inspected; no USER directive found."))) + return out + + +def _ci_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + if repo_file.category != "CI/CD": + return [] + out = [] + for i, line in enumerate(repo_file.lines, start=1): + if re.search(r"pull_request_target|GITHUB_TOKEN|permissions:\s*write-all|contents:\s*write", line, re.I): + out.append(_finding("ci-docker-infra", "Risky CI/CD permission or trigger", "medium", "CI configuration uses a trigger or permission that should be reviewed for least privilege.", _citation(repo_file, i))) + if re.search(r"curl .*\|\s*(sh|bash)|wget .*\|\s*(sh|bash)", line, re.I): + out.append(_finding("ci-docker-infra", "Remote CI script piped to shell", "high", "CI downloads and executes remote content without visible verification.", _citation(repo_file, i))) + return out + + +def _iac_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + if repo_file.category != "infra/IaC": + return [] + out = [] + for i, line in enumerate(repo_file.lines, start=1): + if re.search(r"0\.0\.0\.0/0|::/0", line): + out.append(_finding("ci-docker-infra", "Broad network exposure in IaC", "high", "Infrastructure code references world-open CIDR. Confirm security-group/firewall intent.", _citation(repo_file, i))) + if re.search(r"publicly_accessible\s*=\s*true|public_access\s*=\s*true", line, re.I): + out.append(_finding("ci-docker-infra", "Publicly accessible infrastructure", "high", "IaC enables public accessibility. Confirm exposure is intentional and protected.", _citation(repo_file, i))) + return out + + +def _dependency_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + if repo_file.category != "package/dependency": + return [] + out = [] + for i, line in enumerate(repo_file.lines, start=1): + if re.search(r"[\"']\s*[:=]\s*[\"'][\^~*]|>=|latest", line) and repo_file.path.endswith(("package.json", "pyproject.toml", "requirements.txt", "Gemfile", "composer.json")): + out.append(_finding("dependency", "Broad or floating dependency version", "low", "Dependency version appears broad/floating. Pin or use lockfiles for reproducible builds.", _citation(repo_file, i))) + for pkg in RISKY_PACKAGE_HINTS: + if re.search(rf"[\"']?{re.escape(pkg)}[\"']?", line, re.I): + out.append(_finding("dependency", f"Known risky package name: {pkg}", "medium", "Package name has a history of incidents or deprecation. Verify necessity and current safety.", _citation(repo_file, i))) + return out + + +def _config_findings(repo_file: RepoFile) -> list[dict[str, Any]]: + out = [] + if repo_file.path.lower().endswith((".env", ".env.local", ".npmrc", ".pypirc", ".netrc")): + out.append(_finding("security/config", "Sensitive dotfile committed", "medium", "A file commonly used for local secrets/configuration is committed. Verify it contains only placeholders.", _structure_citation(repo_file.path, "Sensitive configuration filename present in repository."))) + return out + + +def _structural_findings_and_recs(file_paths: set[str], file_index: dict[str, RepoFile], categories: dict[str, int]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + findings = [] + recs = [] + lower_paths = {p.lower() for p in file_paths} + has_readme = any(Path(p).name.lower().startswith("readme") for p in file_paths) + has_tests = categories.get("tests", 0) > 0 + has_ci = categories.get("CI/CD", 0) > 0 + has_docker = categories.get("Docker/container", 0) > 0 + has_lock = any(Path(p).name.lower() in {"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "pipfile.lock", "go.sum", "cargo.lock", "gemfile.lock", "composer.lock", "gradle.lockfile", "deno.lock"} for p in file_paths) + has_pkg = categories.get("package/dependency", 0) > 0 + has_security = any(Path(p).name.lower() == "security.md" or p.lower() == ".github/security.md" for p in file_paths) + has_docs = categories.get("docs", 0) > 0 + root = sorted(p for p in file_paths if "/" not in p)[:1] + cite_path = root[0] if root else "repository-root" + if not has_readme: + findings.append(_finding("docs", "README missing", "medium", "No README file was found, which weakens onboarding and operability.", _structure_citation(cite_path, "No README path found in repository tree."))) + if not has_tests: + findings.append(_finding("tests", "Tests missing", "medium", "No test/spec paths were detected. Add automated tests or document external validation.", _structure_citation(cite_path, "No tests/spec paths found in repository tree."))) + if not has_ci: + findings.append(_finding("ci-docker-infra", "CI/CD workflow missing", "medium", "No common CI/CD workflow file was detected.", _structure_citation(cite_path, "No CI/CD paths found in repository tree."))) + if has_pkg and not has_lock: + findings.append(_finding("dependency", "Dependency lockfile missing", "medium", "Dependency manifests exist but no common lockfile was detected.", _structure_citation(cite_path, "Package/dependency files exist but no lockfile path was detected."))) + if not has_security: + recs.append(_recommendation("medium", "Add a SECURITY.md disclosure policy", "Document supported versions and vulnerability reporting channels.", [_structure_citation(cite_path, "No SECURITY.md found.")], "low", "medium", ["security", "documentation"])) + if not has_docs: + recs.append(_recommendation("medium", "Add basic repository documentation", "Add README/API docs/runbook/examples covering setup, configuration, testing, and deployment.", [_structure_citation(cite_path, "No docs/readme files found.")], "medium", "high", ["documentation"])) + if has_docker and ".dockerignore" not in lower_paths: + recs.append(_recommendation("medium", "Add .dockerignore", "Prevent secrets, build outputs, and dependency folders from entering Docker build context.", [_structure_citation(".dockerignore", "No .dockerignore found while Docker files are present.")], "low", "medium", ["docker", "security"])) + return findings, recs + + +def _recommendations_from_findings(findings: list[dict[str, Any]]) -> list[dict[str, Any]]: + recs: list[dict[str, Any]] = [] + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for finding in findings: + grouped[finding["title"]].append(finding) + for title, items in grouped.items(): + worst = _worst_severity([i["severity"] for i in items]) + first = items[0] + citations = [c for item in items[:5] for c in item.get("citations", [])] + fix = _fix_for_title(title, first["category"]) + recs.append(_recommendation(worst, fix["title"], fix["body"], citations, fix["effort"], fix["impact"], fix["labels"])) + return recs[:80] + + +def _fix_for_title(title: str, category: str) -> dict[str, Any]: + lower = title.lower() + if "secret" in lower or "token" in lower or "private key" in lower: + return {"title": "Remove and rotate possible committed secrets", "body": "Move secrets to a secret manager or CI variables, rotate exposed credentials, and add safe examples instead of real values.", "effort": "medium", "impact": "high", "labels": ["security", "secrets"]} + if "docker" in lower or category == "ci-docker-infra": + return {"title": f"Harden {title}", "body": "Pin versions, reduce permissions, verify downloads, and document the deployment safety control for the cited configuration.", "effort": "medium", "impact": "high", "labels": ["ci", "docker", "security"]} + if "dependency" in lower or category == "dependency": + return {"title": f"Improve dependency hygiene: {title}", "body": "Pin versions where appropriate, commit lockfiles, run audit tooling, and consider SBOM generation in CI.", "effort": "medium", "impact": "medium", "labels": ["dependencies"]} + if category == "tests": + return {"title": "Add automated test coverage", "body": "Add unit/integration tests for critical paths and wire them into CI.", "effort": "high", "impact": "high", "labels": ["testing"]} + if category == "docs": + return {"title": "Improve repository documentation", "body": "Add setup, configuration, test, deploy, API, and runbook documentation.", "effort": "medium", "impact": "high", "labels": ["documentation"]} + return {"title": f"Review and remediate: {title}", "body": "Review the cited evidence and either remediate the risk or document why it is acceptable.", "effort": "medium", "impact": "medium", "labels": [category.split("/")[0]]} + + +def _recommendation(severity: str, title: str, body: str, citations: list[dict[str, Any]], effort: str, impact: str, labels: list[str]) -> dict[str, Any]: + rec_id = _slug(f"rec-{title}-{citations[0].get('path') if citations else 'repo'}") + citation_lines = "\n".join(_format_citation_line(c) for c in citations[:10]) + issue_body = _redact_secrets(f"## Recommendation\n{body}\n\n## Evidence\n{citation_lines}\n\n## Suggested fix\n- Apply the remediation above.\n- Add or update tests/docs/CI checks as appropriate.\n- Re-run GitHub Repo Inspector to verify.\n") + return { + "id": rec_id, + "title": title, + "severity": severity, + "impact": impact, + "effort": effort, + "description": body, + "citations": citations, + "issue_payload": {"title": title[:256], "body": issue_body, "labels": labels[:10]}, + } + + +def _format_citation_line(c: dict[str, Any]) -> str: + if c.get("line_start"): + return f"- `{c.get('path')}:{c.get('line_start')}-{c.get('line_end')}`\n ```\n {c.get('snippet', '')[:500]}\n ```" + return f"- `{c.get('path')}` — {c.get('snippet', '')[:300]}" + + +def _score(findings: list[dict[str, Any]], file_paths: set[str], categories: dict[str, int], skipped: list[dict[str, Any]]) -> dict[str, Any]: + buckets = { + "security": 100, + "maintainability": 100, + "testing": 100, + "docs": 100, + "CI/CD": 100, + "deployability": 100, + "dependency_hygiene": 100, + "observability_configuration": 100, + } + penalties = {"critical": 25, "high": 16, "medium": 9, "low": 3} + for f in findings: + sev = f.get("severity", "low") + p = penalties.get(sev, 3) + cat = f.get("category") + if cat == "security/config": + buckets["security"] -= p + buckets["observability_configuration"] -= max(2, p // 3) + elif cat == "dependency": + buckets["dependency_hygiene"] -= p + elif cat == "tests": + buckets["testing"] -= p + elif cat == "docs": + buckets["docs"] -= p + elif cat == "ci-docker-infra": + buckets["CI/CD"] -= p + buckets["deployability"] -= max(2, p // 2) + elif cat == "dead-code": + buckets["maintainability"] -= min(p, 2) + if categories.get("tests", 0) == 0: + buckets["testing"] -= 20 + if categories.get("docs", 0) == 0: + buckets["docs"] -= 20 + if categories.get("CI/CD", 0) == 0: + buckets["CI/CD"] -= 20 + if not any(Path(p).name.lower() in {"dockerfile", "docker-compose.yml", "compose.yml", "serverless.yml", "template.yaml"} or p.endswith(".tf") for p in file_paths): + buckets["deployability"] -= 8 + if skipped: + buckets["maintainability"] -= min(10, len(skipped) // 20) + sub_scores = {k: max(0, min(100, int(v))) for k, v in buckets.items()} + total = int(round(sum(sub_scores.values()) / len(sub_scores))) + return { + "total": total, + "sub_scores": sub_scores, + "drivers": [f"{len(findings)} evidence-backed findings", f"{categories.get('tests', 0)} test files/entries", f"{categories.get('docs', 0)} docs files/entries", f"{categories.get('CI/CD', 0)} CI/CD entries", f"{len(skipped)} skipped/limited files explicitly reported"], + } + + +def _infer_languages(file_paths: set[str]) -> dict[str, int]: + mapping = {".py": "Python", ".js": "JavaScript", ".jsx": "JavaScript/React", ".ts": "TypeScript", ".tsx": "TypeScript/React", ".go": "Go", ".rs": "Rust", ".java": "Java", ".rb": "Ruby", ".php": "PHP", ".cs": "C#", ".cpp": "C++", ".c": "C", ".swift": "Swift", ".kt": "Kotlin", ".tf": "Terraform", ".sh": "Shell"} + counts = Counter(mapping.get(Path(p).suffix.lower()) for p in file_paths) + counts.pop(None, None) + return dict(counts.most_common()) + + +def _infer_frameworks(file_index: dict[str, RepoFile], file_paths: set[str]) -> list[str]: + frameworks = set() + pkg = file_index.get("package.json") + if pkg: + text = pkg.text.lower() + for name in ["react", "next", "vite", "express", "nestjs", "vue", "svelte"]: + if f'"{name}"' in text: + frameworks.add(name) + pyproject = file_index.get("pyproject.toml") or file_index.get("requirements.txt") + if pyproject: + text = pyproject.text.lower() + for name in ["django", "flask", "fastapi", "pydantic", "pytest"]: + if name in text: + frameworks.add(name) + if "go.mod" in file_paths: + frameworks.add("Go modules") + if "Cargo.toml" in file_paths: + frameworks.add("Cargo") + return sorted(frameworks) + + +def _infer_entrypoints(file_paths: set[str], file_index: dict[str, RepoFile]) -> list[str]: + candidates = [] + names = {"main.py", "app.py", "server.py", "index.js", "server.js", "main.go", "cmd/main.go", "Dockerfile", "package.json", "pyproject.toml"} + for p in sorted(file_paths): + if Path(p).name in names or p in names or p.startswith("cmd/"): + candidates.append(p) + return candidates[:30] + + +def _major_components(file_paths: set[str]) -> list[dict[str, Any]]: + top = Counter(p.split("/", 1)[0] for p in file_paths if "/" in p) + return [{"path": name, "files": count, "category": classify_path(name + "/")} for name, count in top.most_common(20)] + + +def _infer_external_services(files: list[RepoFile]) -> list[str]: + services = set() + patterns = {"postgres": "PostgreSQL", "mysql": "MySQL", "redis": "Redis", "mongodb": "MongoDB", "s3": "AWS S3", "stripe": "Stripe", "sendgrid": "SendGrid", "slack": "Slack", "openai": "OpenAI", "anthropic": "Anthropic", "kafka": "Kafka", "rabbitmq": "RabbitMQ"} + for f in files: + text = f.text.lower() + for needle, label in patterns.items(): + if needle in text: + services.add(label) + return sorted(services) + + +def _infer_data_flow(files: list[RepoFile], external_services: list[str]) -> str: + source_files = [f for f in files if f.category == "source"] + api_terms = sum(1 for f in source_files for term in ("router", "route", "controller", "handler", "endpoint") if term in f.text.lower()) + db_terms = sum(1 for f in source_files for term in ("database", "db", "query", "model", "schema", "migration") if term in f.text.lower()) + parts = [] + if api_terms: + parts.append("request/handler style application code") + if db_terms: + parts.append("database/model access paths") + if external_services: + parts.append("external services: " + ", ".join(external_services[:8])) + return "; ".join(parts) if parts else "Data flow could not be confidently inferred from deterministic heuristics." + + +def _infer_build_test_deploy(file_paths: set[str], file_index: dict[str, RepoFile]) -> dict[str, list[str]]: + build = [p for p in file_paths if Path(p).name.lower() in {"package.json", "pyproject.toml", "makefile", "pom.xml", "build.gradle", "go.mod", "cargo.toml"}] + test = [p for p in file_paths if classify_path(p) == "tests"][:30] + deploy = [p for p in file_paths if classify_path(p) in {"CI/CD", "Docker/container", "infra/IaC"}][:50] + return {"build": sorted(build), "test": sorted(test), "deploy": sorted(deploy)} + + +def _redact_secrets(text: str) -> str: + redacted = text + for label, pattern in SECRET_REGEXES: + def repl(match: re.Match[str]) -> str: + if label == "Generic assigned secret" and match.lastindex and match.lastindex >= 2: + return f"{match.group(1)}=" + value = match.group(0) + return value[:4] + "" + value[-4:] if len(value) > 12 else "" + redacted = pattern.sub(repl, redacted) + return redacted + + +def _worst_severity(severities: list[str]) -> str: + order = {"critical": 4, "high": 3, "medium": 2, "low": 1} + return max(severities or ["low"], key=lambda s: order.get(s, 0)) + + +def _slug(text: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:90] + + +def _render_markdown(analysis: dict[str, Any]) -> str: + repo = analysis["repository"] + score = analysis["scorecard"] + lines = [ + f"# Repository inspection: {repo.get('full_name')}", + "", + f"Selected ref: `{repo.get('selected_ref')}` ", + f"Production-readiness score: **{score['total']}/100**", + "", + "## Scorecard", + "", + ] + for name, value in score["sub_scores"].items(): + lines.append(f"- **{name}**: {value}/100") + lines += ["", "## Architecture summary", ""] + arch = analysis["architecture_summary"] + lines.append(f"- Languages: {', '.join(f'{k} ({v})' for k, v in arch['languages'].items()) or 'not confidently inferred'}") + lines.append(f"- Frameworks/tools: {', '.join(arch['frameworks']) or 'not confidently inferred'}") + lines.append(f"- Entrypoints: {', '.join(f'`{p}`' for p in arch['entrypoints'][:12]) or 'not detected'}") + lines.append(f"- Data flow: {arch['data_flow']}") + lines.append(f"- External services: {', '.join(arch['external_services']) or 'none detected by heuristics'}") + lines += ["", "## Repository map", ""] + for cat, count in analysis["repo_map"]["counts_by_category"].items(): + lines.append(f"- {cat}: {count}") + lines += ["", "## Findings", ""] + if not analysis["findings"]: + lines.append("No evidence-backed risks were detected by the configured heuristics.") + for f in analysis["findings"][:120]: + lines.append(f"### [{f['severity']}] {f['title']}") + lines.append(f"Category: {f['category']} ") + lines.append(f"{f['description']}") + for c in f.get("citations", []): + lines.append(_format_citation_line(c)) + lines.append("") + lines += ["", "## Recommended fixes", ""] + for r in analysis["recommendations"][:80]: + lines.append(f"### [{r['severity']}] {r['title']}") + lines.append(f"Impact: {r['impact']} · Effort: {r['effort']} ") + lines.append(r["description"]) + for c in r.get("citations", [])[:5]: + lines.append(_format_citation_line(c)) + lines.append("") + if analysis["skipped_files"]: + lines += ["", "## Skipped/limited files", ""] + for item in analysis["skipped_files"][:200]: + lines.append(f"- `{item.get('path')}` — {item.get('reason')}: {item.get('detail')}") + return "\n".join(lines) + + +def _runtime_skills_root(ctx: RunContext[Any]) -> str: + workspace = getattr(ctx, "_workspace", None) + prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ()) + if not prefixes: + outputs_prefix = getattr(workspace, "outputs_prefix", None) + prefixes = (outputs_prefix or "outputs/",) + prefix = str(prefixes[0]).strip("/") + return f"/{prefix}/{RUNTIME_SKILLS_DIR}" if prefix else f"/{RUNTIME_SKILLS_DIR}" + + +def _seed_runtime_skills(backend: Any, ctx: RunContext[Any]) -> list[str]: + root = Path(__file__).parent / "skills" + if not root.exists(): + return [] + runtime_skills_root = _runtime_skills_root(ctx) + uploads: list[tuple[str, bytes]] = [] + for path in root.rglob("*"): + if path.is_file(): + rel = path.relative_to(root).as_posix() + uploads.append((runtime_skills_root + rel, path.read_bytes())) + if uploads: + backend.upload_files(uploads) + return [runtime_skills_root] + return [] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..13fdc71 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + github-repo-inspector app + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..92647ee --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "github-repo-inspector-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..88ccc26 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,283 @@ +import { useEffect, useMemo, useState } from "react"; +import { callSkill, loadAgentConfig, loadSession } from "./a2a.js"; + +const PHASES = ["auth", "repo list", "tree fetch", "file fetch", "classification", "analysis", "report"]; +const CATEGORIES = ["source", "config", "CI/CD", "Docker/container", "tests", "docs", "package/dependency", "infra/IaC", "scripts", "hidden/dotfiles", "assets/other"]; + +export function App() { + const [config, setConfig] = useState(null); + const [session, setSession] = useState(null); + const [token, setToken] = useState(""); + const [repos, setRepos] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(""); + const [ref, setRef] = useState(""); + const [maxFiles, setMaxFiles] = useState(750); + const [maxBytes, setMaxBytes] = useState(4000000); + const [includeHidden, setIncludeHidden] = useState(true); + const [loadingRepos, setLoadingRepos] = useState(false); + const [scanning, setScanning] = useState(false); + const [phase, setPhase] = useState("auth"); + const [report, setReport] = useState(null); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [selectedCitation, setSelectedCitation] = useState(null); + const [error, setError] = useState(""); + const [issueState, setIssueState] = useState({}); + + useEffect(() => { + loadAgentConfig() + .then((data) => { + setConfig(data); + return loadSession(data).catch(() => null); + }) + .then(setSession) + .catch((err) => setError(err.message || String(err))); + }, []); + + const repoParts = useMemo(() => { + if (!selectedRepo) return null; + const [owner, name] = selectedRepo.split("/"); + return { owner, repo: name }; + }, [selectedRepo]); + + const json = report?.report?.json || report; + const tree = json?.repo_map?.tree || []; + const filteredTree = categoryFilter === "all" ? tree : tree.filter((item) => item.category === categoryFilter); + const findings = json?.findings || []; + const recommendations = json?.recommendations || []; + const groupedFindings = groupBy(findings, (item) => item.category || "other"); + + async function listRepos(event) { + event.preventDefault(); + if (!config) return; + setError(""); + setLoadingRepos(true); + setPhase("repo list"); + setReport(null); + try { + const result = await callSkill(config, "list_repositories", { github_token: token, per_page: 100, max_pages: 10 }); + if (!result.ok) throw new Error(result.error?.message || "Unable to list repositories"); + setRepos(result.repositories || []); + if (result.repositories?.[0]) setSelectedRepo(result.repositories[0].full_name); + } catch (err) { + setError(err.message || String(err)); + } finally { + setLoadingRepos(false); + } + } + + async function inspectRepo(event) { + event.preventDefault(); + if (!config || !repoParts) return; + setError(""); + setReport(null); + setScanning(true); + const localPhases = ["auth", "tree fetch", "classification", "file fetch", "analysis", "report"]; + let phaseIndex = 0; + setPhase(localPhases[phaseIndex]); + const interval = window.setInterval(() => { + phaseIndex = Math.min(phaseIndex + 1, localPhases.length - 1); + setPhase(localPhases[phaseIndex]); + }, 1200); + try { + const result = await callSkill(config, "inspect_repository", { + owner: repoParts.owner, + repo: repoParts.repo, + github_token: token, + ref, + include_hidden: includeHidden, + max_files: Number(maxFiles), + max_bytes: Number(maxBytes), + }); + if (!result.ok) throw new Error(result.error?.message || "Inspection failed"); + setReport(result); + setPhase("report"); + } catch (err) { + setError(err.message || String(err)); + } finally { + window.clearInterval(interval); + setScanning(false); + } + } + + async function createIssue(rec) { + if (!config || !repoParts) return; + const ok = window.confirm(`Create a GitHub issue for: ${rec.title}?`); + if (!ok) return; + setIssueState((prev) => ({ ...prev, [rec.id]: { loading: true } })); + try { + const result = await callSkill(config, "create_issue", { + owner: repoParts.owner, + repo: repoParts.repo, + github_token: token, + title: rec.issue_payload?.title || rec.title, + body: rec.issue_payload?.body || rec.description, + labels: rec.issue_payload?.labels || [], + }); + if (!result.ok) throw new Error(result.error?.message || "Issue creation failed"); + setIssueState((prev) => ({ ...prev, [rec.id]: { loading: false, url: result.issue?.html_url } })); + } catch (err) { + setIssueState((prev) => ({ ...prev, [rec.id]: { loading: false, error: err.message || String(err) } })); + } + } + + if (!config) { + return

{error || "Loading GitHub Repo Inspector…"}

; + } + + return ( +
+
+
+

Public A2A agent

+

GitHub Repo Inspector

+

Inspect full GitHub repository trees, hidden files, CI/CD, Docker, dependencies, docs, tests, IaC, and source code with exact citations.

+
+
+ {session?.authenticated ? "Signed in" : "Public app"} + {config.agent?.name} v{config.agent?.version} + Required scopes: repo/read · issues:write for issue creation +
+
+ +
+
+

1. Connect GitHub

+ + +

You can also call skills with a platform-configured consumer secret named github_token.

+
+ +
+

2. Select repository

+ + +
+ +
+
+ + +
+ +
+
+ + {error &&
{error}
} + +
+

Scan progress

+
+ {PHASES.map((item) => {item})} +
+
+ + {json && ( + <> +
+
+ Total score + {json.scorecard?.total ?? "—"} + /100 production readiness +
+ {Object.entries(json.scorecard?.sub_scores || {}).map(([name, value]) => ( +
{name}{value}
+ ))} +
+ +
+
+

Architecture summary

+
+
Languages
{formatMap(json.architecture_summary?.languages)}
+
Frameworks
{(json.architecture_summary?.frameworks || []).join(", ") || "Not inferred"}
+
Entrypoints
{(json.architecture_summary?.entrypoints || []).slice(0, 12).join(", ") || "Not detected"}
+
Data flow
{json.architecture_summary?.data_flow}
+
External services
{(json.architecture_summary?.external_services || []).join(", ") || "None detected"}
+
+
+ +
+

Repository map

+
+ + {CATEGORIES.map((cat) => )} +
+
+ {filteredTree.slice(0, 600).map((item) =>
{item.hidden ? "·" : ""}{item.path}{item.category}
)} +
+

Skipped/limited files: {json.skipped_files?.length || 0}

+
+
+ +
+

Risk cards

+
+ {Object.entries(groupedFindings).map(([group, items]) => ( +
+

{group}

+ {items.slice(0, 12).map((finding) => )} +
+ ))} + {!findings.length &&

No evidence-backed risks detected by the configured heuristics.

} +
+
+ +
+

Recommended fixes

+
+ {recommendations.map((rec) => ( +
+
+ [{rec.severity}] {rec.title} +

{rec.description}

+ Impact: {rec.impact} · Effort: {rec.effort} +
+
+ + + {issueState[rec.id]?.url && Created issue} + {issueState[rec.id]?.error && {issueState[rec.id].error}} +
+
+ ))} +
+
+ + )} + + {selectedCitation && setSelectedCitation(null)} />} +
+ ); +} + +function FindingCard({ finding, onCitation }) { + const first = finding.citations?.[0]; + return
{finding.title}

{finding.description}

; +} + +function CitationModal({ citation, onClose }) { + return
e.stopPropagation()}>

{citation.path}

Lines {citation.line_start || "structure"}{citation.line_end ? `-${citation.line_end}` : ""}

{citation.snippet}
; +} + +function groupBy(items, fn) { + return items.reduce((acc, item) => { + const key = fn(item); + acc[key] ||= []; + acc[key].push(item); + return acc; + }, {}); +} + +function formatMap(map = {}) { + const parts = Object.entries(map).map(([k, v]) => `${k} (${v})`); + return parts.join(", ") || "Not inferred"; +} diff --git a/frontend/src/a2a.js b/frontend/src/a2a.js new file mode 100644 index 0000000..dd521da --- /dev/null +++ b/frontend/src/a2a.js @@ -0,0 +1,55 @@ +export const CONFIG_URL = import.meta.env.DEV ? "/app/config.json" : "./config.json"; + +export async function requestJson(url, init = {}) { + const response = await fetch(url, { credentials: "same-origin", ...init }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!response.ok) { + const detail = data && (data.detail || data.message || data.raw); + throw new Error(detail || `request failed: ${response.status}`); + } + return data; +} + +export async function loadAgentConfig() { + const config = await requestJson(CONFIG_URL); + return config; +} + +export async function loadSession(config) { + if (!config?.endpoints?.session) return null; + return requestJson(config.endpoints.session); +} + +export async function requireSession(config) { + const session = await loadSession(config); + if (!session?.authenticated) { + const loginUrl = config.auth?.loginUrl; + if (loginUrl) { + const next = encodeURIComponent(window.location.href); + window.location.assign(`${loginUrl}${loginUrl.includes("?") ? "&" : "?"}next=${next}`); + } + throw new Error("sign in required"); + } + return session; +} + +export async function callSkill(config, skillName, args) { + if (config.auth?.invokeRequiresSession) { + await requireSession(config); + } + return requestJson(`${config.endpoints.invoke}/${encodeURIComponent(skillName)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ arguments: args }), + }); +} + +export function skillNames(config) { + return (config?.skills || []).map((skill) => skill.name); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..4e6531f --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.jsx"; +import "./style.css"; + +createRoot(document.getElementById("root")).render( + + + , +); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..af0afa2 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,72 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #070b12; + color: #eef4ff; +} +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #19345a 0, transparent 35%), #070b12; } +button, input, select, textarea { font: inherit; } +button { cursor: pointer; border: 0; border-radius: 10px; padding: 10px 14px; background: #57d6a6; color: #06120d; font-weight: 700; } +button:disabled { opacity: .55; cursor: not-allowed; } +code { color: #9ee9ff; } +.loading { min-height: 100vh; display: grid; place-items: center; color: #aebbd0; } +.app { width: min(1440px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 60px; } +.hero { display: grid; grid-template-columns: 1fr minmax(260px, 360px); gap: 22px; align-items: stretch; margin-bottom: 22px; } +.eyebrow { margin: 0 0 10px; color: #57d6a6; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; font-weight: 800; } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 14px; font-size: clamp(42px, 7vw, 92px); line-height: .88; letter-spacing: -0.07em; } +h2 { margin-bottom: 16px; font-size: 22px; letter-spacing: -0.02em; } +h3 { margin: 0 0 10px; color: #cdd9ea; } +.subtitle { max-width: 850px; color: #b9c7da; font-size: 19px; line-height: 1.55; } +.card, .status-card { border: 1px solid rgba(170, 205, 255, .14); border-radius: 20px; background: rgba(9, 17, 30, .82); box-shadow: 0 20px 50px rgba(0,0,0,.22); padding: 20px; backdrop-filter: blur(14px); } +.status-card { display: grid; align-content: center; gap: 8px; } +.status-card span, .hint, small { color: #8fa3bd; } +.status-card strong { font-size: 20px; } +.setup-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-bottom: 18px; } +.form { display: grid; gap: 14px; } +label { display: grid; gap: 7px; color: #d8e5f8; } +input, select, textarea { width: 100%; border: 1px solid rgba(170, 205, 255, .16); border-radius: 10px; color: #eef4ff; background: #07101d; padding: 11px 12px; outline: none; } +input:focus, select:focus { border-color: #57d6a6; } +.limits { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.toggles label { display: flex; align-items: center; gap: 8px; } +.toggles input { width: auto; } +.error { border: 1px solid #ff6b6b; color: #ffd0d0; background: rgba(130, 25, 25, .3); border-radius: 14px; padding: 14px 16px; margin-bottom: 18px; } +.progress { margin-bottom: 18px; } +.phase-row { display: flex; flex-wrap: wrap; gap: 9px; } +.phase { color: #92a4bb; border: 1px solid rgba(170, 205, 255, .15); border-radius: 999px; padding: 8px 11px; background: #08111f; } +.phase.active { color: #05150f; background: #57d6a6; border-color: #57d6a6; } +.score-grid { display: grid; grid-template-columns: 1.4fr repeat(4, minmax(120px, 1fr)); gap: 14px; margin-bottom: 18px; } +.score-main, .score { display: grid; gap: 4px; } +.score-main strong { font-size: 64px; line-height: .95; color: #57d6a6; } +.score strong { font-size: 34px; color: #9ee9ff; } +.score span, .score-main span { color: #b9c7da; text-transform: capitalize; } +.dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 18px; margin-bottom: 18px; } +.wide { min-width: 0; } +.arch { display: grid; grid-template-columns: 160px 1fr; gap: 12px; } +dt { color: #8fa3bd; } dd { margin: 0; color: #eef4ff; overflow-wrap: anywhere; } +.filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.filters button { background: #122136; color: #cfe0f6; font-weight: 600; padding: 8px 10px; } +.filters button.active { background: #57d6a6; color: #06120d; } +.tree { max-height: 430px; overflow: auto; border: 1px solid rgba(170, 205, 255, .10); border-radius: 14px; background: #050b14; } +.tree-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 12px; border-bottom: 1px solid rgba(170, 205, 255, .07); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } +.tree-row span { overflow-wrap: anywhere; } +.tree-row small { white-space: nowrap; } +.risk-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.risk-group { border: 1px solid rgba(170, 205, 255, .10); border-radius: 16px; padding: 14px; background: rgba(4, 10, 18, .62); } +.finding { display: grid; gap: 8px; border-left: 4px solid #7893b5; background: #07101d; border-radius: 12px; padding: 12px; margin-bottom: 10px; } +.finding.high { border-left-color: #ff6b6b; } .finding.medium { border-left-color: #ffd166; } .finding.low { border-left-color: #9ee9ff; } +.finding p, .rec p { color: #b9c7da; line-height: 1.45; margin-bottom: 0; } +.finding button, .rec-actions button { justify-self: start; background: #1c314f; color: #dcecff; } +.recs { display: grid; gap: 12px; } +.rec { display: grid; grid-template-columns: 1fr auto; gap: 18px; border: 1px solid rgba(170, 205, 255, .10); border-radius: 16px; padding: 16px; background: #07101d; } +.rec-actions { min-width: 190px; display: grid; align-content: start; gap: 8px; } +.rec-actions a { color: #57d6a6; font-weight: 800; } +.issue-error { color: #ffb4b4; } +.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.72); display: grid; place-items: center; padding: 20px; z-index: 20; } +.modal { width: min(900px, 100%); max-height: 85vh; overflow: auto; position: relative; } +.modal.card, .modal { border: 1px solid rgba(170, 205, 255, .18); border-radius: 20px; background: #08111f; padding: 22px; } +.close { position: absolute; top: 12px; right: 12px; width: 36px; height: 36px; padding: 0; border-radius: 50%; background: #1c314f; color: #fff; } +pre { overflow: auto; background: #02060c; color: #dbe9ff; border-radius: 12px; padding: 16px; line-height: 1.5; font-size: 13px; } +@media (max-width: 1000px) { .hero, .setup-grid, .dashboard, .rec { grid-template-columns: 1fr; } .score-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .risk-grid { grid-template-columns: 1fr; } .arch { grid-template-columns: 1fr; } } +@media (max-width: 600px) { .app { width: min(100% - 20px, 1440px); padding-top: 14px; } .limits, .score-grid { grid-template-columns: 1fr; } h1 { font-size: 48px; } } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..95967e9 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +const agent = process.env.A2A_DEV_AGENT_URL || "http://127.0.0.1:8000"; + +export default defineConfig({ + base: "./", + plugins: [react()], + server: { + proxy: { + "/app": agent, + "/invoke": agent, + "/auth": agent, + "/mcp": agent, + "/.well-known": agent, + }, + }, +}); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e074cf3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +# a2a-pack is auto-installed by the deploy build. +httpx>=0.27 +pydantic>=2 +# Optional inner DeepAgent dependencies; public inspection skills are deterministic. +deepagents>=0.5.0 +langchain>=0.3 +langchain-openai>=0.2 +langgraph>=0.6 diff --git a/skills/repo-inspection-review/SKILL.md b/skills/repo-inspection-review/SKILL.md new file mode 100644 index 0000000..cc5c0a1 --- /dev/null +++ b/skills/repo-inspection-review/SKILL.md @@ -0,0 +1,18 @@ +--- +name: repo-inspection-review +description: "Review repository inspection summaries for architecture, production readiness, and prioritized fixes using cited evidence. Use after deterministic GitHub file classification and risk scanning are complete." +--- +# repo-inspection-review + +# Repository Inspection Review + +When reviewing a repository inspection summary: + +1. Preserve exact citations from the deterministic scanner. Never invent files, line numbers, vulnerabilities, dependencies, or source snippets. +2. Summarize architecture from observed files only: tech stack, entrypoints, modules/services, data stores, integrations, deployment model, CI/CD flow, runtime/configuration model, and likely request/data flow. +3. Score production readiness conservatively. Security, dependency hygiene, test coverage, CI/CD, observability, configuration, deployment, docs, reliability, maintainability, and CI/CD should all be grounded in evidence. +4. Prioritize recommended fixes by risk and blast radius. Each fix should include severity, rationale, cited files, effort estimate, and implementation guidance. +5. If evidence is incomplete because large or binary files were skipped, say so explicitly and avoid overclaiming. +6. Secret-like snippets must remain redacted. Do not reconstruct or expose credentials. + +Return concise JSON-compatible prose fields only. Do not include markdown fences. diff --git a/skills/repository-inspection/SKILL.md b/skills/repository-inspection/SKILL.md new file mode 100644 index 0000000..fec2bbe --- /dev/null +++ b/skills/repository-inspection/SKILL.md @@ -0,0 +1,32 @@ +--- +name: repository-inspection +description: "Apply conservative GitHub repository inspection heuristics, citations, scoring, and issue-ready recommendations. Use when inspecting source trees, configs, CI/CD, Docker, dependencies, tests, docs, infra, scripts, hidden files, and security/config risks." +--- +# repository-inspection + +# Repository Inspection Rules + +Use deterministic evidence first. Every finding and recommendation must cite one or more repository files with line ranges and redacted snippets. Do not claim a vulnerability unless the cited file content shows the risky pattern. Prefer phrasing such as "possible", "appears", or "review" when the evidence is heuristic. + +Inspection phases: +1. Authenticate and enumerate repositories/branches. +2. Fetch the complete recursive tree metadata, including dotfiles and hidden paths. +3. Record skipped folders and files explicitly. Skip only binary files and common vendor/build/cache folders unless limits require additional truncation. +4. Fetch supported text blobs within byte/file limits. +5. Classify every tree entry by role: source, config, CI/CD, Docker/container, tests, docs, package/dependency, infra/IaC, scripts, hidden/dotfiles, assets/other. +6. Analyze every fetched text file line-by-line for secrets patterns, risky configuration, TODO/FIXME/HACK/XXX, dependency hygiene, CI/CD and Docker risks, docs/tests gaps, and architecture clues. +7. Produce both machine-readable JSON and a markdown summary. + +Citation rules: +- Include path, line_start, line_end, and a redacted snippet/excerpt whenever content is available. +- Redact likely secrets in snippets; never echo full tokens. +- If a finding is based only on file presence/absence, cite the nearest relevant file if possible or mark evidence_type as "repository-structure". + +Scoring rules: +- Start from 100 and subtract conservative penalties for evidenced risks and missing repo capabilities. +- Include sub-scores for security, maintainability, testing, docs, CI/CD, deployability, dependency hygiene, and observability/configuration. +- Explain score drivers in markdown. + +Issue payload rules: +- Each recommendation should include an issue_payload with title, body, and labels suitable for the create_issue skill. +- The body should include citations and concise remediation steps. diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..8367bc9 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,67 @@ +from agent import RepoFile, _analyze_repository, _redact_secrets +from a2a_pack.context import LLMCreds + + +def test_analysis_detects_security_dependency_tests_docs_and_scores(): + files = [ + RepoFile( + path="app.py", + size=80, + sha="a", + category="source", + text="DEBUG = True\nAPI_KEY='abcdef1234567890'\n# TODO fix auth\n", + lines=["DEBUG = True", "API_KEY='abcdef1234567890'", "# TODO fix auth", ""], + ), + RepoFile( + path="requirements.txt", + size=30, + sha="b", + category="package/dependency", + text="fastapi\nuvicorn==0.30.0\n", + lines=["fastapi", "uvicorn==0.30.0", ""], + ), + RepoFile( + path="Dockerfile", + size=30, + sha="c", + category="Docker/container", + text="FROM python\n", + lines=["FROM python", ""], + ), + ] + tree_entries = [ + {"path": "app.py", "type": "blob"}, + {"path": "requirements.txt", "type": "blob"}, + {"path": "Dockerfile", "type": "blob"}, + ] + classified = { + "counts_by_category": {"source": 1, "package/dependency": 1, "Docker/container": 1, "tests": 0, "docs": 0, "CI/CD": 0}, + "tree": [], + "categories": {}, + "total_entries": 3, + "total_files": 3, + } + report = _analyze_repository( + repo_meta={"owner": {"login": "octo"}, "name": "demo", "full_name": "octo/demo", "default_branch": "main"}, + ref="main", + branches=[], + tree_entries=tree_entries, + classified=classified, + files=files, + skipped=[], + limits={"max_files": 100, "max_bytes": 1000, "max_file_bytes": 1000}, + llm_creds=LLMCreds(base_url="", api_key="", model="", source="platform"), + ) + categories = {f["category"] for f in report["findings"]} + assert "security/config" in categories + assert "dependency" in categories + assert "tests" in categories + assert "docs" in categories + assert report["scorecard"]["total"] < 90 + assert any(c["path"] == "app.py" and "REDACTED" in c["snippet"] for f in report["findings"] for c in f["citations"]) + + +def test_redacts_secret_like_values(): + text = "API_KEY=sk_test_1234567890abcdef" + assert "sk_test_1234567890abcdef" not in _redact_secrets(text) + assert "" in _redact_secrets(text) diff --git a/tests/test_inspector.py b/tests/test_inspector.py new file mode 100644 index 0000000..f665016 --- /dev/null +++ b/tests/test_inspector.py @@ -0,0 +1,72 @@ +from agent import ( + RepoFile, + _citation, + _recommendation, + _redact_secrets, + _score, + classify_path, +) + + +def test_file_classification_core_categories(): + assert classify_path("src/app.py") == "source" + assert classify_path(".github/workflows/ci.yml") == "CI/CD" + assert classify_path("Dockerfile") == "Docker/container" + assert classify_path("tests/test_app.py") == "tests" + assert classify_path("README.md") == "docs" + assert classify_path("package-lock.json") == "package/dependency" + assert classify_path("infra/main.tf") == "infra/IaC" + assert classify_path("scripts/deploy.sh") == "scripts" + assert classify_path(".env.example") == "hidden/dotfiles" + + +def test_citation_extracts_line_range_and_redacts_secret(): + f = RepoFile( + path="config.py", + sha="abc", + size=50, + category="source", + text="SAFE=1\nAPI_KEY='supersecretvalue12345'\nEND=1", + lines=["SAFE=1", "API_KEY='supersecretvalue12345'", "END=1"], + ) + c = _citation(f, 2) + assert c["path"] == "config.py" + assert c["line_start"] == 2 + assert "supersecretvalue12345" not in c["snippet"] + assert "" in c["snippet"] + + +def test_redact_secret_patterns(): + text = "token = ghp_abcdefghijklmnopqrstuvwxyz1234567890 and password=abc123456789xyz" + redacted = _redact_secrets(text) + assert "ghp_abcdefghijklmnopqrstuvwxyz" not in redacted + assert "abc123456789xyz" not in redacted + assert "" in redacted + + +def test_scoring_penalizes_missing_repo_capabilities(): + score = _score( + findings=[{"severity": "high", "category": "security/config"}], + file_paths={"src/app.py", "package.json"}, + categories={"tests": 0, "docs": 0, "CI/CD": 0}, + skipped=[], + ) + assert 0 <= score["total"] <= 100 + assert score["sub_scores"]["security"] < 100 + assert score["sub_scores"]["testing"] < 100 + assert score["sub_scores"]["docs"] < 100 + + +def test_issue_payload_generation_contains_citations(): + rec = _recommendation( + "medium", + "Add .dockerignore", + "Prevent unwanted files from entering Docker build context.", + [{"path": ".dockerignore", "line_start": None, "line_end": None, "snippet": "No .dockerignore found."}], + "low", + "medium", + ["docker", "security"], + ) + assert rec["issue_payload"]["title"] == "Add .dockerignore" + assert "No .dockerignore found" in rec["issue_payload"]["body"] + assert "docker" in rec["issue_payload"]["labels"]