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) - '
' + - assessmentIcon(r.assessment) + - '' + - '' + - '' + - '
' + - // 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() { '' + '' + '' + - '' + + '' + '' + '' + '