deploy
This commit is contained in:
36
README.md
Normal file
36
README.md
Normal 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
11
a2a.yaml
Normal 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
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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
19
frontend/package.json
Normal 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
283
frontend/src/App.jsx
Normal 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
55
frontend/src/a2a.js
Normal 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
10
frontend/src/main.jsx
Normal 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
72
frontend/src/style.css
Normal 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
18
frontend/vite.config.js
Normal 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
8
requirements.txt
Normal 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
|
||||
18
skills/repo-inspection-review/SKILL.md
Normal file
18
skills/repo-inspection-review/SKILL.md
Normal 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.
|
||||
32
skills/repository-inspection/SKILL.md
Normal file
32
skills/repository-inspection/SKILL.md
Normal 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
67
tests/test_analysis.py
Normal 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
72
tests/test_inspector.py
Normal 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"]
|
||||
Reference in New Issue
Block a user