diff --git a/__tests__/integration/ai-review.test.js b/__tests__/integration/ai-review.test.js
index fd3c972..f49f64b 100644
--- a/__tests__/integration/ai-review.test.js
+++ b/__tests__/integration/ai-review.test.js
@@ -1703,16 +1703,18 @@ describe('ai-review', () => {
storeReview({ repo: 'owner/repo', prNumber: 1, status: 'open' });
storeReview({ repo: 'owner/repo', prNumber: 2, status: 'open' });
updateReviewStatus('owner/repo', 1, 'merged');
- expect(getReviews()[0].status).toBe('merged');
- expect(getReviews()[1].status).toBe('open');
+ // getReviews() returns newest-first, so [0] is prNumber:2, [1] is prNumber:1
+ expect(getReviews()[0].status).toBe('open');
+ expect(getReviews()[1].status).toBe('merged');
});
it('does not update reviews for different repo', () => {
storeReview({ repo: 'owner/repo-a', prNumber: 1, status: 'open' });
storeReview({ repo: 'owner/repo-b', prNumber: 1, status: 'open' });
updateReviewStatus('owner/repo-a', 1, 'closed');
- expect(getReviews()[0].status).toBe('closed');
- expect(getReviews()[1].status).toBe('open');
+ // getReviews() returns newest-first, so [0] is repo-b, [1] is repo-a
+ expect(getReviews()[0].status).toBe('open');
+ expect(getReviews()[1].status).toBe('closed');
});
it('returns 0 when no reviews match', () => {
diff --git a/src/ai-review.js b/src/ai-review.js
index d11a895..fdf478f 100644
--- a/src/ai-review.js
+++ b/src/ai-review.js
@@ -427,8 +427,28 @@ function storeReview(entry) {
saveReviews();
}
-function getReviews() {
- return _reviews;
+function getReviews(opts = {}) {
+ const { limit, offset = 0 } = typeof opts === 'object' ? opts : {};
+ // Newest-first
+ const sorted = [..._reviews].reverse();
+ if (limit !== undefined && limit !== null) return sorted.slice(offset, offset + limit);
+ return sorted;
+}
+
+function getReviewStats() {
+ const now = Date.now();
+ const weekAgo = now - 7 * 24 * 60 * 60 * 1000;
+ let total = 0, approve = 0, minor = 0, major = 0, unknown = 0, thisWeek = 0, totalFindings = 0;
+ for (const r of _reviews) {
+ total++;
+ if (r.assessment === 'approve') approve++;
+ else if (r.assessment === 'minor') minor++;
+ else if (r.assessment === 'major') major++;
+ else unknown++;
+ if (r.timestamp && new Date(r.timestamp).getTime() > weekAgo) thisWeek++;
+ totalFindings += r.findings || 0;
+ }
+ return { total, approve, minor, major, unknown, thisWeek, avgFindings: total ? +(totalFindings / total).toFixed(1) : 0 };
}
/**
@@ -470,6 +490,7 @@ export {
supersedePreviousReviews,
storeReview,
getReviews,
+ getReviewStats,
updateReviewStatus,
_reviewTimestamps,
_resetReviews,
diff --git a/src/dashboard-client.js b/src/dashboard-client.js
index e8d3eac..47327ec 100644
--- a/src/dashboard-client.js
+++ b/src/dashboard-client.js
@@ -77,8 +77,13 @@ function toggleInsightsView(showInsights) {
}
function toggleReviewDetail(id) {
- var el = document.getElementById("review-detail-" + id);
- if (el) el.classList.toggle("open");
+ // Close any previously open detail row (accordion)
+ var prev = document.querySelector('tr.review-detail-row[style*="table-row"]');
+ if (prev && prev.getAttribute("data-pair-of") !== id) {
+ prev.style.display = "none";
+ }
+ var el = document.querySelector('tr.review-detail-row[data-pair-of="' + id + '"]');
+ if (el) el.style.display = el.style.display === "table-row" ? "none" : "table-row";
}
function runCmd(cmd, resultId) {
@@ -137,6 +142,14 @@ document.body.addEventListener("click", function(e) {
return;
}
+ // ── Review table row click → expand detail ──
+ var reviewRow = e.target.closest("tr.review-row");
+ if (reviewRow && !e.target.closest("a")) {
+ var rid = reviewRow.getAttribute("data-review-id");
+ if (rid) toggleReviewDetail(rid);
+ return;
+ }
+
// ── Table sorting ──
var th = e.target.closest("th[data-sort]");
if (!th) return;
@@ -144,7 +157,7 @@ document.body.addEventListener("click", function(e) {
var idx = Array.from(th.parentElement.children).indexOf(th);
var tbody = table.querySelector("tbody");
if (!tbody) return;
- var rows = Array.from(tbody.querySelectorAll("tr"));
+ var rows = Array.from(tbody.querySelectorAll("tr:not([data-pair-of])"));
var asc = th.getAttribute("data-sort") !== "asc";
th.parentElement.querySelectorAll("th[data-sort]").forEach(function(h) { h.setAttribute("data-sort",""); });
th.setAttribute("data-sort", asc ? "asc" : "desc");
@@ -153,7 +166,15 @@ document.body.addEventListener("click", function(e) {
var bt = (b.children[idx]||{}).textContent || "";
return (asc ? 1 : -1) * at.localeCompare(bt, undefined, {numeric:true});
});
- rows.forEach(function(r) { tbody.appendChild(r); });
+ rows.forEach(function(r) {
+ tbody.appendChild(r);
+ // Re-pair detail row after its data row
+ var pairId = r.getAttribute("data-review-id");
+ if (pairId) {
+ var detail = tbody.querySelector('tr[data-pair-of="' + pairId + '"]');
+ if (detail) tbody.appendChild(detail);
+ }
+ });
});
// ── Table filtering ──
@@ -163,18 +184,15 @@ document.body.addEventListener("input", function(e) {
var id = e.target.getAttribute("data-filter");
var tbody = document.querySelector("#" + id + " tbody");
if (!tbody) return;
- Array.from(tbody.querySelectorAll("tr")).forEach(function(row) {
- row.style.display = row.textContent.toLowerCase().indexOf(q) !== -1 ? "" : "none";
- });
-});
-
-// ── Reviews filter ──
-document.body.addEventListener("input", function(e) {
- if (e.target.id !== "reviews-filter") return;
- var q = e.target.value.toLowerCase();
- document.querySelectorAll(".review-entry").forEach(function(entry) {
- var filterText = entry.getAttribute("data-review-filter") || "";
- entry.style.display = filterText.toLowerCase().indexOf(q) !== -1 ? "" : "none";
+ Array.from(tbody.querySelectorAll("tr:not([data-pair-of])")).forEach(function(row) {
+ var match = row.textContent.toLowerCase().indexOf(q) !== -1;
+ row.style.display = match ? "" : "none";
+ // Cascade: hide/close paired detail row when data row is hidden
+ var pairId = row.getAttribute("data-review-id");
+ if (pairId) {
+ var detail = tbody.querySelector('tr[data-pair-of="' + pairId + '"]');
+ if (detail) detail.style.display = match ? detail.style.display : "none";
+ }
});
});
diff --git a/src/dashboard.js b/src/dashboard.js
index 6fba1d1..cc760fb 100644
--- a/src/dashboard.js
+++ b/src/dashboard.js
@@ -5,7 +5,7 @@ import { checkDependabotConfiguration, checkExistingDependabotConfig, fixDependa
import { generateConfigurationReport } from './reporting.js';
import { createAppAuth } from '@octokit/auth-app';
import { Octokit } from '@octokit/rest';
-import { getReviews, reviewPullRequest } from './ai-review.js';
+import { getReviews, getReviewStats, reviewPullRequest } from './ai-review.js';
// ── Cache ────────────────────────────────────────────────────────────
const cache = { data: null, timestamp: 0 };
@@ -375,7 +375,7 @@ function renderIssuesPartial(data) {
// ── AI Reviews partial ───────────────────────────────────────────────
function renderReviewsPartial() {
let reviews;
- try { reviews = getReviews(15); } catch { reviews = []; }
+ try { reviews = getReviews({ limit: 15 }); } catch { reviews = []; }
if (reviews.length === 0) {
return '
No AI reviews yet. Reviews are triggered automatically on PR open/sync, or manually via /review-pr command.
';
}
@@ -407,15 +407,108 @@ function renderReviewsPartial() {
}
// ── Full reviews partial (for dedicated Reviews tab) ─────────────────
-function renderReviewsFullPartial() {
- let reviews;
- try { reviews = getReviews(50); } catch { reviews = []; }
- const reviewCount = reviews.length;
- // Inject badge count update
- const badgeScript = `${reviewCount}`;
+function reviewAssessmentBadge(a) {
+ if (a === 'approve') return badge('ok', 'approve');
+ if (a === 'minor') return badge('warn', 'minor');
+ if (a === 'major') return badge('err', 'major');
+ return badge('muted', 'no verdict');
+}
- if (reviews.length === 0) {
+function reviewVerdictIcon(a) {
+ if (a === 'approve') return '✓';
+ if (a === 'minor') return '⚠';
+ if (a === 'major') return '✗';
+ return '?';
+}
+
+function renderReviewStatsBanner(stats) {
+ const cards = [
+ { label: 'Total', value: stats.total, cls: '' },
+ { label: 'Approved', value: stats.approve, cls: 'stat-green' },
+ { label: 'Minor', value: stats.minor, cls: 'stat-yellow' },
+ { label: 'Major', value: stats.major, cls: 'stat-red' },
+ { label: 'This Week', value: stats.thisWeek, cls: '' },
+ { label: 'Avg Findings', value: stats.avgFindings, cls: '' },
+ ];
+ const inner = cards.map(c =>
+ '' +
+ '
' + c.value + '
' +
+ '
' + c.label + '
' +
+ '
'
+ ).join('');
+ return '' + inner + '
';
+}
+
+function renderReviewRowPair(r) {
+ const id = esc(r.id || (r.repo + '-' + r.pr + '-' + r.timestamp));
+ const findingsTag = r.findings > 0
+ ? ' ' + badge(r.findings > 2 ? 'err' : 'warn', r.findings + ' finding' + (r.findings !== 1 ? 's' : ''))
+ : '';
+ const repoShort = (r.repo || '').split('/').pop();
+
+ const dataRow =
+ '' +
+ '| ' + reviewVerdictIcon(r.assessment) + ' | ' +
+ '' + esc(repoShort) + ' | ' +
+ '#' + r.pr + ' | ' +
+ '' + esc(r.title || '') + ' | ' +
+ '' + reviewAssessmentBadge(r.assessment) + findingsTag + ' | ' +
+ '' + (r.findings || 0) + ' | ' +
+ '' + esc(r.author || '') + ' | ' +
+ '' + esc(timeAgo(r.timestamp)) + ' | ' +
+ '
';
+
+ const detailRow =
+ '' +
+ '' +
+ '' +
+ ' Summary: ' + esc(r.summary || 'No summary available') + ' ' +
+ ' ' + (r.body || 'No review body stored') + ' ' +
+ ' ' +
+ ' ' +
+ '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' | ' +
+ '
';
+
+ return dataRow + detailRow;
+}
+
+function renderReviewsTable(reviews) {
+ const headers =
+ '' +
+ '| V | ' +
+ 'Repo | ' +
+ 'PR | ' +
+ 'Title | ' +
+ 'Assessment | ' +
+ 'Findings | ' +
+ 'Author | ' +
+ 'Age | ' +
+ '
';
+
+ const rows = reviews.map(renderReviewRowPair).join('');
+
+ return '' + headers + '' + rows + '
';
+}
+
+function renderReviewsFullPartial(url) {
+ const params = new URL(url || 'http://x/', 'http://x').searchParams;
+ const limit = parseInt(params.get('limit')) || 25;
+ const offset = parseInt(params.get('offset')) || 0;
+
+ let stats;
+ try { stats = getReviewStats(); } catch { stats = { total: 0, approve: 0, minor: 0, major: 0, unknown: 0, thisWeek: 0, avgFindings: 0 }; }
+
+ const badgeScript = '' + stats.total + '';
+
+ if (stats.total === 0) {
return badgeScript +
'' +
'
☰
' +
@@ -425,62 +518,47 @@ function renderReviewsFullPartial() {
'
';
}
- const assessmentBadge = (a) => {
- if (a === 'approve') return badge('ok', 'approve');
- if (a === 'minor') return badge('warn', 'minor');
- if (a === 'major') return badge('err', 'major');
- return badge('muted', 'no verdict');
- };
-
- const assessmentIcon = (a) => {
- if (a === 'approve') return '✓';
- if (a === 'minor') return '⚠';
- if (a === 'major') return '✗';
- return '?';
- };
-
- const rows = reviews.map(r => {
- const findingsTag = r.findings > 0
- ? ' ' + badge(r.findings > 2 ? 'err' : 'warn', r.findings + ' finding' + (r.findings !== 1 ? 's' : ''))
- : '';
- const id = esc(r.id || `${r.repo}-${r.pr}-${r.timestamp}`);
-
- return '' +
- // Header row (always visible)
- '' +
- // Detail (collapsed by default)
- '
' +
- '
' +
- 'Summary: ' + esc(r.summary || 'No summary available') +
- '
' +
- '
' + (r.body || 'No review body stored') + '
' +
- '
' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
';
- }).join('');
+ let reviews;
+ try { reviews = getReviews({ limit, offset }); } catch { reviews = []; }
+
+ const hasMore = (offset + limit) < stats.total;
+ const nextOffset = offset + limit;
+ const loadMoreBtn = hasMore
+ ? '' +
+ '' +
+ '
'
+ : '';
+
+ return badgeScript + renderReviewStatsBanner(stats) + renderReviewsTable(reviews) + loadMoreBtn;
+}
- return badgeScript + rows;
+function renderReviewsMorePartial(url) {
+ const params = new URL(url || 'http://x/', 'http://x').searchParams;
+ const limit = parseInt(params.get('limit')) || 25;
+ const offset = parseInt(params.get('offset')) || 0;
+
+ let reviews, stats;
+ try { reviews = getReviews({ limit, offset }); } catch { reviews = []; }
+ try { stats = getReviewStats(); } catch { stats = { total: 0 }; }
+
+ const rows = reviews.map(renderReviewRowPair).join('');
+
+ const hasMore = (offset + limit) < stats.total;
+ const nextOffset = offset + limit;
+ // OOB swap to update the load-more button
+ const oobBtn = hasMore
+ ? '' +
+ '' +
+ '
'
+ : '';
+
+ return rows + oobBtn;
}
// ── Static assets ────────────────────────────────────────────────────
@@ -684,6 +762,24 @@ th[data-sort="desc"]::after{content:" \\25BC";color:var(--accent)}
.empty-text{font-size:0.8rem;line-height:1.6}
.empty-text code{color:var(--accent);background:var(--surface);padding:0.1rem 0.3rem;border-radius:2px}
+/* ── Reviews stats banner ── */
+.reviews-stats-banner{display:grid;grid-template-columns:repeat(6,1fr);gap:0.5rem;margin-bottom:1rem}
+.reviews-stats-banner .stat-card{text-align:center;padding:0.6rem 0.4rem}
+.stat-green .stat-value{color:var(--green)}
+.stat-yellow .stat-value{color:var(--yellow)}
+.stat-red .stat-value{color:var(--red)}
+
+/* ── Reviews table ── */
+#reviews-table{width:100%}
+#reviews-table th{font-size:0.6rem;text-transform:uppercase;letter-spacing:0.08em;padding:0.4rem 0.5rem;cursor:pointer;user-select:none;white-space:nowrap}
+#reviews-table td{font-size:0.75rem;padding:0.45rem 0.5rem;vertical-align:middle}
+#reviews-table .review-row{cursor:pointer;transition:background .1s}
+#reviews-table .review-row:hover{background:var(--surface2)}
+.review-row-title{max-width:18rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.review-detail-row{background:var(--surface)}
+.review-detail-inner{padding:0.8rem}
+.reviews-load-more{text-align:center;padding:1rem 0}
+@media(max-width:768px){.reviews-stats-banner{grid-template-columns:repeat(3,1fr)}}
/* ── Insights ── */
.insights-toggle{display:flex;gap:0.35rem;align-items:center}
@@ -725,7 +821,7 @@ th[data-sort="desc"]::after{content:" \\25BC";color:var(--accent)}
// ── Review Insights ──────────────────────────────────────────────────
function computeReviewInsights() {
let reviews;
- try { reviews = getReviews(100); } catch { reviews = []; }
+ try { reviews = getReviews({ limit: 100 }); } catch { reviews = []; }
if (!reviews.length) return null;
const now = Date.now();
@@ -986,7 +1082,7 @@ function renderDashboardPage() {
'' +
'' +
'' +
- '' +
+ '' +
'' +
'' +
'' +
@@ -1152,10 +1248,16 @@ export function createDashboardHandler() {
// AI Reviews full partial (for dedicated Reviews tab)
if (req.method === 'GET' && path === '/dashboard/partials/reviews-full') {
- try { sendHtml(res, 200, renderReviewsFullPartial()); }
+ try { sendHtml(res, 200, renderReviewsFullPartial(req.url)); }
catch (err) { getLogger().error({err},'Dashboard reviews-full error'); sendHtml(res, 200, renderError(err)); }
return true;
}
+ // AI Reviews "load more" partial (appends rows)
+ if (req.method === 'GET' && path === '/dashboard/partials/reviews-more') {
+ try { sendHtml(res, 200, renderReviewsMorePartial(req.url)); }
+ catch (err) { getLogger().error({err},'Dashboard reviews-more error'); sendHtml(res, 200, renderError(err)); }
+ return true;
+ }
// JSON APIs
if (req.method === 'GET' && path === '/api/org/health') {