| |
| const form = document.getElementById("review-form"); |
| const urlInput = document.getElementById("url-input"); |
| const submitBtn = document.getElementById("submit-btn"); |
| const inputError = document.getElementById("input-error"); |
| const metaEl = document.getElementById("meta"); |
| const metaRepo = document.getElementById("meta-repo"); |
| const metaPath = document.getElementById("meta-path"); |
| const metaBranch = document.getElementById("meta-branch"); |
| const tabsEl = document.getElementById("tabs"); |
| const tabContent = document.getElementById("tab-content"); |
| const streamError = document.getElementById("stream-error"); |
| const tabButtons = document.querySelectorAll(".tab"); |
| const brutalityBtns = document.querySelectorAll(".brutality-btn"); |
| const issueBadge = document.getElementById("issue-badge"); |
| const reviewMetrics = document.getElementById("review-metrics"); |
| const saveBtn = document.getElementById("save-btn"); |
| const toast = document.getElementById("toast"); |
|
|
| const GITHUB_BLOB_RE = /^https?:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[^/]+\/.+$/; |
|
|
| |
|
|
| const SECTION_KEYS = ["summary", "quality", "performance", "security", "suggestions", "verdicts"]; |
|
|
| |
| const HEADING_MAP = { |
| summary: "summary", |
| "code quality": "quality", |
| performance: "performance", |
| security: "security", |
| suggestions: "suggestions", |
| verdicts: "verdicts", |
| }; |
|
|
| function parseSections(markdown) { |
| const sections = {}; |
| let currentKey = null; |
|
|
| for (const line of markdown.split("\n")) { |
| const m = line.match(/^## (.+)$/); |
| if (m) { |
| const title = m[1].trim().toLowerCase(); |
| const matched = Object.entries(HEADING_MAP).find(([kw]) => title.includes(kw)); |
| if (matched) { |
| currentKey = matched[1]; |
| sections[currentKey] = ""; |
| continue; |
| } |
| } |
| if (currentKey) { |
| sections[currentKey] = (sections[currentKey] || "") + line + "\n"; |
| } |
| } |
| return sections; |
| } |
|
|
| |
| function detectCurrentSection(markdown) { |
| let last = null; |
| for (const line of markdown.split("\n")) { |
| const m = line.match(/^## (.+)$/); |
| if (m) { |
| const title = m[1].trim().toLowerCase(); |
| const matched = Object.entries(HEADING_MAP).find(([kw]) => title.includes(kw)); |
| if (matched) last = matched[1]; |
| } |
| } |
| return last; |
| } |
|
|
| |
|
|
| function escapeHtml(str) { |
| return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| } |
|
|
| function isDiffContent(text) { |
| const lines = text.split("\n"); |
| let diffLines = 0; |
| for (const l of lines) { |
| if (l.startsWith("+") || l.startsWith("-") || l.startsWith("@@")) diffLines++; |
| } |
| return diffLines >= 2; |
| } |
|
|
| function renderDiffBlock(text) { |
| const lines = text.split("\n").map((line) => { |
| const escaped = escapeHtml(line); |
| if (line.startsWith("+++") || line.startsWith("---")) { |
| return `<span class="diff-line diff-info">${escaped}</span>`; |
| } |
| if (line.startsWith("+")) { |
| return `<span class="diff-line diff-add">${escaped}</span>`; |
| } |
| if (line.startsWith("-")) { |
| return `<span class="diff-line diff-del">${escaped}</span>`; |
| } |
| if (line.startsWith("@@")) { |
| return `<span class="diff-line diff-info">${escaped}</span>`; |
| } |
| return `<span class="diff-line diff-neutral">${escaped}</span>`; |
| }); |
| return `<pre class="diff-block"><code>${lines.join("")}</code></pre>`; |
| } |
|
|
| const renderer = { |
| code({ text, lang, language }) { |
| const codeLang = (lang || language || "").toLowerCase().trim(); |
| |
| if (codeLang === "diff" || (!codeLang && isDiffContent(text))) { |
| return renderDiffBlock(text); |
| } |
| const langClass = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : ""; |
| return `<pre><code${langClass}>${escapeHtml(text)}</code></pre>`; |
| }, |
| }; |
|
|
| marked.use({ renderer }); |
|
|
| function renderMarkdown(md) { |
| return DOMPurify.sanitize(marked.parse(md)); |
| } |
|
|
| |
|
|
| let activeTab = "summary"; |
| let manualSwitch = false; |
| let currentSections = {}; |
| let brutalityLevel = "standard"; |
| let fullMarkdown = ""; |
|
|
| tabButtons.forEach((btn) => { |
| btn.addEventListener("click", () => { |
| manualSwitch = true; |
| setActiveTab(btn.dataset.section); |
| renderActiveTab(); |
| }); |
| }); |
|
|
| function setActiveTab(section) { |
| activeTab = section; |
| tabButtons.forEach((btn) => { |
| btn.classList.toggle("active", btn.dataset.section === section); |
| }); |
| } |
|
|
| function renderActiveTab() { |
| const md = currentSections[activeTab] || ""; |
| if (md.trim()) { |
| tabContent.innerHTML = renderMarkdown(md); |
| tabContent.classList.remove("tab-content-empty"); |
| } else { |
| tabContent.textContent = "Waiting for content\u2026"; |
| tabContent.classList.add("tab-content-empty"); |
| } |
| } |
|
|
| function updateTabs(sections, currentStreamSection) { |
| currentSections = sections; |
|
|
| |
| tabButtons.forEach((btn) => { |
| const key = btn.dataset.section; |
| btn.classList.toggle("has-content", !!sections[key]?.trim()); |
| btn.classList.toggle("streaming", key === currentStreamSection); |
| }); |
|
|
| |
| if (!manualSwitch && currentStreamSection) { |
| setActiveTab(currentStreamSection); |
| } |
|
|
| renderActiveTab(); |
| } |
|
|
| |
|
|
| brutalityBtns.forEach((btn) => { |
| btn.addEventListener("click", () => { |
| brutalityBtns.forEach((b) => b.classList.remove("active")); |
| btn.classList.add("active"); |
| brutalityLevel = btn.dataset.level; |
| }); |
| }); |
|
|
| |
|
|
| function countIssuePriorities(markdown) { |
| const counts = { critical: 0, high: 0, medium: 0, low: 0 }; |
| const pattern = /\[CRITICAL\]|\[HIGH\]|\[MEDIUM\]|\[LOW\]/gi; |
| const matches = markdown.match(pattern) || []; |
| for (const m of matches) { |
| const level = m.slice(1, -1).toLowerCase(); |
| if (counts[level] !== undefined) counts[level]++; |
| } |
| return counts; |
| } |
|
|
| function renderIssueBadge(counts) { |
| const total = counts.critical + counts.high + counts.medium + counts.low; |
| if (total === 0) { |
| issueBadge.hidden = true; |
| return; |
| } |
|
|
| const items = [ |
| { level: "critical", label: "Critical", count: counts.critical }, |
| { level: "high", label: "High", count: counts.high }, |
| { level: "medium", label: "Medium", count: counts.medium }, |
| { level: "low", label: "Low", count: counts.low }, |
| ]; |
|
|
| |
| issueBadge.textContent = ""; |
|
|
| for (const item of items) { |
| if (item.count === 0) continue; |
|
|
| const span = document.createElement("span"); |
| span.className = "badge-item"; |
|
|
| const dot = document.createElement("span"); |
| dot.className = `badge-dot ${item.level}`; |
|
|
| const countEl = document.createElement("span"); |
| countEl.className = "badge-count"; |
| countEl.textContent = item.count; |
|
|
| const labelEl = document.createElement("span"); |
| labelEl.className = "badge-label"; |
| labelEl.textContent = item.label; |
|
|
| span.appendChild(dot); |
| span.appendChild(countEl); |
| span.appendChild(labelEl); |
| issueBadge.appendChild(span); |
| } |
|
|
| issueBadge.hidden = false; |
| } |
|
|
| |
|
|
| function computeMetrics(markdown, totalLines, elapsedMs) { |
| |
| const diffBlocks = (markdown.match(/```diff/g) || []).length; |
|
|
| |
| const issueCount = (markdown.match(/\[CRITICAL\]|\[HIGH\]|\[MEDIUM\]|\[LOW\]/gi) || []).length; |
|
|
| return { |
| issues: issueCount, |
| fixes: diffBlocks, |
| totalLines, |
| elapsedSec: (elapsedMs / 1000).toFixed(1), |
| }; |
| } |
|
|
| function renderMetrics(m) { |
| reviewMetrics.textContent = ""; |
|
|
| const items = [ |
| { value: m.issues, label: "issues found" }, |
| { value: m.fixes, label: "concrete fixes" }, |
| { value: m.totalLines.toLocaleString(), label: "lines analyzed" }, |
| { value: `${m.elapsedSec}s`, label: "review time" }, |
| ]; |
|
|
| for (const item of items) { |
| const span = document.createElement("span"); |
| span.className = "metric-item"; |
|
|
| const val = document.createElement("span"); |
| val.className = "metric-value"; |
| val.textContent = item.value; |
|
|
| const lbl = document.createElement("span"); |
| lbl.className = "metric-label"; |
| lbl.textContent = item.label; |
|
|
| span.appendChild(val); |
| span.appendChild(lbl); |
| reviewMetrics.appendChild(span); |
| } |
|
|
| reviewMetrics.hidden = false; |
| } |
|
|
| |
|
|
| function buildMarkdownExport() { |
| const sectionTitles = { |
| summary: "## Summary", |
| quality: "## Code Quality", |
| performance: "## Performance", |
| security: "## Security", |
| suggestions: "## Suggestions", |
| verdicts: "## Verdicts", |
| }; |
|
|
| let output = `# Code Review: ${metaRepo.textContent}\n`; |
| output += `**File:** ${metaPath.textContent} | **Branch:** ${metaBranch.textContent}\n\n`; |
|
|
| for (const key of SECTION_KEYS) { |
| const content = currentSections[key]?.trim(); |
| if (content) { |
| output += `${sectionTitles[key]}\n${content}\n\n`; |
| } |
| } |
|
|
| return output.trim(); |
| } |
|
|
| saveBtn.addEventListener("click", () => { |
| const markdown = buildMarkdownExport(); |
| const filename = `${metaRepo.textContent.replace(/\//g, '-')}-${metaPath.textContent.split('/').pop()}-${brutalityLevel}.md`; |
| |
| const blob = new Blob([markdown], { type: 'text/markdown' }); |
| const url = URL.createObjectURL(blob); |
| |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = filename; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| |
| showToast(); |
| }); |
|
|
| function showToast() { |
| toast.hidden = false; |
| |
| toast.offsetHeight; |
| toast.classList.add("show"); |
| setTimeout(() => { |
| toast.classList.remove("show"); |
| setTimeout(() => { toast.hidden = true; }, 300); |
| }, 2000); |
| } |
|
|
| |
|
|
| let currentSource = null; |
|
|
| form.addEventListener("submit", (e) => { |
| e.preventDefault(); |
| startReview(); |
| }); |
|
|
| |
|
|
| document.querySelectorAll(".sample-btn").forEach((btn) => { |
| btn.addEventListener("click", () => { |
| urlInput.value = btn.dataset.url; |
| startReview(); |
| }); |
| }); |
|
|
| function startReview() { |
| const url = urlInput.value.trim(); |
|
|
| |
| inputError.hidden = true; |
| streamError.hidden = true; |
| metaEl.hidden = true; |
| tabsEl.hidden = true; |
| tabContent.textContent = ""; |
| manualSwitch = false; |
| setActiveTab("summary"); |
| tabButtons.forEach((btn) => { |
| btn.classList.remove("has-content", "streaming"); |
| }); |
| issueBadge.hidden = true; |
| reviewMetrics.hidden = true; |
| saveBtn.hidden = true; |
| fullMarkdown = ""; |
|
|
| if (!url) { showInputError("Please enter a GitHub file URL."); return; } |
| if (!GITHUB_BLOB_RE.test(url)) { |
| showInputError("URL must be a GitHub blob URL (e.g. https://github.com/owner/repo/blob/main/file.js)."); |
| return; |
| } |
|
|
| if (currentSource) { currentSource.close(); currentSource = null; } |
| setLoading(true); |
|
|
| let markdown = ""; |
| let reviewStartTime = performance.now(); |
| let fileTotalLines = 0; |
| const apiUrl = `/api/review?url=${encodeURIComponent(url)}&brutality=${encodeURIComponent(brutalityLevel)}`; |
| const source = new EventSource(apiUrl); |
| currentSource = source; |
|
|
| source.addEventListener("meta", (e) => { |
| const data = JSON.parse(e.data); |
| metaRepo.textContent = `${data.owner}/${data.repo}`; |
| metaPath.textContent = data.path; |
| metaBranch.textContent = data.branch; |
| fileTotalLines = data.lines || 0; |
| metaEl.hidden = false; |
| tabsEl.hidden = false; |
| tabContent.classList.add("is-streaming"); |
| }); |
|
|
| source.addEventListener("content", (e) => { |
| const data = JSON.parse(e.data); |
| markdown += data.text; |
| const sections = parseSections(markdown); |
| const current = detectCurrentSection(markdown); |
| updateTabs(sections, current); |
| }); |
|
|
| source.addEventListener("done", () => { |
| cleanup(); |
| fullMarkdown = markdown; |
| |
| const sections = parseSections(markdown); |
| updateTabs(sections, null); |
| |
| const counts = countIssuePriorities(markdown); |
| renderIssueBadge(counts); |
| const elapsed = performance.now() - reviewStartTime; |
| const metrics = computeMetrics(markdown, fileTotalLines, elapsed); |
| renderMetrics(metrics); |
| saveBtn.hidden = false; |
| }); |
|
|
| source.addEventListener("error", (e) => { |
| if (e.data) { |
| const data = JSON.parse(e.data); |
| showStreamError(data.message); |
| } else { |
| showStreamError("Connection lost. Please try again."); |
| } |
| cleanup(); |
| }); |
|
|
| function cleanup() { |
| source.close(); |
| currentSource = null; |
| setLoading(false); |
| tabContent.classList.remove("is-streaming"); |
| tabButtons.forEach((btn) => btn.classList.remove("streaming")); |
| } |
| } |
|
|
| |
|
|
| function setLoading(on) { |
| submitBtn.disabled = on; |
| submitBtn.textContent = on ? "Reviewing\u2026" : "Review Code"; |
| document.body.classList.toggle("loading", on); |
| } |
|
|
| function showInputError(msg) { |
| inputError.textContent = msg; |
| inputError.hidden = false; |
| } |
|
|
| function showStreamError(msg) { |
| streamError.textContent = msg; |
| streamError.hidden = false; |
| } |
|
|