This commit is contained in:
a2a-platform
2026-06-04 00:33:54 +00:00
commit d143e62918
15 changed files with 1922 additions and 0 deletions

36
README.md Normal file
View File

@@ -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.

11
a2a.yaml Normal file
View File

@@ -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

1209
agent.py Normal file

File diff suppressed because it is too large Load Diff

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>github-repo-inspector app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

19
frontend/package.json Normal file
View File

@@ -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"
}
}

283
frontend/src/App.jsx Normal file
View File

@@ -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 <main className="loading"><p>{error || "Loading GitHub Repo Inspector…"}</p></main>;
}
return (
<main className="app">
<header className="hero">
<div>
<p className="eyebrow">Public A2A agent</p>
<h1>GitHub Repo Inspector</h1>
<p className="subtitle">Inspect full GitHub repository trees, hidden files, CI/CD, Docker, dependencies, docs, tests, IaC, and source code with exact citations.</p>
</div>
<div className="status-card">
<span>{session?.authenticated ? "Signed in" : "Public app"}</span>
<strong>{config.agent?.name} v{config.agent?.version}</strong>
<small>Required scopes: repo/read · issues:write for issue creation</small>
</div>
</header>
<section className="setup-grid">
<form className="card form" onSubmit={listRepos}>
<h2>1. Connect GitHub</h2>
<label>GitHub token (not stored)
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="ghp_… or fine-grained token" />
</label>
<button disabled={loadingRepos} type="submit">{loadingRepos ? "Loading repos…" : "List repositories"}</button>
<p className="hint">You can also call skills with a platform-configured consumer secret named <code>github_token</code>.</p>
</form>
<form className="card form" onSubmit={inspectRepo}>
<h2>2. Select repository</h2>
<label>Repository
<select value={selectedRepo} onChange={(e) => setSelectedRepo(e.target.value)}>
<option value="">Select a repository</option>
{repos.map((repo) => <option key={repo.full_name} value={repo.full_name}>{repo.full_name}{repo.private ? " (private)" : ""}</option>)}
</select>
</label>
<label>Branch/ref (optional)
<input value={ref} onChange={(e) => setRef(e.target.value)} placeholder="default branch" />
</label>
<div className="toggles">
<label><input type="checkbox" checked={includeHidden} onChange={(e) => setIncludeHidden(e.target.checked)} /> Include hidden/dotfiles</label>
</div>
<div className="limits">
<label>Max files <input type="number" value={maxFiles} min="1" max="5000" onChange={(e) => setMaxFiles(e.target.value)} /></label>
<label>Max bytes <input type="number" value={maxBytes} min="10000" max="25000000" onChange={(e) => setMaxBytes(e.target.value)} /></label>
</div>
<button disabled={scanning || !selectedRepo} type="submit">{scanning ? "Scanning…" : "Inspect repository"}</button>
</form>
</section>
{error && <div className="error">{error}</div>}
<section className="progress card">
<h2>Scan progress</h2>
<div className="phase-row">
{PHASES.map((item) => <span key={item} className={item === phase ? "phase active" : "phase"}>{item}</span>)}
</div>
</section>
{json && (
<>
<section className="score-grid">
<div className="card score-main">
<span>Total score</span>
<strong>{json.scorecard?.total ?? "—"}</strong>
<small>/100 production readiness</small>
</div>
{Object.entries(json.scorecard?.sub_scores || {}).map(([name, value]) => (
<div className="card score" key={name}><span>{name}</span><strong>{value}</strong></div>
))}
</section>
<section className="dashboard">
<div className="card wide">
<h2>Architecture summary</h2>
<dl className="arch">
<dt>Languages</dt><dd>{formatMap(json.architecture_summary?.languages)}</dd>
<dt>Frameworks</dt><dd>{(json.architecture_summary?.frameworks || []).join(", ") || "Not inferred"}</dd>
<dt>Entrypoints</dt><dd>{(json.architecture_summary?.entrypoints || []).slice(0, 12).join(", ") || "Not detected"}</dd>
<dt>Data flow</dt><dd>{json.architecture_summary?.data_flow}</dd>
<dt>External services</dt><dd>{(json.architecture_summary?.external_services || []).join(", ") || "None detected"}</dd>
</dl>
</div>
<div className="card wide">
<h2>Repository map</h2>
<div className="filters">
<button className={categoryFilter === "all" ? "active" : ""} onClick={() => setCategoryFilter("all")}>all</button>
{CATEGORIES.map((cat) => <button key={cat} className={categoryFilter === cat ? "active" : ""} onClick={() => setCategoryFilter(cat)}>{cat} ({json.repo_map?.counts_by_category?.[cat] || 0})</button>)}
</div>
<div className="tree">
{filteredTree.slice(0, 600).map((item) => <div key={item.path} className="tree-row" style={{ paddingLeft: 12 + item.depth * 14 }}><span>{item.hidden ? "·" : ""}{item.path}</span><small>{item.category}</small></div>)}
</div>
<p className="hint">Skipped/limited files: {json.skipped_files?.length || 0}</p>
</div>
</section>
<section className="card">
<h2>Risk cards</h2>
<div className="risk-grid">
{Object.entries(groupedFindings).map(([group, items]) => (
<div className="risk-group" key={group}>
<h3>{group}</h3>
{items.slice(0, 12).map((finding) => <FindingCard key={finding.id} finding={finding} onCitation={setSelectedCitation} />)}
</div>
))}
{!findings.length && <p>No evidence-backed risks detected by the configured heuristics.</p>}
</div>
</section>
<section className="card">
<h2>Recommended fixes</h2>
<div className="recs">
{recommendations.map((rec) => (
<article className="rec" key={rec.id}>
<div>
<strong>[{rec.severity}] {rec.title}</strong>
<p>{rec.description}</p>
<small>Impact: {rec.impact} · Effort: {rec.effort}</small>
</div>
<div className="rec-actions">
<button onClick={() => setSelectedCitation(rec.citations?.[0])} disabled={!rec.citations?.[0]}>View citation</button>
<button onClick={() => createIssue(rec)} disabled={issueState[rec.id]?.loading}>{issueState[rec.id]?.loading ? "Creating…" : "Create GitHub Issue"}</button>
{issueState[rec.id]?.url && <a href={issueState[rec.id].url} target="_blank" rel="noreferrer">Created issue</a>}
{issueState[rec.id]?.error && <small className="issue-error">{issueState[rec.id].error}</small>}
</div>
</article>
))}
</div>
</section>
</>
)}
{selectedCitation && <CitationModal citation={selectedCitation} onClose={() => setSelectedCitation(null)} />}
</main>
);
}
function FindingCard({ finding, onCitation }) {
const first = finding.citations?.[0];
return <article className={`finding ${finding.severity}`}><strong>{finding.title}</strong><p>{finding.description}</p><button onClick={() => onCitation(first)} disabled={!first}>View citation</button></article>;
}
function CitationModal({ citation, onClose }) {
return <div className="modal-backdrop" onClick={onClose}><div className="modal" onClick={(e) => e.stopPropagation()}><button className="close" onClick={onClose}>×</button><h2>{citation.path}</h2><p>Lines {citation.line_start || "structure"}{citation.line_end ? `-${citation.line_end}` : ""}</p><pre>{citation.snippet}</pre></div></div>;
}
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";
}

55
frontend/src/a2a.js Normal file
View File

@@ -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);
}

10
frontend/src/main.jsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
);

72
frontend/src/style.css Normal file
View File

@@ -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; } }

18
frontend/vite.config.js Normal file
View File

@@ -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,
},
},
});

8
requirements.txt Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

67
tests/test_analysis.py Normal file
View File

@@ -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 "<REDACTED>" in _redact_secrets(text)

72
tests/test_inspector.py Normal file
View File

@@ -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 "<REDACTED>" 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 "<REDACTED>" 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"]