diff --git a/app/src/app/api/repos/[id]/git/route.ts b/app/src/app/api/repos/[id]/git/route.ts index 49a5c04..485b94f 100644 --- a/app/src/app/api/repos/[id]/git/route.ts +++ b/app/src/app/api/repos/[id]/git/route.ts @@ -11,11 +11,14 @@ import { push, commit, diffFile, + diffCommitsFile, + getCommitDiffFiles, restoreFile, log, withToken, isGithubHost, } from "@/lib/gitops"; +import { redactCredentials } from "@/lib/indexer"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -26,7 +29,10 @@ function hasStderr(e: unknown): e is { stderr: string } { function err(e: unknown, status = 500) { let msg = e instanceof Error ? e.message : String(e); if (hasStderr(e) && e.stderr.trim()) msg = e.stderr.trim(); - return NextResponse.json({ error: msg }, { status }); + // A failed push/clone-adjacent op embeds the token-bearing remote URL + // (https://x-access-token:@github.com/...) in execFile's error message + // and stderr; strip it before it reaches the client (matches F004/F017). + return NextResponse.json({ error: redactCredentials(msg) }, { status }); } // GET /api/repos/:id/git?op=status|branches|log|diff&path=&limit= @@ -49,6 +55,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: if (!p) return NextResponse.json({ error: "Missing path" }, { status: 400 }); return NextResponse.json({ diff: await diffFile(ws.dir, p) }); } + if (op === "diffFiles") { + const base = searchParams.get("base"); + const head = searchParams.get("head"); + if (!base || !head) return NextResponse.json({ error: "Missing base or head" }, { status: 400 }); + return NextResponse.json({ files: await getCommitDiffFiles(ws.dir, base, head) }); + } + if (op === "diffCommits") { + const base = searchParams.get("base"); + const head = searchParams.get("head"); + const p = searchParams.get("path"); + if (!base || !head || !p) return NextResponse.json({ error: "Missing base, head, or path" }, { status: 400 }); + return NextResponse.json({ diff: await diffCommitsFile(ws.dir, base, head, p) }); + } if (op === "saveMode") return NextResponse.json({ saveMode: getSaveMode(id) }); return NextResponse.json({ error: "Unknown op" }, { status: 400 }); } catch (e) { diff --git a/app/src/components/TimelineView.tsx b/app/src/components/TimelineView.tsx index b4c8e92..ebb96f3 100644 --- a/app/src/components/TimelineView.tsx +++ b/app/src/components/TimelineView.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState, useRef } from "react"; -import { Play, Pause, Loader2, ChevronLeft, ChevronRight, Info } from "lucide-react"; -import { timelineMetadata, timelineSnapshot, timelineBuild, timelineCompare } from "@/lib/api"; +import { Play, Pause, Loader2, ChevronLeft, ChevronRight, Info, FileText, X } from "lucide-react"; +import { timelineMetadata, timelineSnapshot, timelineBuild, timelineCompare, gitDiffFiles, gitDiffCommits } from "@/lib/api"; import type { TimelineSnapshot, ArchitectureSnapshot, ArchitectureEvolution } from "@/lib/gitops/timelineApi"; import { CirclePackView } from "@/components/CirclePackView"; @@ -20,6 +20,10 @@ export function TimelineView({ repoId }: { repoId: string }) { const [compareHead, setCompareHead] = useState(""); const [comparisonEvolution, setComparisonEvolution] = useState(null); const [comparing, setComparing] = useState(false); + const [changedFiles, setChangedFiles] = useState>([]); + const [selectedFile, setSelectedFile] = useState(null); + const [fileDiffText, setFileDiffText] = useState(null); + const [loadingDiff, setLoadingDiff] = useState(false); const playRef = useRef(undefined); useEffect(() => { @@ -122,7 +126,8 @@ export function TimelineView({ repoId }: { repoId: string }) { const currentMeta = snapshots[currentIndex]; return ( -
+ <> +
{/* Metrics Row */}
@@ -227,6 +232,18 @@ export function TimelineView({ repoId }: { repoId: string }) {
+ {/* Main View Issues */} + {currentGraph?.result?.issues && currentGraph.result.issues.length > 0 && ( +
+

Issues in this Snapshot ({currentGraph.result.issues.length})

+
+ {currentGraph.result.issues.map((iss: any, i: number) => ( + + ))} +
+
+ )} + {/* Ad-Hoc Compare Section */}

Ad-Hoc Architecture Diff

@@ -262,8 +279,12 @@ export function TimelineView({ repoId }: { repoId: string }) { if (!compareBase || !compareHead) return; setComparing(true); try { - const evo = await timelineCompare(repoId, compareBase, compareHead); + const [evo, files] = await Promise.all([ + timelineCompare(repoId, compareBase, compareHead), + gitDiffFiles(repoId, compareBase, compareHead) + ]); setComparisonEvolution(evo); + setChangedFiles(files); } catch (err) { console.error(err); } finally { @@ -278,50 +299,178 @@ export function TimelineView({ repoId }: { repoId: string }) {
{comparisonEvolution && ( -
-
-

- Evolution Events ({compareBase.substring(0,7)} → {compareHead.substring(0,7)}) -

- {comparisonEvolution.events.map((e, i) => ( -
- {e.category} -
{e.title}
-
{e.description}
-
- ))} -
-
-
-

Metrics Shift

-
- - -
+
+
+
+

+ Evolution Events ({compareBase.substring(0,7)} → {compareHead.substring(0,7)}) +

+ {comparisonEvolution.events.map((e, i) => ( +
+ {e.category} +
{e.title}
+
{e.description}
+
+ ))} +
+ + {/* Changed Files Column */} +
+

+ Changed Files ({changedFiles.length}) +

+ {changedFiles.length === 0 ? ( +
No files changed.
+ ) : ( + changedFiles.map((file, i) => { + const parts = file.path.split('/'); + const fileName = parts.pop() || file.path; + return ( + + ); + }) + )}
- {comparisonEvolution.aiNarrative && ( -
-

AI Summary

-

{comparisonEvolution.aiNarrative.reason}

-

💡 {comparisonEvolution.aiNarrative.recommendation}

+
+
+

Metrics Shift

+
+ + +
- )} + + {comparisonEvolution.aiNarrative && ( +
+

AI Summary

+

{comparisonEvolution.aiNarrative.reason}

+

💡 {comparisonEvolution.aiNarrative.recommendation}

+
+ )} +
+ + {/* Issues Shift */} + {(comparisonEvolution.issueDiff?.introduced?.length > 0 || comparisonEvolution.issueDiff?.resolved?.length > 0) && ( +
+
+

+ Issues Introduced ({comparisonEvolution.issueDiff.introduced.length}) +

+ {comparisonEvolution.issueDiff.introduced.length === 0 ? ( +
No new issues introduced.
+ ) : ( + comparisonEvolution.issueDiff.introduced.map((iss: any, i: number) => ( + + )) + )} +
+
+

+ Issues Resolved ({comparisonEvolution.issueDiff.resolved.length}) +

+ {comparisonEvolution.issueDiff.resolved.length === 0 ? ( +
No issues resolved.
+ ) : ( + comparisonEvolution.issueDiff.resolved.map((iss: any, i: number) => ( + + )) + )} +
+
+ )}
)}
+ + {/* File Diff Modal */} + {selectedFile && ( +
+
+
+
+ +

{selectedFile}

+
+ +
+
+ {loadingDiff ? ( +
+ Loading diff... +
+ ) : ( +
+ {!fileDiffText ? ( + No changes visible. + ) : ( + fileDiffText.split("\n").map((line, i) => { + let lineClass = "px-4 py-0.5 w-full "; + if (line.startsWith("+") && !line.startsWith("+++")) { + lineClass += "bg-green-500/10 text-green-400"; + } else if (line.startsWith("-") && !line.startsWith("---")) { + lineClass += "bg-red-500/10 text-red-400"; + } else if (line.startsWith("@@")) { + lineClass += "bg-cyan-500/10 text-cyan-400 mt-2 mb-1 rounded"; + } else if (line.startsWith("+++") || line.startsWith("---")) { + lineClass += "text-gray-500 font-bold bg-white/5"; + } else { + lineClass += "text-gray-400"; + } + return ( + + {line || " "} + + ); + }) + )} +
+ )} +
+
+
+ )} + ); } @@ -344,3 +493,25 @@ function MetricCard({ label, value, sub, info }: { label: string, value: string
); } + +const SEV_COLOR: Record = { + 5: "text-rose-400 bg-rose-500/10 border-rose-500/20", + 4: "text-orange-400 bg-orange-500/10 border-orange-500/20", + 3: "text-amber-400 bg-amber-500/10 border-amber-500/20", + 2: "text-yellow-300 bg-yellow-500/10 border-yellow-500/20", + 1: "text-gray-400 bg-white/5 border-white/10", +}; + +function IssueItem({ issue }: { issue: any }) { + return ( +
+ + S{issue.severity} + +
+
{issue.title}
+
{issue.file}:{issue.line}
+
+
+ ); +} diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts index dcb893b..767e9c2 100644 --- a/app/src/lib/api.ts +++ b/app/src/lib/api.ts @@ -298,6 +298,18 @@ export async function gitDiff(repoId: string, path: string): Promise { const d = await asJson<{ diff: string }>(res); return d.diff; } +export async function gitDiffFiles(repoId: string, base: string, head: string): Promise> { + const res = await fetch(`/api/repos/${repoId}/git?op=diffFiles&base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`, { cache: "no-store" }); + const d = await asJson<{ files: Array<{ status: string, path: string }> }>(res); + return d.files; +} + +export async function gitDiffCommits(repoId: string, base: string, head: string, path: string): Promise { + const res = await fetch(`/api/repos/${repoId}/git?op=diffCommits&base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}&path=${encodeURIComponent(path)}`, { cache: "no-store" }); + const d = await asJson<{ diff: string }>(res); + return d.diff; +} + export async function gitRestoreFile(repoId: string, path: string): Promise { await gitPost(repoId, { op: "restore", path }); diff --git a/app/src/lib/gitops.ts b/app/src/lib/gitops.ts index d407161..167db37 100644 --- a/app/src/lib/gitops.ts +++ b/app/src/lib/gitops.ts @@ -142,6 +142,31 @@ export async function diffFile(dir: string, relPath: string): Promise { } } +export async function diffCommitsFile(dir: string, base: string, head: string, relPath: string): Promise { + try { + return await git(dir, ["diff", `${base}..${head}`, "--", relPath]); + } catch { + return ""; + } +} + +export async function getCommitDiffFiles(dir: string, base: string, head: string): Promise> { + try { + const out = await git(dir, ["diff", "--name-status", `${base}..${head}`]); + if (!out.trim()) return []; + return out.trim().split("\n").map(line => { + const [status, ...pathParts] = line.split("\t"); + return { status: status.trim()[0], path: pathParts.join("\t").trim() }; // Handle tab-separated rename paths if necessary by just taking the last or keeping it raw. Actually name-status with renames is `R100 \t old \t new`. + // To be safe: + }).map(item => { + const parts = item.path.split("\t"); + return { status: item.status, path: parts[parts.length - 1] }; + }); + } catch { + return []; + } +} + export async function restoreFile(dir: string, relPath: string): Promise { try { // use checkout instead of restore for maximum compatibility with older git versions diff --git a/app/src/lib/gitops/evolutionEngine.ts b/app/src/lib/gitops/evolutionEngine.ts index 4b1b9fc..6718537 100644 --- a/app/src/lib/gitops/evolutionEngine.ts +++ b/app/src/lib/gitops/evolutionEngine.ts @@ -1,7 +1,7 @@ import type { ArchitectureSnapshot } from "./historicalAnalysis"; import { generateNarrative } from "../agents/narrativeAgent"; import { diffSnapshots, type GraphDiff } from "./graphDiff"; -import type { CodeSymbol, GraphNode, ModuleNode } from "../types"; +import type { CodeSymbol, GraphNode, ModuleNode, Issue } from "../types"; export type EvolutionCategory = | "FEATURE_INTRODUCED" @@ -68,10 +68,16 @@ export interface FeatureEvolution { currentStatus: string; } +export interface IssueDiff { + introduced: Issue[]; + resolved: Issue[]; +} + export interface ArchitectureEvolution { metrics: ArchitectureMetrics; baselineMetrics?: ArchitectureMetrics; events: EvolutionEvent[]; + issueDiff: IssueDiff; moduleHealth: Record; featureEvolution: Record; aiNarrative?: { @@ -117,11 +123,13 @@ export async function analyzeEvolution( aiNarrative = await generateNarrative(oldMetrics, metrics, events); } - // 4. Compute Module Health + // 4. Compute Module Health & Issue Diff + const issueDiff = computeIssueDiff(older, newer); const moduleHealth = computeModuleHealth(older, newer, diff); return { metrics, + issueDiff, baselineMetrics, events, moduleHealth, @@ -130,6 +138,19 @@ export async function analyzeEvolution( }; } +function computeIssueDiff(older: ArchitectureSnapshot | null, newer: ArchitectureSnapshot): IssueDiff { + if (!older) return { introduced: newer.result.issues, resolved: [] }; + + const issueKey = (i: Issue) => `${i.file}::${i.title}`; + const oldKeys = new Set(older.result.issues.map(issueKey)); + const newKeys = new Set(newer.result.issues.map(issueKey)); + + return { + introduced: newer.result.issues.filter(i => !oldKeys.has(issueKey(i))), + resolved: older.result.issues.filter(i => !newKeys.has(issueKey(i))) + }; +} + function computeArchitectureMetrics(snap: ArchitectureSnapshot): ArchitectureMetrics { const nodes = snap.result.viz.nodes; const edges = snap.result.viz.edges;