diff --git a/core/analysis/classify_commits.py b/core/analysis/classify_commits.py index 875ecb4..404ed37 100644 --- a/core/analysis/classify_commits.py +++ b/core/analysis/classify_commits.py @@ -3,6 +3,7 @@ import git import json from thefuzz import fuzz +from datetime import datetime # fuzz matching tolerance FUZZ_THRESHOLD = 80 @@ -73,10 +74,21 @@ def classify_commits(repo_path): for commit in commits: message = commit.message.strip() classification = classifier.classify(message) + + stats = getattr(commit, 'stats', None) + files_changed = list((getattr(stats, "files", None) or {}).keys()) results.append({ "hash": commit.hexsha, "message": message, - "classification": classification + "classification": classification, + "author_name": getattr(commit.author, "name", None), + "author_email": getattr(commit.author, "email", None), + "authored_date": datetime.utcfromtimestamp(commit.authored_date).isoformat(), + "committer_name": getattr(commit.committer, "name", None), + "committer_email": getattr(commit.committer, "email", None), + "committed_date": datetime.utcfromtimestamp(commit.committed_date).isoformat(), + "is_merge": len(commit.parents) > 1, + "files_changed": files_changed, }) corrective_commits = [c for c in results if c["classification"] == "Corrective"] diff --git a/core/analysis/compute_metrics.py b/core/analysis/compute_metrics.py index 8ad6461..b2678c2 100644 --- a/core/analysis/compute_metrics.py +++ b/core/analysis/compute_metrics.py @@ -131,7 +131,13 @@ def getCommitStatsProperties(stats, commitFiles, devExperience, author, unixTime if p > 0: entropy -= p * math.log(p, 2) - return f', "la": {la}, "ld": {ld}, "lt": {lt}, "ns": {ns}, "nd": {nd}, "nf": {nf}, "entropy": {entropy}, "exp": {exp}, "ndev": {ndev}, "age": {age}, "nuc": {nuc}' + return { + "la": la, "ld": ld, "lt": lt, + "ns": ns, "nd": nd, "nf": nf, + "entropy": entropy, + "exp": exp, "ndev": ndev, "age": age, "nuc": nuc, + "rexp": 0, "sexp": 0 + } def log(repo_path): commitFiles = {} @@ -176,7 +182,7 @@ def log(repo_path): "author": author, "author_date": commit.author_date.isoformat(), "message": commit.msg, - "stats": metrics, + "stats": stat_props_str, } results.append(commit_obj) diff --git a/core/analysis/link_commits.py b/core/analysis/link_commits.py index 13f861b..6075a32 100644 --- a/core/analysis/link_commits.py +++ b/core/analysis/link_commits.py @@ -19,10 +19,15 @@ def __init__(self, repo): sys.exit(f"{repo} is not a git repo") self.repo = os.path.abspath(repo) - def _run(self,*a:Sequence[str])->str: + def _run(self, *a: Sequence[str]) -> str: try: - return subprocess.check_output(["git",*a],cwd=self.repo, - text=True,stderr=subprocess.DEVNULL) + return subprocess.check_output( + ["git", *a], + cwd=self.repo, + stderr=subprocess.DEVNULL, + encoding="utf-8", + errors="replace" + ) except subprocess.CalledProcessError as e: sys.exit(f"git {' '.join(a)} failed: {e}") diff --git a/core/db/schema.sql b/core/db/schema.sql index 498d1e4..6c0811e 100644 --- a/core/db/schema.sql +++ b/core/db/schema.sql @@ -54,7 +54,8 @@ CREATE TABLE commits ( is_linked BOOLEAN DEFAULT FALSE, contains_bug BOOLEAN DEFAULT FALSE, fixes JSONB, - created_at TIMESTAMP DEFAULT NOW() -- Date when the commit was ingested + created_at TIMESTAMP DEFAULT NOW(), -- Date when the commit was ingested + files_changed TEXT[] -- Array of file paths changed in the commit ); CREATE TABLE feedback ( diff --git a/core/ingestion/extract_commits.py b/core/ingestion/extract_commits.py index c407e4a..fbce548 100644 --- a/core/ingestion/extract_commits.py +++ b/core/ingestion/extract_commits.py @@ -33,7 +33,7 @@ def extract_commits(repo_path): # Collect stats (numstat equivalent) stats = commit.stats total = stats.total - files_changed = list(stats.files.keys()) + files_changed = list((stats.files or {}).keys()) insertions = total.get("insertions", 0) deletions = total.get("deletions", 0) diff --git a/core/worker/tasks/analyzeRepo.js b/core/worker/tasks/analyzeRepo.js index f8b9098..0f65cfa 100644 --- a/core/worker/tasks/analyzeRepo.js +++ b/core/worker/tasks/analyzeRepo.js @@ -50,12 +50,23 @@ async function storeCommits(commits, repoId) { INSERT INTO commits ( repository_id, hash, author_name, author_email, authored_date, committer_name, committer_email, committed_date, message, - classification, is_merged, created_at, contains_bug + classification, is_merged, files_changed, created_at, contains_bug ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, NOW(), FALSE - ) ON CONFLICT (hash) DO NOTHING + $10, $11, $12::text[], NOW(), FALSE + ) + ON CONFLICT (hash) DO UPDATE SET + author_name = EXCLUDED.author_name, + author_email = EXCLUDED.author_email, + authored_date = EXCLUDED.authored_date, + committer_name = EXCLUDED.committer_name, + committer_email = EXCLUDED.committer_email, + committed_date = EXCLUDED.committed_date, + message = EXCLUDED.message, + classification = EXCLUDED.classification, + is_merged = EXCLUDED.is_merged, + files_changed = EXCLUDED.files_changed `; for (const c of commits) { @@ -70,12 +81,13 @@ async function storeCommits(commits, repoId) { c.committed_date, c.message, c.classification || "None", - c.is_merge || false + c.is_merge || false, + (Array.isArray(c.files_changed) && c.files_changed.length ? c.files_changed : null) ]); } await client.query("COMMIT"); - return `${commits.length} commits inserted`; + return `${commits.length} commits inserted/updated`; } catch (e) { await client.query("ROLLBACK"); throw e; diff --git a/docker-compose.yml b/docker-compose.yml index 9605020..faa5b1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: db: image: postgres:15 @@ -11,6 +9,11 @@ services: - ./core/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql ports: - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-commitguru}"] + interval: 5s + timeout: 5s + retries: 10 backend: build: @@ -21,9 +24,10 @@ services: env_file: - .env depends_on: - - db + db: + condition: service_healthy ports: - - "4000:4000" + - "4000:4000" frontend: build: diff --git a/web/src/app/analyze/[jobId]/page.js b/web/src/app/analyze/[jobId]/page.js index 7df6a9a..6e4bb66 100644 --- a/web/src/app/analyze/[jobId]/page.js +++ b/web/src/app/analyze/[jobId]/page.js @@ -41,7 +41,7 @@ export default function AnalyzeStatusPage() { setError(error) if (status === "completed") { - setTimeout(() => router.push("/dashboard"), 2000) + setTimeout(() => router.push("/repo"), 2000) } } diff --git a/web/src/app/repo/RepoPageClient.js b/web/src/app/repo/RepoPageClient.js new file mode 100644 index 0000000..c65d0b7 --- /dev/null +++ b/web/src/app/repo/RepoPageClient.js @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Toaster, toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Filter } from "lucide-react"; + +import RepoControls from "@/components/repos/RepoControls"; +import RepoList from "@/components/repos/RepoList"; +import RepoFooter from "@/components/repos/RepoFooter"; +import { useDebounced, fmtDate } from "@/components/repos/utils"; + +export default function RepoPageClient() { + const router = useRouter(); + const pathname = usePathname(); // should be "/repo" + const sp = useSearchParams(); + + // URL-backed state + const [q, setQ] = useState(sp.get("q") || ""); + const [visibility, setVisibility] = useState(sp.get("visibility") || "all"); + const [status, setStatus] = useState(sp.get("status") || "all"); + const [sort, setSort] = useState(sp.get("sort") || "last_job"); + const [order, setOrder] = useState(sp.get("order") || "desc"); + const [owner, setOwner] = useState(sp.get("owner") || "all"); + const [pageSize, setPageSize] = useState(sp.get("pageSize") || "20"); + const [page, setPage] = useState(parseInt(sp.get("page") || "1", 10)); + + // data + const [data, setData] = useState({ + repos: [], + meta: { total: 0, pages: 1, page: 1 }, + facets: { status: {}, visibility: {} }, + }); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(""); + + // mobile filters toggle + const [showFilters, setShowFilters] = useState(false); + + // dev StrictMode guard + const fetchedOnce = useRef(false); + + const qDebounced = useDebounced(q, 450); + + const queryString = useMemo(() => { + const p = new URLSearchParams(); + if (qDebounced) p.set("q", qDebounced); + if (visibility !== "all") p.set("visibility", visibility); + if (status !== "all") p.set("status", status); + if (owner !== "all") p.set("owner", owner); + if (sort) p.set("sort", sort); + if (order) p.set("order", order); + if (pageSize) p.set("pageSize", String(pageSize)); + p.set("page", String(page)); + return p.toString(); + }, [qDebounced, visibility, status, owner, sort, order, pageSize, page]); + + async function fetchData(signal) { + setLoading(true); + setErr(""); + try { + const res = await fetch(`/api/repo?${queryString}`, { + signal, + headers: owner === "me" ? { "x-user-id": "demo-user" } : {}, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + setData(json); + } catch (e) { + if (e.name === "AbortError") { + return; + } + console.error(e); + setErr("Failed to load repositories."); + } finally { + setLoading(false); + } + } + + useEffect(() => { + // Only update the URL if it actually changed + const current = sp.toString(); + if (current !== queryString) { + router.replace(`${pathname}?${queryString}`, { scroll: false }); + } + + // Guard duplicate fetches in dev StrictMode + if (!fetchedOnce.current || process.env.NODE_ENV === "production") { + fetchedOnce.current = true; + } + + const ctrl = new AbortController(); + fetchData(ctrl.signal); + return () => ctrl.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryString]); + + function toggleSort(key) { + if (sort === key) setOrder((o) => (o === "asc" ? "desc" : "asc")); + else { + setSort(key); + setOrder("desc"); + } + } + + async function analyze(repoId) { + toast.error("Analysis is temporarily disabled."); + // If you later enable: + // toast.loading("Queuing analysis…", { id: repoId }); + // try { ... } catch { ... } + } + + const { repos, meta, facets } = data; + + return ( +
+ + + {/* Header */} +
+
+

+ Repositories{" "} + + ({meta.total}) + +

+

+ Explore all repositories analyzed or imported in Commit Guru. +

+
+ + {/* Mobile filter toggle (hidden on md+) */} +
+ +
+
+ + {/* Controls: always visible on md+, collapsible on mobile */} +
+ +
+ + {/* List */} + + + {/* Pagination */} + +
+ ); +} \ No newline at end of file diff --git a/web/src/app/repo/page.js b/web/src/app/repo/page.js index d1a3c67..cc89bb5 100644 --- a/web/src/app/repo/page.js +++ b/web/src/app/repo/page.js @@ -1,179 +1,10 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Toaster, toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Filter } from "lucide-react"; - -import RepoControls from "@/components/repos/RepoControls"; -import RepoList from "@/components/repos/RepoList"; -import RepoFooter from "@/components/repos/RepoFooter"; -import { useDebounced, fmtDate } from "@/components/repos/utils"; - -export default function ReposPage() { - const router = useRouter(); - const pathname = usePathname(); // should be "/repos" - const sp = useSearchParams(); - - // URL-backed state - const [q, setQ] = useState(sp.get("q") || ""); - const [visibility, setVisibility] = useState(sp.get("visibility") || "all"); - const [status, setStatus] = useState(sp.get("status") || "all"); - const [sort, setSort] = useState(sp.get("sort") || "last_job"); - const [order, setOrder] = useState(sp.get("order") || "desc"); - const [owner, setOwner] = useState(sp.get("owner") || "all"); - const [pageSize, setPageSize] = useState(sp.get("pageSize") || "20"); - const [page, setPage] = useState(parseInt(sp.get("page") || "1", 10)); - - // data - const [data, setData] = useState({ - repos: [], - meta: { total: 0, pages: 1, page: 1 }, - facets: { status: {}, visibility: {} }, - }); - const [loading, setLoading] = useState(true); - const [err, setErr] = useState(""); - - // mobile filters toggle - const [showFilters, setShowFilters] = useState(false); - - // dev StrictMode guard - const fetchedOnce = useRef(false); - - const qDebounced = useDebounced(q, 450); - - const queryString = useMemo(() => { - const p = new URLSearchParams(); - if (qDebounced) p.set("q", qDebounced); - if (visibility !== "all") p.set("visibility", visibility); - if (status !== "all") p.set("status", status); - if (owner !== "all") p.set("owner", owner); - if (sort) p.set("sort", sort); - if (order) p.set("order", order); - if (pageSize) p.set("pageSize", String(pageSize)); - p.set("page", String(page)); - return p.toString(); - }, [qDebounced, visibility, status, owner, sort, order, pageSize, page]); - - async function fetchData(signal) { - setLoading(true); - setErr(""); - try { - // ✅ plural API - const res = await fetch(`/api/repo?${queryString}`, { - signal, - headers: owner === "me" ? { "x-user-id": "demo-user" } : {}, - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - setData(json); - } catch (e) { - console.error(e); - setErr("Failed to load repositories."); - } finally { - setLoading(false); - } - } - - useEffect(() => { - // Only update the URL if it actually changed - const current = sp.toString(); - if (current !== queryString) { - router.replace(`${pathname}?${queryString}`, { scroll: false }); - } - - // Guard duplicate fetches in dev StrictMode - if (!fetchedOnce.current || process.env.NODE_ENV === "production") { - fetchedOnce.current = true; - } - - const ctrl = new AbortController(); - fetchData(ctrl.signal); - return () => ctrl.abort(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queryString]); - - function toggleSort(key) { - if (sort === key) setOrder((o) => (o === "asc" ? "desc" : "asc")); - else { - setSort(key); - setOrder("desc"); - } - } - - async function analyze(repoId) { - // currently disabled; keep toast so users know - toast.error("Analysis is temporarily disabled."); - // If you later enable: - // toast.loading("Queuing analysis…", { id: repoId }); - // try { ... } catch { ... } - } - - const { repos, meta, facets } = data; +import { Suspense } from "react"; +import RepoPageClient from "./RepoPageClient"; +export default function Page() { return ( -
- - - {/* Header */} -
-
-

- Repositories ({meta.total}) -

-

- Explore all repositories analyzed or imported in Commit Guru. -

-
- - {/* Mobile filter toggle (hidden on md+) */} -
- -
-
- - {/* Controls: always visible on md+, collapsible on mobile */} -
- -
- - {/* List */} - - - {/* Pagination */} - -
+ Loading…}> + + ); } \ No newline at end of file diff --git a/web/src/components/Repo/CommitItem.js b/web/src/components/Repo/CommitItem.js index ad1a072..66a2460 100644 --- a/web/src/components/Repo/CommitItem.js +++ b/web/src/components/Repo/CommitItem.js @@ -49,7 +49,7 @@ function MetricGroup({ title, children }) { export default function CommitItem({ commit }) { const [open, setOpen] = useState(false); - const isBuggy = !!commit.contains_bug; + const isBuggy = Array.isArray(commit.fixes) && commit.fixes.length > 0; const authoredOrCommitted = commit.authored_date || commit.committed_date; const totals = useMemo(() => ({