package cli import ( "bytes" "context" "encoding/json" "fmt" "path/filepath" "strings" "testing" "github.com/openclaw/gitcrawl/internal/store" ) func TestCoverageCommandJSONAndTable(t *testing.T) { ctx := context.Background() dir := t.TempDir() configPath := filepath.Join(dir, "config.toml") dbPath := filepath.Join(dir, "gitcrawl.db") if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { t.Fatalf("init: %v", err) } seedCoverageStore(t, ctx, dbPath) jsonRun := New() var jsonOut bytes.Buffer jsonRun.Stdout = &jsonOut if err := jsonRun.Run(ctx, []string{"--config", configPath, "coverage", "openclaw/gitcrawl", "--min-missing-pr-details", "2", "--json"}); err != nil { t.Fatalf("coverage json: %v", err) } var payload struct { RepositoryFilters []string `json:"repository_filters"` HydrationFailuresAvailable bool `json:"hydration_failures_available"` Repositories []store.ArchiveCoverageRow `json:"repositories"` Totals store.ArchiveCoverageRow `json:"totals"` } if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil { t.Fatalf("decode coverage json: %v\n%s", err, jsonOut.String()) } if len(payload.Repositories) != 1 { t.Fatalf("repositories = %+v", payload.Repositories) } row := payload.Repositories[0] if row.Repository != "openclaw/gitcrawl" || row.PullRequests != 3 || row.PullRequestsWithDetails != 1 || row.MissingPRDetails != 2 { t.Fatalf("coverage row = %+v", row) } if row.Comments != 2 || row.PRReviews != 1 { t.Fatalf("comment coverage = %+v", row) } if !row.HydrationFailuresSupported || row.KnownFailedHydrations == nil || *row.KnownFailedHydrations != 1 { t.Fatalf("failure ledger fields = %+v", row) } if !payload.Totals.HydrationFailuresSupported || payload.Totals.KnownFailedHydrations == nil || *payload.Totals.KnownFailedHydrations != 1 { t.Fatalf("failure ledger totals = %+v", payload.Totals) } if !row.Enrichment.Revisions.Supported || row.Enrichment.Revisions.Eligible != 4 || row.Enrichment.Revisions.Covered != 0 || row.Enrichment.Revisions.CoverageRatio != 0 || row.Enrichment.PRDetails.Eligible != 3 || row.Enrichment.PRDetails.Covered != 1 { t.Fatalf("enrichment coverage = %+v", row.Enrichment) } tableRun := New() var tableOut bytes.Buffer tableRun.Stdout = &tableOut if err := tableRun.Run(ctx, []string{"--config", configPath, "coverage"}); err != nil { t.Fatalf("coverage table: %v", err) } if !strings.Contains(tableOut.String(), "MISSING_PR_DETAILS") || !strings.Contains(tableOut.String(), "openclaw/gitcrawl") || !strings.Contains(tableOut.String(), "openclaw/other") { t.Fatalf("coverage table output = %q", tableOut.String()) } multiRun := New() var multiOut bytes.Buffer multiRun.Stdout = &multiOut if err := multiRun.Run(ctx, []string{"--config", configPath, "coverage", "--repos", "openclaw/gitcrawl,openclaw/other,openclaw/gitcrawl", "--json"}); err != nil { t.Fatalf("coverage multi-repo json: %v", err) } if err := json.Unmarshal(multiOut.Bytes(), &payload); err != nil { t.Fatalf("decode multi-repo coverage json: %v\n%s", err, multiOut.String()) } if got := payload.RepositoryFilters; len(got) != 2 || got[0] != "openclaw/gitcrawl" || got[1] != "openclaw/other" { t.Fatalf("repository filters = %v", got) } if len(payload.Repositories) != 2 { t.Fatalf("filtered repositories = %+v", payload.Repositories) } filteredRun := New() var filteredOut bytes.Buffer filteredRun.Stdout = &filteredOut if err := filteredRun.Run(ctx, []string{"--config", configPath, "coverage", "--repos", "openclaw/gitcrawl,openclaw/other", "--min-missing-pr-details", "4", "--json"}); err != nil { t.Fatalf("coverage filtered json: %v", err) } payload.Repositories = nil if err := json.Unmarshal(filteredOut.Bytes(), &payload); err != nil { t.Fatalf("decode filtered coverage json: %v\n%s", err, filteredOut.String()) } if payload.Repositories == nil || len(payload.Repositories) != 0 { t.Fatalf("empty repositories should encode as an array: %+v", payload.Repositories) } if !payload.HydrationFailuresAvailable || !payload.Totals.HydrationFailuresSupported || payload.Totals.KnownFailedHydrations == nil || *payload.Totals.KnownFailedHydrations != 0 { t.Fatalf("empty filtered failure ledger totals = %+v available=%v", payload.Totals, payload.HydrationFailuresAvailable) } for _, args := range [][]string{ {"--config", configPath, "coverage", "openclaw/gitcrawl", "--repos", "openclaw/other", "--json"}, {"--config", configPath, "coverage", "--repos", "openclaw/gitcrawl,,openclaw/other", "--json"}, } { if err := New().Run(ctx, args); err == nil { t.Fatalf("coverage args should fail: %v", args) } } } func TestCoverageCommandSupportsCanonicalPortableArchive(t *testing.T) { ctx := context.Background() dir := t.TempDir() configPath := filepath.Join(dir, "config.toml") dbPath := filepath.Join(dir, "gitcrawl.db") if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { t.Fatalf("init: %v", err) } seedCoverageStore(t, ctx, dbPath) st, err := store.Open(ctx, dbPath) if err != nil { t.Fatalf("open store: %v", err) } if _, err := st.PrunePortablePayloads(ctx, store.PortablePruneOptions{BodyChars: 2000, Vacuum: false}); err != nil { st.Close() t.Fatalf("prune portable archive: %v", err) } var syncRuns int if err := st.DB().QueryRowContext(ctx, `select count(*) from sqlite_master where type = 'table' and name = 'sync_runs'`).Scan(&syncRuns); err != nil { st.Close() t.Fatalf("inspect portable archive: %v", err) } if err := st.Close(); err != nil { t.Fatalf("close portable archive: %v", err) } if syncRuns != 0 { t.Fatalf("portable archive retained sync_runs") } run := New() var out bytes.Buffer run.Stdout = &out if err := run.Run(ctx, []string{"--config", configPath, "coverage", "--json"}); err != nil { t.Fatalf("coverage portable archive: %v", err) } var payload struct { Repositories []store.ArchiveCoverageRow `json:"repositories"` } if err := json.Unmarshal(out.Bytes(), &payload); err != nil { t.Fatalf("decode portable coverage json: %v\n%s", err, out.String()) } if len(payload.Repositories) != 2 { t.Fatalf("portable coverage rows = %+v", payload.Repositories) } for _, row := range payload.Repositories { if row.LastSyncAt == "" { t.Fatalf("portable coverage missing last sync: %+v", row) } } } func TestCoverageCommandSupportsArchiveWithoutOptionalCoverageTables(t *testing.T) { ctx := context.Background() dir := t.TempDir() configPath := filepath.Join(dir, "config.toml") dbPath := filepath.Join(dir, "gitcrawl.db") if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { t.Fatalf("init: %v", err) } seedCoverageStore(t, ctx, dbPath) st, err := store.Open(ctx, dbPath) if err != nil { t.Fatalf("open store: %v", err) } for _, table := range []string{ "comments", "pull_request_files", "pull_request_commits", "pull_request_checks", "pull_request_review_threads", "pull_request_details", "github_workflow_runs", "sync_runs", "repo_sync_state", "sync_attempt_failures", } { if _, err := st.DB().ExecContext(ctx, `drop table `+table); err != nil { st.Close() t.Fatalf("drop %s: %v", table, err) } } if err := st.Close(); err != nil { t.Fatalf("close compatibility archive: %v", err) } run := New() var out bytes.Buffer run.Stdout = &out if err := run.Run(ctx, []string{"--config", configPath, "coverage", "--json"}); err != nil { t.Fatalf("coverage compatibility archive: %v", err) } var payload struct { Repositories []store.ArchiveCoverageRow `json:"repositories"` } if err := json.Unmarshal(out.Bytes(), &payload); err != nil { t.Fatalf("decode compatibility coverage json: %v\n%s", err, out.String()) } if len(payload.Repositories) != 2 { t.Fatalf("compatibility coverage rows = %+v", payload.Repositories) } for _, row := range payload.Repositories { if row.PullRequestsWithDetails != 0 || row.MissingPRDetails != row.PullRequests || row.Comments != 0 || row.PRFiles != 0 || row.WorkflowRuns != 0 { t.Fatalf("compatibility coverage row = %+v", row) } } } func seedCoverageStore(t *testing.T, ctx context.Context, dbPath string) { t.Helper() st, err := store.Open(ctx, dbPath) if err != nil { t.Fatalf("open store: %v", err) } defer st.Close() gitcrawlID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "gitcrawl", FullName: "openclaw/gitcrawl", RawJSON: "{}", UpdatedAt: "2026-07-03T00:00:00Z"}) if err != nil { t.Fatalf("gitcrawl repo: %v", err) } otherID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "other", FullName: "openclaw/other", RawJSON: "{}", UpdatedAt: "2026-07-03T00:00:00Z"}) if err != nil { t.Fatalf("other repo: %v", err) } issueID, err := st.UpsertThread(ctx, coverageThread(gitcrawlID, 1, "issue")) if err != nil { t.Fatalf("issue thread: %v", err) } var detailedPRID int64 for _, number := range []int{2, 3, 4} { id, err := st.UpsertThread(ctx, coverageThread(gitcrawlID, number, "pull_request")) if err != nil { t.Fatalf("gitcrawl pr %d: %v", number, err) } if number == 3 { detailedPRID = id } } if _, err := st.UpsertThread(ctx, coverageThread(otherID, 9, "pull_request")); err != nil { t.Fatalf("other pr: %v", err) } if err := st.UpsertPullRequestCache(ctx, store.PullRequestDetail{ ThreadID: detailedPRID, RepoID: gitcrawlID, Number: 3, HeadSHA: "coverage-head", RawJSON: "{}", FetchedAt: "2026-07-03T00:00:00Z", UpdatedAt: "2026-07-03T00:00:00Z", }, []store.PullRequestFile{{ ThreadID: detailedPRID, Path: "README.md", RawJSON: "{}", FetchedAt: "2026-07-03T00:00:00Z", }}, []store.PullRequestCommit{{ ThreadID: detailedPRID, SHA: "abc", RawJSON: "{}", FetchedAt: "2026-07-03T00:00:00Z", }}, []store.PullRequestCheck{{ ThreadID: detailedPRID, Name: "test", RawJSON: "{}", FetchedAt: "2026-07-03T00:00:00Z", }}, []store.WorkflowRun{{ RepoID: gitcrawlID, RunID: "99", HeadSHA: "coverage-head", RawJSON: "{}", FetchedAt: "2026-07-03T00:00:00Z", }}); err != nil { t.Fatalf("pr details: %v", err) } if err := st.UpsertPullRequestReviewThreads(ctx, detailedPRID, "2026-07-03T00:00:00Z", []store.PullRequestReviewThread{{ ThreadID: detailedPRID, ReviewThreadID: "thread-1", RawJSON: "{}", CommentsJSON: "[]", FetchedAt: "2026-07-03T00:00:00Z", }}); err != nil { t.Fatalf("review thread: %v", err) } for _, comment := range []store.Comment{ {ThreadID: issueID, GitHubID: "issue-comment", CommentType: "issue_comment", Body: "comment", RawJSON: "{}"}, {ThreadID: detailedPRID, GitHubID: "review", CommentType: "pull_review", Body: "review", RawJSON: "{}"}, } { if _, err := st.UpsertComment(ctx, comment); err != nil { t.Fatalf("comment: %v", err) } } if _, err := st.RecordSyncAttemptFailure(ctx, store.SyncAttemptFailure{ RepoID: gitcrawlID, ThreadID: detailedPRID, Number: 4, Operation: "pull_request_details", ErrorClass: "api", ErrorMessage: "api failure", FirstSeenAt: "2026-07-03T00:01:00Z", LastSeenAt: "2026-07-03T00:01:00Z", }); err != nil { t.Fatalf("record failure: %v", err) } if _, err := st.RecordSyncAttemptFailure(ctx, store.SyncAttemptFailure{ RepoID: gitcrawlID, Number: 2, Operation: "pull_request_details", ErrorClass: "temporary", ErrorMessage: "temporary failure", FirstSeenAt: "2026-07-03T00:02:00Z", LastSeenAt: "2026-07-03T00:02:00Z", }); err != nil { t.Fatalf("record resolved failure: %v", err) } if _, err := st.ResolveSyncAttemptFailures(ctx, gitcrawlID, 2, "2026-07-03T00:03:00Z"); err != nil { t.Fatalf("resolve failure: %v", err) } } func coverageThread(repoID int64, number int, kind string) store.Thread { return store.Thread{ RepoID: repoID, GitHubID: fmt.Sprintf("gid-%d", number), Number: number, Kind: kind, State: "open", Title: "thread", HTMLURL: "https://github.com/openclaw/gitcrawl/issues/1", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "hash", UpdatedAt: "2026-07-03T00:00:00Z", } }