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

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