feat: 持久化 AI 审查结果,文件级内联展示与页面集成
This commit is contained in:
+68
-10
@@ -84,23 +84,18 @@ func (h *ReviewHandler) Review(c *gin.Context) {
|
||||
h.db.Exec(`UPDATE repositories SET last_used = datetime('now') WHERE id = ?`, id)
|
||||
|
||||
// Run AI review
|
||||
err = services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, sendEvent)
|
||||
reviewResult, err := services.GenerateReview(h.db, localPath, req.Base, req.Head, topN, sendEvent)
|
||||
if err != nil {
|
||||
sendEvent("error", map[string]interface{}{"message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save analysis to DB
|
||||
resultData := map[string]interface{}{
|
||||
"base": req.Base,
|
||||
"head": req.Head,
|
||||
"top_n": topN,
|
||||
}
|
||||
resultJSON, _ := json.Marshal(resultData)
|
||||
result, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`,
|
||||
// Save analysis to DB with full review result
|
||||
resultJSON, _ := json.Marshal(reviewResult)
|
||||
res, err := h.db.Exec(`INSERT INTO analyses (repo_id, type, base_ref, head_ref, result) VALUES (?, 'code_review', ?, ?, ?)`,
|
||||
id, req.Base, req.Head, string(resultJSON))
|
||||
if err == nil {
|
||||
analysisID, _ := result.LastInsertId()
|
||||
analysisID, _ := res.LastInsertId()
|
||||
sendEvent("analysis_saved", map[string]interface{}{
|
||||
"analysis_id": analysisID,
|
||||
})
|
||||
@@ -166,6 +161,69 @@ func (h *ReviewHandler) GetNotes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, notes)
|
||||
}
|
||||
|
||||
// ListReviews handles GET /api/repos/:id/review/analyses — list past code review analyses.
|
||||
func (h *ReviewHandler) ListReviews(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
rows, err := h.db.Query(`SELECT id, base_ref, head_ref, result, created_at FROM analyses WHERE repo_id = ? AND type = 'code_review' ORDER BY created_at DESC`, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var analyses []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var aid int64
|
||||
var baseRef, headRef, result string
|
||||
var createdAt string
|
||||
if err := rows.Scan(&aid, &baseRef, &headRef, &result, &createdAt); err != nil {
|
||||
continue
|
||||
}
|
||||
analyses = append(analyses, map[string]interface{}{
|
||||
"id": aid,
|
||||
"base_ref": baseRef,
|
||||
"head_ref": headRef,
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
if analyses == nil {
|
||||
analyses = []map[string]interface{}{}
|
||||
}
|
||||
c.JSON(http.StatusOK, analyses)
|
||||
}
|
||||
|
||||
// GetReview handles GET /api/repos/:id/review/analyses/:aid — get a single review with full result.
|
||||
func (h *ReviewHandler) GetReview(c *gin.Context) {
|
||||
aid := c.Param("aid")
|
||||
|
||||
var result, baseRef, headRef, createdAt string
|
||||
err := h.db.QueryRow(`SELECT result, base_ref, head_ref, created_at FROM analyses WHERE id = ? AND type = 'code_review'`, aid).Scan(&result, &baseRef, &headRef, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the result JSON to include structured data
|
||||
var reviewResult interface{}
|
||||
if err := json.Unmarshal([]byte(result), &reviewResult); err != nil {
|
||||
reviewResult = result
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": aid,
|
||||
"base_ref": baseRef,
|
||||
"head_ref": headRef,
|
||||
"created_at": createdAt,
|
||||
"result": reviewResult,
|
||||
})
|
||||
}
|
||||
|
||||
// GeneratePDF handles POST /api/repos/:id/review/pdf — generate and download a PDF report.
|
||||
func (h *ReviewHandler) GeneratePDF(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -89,6 +89,8 @@ func main() {
|
||||
r.GET("/api/repos/:id/diff", repos.GetDiff)
|
||||
r.POST("/api/repos/:id/generate", generate.Generate)
|
||||
r.POST("/api/repos/:id/review", review.Review)
|
||||
r.GET("/api/repos/:id/review/analyses", review.ListReviews)
|
||||
r.GET("/api/repos/:id/review/analyses/:aid", review.GetReview)
|
||||
r.POST("/api/repos/:id/review/notes", review.SaveNotes)
|
||||
r.GET("/api/repos/:id/review/notes", review.GetNotes)
|
||||
r.POST("/api/repos/:id/review/pdf", review.GeneratePDF)
|
||||
|
||||
+33
-1
@@ -63,7 +63,39 @@ func parseReportData(data ReportData) ParsedReview {
|
||||
noteMap[key] = append(noteMap[key], n.Content)
|
||||
}
|
||||
|
||||
// Try to parse structured analysis result
|
||||
// Try to parse structured analysis result (new ReviewResult format)
|
||||
var reviewResult ReviewResult
|
||||
if err := json.Unmarshal([]byte(data.Result), &reviewResult); err == nil && len(reviewResult.FileReviews) > 0 {
|
||||
// New format: { file_reviews: [...], summary: {...}, top_n: N }
|
||||
result.Score = reviewResult.Summary.Score
|
||||
result.Overall = reviewResult.Summary.Overall
|
||||
result.Findings = reviewResult.Summary.Findings
|
||||
result.Recommendations = reviewResult.Summary.Recommendations
|
||||
|
||||
for _, fr := range reviewResult.FileReviews {
|
||||
fileReport := FileReviewForReport{
|
||||
FileName: fr.FileName,
|
||||
ChangeLines: fr.ChangeLines,
|
||||
}
|
||||
// Collect file-level notes
|
||||
fileReport.Notes = noteMap["file:"+fr.FileName]
|
||||
|
||||
for _, s := range fr.Suggestions {
|
||||
sug := SuggestionForReport{
|
||||
Severity: s.Severity,
|
||||
SeverityCN: severityCN(s.Severity),
|
||||
Description: s.Description,
|
||||
Suggestion: s.Suggestion,
|
||||
CodeExample: s.CodeExample,
|
||||
}
|
||||
fileReport.Suggestions = append(fileReport.Suggestions, sug)
|
||||
}
|
||||
result.FileReviews = append(result.FileReviews, fileReport)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Fallback: try legacy flat format { score, overall, findings, recommendations }
|
||||
var analysisMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(data.Result), &analysisMap); err == nil {
|
||||
if score, ok := analysisMap["score"].(float64); ok {
|
||||
|
||||
+20
-8
@@ -34,6 +34,13 @@ type ReviewSummary struct {
|
||||
Recommendations string `json:"recommendations"`
|
||||
}
|
||||
|
||||
// ReviewResult holds the complete review output for persistence.
|
||||
type ReviewResult struct {
|
||||
FileReviews []FileReview `json:"file_reviews"`
|
||||
Summary ReviewSummary `json:"summary"`
|
||||
TopN int `json:"top_n"`
|
||||
}
|
||||
|
||||
// countDiffLines counts the number of added/removed lines in a diff patch.
|
||||
func countDiffLines(patch string) int {
|
||||
count := 0
|
||||
@@ -48,24 +55,25 @@ func countDiffLines(patch string) int {
|
||||
}
|
||||
|
||||
// GenerateReview performs AI code review on diff files with Top-N strategy.
|
||||
// It streams events (file_start, suggestion, file_end, summary, done) via callback.
|
||||
func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) error {
|
||||
// It streams events (file_start, suggestion, file_end, summary, done) via callback
|
||||
// and returns the complete ReviewResult for persistence.
|
||||
func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback StreamCallback) (*ReviewResult, error) {
|
||||
// Read LLM config
|
||||
config, err := GetLLMConfig(db)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Open repo
|
||||
repo, err := OpenRepo(repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open repo: %w", err)
|
||||
return nil, fmt.Errorf("open repo: %w", err)
|
||||
}
|
||||
|
||||
// Get diff files
|
||||
files, err := GetDiffFiles(repo, base, head)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get diff files: %w", err)
|
||||
return nil, fmt.Errorf("get diff files: %w", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
@@ -73,7 +81,7 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
|
||||
callback("summary", map[string]interface{}{"content": "没有检测到代码变更。"})
|
||||
callback("done", map[string]interface{}{"content": ""})
|
||||
}
|
||||
return nil
|
||||
return &ReviewResult{TopN: topN}, nil
|
||||
}
|
||||
|
||||
// Sort by change size (descending)
|
||||
@@ -226,6 +234,7 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
|
||||
}
|
||||
|
||||
// Generate summary
|
||||
var summary ReviewSummary
|
||||
if len(fileReviews) > 0 {
|
||||
if callback != nil {
|
||||
callback("progress", map[string]interface{}{"step": "generating_summary"})
|
||||
@@ -265,7 +274,6 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
|
||||
summaryResponse, err := ChatStream(config, messages, callback)
|
||||
if err == nil {
|
||||
jsonStr := extractJSON(summaryResponse)
|
||||
var summary ReviewSummary
|
||||
if json.Unmarshal([]byte(jsonStr), &summary) == nil {
|
||||
summaryText := fmt.Sprintf("整体评分: %d/10\n\n%s", summary.Score, summary.Overall)
|
||||
if summary.Findings != "" {
|
||||
@@ -294,5 +302,9 @@ func GenerateReview(db *sql.DB, repoPath, base, head string, topN int, callback
|
||||
callback("done", map[string]interface{}{"content": ""})
|
||||
}
|
||||
|
||||
return nil
|
||||
return &ReviewResult{
|
||||
FileReviews: fileReviews,
|
||||
Summary: summary,
|
||||
TopN: topN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -780,6 +780,94 @@ const DiffViewer = {
|
||||
</td>`;
|
||||
|
||||
row.parentNode.insertBefore(suggestionRow, row.nextSibling);
|
||||
},
|
||||
|
||||
// ── Insert file-level suggestion (no line number required) ─────
|
||||
|
||||
insertFileSuggestion(filename, severity, content, suggestionId) {
|
||||
const diffContent = this.container.querySelector('#diff-content');
|
||||
if (!diffContent) return;
|
||||
|
||||
// Find or index the file
|
||||
const fileEntry = this._ensureFileIndexed(filename);
|
||||
if (!fileEntry) return;
|
||||
|
||||
const wrapper = fileEntry.wrapper;
|
||||
|
||||
// Expand the file if collapsed
|
||||
const fileContent = wrapper.querySelector('.d2h-file-diff');
|
||||
if (fileContent && fileContent.style.display === 'none') {
|
||||
fileContent.style.display = 'block';
|
||||
wrapper.classList.remove('d2h-collapsed');
|
||||
}
|
||||
|
||||
// Find or create the file-level suggestions container
|
||||
let sugContainer = wrapper.querySelector('.file-suggestions-container');
|
||||
if (!sugContainer) {
|
||||
sugContainer = document.createElement('div');
|
||||
sugContainer.className = 'file-suggestions-container';
|
||||
// Insert after the file header, before the diff content
|
||||
const header = wrapper.querySelector('.d2h-file-header');
|
||||
if (header) {
|
||||
header.parentNode.insertBefore(sugContainer, header.nextSibling);
|
||||
} else {
|
||||
wrapper.prepend(sugContainer);
|
||||
}
|
||||
}
|
||||
|
||||
const severityStyles = {
|
||||
critical: 'border-l-4 border-red-500 bg-red-50',
|
||||
warning: 'border-l-4 border-yellow-500 bg-yellow-50',
|
||||
info: 'border-l-4 border-green-500 bg-green-50',
|
||||
};
|
||||
const severityLabels = {
|
||||
critical: '🔴 严重',
|
||||
warning: '🟡 建议',
|
||||
info: '🟢 提示',
|
||||
};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `review-file-suggestion ${severityStyles[severity] || severityStyles.info} p-3 mx-2 my-1 rounded`;
|
||||
card.setAttribute('data-suggestion-id', suggestionId || '');
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium">${severityLabels[severity] || severityLabels.info}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 review-content">${typeof renderMarkdown === 'function' ? renderMarkdown(content) : content}</div>
|
||||
<div class="mt-2 note-editor-placeholder" data-scope="suggestion" data-scope-key="${suggestionId || ''}"></div>`;
|
||||
|
||||
sugContainer.appendChild(card);
|
||||
},
|
||||
|
||||
// ── Mark file severity in sidebar ──────────────────────────────
|
||||
|
||||
markFileSeverity(filename, severity) {
|
||||
const treeFiles = this.container.querySelectorAll('.file-tree-file');
|
||||
const severityColors = {
|
||||
critical: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-green-500',
|
||||
};
|
||||
const severityOrder = { critical: 3, warning: 2, info: 1 };
|
||||
|
||||
for (const item of treeFiles) {
|
||||
const treeName = item.getAttribute('data-filename');
|
||||
if (treeName && (treeName === filename || treeName.endsWith('/' + filename) || filename.endsWith('/' + treeName))) {
|
||||
let badge = item.querySelector('.file-tree-severity');
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'file-tree-severity inline-block w-2 h-2 rounded-full ml-1';
|
||||
item.querySelector('.file-tree-name').appendChild(badge);
|
||||
}
|
||||
// Only upgrade severity, never downgrade
|
||||
const currentSev = badge.getAttribute('data-severity') || '';
|
||||
if (!currentSev || (severityOrder[severity] || 0) > (severityOrder[currentSev] || 0)) {
|
||||
badge.className = `file-tree-severity inline-block w-2 h-2 rounded-full ml-1 ${severityColors[severity] || severityColors.info}`;
|
||||
badge.setAttribute('data-severity', severity);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+166
-1
@@ -9,6 +9,7 @@
|
||||
<script src="/static/js/sse.js"></script>
|
||||
<script src="/static/js/graph.js"></script>
|
||||
<script src="/static/js/diff-viewer.js"></script>
|
||||
<script src="/static/js/review-inline.js"></script>
|
||||
<style>
|
||||
.graph-container {
|
||||
background: #f8fafc;
|
||||
@@ -98,9 +99,12 @@
|
||||
<button onclick="loadDiff()" class="px-4 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
|
||||
查看 Diff
|
||||
</button>
|
||||
<div class="flex gap-1 ml-auto">
|
||||
<div class="flex gap-1 ml-auto items-center">
|
||||
<button onclick="setDiffView('side-by-side')" id="btn-side-by-side" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium">并排</button>
|
||||
<button onclick="setDiffView('unified')" id="btn-unified" class="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200">统一</button>
|
||||
<button onclick="startInlineReview()" id="btn-inline-review" class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded font-medium hover:bg-purple-200 ml-2">
|
||||
🤖 AI 评审
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
@@ -136,6 +140,28 @@
|
||||
<!-- Diff Tab -->
|
||||
<div id="panel-diff" class="p-4 hidden">
|
||||
<div id="diff-container"></div>
|
||||
<!-- Inline review progress -->
|
||||
<div id="review-progress" class="hidden mt-4 bg-white rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">🤖 AI 代码审查</span>
|
||||
<button onclick="cancelInlineReview()" class="text-xs text-gray-400 hover:text-gray-600">取消</button>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div id="review-progress-bar" class="bg-purple-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<p id="review-progress-text" class="text-xs text-gray-500">准备中...</p>
|
||||
</div>
|
||||
<!-- Review summary (shown after completion) -->
|
||||
<div id="review-summary" class="hidden mt-4 bg-white rounded-lg border p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">审查总结</span>
|
||||
<div class="flex gap-2">
|
||||
<a id="btn-view-full-review" href="#" class="text-xs text-purple-600 hover:underline">查看完整评审</a>
|
||||
<button onclick="clearInlineReview()" class="text-xs text-gray-400 hover:text-gray-600">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="review-summary-content" class="text-sm text-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,6 +426,145 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inline AI Review ────────────────────────────────────────
|
||||
|
||||
let inlineReviewSSE = null;
|
||||
let inlineSuggestions = [];
|
||||
|
||||
function startInlineReview() {
|
||||
const base = document.getElementById('select-base').value;
|
||||
const head = document.getElementById('select-head').value;
|
||||
if (!base || !head) {
|
||||
alert('请先选择 Base 和 Head 并查看 Diff');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous if running
|
||||
if (inlineReviewSSE) {
|
||||
inlineReviewSSE.abort();
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-inline-review');
|
||||
const progress = document.getElementById('review-progress');
|
||||
const summary = document.getElementById('review-summary');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 审查中...';
|
||||
progress.classList.remove('hidden');
|
||||
summary.classList.add('hidden');
|
||||
|
||||
inlineSuggestions = [];
|
||||
let totalFiles = 0;
|
||||
let completedFiles = 0;
|
||||
|
||||
inlineReviewSSE = SSE.post(`/api/repos/${repoId}/review`, {
|
||||
base, head, top_n: 0,
|
||||
}, {
|
||||
start(data) {
|
||||
totalFiles = data.total_files || 0;
|
||||
document.getElementById('review-progress-text').textContent = `准备审查 ${totalFiles} 个文件...`;
|
||||
document.getElementById('review-progress-bar').style.width = '5%';
|
||||
},
|
||||
file_start(data) {
|
||||
completedFiles++;
|
||||
const pct = totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 80 + 10) : 50;
|
||||
document.getElementById('review-progress-bar').style.width = pct + '%';
|
||||
document.getElementById('review-progress-text').textContent = `正在审查: ${data.file || ''} (${completedFiles}/${totalFiles})`;
|
||||
},
|
||||
suggestion(data) {
|
||||
inlineSuggestions.push(data);
|
||||
},
|
||||
summary(data) {
|
||||
document.getElementById('review-summary-content').innerHTML = renderInlineMarkdown(data.content || '');
|
||||
document.getElementById('review-progress-bar').style.width = '90%';
|
||||
},
|
||||
done() {
|
||||
document.getElementById('review-progress-bar').style.width = '100%';
|
||||
document.getElementById('review-progress-text').textContent = '审查完成!';
|
||||
|
||||
setTimeout(() => {
|
||||
progress.classList.add('hidden');
|
||||
summary.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🤖 AI 评审';
|
||||
}, 500);
|
||||
|
||||
// Insert suggestions into the existing diff
|
||||
insertInlineSuggestions();
|
||||
inlineReviewSSE = null;
|
||||
},
|
||||
analysis_saved(data) {
|
||||
const viewLink = document.getElementById('btn-view-full-review');
|
||||
viewLink.href = `/repo/${repoId}/review?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
|
||||
},
|
||||
error(data) {
|
||||
alert('审查失败: ' + (data.message || '未知错误'));
|
||||
progress.classList.add('hidden');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🤖 AI 评审';
|
||||
inlineReviewSSE = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancelInlineReview() {
|
||||
if (inlineReviewSSE) {
|
||||
inlineReviewSSE.abort();
|
||||
inlineReviewSSE = null;
|
||||
}
|
||||
document.getElementById('review-progress').classList.add('hidden');
|
||||
const btn = document.getElementById('btn-inline-review');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🤖 AI 评审';
|
||||
}
|
||||
|
||||
function insertInlineSuggestions() {
|
||||
const fileSeverities = {};
|
||||
const severityOrder = { critical: 3, warning: 2, info: 1 };
|
||||
|
||||
inlineSuggestions.forEach((s, i) => {
|
||||
if (s.file) {
|
||||
DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, 'inline-sug-' + i);
|
||||
const cur = fileSeverities[s.file] || 'info';
|
||||
if ((severityOrder[s.severity] || 0) > (severityOrder[cur] || 0)) {
|
||||
fileSeverities[s.file] = s.severity;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(fileSeverities).forEach(([file, sev]) => {
|
||||
DiffViewer.markFileSeverity(file, sev);
|
||||
});
|
||||
|
||||
// Re-initialize note editor for any new placeholders
|
||||
if (typeof NoteEditor !== 'undefined') {
|
||||
NoteEditor._observeNewPlaceholders && NoteEditor._observeNewPlaceholders();
|
||||
}
|
||||
}
|
||||
|
||||
function clearInlineReview() {
|
||||
// Remove all suggestion elements from the diff
|
||||
const container = document.getElementById('diff-container');
|
||||
container.querySelectorAll('.file-suggestions-container').forEach(el => el.remove());
|
||||
container.querySelectorAll('.file-tree-severity').forEach(el => el.remove());
|
||||
document.getElementById('review-summary').classList.add('hidden');
|
||||
inlineSuggestions = [];
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/^### (.+)$/gm, '<h3 class="font-semibold text-sm mt-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="font-semibold mt-2">$1</h2>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded text-xs">$1</code>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+141
-3
@@ -41,6 +41,16 @@
|
||||
开始审查
|
||||
</button>
|
||||
<div id="load-error" class="hidden mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm"></div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-gray-600">📋 历史评审:</label>
|
||||
<select id="history-select" class="flex-1 border rounded-md px-3 py-1.5 text-sm" onchange="loadHistoryReview(this.value)">
|
||||
<option value="">加载中...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
@@ -376,12 +386,26 @@
|
||||
DiffViewer.init('review-diff-container');
|
||||
DiffViewer.renderDiff(diffString, {
|
||||
onComplete() {
|
||||
// Insert suggestions after all files are rendered
|
||||
// Track max severity per file for sidebar markers
|
||||
const fileSeverities = {};
|
||||
const severityOrder = { critical: 3, warning: 2, info: 1 };
|
||||
|
||||
// Insert file-level suggestions after all files are rendered
|
||||
allSuggestions.forEach((s, i) => {
|
||||
if (s.file && s.line) {
|
||||
DiffViewer.insertSuggestion(s.file, s.line, s.side || 'right', s.severity, s.content, 'sug-' + i);
|
||||
if (s.file) {
|
||||
DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, 'sug-' + i);
|
||||
// Track max severity per file
|
||||
const cur = fileSeverities[s.file] || 'info';
|
||||
if ((severityOrder[s.severity] || 0) > (severityOrder[cur] || 0)) {
|
||||
fileSeverities[s.file] = s.severity;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mark severity in file tree sidebar
|
||||
Object.entries(fileSeverities).forEach(([file, sev]) => {
|
||||
DiffViewer.markFileSeverity(file, sev);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -418,7 +442,121 @@
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── History loading ────────────────────────────────────────
|
||||
|
||||
async function loadHistoryList() {
|
||||
try {
|
||||
const resp = await fetch(`/api/repos/${repoId}/review/analyses`);
|
||||
if (!resp.ok) return;
|
||||
const analyses = await resp.json();
|
||||
|
||||
const select = document.getElementById('history-select');
|
||||
select.innerHTML = '<option value="">选择历史评审...</option>';
|
||||
|
||||
analyses.forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
const date = (a.created_at || '').substring(0, 16).replace('T', ' ');
|
||||
opt.textContent = `${a.base_ref} → ${a.head_ref} (${date})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (analyses.length === 0) {
|
||||
select.innerHTML = '<option value="">暂无历史评审</option>';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistoryReview(analysisId) {
|
||||
if (!analysisId) return;
|
||||
|
||||
try {
|
||||
// Fetch the review result
|
||||
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load review');
|
||||
const data = await resp.json();
|
||||
|
||||
const result = data.result;
|
||||
if (!result || !result.file_reviews) {
|
||||
alert('该历史记录没有完整的评审数据');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set current analysis ID for notes
|
||||
currentAnalysisId = parseInt(analysisId);
|
||||
|
||||
// Populate suggestions from stored data
|
||||
allSuggestions = [];
|
||||
fileReviews = {};
|
||||
|
||||
result.file_reviews.forEach(fr => {
|
||||
fileReviews[fr.file_name] = { filename: fr.file_name, suggestions: [], summary: '' };
|
||||
(fr.suggestions || []).forEach(s => {
|
||||
const content = s.description || '';
|
||||
const fullContent = content +
|
||||
(s.suggestion ? '\n\n**建议修改:** ' + s.suggestion : '') +
|
||||
(s.code_example ? '\n\n```\n' + s.code_example + '\n```' : '');
|
||||
const sug = { file: fr.file_name, severity: s.severity, content: fullContent };
|
||||
allSuggestions.push(sug);
|
||||
fileReviews[fr.file_name].suggestions.push(sug);
|
||||
});
|
||||
});
|
||||
|
||||
// Render summary
|
||||
const summary = result.summary || {};
|
||||
let summaryText = '';
|
||||
if (summary.score) summaryText += `整体评分: ${summary.score}/10\n\n`;
|
||||
if (summary.overall) summaryText += summary.overall;
|
||||
if (summary.findings) summaryText += '\n\n**主要发现:**\n' + summary.findings;
|
||||
if (summary.recommendations) summaryText += '\n\n**改进建议:**\n' + summary.recommendations;
|
||||
|
||||
document.getElementById('summary-content').innerHTML = renderMarkdown(summaryText);
|
||||
|
||||
// Clear and rebuild card view
|
||||
document.getElementById('file-reviews').innerHTML = '';
|
||||
allSuggestions.forEach(s => appendSuggestionCard(s));
|
||||
|
||||
// Show results
|
||||
document.getElementById('results').classList.remove('hidden');
|
||||
document.getElementById('review-actions').classList.remove('hidden');
|
||||
|
||||
// Load diff and render inline
|
||||
const base = data.base_ref;
|
||||
const head = data.head_ref;
|
||||
|
||||
// Load diff from API
|
||||
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`);
|
||||
if (diffResp.ok) {
|
||||
const diffData = await diffResp.json();
|
||||
const diffString = diffData.diff || '';
|
||||
if (diffString) {
|
||||
renderInlineReview(diffString);
|
||||
}
|
||||
}
|
||||
|
||||
// Init note editor
|
||||
NoteEditor.init(repoId, currentAnalysisId);
|
||||
NoteEditor.loadNotes();
|
||||
|
||||
} catch (e) {
|
||||
console.error('Load history error:', e);
|
||||
alert('加载历史评审失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-load history from URL param
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlAnalysisId = urlParams.get('analysis_id');
|
||||
|
||||
loadRefs();
|
||||
loadHistoryList();
|
||||
|
||||
// If analysis_id in URL, auto-load after refs are ready
|
||||
if (urlAnalysisId) {
|
||||
setTimeout(() => loadHistoryReview(urlAnalysisId), 500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user