|
|
|
|
|
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; |
|
|
} |
|
|
|