Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion core/analysis/classify_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import git
import json
from thefuzz import fuzz
from datetime import datetime

# fuzz matching tolerance
FUZZ_THRESHOLD = 80
Expand Down Expand Up @@ -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"]
Expand Down
10 changes: 8 additions & 2 deletions core/analysis/compute_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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)

Expand Down
11 changes: 8 additions & 3 deletions core/analysis/link_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
3 changes: 2 additions & 1 deletion core/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion core/ingestion/extract_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
22 changes: 17 additions & 5 deletions core/worker/tasks/analyzeRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
version: '3.8'

services:
db:
image: postgres:15
Expand All @@ -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:
Expand All @@ -21,9 +24,10 @@ services:
env_file:
- .env
depends_on:
- db
db:
condition: service_healthy
ports:
- "4000:4000"
- "4000:4000"

frontend:
build:
Expand Down
2 changes: 1 addition & 1 deletion web/src/app/analyze/[jobId]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default function AnalyzeStatusPage() {
setError(error)

if (status === "completed") {
setTimeout(() => router.push("/dashboard"), 2000)
setTimeout(() => router.push("/repo"), 2000)
}
}

Expand Down
191 changes: 191 additions & 0 deletions web/src/app/repo/RepoPageClient.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
<Toaster position="bottom-right" richColors />

{/* Header */}
<div className="flex flex-col gap-2 sm:gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Repositories{" "}
<span className="text-muted-foreground font-normal">
({meta.total})
</span>
</h1>
<p className="text-sm text-muted-foreground">
Explore all repositories analyzed or imported in Commit Guru.
</p>
</div>

{/* Mobile filter toggle (hidden on md+) */}
<div className="sm:hidden mt-2">
<Button
variant="outline"
className="w-full justify-center"
aria-expanded={showFilters}
aria-controls="repo-filters"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="h-4 w-4 mr-2" />
{showFilters ? "Hide filters" : "Show filters"}
</Button>
</div>
</div>

{/* Controls: always visible on md+, collapsible on mobile */}
<div
id="repo-filters"
className={`mt-4 ${showFilters ? "block" : "hidden"} sm:block`}
>
<RepoControls
q={q}
setQ={setQ}
visibility={visibility}
setVisibility={setVisibility}
status={status}
setStatus={setStatus}
owner={owner}
setOwner={setOwner}
facets={facets}
sort={sort}
order={order}
toggleSort={toggleSort}
setPage={setPage}
/>
</div>

{/* List */}
<RepoList
loading={loading}
error={err}
repos={repos}
analyze={analyze}
fmtDate={fmtDate}
/>

{/* Pagination */}
<RepoFooter
meta={meta}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
currentCount={repos.length}
/>
</div>
);
}
Loading