From c641826fc7b46e3c2a08fd091de8c7e56861d974 Mon Sep 17 00:00:00 2001 From: Nitish Agarwal <1592163+nitishagar@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:41:38 +0530 Subject: [PATCH] feat(session): add 'ocr session comments ' viewer Add a read-only subcommand that surfaces the materialized content of review comments from a saved session's review_item_done and review_item_reused records, rendered in the same style as 'ocr review'. Today 'ocr session show' prints only per-file comment counts. Loader (internal/session): - New CommentEntry type + LoadComments(repoDir, sessionID) returning per-record comment groups. ItemDetail.Comments stays int (ocr session show unchanged). - Sources comments from both done + reused records (matching TotalComments); failed records carry no comments and are naturally excluded. CLI (cmd/opencodereview): - New 'ocr session comments ' subcommand mirroring 'ocr session show'. - Flags: --repo, --json, --severity, --category (exact-match filters). - Text path reuses the existing renderComment ([category . severity] + excerpt). - --json emits a focused {session_id, comments:[]} payload whose element schema matches the live review JSON. Empty/filtered-empty results are explicit. Closes #505 --- README.ja-JP.md | 6 + README.ko-KR.md | 6 + README.md | 6 + README.ru-RU.md | 6 + README.zh-CN.md | 6 + cmd/opencodereview/session_cmd.go | 101 ++++++++++++- cmd/opencodereview/session_cmd_test.go | 200 +++++++++++++++++++++++++ internal/session/list.go | 89 +++++++++++ internal/session/list_test.go | 122 +++++++++++++++ 9 files changed, 539 insertions(+), 3 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index e59d35d5..ebb1e46c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -145,6 +145,12 @@ ocr review --commit abc123 ocr session list ocr review --from main --to feature-branch --resume +# 保存済みセッションの確認:メタデータ、ファイルごとの項目、具体化されたコメント +ocr session show +ocr session comments # ocr review スタイルでコメントを表示 +ocr session comments --severity high # 重要度で絞り込み +ocr session comments --json # スクリプト用に JSON を出力 + # フルファイルスキャン — diffではなくファイル全体をレビュー(git履歴不要) ocr scan # リポジトリ全体をスキャン ocr scan --path internal/agent # ディレクトリまたは特定のファイルをスキャン diff --git a/README.ko-KR.md b/README.ko-KR.md index 2aab10df..8be6dee0 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -145,6 +145,12 @@ ocr review --commit abc123 ocr session list ocr review --from main --to feature-branch --resume +# 저장된 세션 확인: 메타데이터, 파일별 항목, 구체화된 코멘트 +ocr session show +ocr session comments # ocr review 스타일로 코멘트 보기 +ocr session comments --severity high # 심각도로 필터링 +ocr session comments --json # 스크립트용 JSON 출력 + # 전체 파일 스캔 — diff 대신 파일 전체를 리뷰 (git 이력 불필요) ocr scan # 전체 repository 스캔 ocr scan --path internal/agent # 디렉터리 또는 특정 파일 스캔 diff --git a/README.md b/README.md index aada7ebb..84c6b0a7 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ ocr review --commit abc123 ocr session list ocr review --from main --to feature-branch --resume +# Inspect a saved session: metadata, per-file items, and the materialized comments +ocr session show +ocr session comments # view comments in ocr review style +ocr session comments --severity high # filter by severity +ocr session comments --json # emit JSON for scripting + # Full-file scan — review whole files instead of a diff (no git history needed) ocr scan # scan the entire repository ocr scan --path internal/agent # scan a directory or specific files diff --git a/README.ru-RU.md b/README.ru-RU.md index c3f00ec3..fb7cd6c7 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -145,6 +145,12 @@ ocr review --commit abc123 ocr session list ocr review --from main --to feature-branch --resume +# Просмотр сохранённой сессии: метаданные, записи по файлам и сами комментарии +ocr session show +ocr session comments # комментарии в стиле ocr review +ocr session comments --severity high # фильтр по критичности +ocr session comments --json # вывод JSON для скриптов + # Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна) ocr scan # сканировать весь репозиторий ocr scan --path internal/agent # сканировать каталог или конкретные файлы diff --git a/README.zh-CN.md b/README.zh-CN.md index aafdc069..cb08a08d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -145,6 +145,12 @@ ocr review --commit abc123 ocr session list ocr review --from main --to feature-branch --resume +# 查看已保存的会话:元数据、按文件汇总的条目以及具体评论内容 +ocr session show +ocr session comments # 以 ocr review 风格查看评论 +ocr session comments --severity high # 按严重程度筛选 +ocr session comments --json # 输出 JSON 以便脚本处理 + # 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史) ocr scan # 扫描整个仓库 ocr scan --path internal/agent # 扫描指定目录或文件 diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go index 805b8a54..e1b214bd 100644 --- a/cmd/opencodereview/session_cmd.go +++ b/cmd/opencodereview/session_cmd.go @@ -9,6 +9,7 @@ import ( "text/tabwriter" "time" + "github.com/open-code-review/open-code-review/internal/model" "github.com/open-code-review/open-code-review/internal/session" ) @@ -22,6 +23,8 @@ func runSession(args []string) error { return runSessionList(args[1:]) case "show": return runSessionShow(args[1:]) + case "comments": + return runSessionComments(args[1:]) case "-h", "--help": printSessionUsage() return nil @@ -116,6 +119,82 @@ func runSessionShow(args []string) error { return nil } +func runSessionComments(args []string) error { + a := newOcrFlagSet("ocr session comments") + var repoDir string + var asJSON bool + var severity string + var category string + a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") + a.BoolVar(&asJSON, "json", false, "emit JSON instead of text") + a.StringVar(&severity, "severity", "", "show only comments with this exact severity (e.g. high)") + a.StringVar(&category, "category", "", "show only comments with this exact category (e.g. bug)") + if err := a.Parse(args); err != nil { + return err + } + if a.showHelp { + printSessionCommentsUsage() + return nil + } + + rest := a.fs.Args() + if len(rest) == 0 { + printSessionCommentsUsage() + return fmt.Errorf("session comments requires a session ID") + } + sessionID := rest[0] + + resolvedRepo, err := resolveWorkingDirForSession(repoDir) + if err != nil { + return err + } + summary, entries, err := session.LoadComments(resolvedRepo, sessionID) + if err != nil { + return fmt.Errorf("load session %q: %w", sessionID, err) + } + + // Flatten surviving comments in on-disk record order; filters are exact + // match and select but never reorder, so two runs over the same session + // diff cleanly. + var comments []model.LlmComment + for _, e := range entries { + for _, c := range e.Comments { + if severity != "" && c.Severity != severity { + continue + } + if category != "" && c.Category != category { + continue + } + comments = append(comments, c) + } + } + + if asJSON { + payload := struct { + SessionID string `json:"session_id"` + Comments []model.LlmComment `json:"comments"` + }{ + SessionID: summary.SessionID, + Comments: comments, // nil → marshals as null; normalize to [] + } + if payload.Comments == nil { + payload.Comments = []model.LlmComment{} + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + + if len(comments) == 0 { + fmt.Println("No comments found for this session.") + return nil + } + for _, c := range comments { + renderComment(c) + } + return nil +} + // resolveWorkingDirForSession accepts an explicit --repo flag value and falls // back to the current working directory. Unlike resolveRepoDir it does not // require the target to be a git repository, so users can inspect sessions @@ -267,10 +346,11 @@ func printSessionUsage() { ocr session Sub-commands: - list, ls List recent review sessions for the current repo - show Show one session's metadata and per-file items + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items + comments Show the materialized comments of one session -Use "ocr session list -h" or "ocr session show -h" for details.`) +Use "ocr session list -h", "ocr session show -h", or "ocr session comments -h" for details.`) } func printSessionListUsage() { @@ -297,3 +377,18 @@ Flags: --repo string Root directory of the git repository (default: current dir) --json Emit JSON instead of a table`) } + +func printSessionCommentsUsage() { + fmt.Println(`Usage: + ocr session comments [flags] + +Show the materialized review comments of a single session, in the same style as +'ocr review'. Comments are sourced from review_item_done and review_item_reused +records, in on-disk order. + +Flags: + --repo string Root directory of the git repository (default: current dir) + --json Emit {"session_id","comments":[...]} JSON instead of text + --severity string Show only comments with this exact severity (e.g. high) + --category string Show only comments with this exact category (e.g. bug)`) +} diff --git a/cmd/opencodereview/session_cmd_test.go b/cmd/opencodereview/session_cmd_test.go index 1c32a71b..a33445c1 100644 --- a/cmd/opencodereview/session_cmd_test.go +++ b/cmd/opencodereview/session_cmd_test.go @@ -152,6 +152,206 @@ func TestRunSessionShow_MissingID(t *testing.T) { } } +func TestRunSessionComments_Text(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{ + {Path: "a.go", Content: "this is a finding", Category: "bug", Severity: "high", StartLine: 10, EndLine: 12}, + }) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, sh.SessionID}); err != nil { + t.Fatalf("runSessionComments: %v", err) + } + }) + + // Output parity with ocr review: content, [category · severity] badge, file path:line header. + for _, want := range []string{"this is a finding", "[bug · high]", "a.go:10-12"} { + if !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, got %q", want, got) + } + } +} + +func TestRunSessionComments_JSON(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{ + {Path: "a.go", Content: "json finding", Category: "bug", Severity: "high"}, + }) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil { + t.Fatalf("runSessionComments: %v", err) + } + }) + + // Element schema is []model.LlmComment (same as live review jsonOutput.Comments). + var payload struct { + SessionID string `json:"session_id"` + Comments []model.LlmComment `json:"comments"` + } + if err := json.Unmarshal([]byte(got), &payload); err != nil { + t.Fatalf("unmarshal: %v (out=%q)", err, got) + } + if payload.SessionID != sh.SessionID { + t.Errorf("session_id = %q, want %q", payload.SessionID, sh.SessionID) + } + if len(payload.Comments) != 1 || payload.Comments[0].Content != "json finding" { + t.Fatalf("comments = %+v", payload.Comments) + } +} + +func TestRunSessionComments_SeverityFilter(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{ + {Path: "a.go", Content: "keep me", Severity: "high"}, + {Path: "a.go", Content: "drop me", Severity: "low"}, + }) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, "--severity", "high", sh.SessionID}); err != nil { + t.Fatalf("runSessionComments: %v", err) + } + }) + + if !strings.Contains(got, "keep me") { + t.Errorf("expected filtered output to keep high-severity comment, got %q", got) + } + if strings.Contains(got, "drop me") { + t.Errorf("expected low-severity comment to be filtered out, got %q", got) + } +} + +func TestRunSessionComments_CategoryFilter(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{ + {Path: "a.go", Content: "a bug", Category: "bug", Severity: "high"}, + {Path: "a.go", Content: "a nit", Category: "style", Severity: "low"}, + }) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, "--category", "bug", sh.SessionID}); err != nil { + t.Fatalf("runSessionComments: %v", err) + } + }) + + if !strings.Contains(got, "a bug") { + t.Errorf("expected bug-category comment kept, got %q", got) + } + if strings.Contains(got, "a nit") { + t.Errorf("expected style-category comment filtered out, got %q", got) + } +} + +func TestRunSessionComments_Empty(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + // Failed-only session: no comment-bearing records. + sh.RecordReviewItemFailed("c.go", "c.go", "c.go", "fp-c", "boom") + sh.Finalize() + + // Text path: explicit "no comments" message, never silent. + textOut := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, sh.SessionID}); err != nil { + t.Fatalf("runSessionComments (text): %v", err) + } + }) + if !strings.Contains(strings.ToLower(textOut), "no comments") { + t.Errorf("expected explicit no-comments message, got %q", textOut) + } + + // JSON path: envelope with "comments": [] even when empty. + jsonOut := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil { + t.Fatalf("runSessionComments (json): %v", err) + } + }) + var payload struct { + Comments []model.LlmComment `json:"comments"` + } + if err := json.Unmarshal([]byte(jsonOut), &payload); err != nil { + t.Fatalf("unmarshal: %v (out=%q)", err, jsonOut) + } + if payload.Comments == nil || len(payload.Comments) != 0 { + t.Errorf("expected comments: [] envelope, got %+v (raw %q)", payload.Comments, jsonOut) + } +} + +func TestRunSessionComments_FilteredToEmpty(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{ + {Path: "a.go", Content: "low sev", Severity: "low"}, + }) + sh.Finalize() + + // Filter excludes the only comment → must still be explicit, not silent. + got := captureStdout(t, func() { + if err := runSessionComments([]string{"--repo", repoDir, "--severity", "critical", sh.SessionID}); err != nil { + t.Fatalf("runSessionComments: %v", err) + } + }) + if !strings.Contains(strings.ToLower(got), "no comments") { + t.Errorf("filtered-to-empty must print explicit message, got %q", got) + } +} + +func TestRunSessionComments_MissingID(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + got := captureStdout(t, func() { + if err := runSessionComments([]string{}); err == nil { + t.Fatal("expected error for missing session id") + } + }) + if !strings.Contains(got, "session comments") { + t.Errorf("expected usage output, got %q", got) + } +} + func TestTruncateUnicode(t *testing.T) { got := truncate("错误原因:超过限制", 6) if !strings.HasSuffix(got, "…") { diff --git a/internal/session/list.go b/internal/session/list.go index 3e91494c..ec593d9c 100644 --- a/internal/session/list.go +++ b/internal/session/list.go @@ -10,6 +10,8 @@ import ( "sort" "strings" "time" + + "github.com/open-code-review/open-code-review/internal/model" ) // Summary is a compact digest of one persisted session, suitable for @@ -49,6 +51,16 @@ type ItemDetail struct { Error string `json:"error,omitempty"` } +// CommentEntry is one file-level record carrying its materialized comments, +// returned by LoadComments for the `ocr session comments` viewer. It is +// intentionally a standalone type (not a field on ItemDetail) so that +// ItemDetail.Comments remains an int count for `ocr session show`. +type CommentEntry struct { + Type string `json:"type"` // "done" | "reused" + FilePath string `json:"file_path"` + Comments []model.LlmComment `json:"comments"` +} + // summaryRecord is a superset of resumeRecord that also carries session_end fields. type summaryRecord struct { Type string `json:"type"` @@ -156,6 +168,83 @@ func LoadDetail(repoDir, sessionID string) (*Summary, []ItemDetail, error) { return summary, items, nil } +// LoadComments returns the summary plus per-record comment groups for one +// session, sourcing materialized comments from review_item_done and +// review_item_reused records (the same records TotalComments counts). It is +// read-only and does not mutate the session store. Records that carry no +// comments (e.g. review_item_failed, which persists nil) are skipped. +func LoadComments(repoDir, sessionID string) (*Summary, []CommentEntry, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, nil, err + } + summary := &Summary{ + SessionID: sessionID, + FilePath: path, + RepoDir: repoDir, + Aborted: true, + } + var entries []CommentEntry + err = walkSessionFile(path, func(rec summaryRecord) { + applyRecordToSummary(summary, rec) + if comments, ok := commentsFromRecord(rec); ok { + entries = append(entries, comments) + } + }) + if err != nil { + return nil, nil, err + } + if summary.SessionID == "" { + summary.SessionID = sessionID + } + return summary, entries, nil +} + +// commentsFromRecord extracts a CommentEntry for review_item_done and +// review_item_reused records. It returns ok=false when the record carries no +// comment array (failed records, non-item records, or empty arrays), so such +// records are naturally excluded from the viewer output. +func commentsFromRecord(rec summaryRecord) (CommentEntry, bool) { + kind := "" + switch rec.Type { + case "review_item_done": + kind = "done" + case "review_item_reused": + kind = "reused" + default: + return CommentEntry{}, false + } + comments := decodeCommentsRaw(rec.Comments) + if len(comments) == 0 { + return CommentEntry{}, false + } + filePath := rec.FilePath + if filePath == "" { + filePath = rec.NewPath + } + return CommentEntry{ + Type: kind, + FilePath: filePath, + Comments: comments, + }, true +} + +// decodeCommentsRaw unmarshals a record's comments field (json.RawMessage) +// into []model.LlmComment. The on-disk shape is identical to the one the +// writer persists (WriteReviewItemDone/WriteReviewItemReused) and that +// resumeRecord.Comments already decodes directly. A nil/empty or malformed +// field yields nil. +func decodeCommentsRaw(raw json.RawMessage) []model.LlmComment { + if len(raw) == 0 { + return nil + } + var comments []model.LlmComment + if err := json.Unmarshal(raw, &comments); err != nil { + return nil + } + return comments +} + func loadSummaryFromFile(path, sessionID, repoDir string) (*Summary, error) { summary := &Summary{ SessionID: sessionID, diff --git a/internal/session/list_test.go b/internal/session/list_test.go index 2c5c3946..9e3d76b5 100644 --- a/internal/session/list_test.go +++ b/internal/session/list_test.go @@ -146,6 +146,128 @@ func TestLoadSummary_MissingFile(t *testing.T) { } } +func TestLoadComments_ReturnsContent(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{ + {Path: "a.go", Content: "note", Category: "bug", Severity: "high"}, + }) + sh.RecordReviewItemReused("b.go", "b.go", "b.go", "fp-b", "prior-session", []model.LlmComment{ + {Path: "b.go", Content: "cached", Category: "style", Severity: "low"}, + }) + // Failed records carry no comments and must contribute nothing. + sh.RecordReviewItemFailed("c.go", "c.go", "c.go", "fp-c", "boom") + sh.Finalize() + + summary, entries, err := LoadComments(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadComments: %v", err) + } + + // done + reused only; failed naturally excluded. + if len(entries) != 2 { + t.Fatalf("expected 2 comment entries (done+reused), got %d: %+v", len(entries), entries) + } + if entries[0].Type != "done" || entries[0].FilePath != "a.go" { + t.Errorf("entry[0] = %+v", entries[0]) + } + if len(entries[0].Comments) != 1 || entries[0].Comments[0].Content != "note" { + t.Errorf("done comments = %+v", entries[0].Comments) + } + if entries[1].Type != "reused" || entries[1].FilePath != "b.go" { + t.Errorf("entry[1] = %+v", entries[1]) + } + if len(entries[1].Comments) != 1 || entries[1].Comments[0].Content != "cached" { + t.Errorf("reused comments = %+v", entries[1].Comments) + } + + // Parity: LoadComments' summary TotalComments must equal LoadDetail's + // because both walk the same records via applyRecordToSummary. + detailSummary, _, err := LoadDetail(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.TotalComments != detailSummary.TotalComments { + t.Errorf("TotalComments parity broken: LoadComments=%d, LoadDetail=%d", + summary.TotalComments, detailSummary.TotalComments) + } +} + +func TestLoadComments_EmptySession(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + // Only a failed record (no comments) and no done/reused records. + sh.RecordReviewItemFailed("c.go", "c.go", "c.go", "fp-c", "boom") + sh.Finalize() + + summary, entries, err := LoadComments(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadComments: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected 0 entries for comment-less session, got %d", len(entries)) + } + if summary.TotalComments != 0 { + t.Errorf("TotalComments = %d, want 0", summary.TotalComments) + } +} + +func TestLoadComments_PreservesRecordOrder(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + // Two done records + one reused in deliberate order; on-disk order must be preserved. + sh.RecordReviewItemDone("first.go", "first.go", "first.go", "fp-1", []model.LlmComment{ + {Path: "first.go", Content: "one"}, + }) + sh.RecordReviewItemReused("second.go", "second.go", "second.go", "fp-2", "src", []model.LlmComment{ + {Path: "second.go", Content: "two"}, + }) + sh.RecordReviewItemDone("third.go", "third.go", "third.go", "fp-3", []model.LlmComment{ + {Path: "third.go", Content: "three"}, + }) + sh.Finalize() + + _, entries, err := LoadComments(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadComments: %v", err) + } + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + wantPaths := []string{"first.go", "second.go", "third.go"} + for i, want := range wantPaths { + if entries[i].FilePath != want { + t.Errorf("entry[%d] FilePath = %q, want %q (on-disk order not preserved)", i, entries[i].FilePath, want) + } + } +} + +func TestLoadComments_MissingFile(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + if _, _, err := LoadComments(t.TempDir(), "nonexistent"); err == nil { + t.Fatal("expected error for missing session") + } +} + // writeTestSession creates a real JSONL session using the persistence layer // so tests exercise the same on-disk format that ListSessions consumes. // It returns the session id.