gitcrawl/internal/syncer/syncer_test.go
2026-05-05 18:16:00 -07:00

716 lines
23 KiB
Go

package syncer
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
gh "github.com/openclaw/gitcrawl/internal/github"
"github.com/openclaw/gitcrawl/internal/store"
)
type fakeGitHub struct{}
func (fakeGitHub) GetRepo(ctx context.Context, owner, repo string, reporter gh.Reporter) (map[string]any, error) {
return map[string]any{"id": 123}, nil
}
func (fakeGitHub) GetIssue(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) (map[string]any, error) {
if number == 8 {
return map[string]any{
"id": 2,
"number": 8,
"state": "open",
"title": "fix sync",
"body": "",
"html_url": "https://github.com/openclaw/gitcrawl/pull/8",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:00:00Z",
"labels": []map[string]any{},
"assignees": []map[string]any{},
"user": map[string]any{"login": "vincentkoc", "type": "User"},
"pull_request": map[string]any{"url": "https://api.github.com/repos/openclaw/gitcrawl/pulls/8"},
}, nil
}
return map[string]any{
"id": 1,
"number": 7,
"state": "open",
"title": "download stalls",
"body": "large file download stalls",
"html_url": "https://github.com/openclaw/gitcrawl/issues/7",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:00:00Z",
"labels": []map[string]any{{"name": "bug"}},
"assignees": []map[string]any{},
"user": map[string]any{"login": "vincentkoc", "type": "User"},
}, nil
}
func (fakeGitHub) GetPull(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) (map[string]any, error) {
return map[string]any{
"number": number,
"head": map[string]any{"sha": "head-sha", "ref": "feature", "repo": map[string]any{"full_name": "openclaw/gitcrawl"}},
"base": map[string]any{"sha": "base-sha"},
"mergeable_state": "clean",
"additions": 12,
"deletions": 3,
"changed_files": 2,
}, nil
}
func (fakeGitHub) ListRepositoryIssues(ctx context.Context, owner, repo string, options gh.ListIssuesOptions, reporter gh.Reporter) ([]map[string]any, error) {
if options.State == "closed" {
return nil, nil
}
return []map[string]any{
{
"id": 1,
"number": 7,
"state": "open",
"title": "download stalls",
"body": "large file download stalls",
"html_url": "https://github.com/openclaw/gitcrawl/issues/7",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:00:00Z",
"labels": []map[string]any{{"name": "bug"}},
"assignees": []map[string]any{},
"user": map[string]any{"login": "vincentkoc", "type": "User"},
},
{
"id": 2,
"number": 8,
"state": "open",
"title": "fix sync",
"body": "",
"html_url": "https://github.com/openclaw/gitcrawl/pull/8",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:00:00Z",
"labels": []map[string]any{},
"assignees": []map[string]any{},
"user": map[string]any{"login": "vincentkoc", "type": "User"},
"pull_request": map[string]any{"url": "https://api.github.com/repos/openclaw/gitcrawl/pulls/8"},
},
}, nil
}
func (fakeGitHub) ListIssueComments(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
if number != 7 {
return nil, nil
}
return []map[string]any{{
"id": 11,
"body": "same bug here",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:00:00Z",
"user": map[string]any{"login": "vincentkoc", "type": "User"},
}}, nil
}
func (fakeGitHub) ListPullReviews(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
return nil, nil
}
func (fakeGitHub) ListPullReviewComments(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
return nil, nil
}
func (fakeGitHub) ListPullFiles(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
return nil, nil
}
func (fakeGitHub) ListPullCommits(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
return nil, nil
}
func (fakeGitHub) ListCommitCheckRuns(ctx context.Context, owner, repo, ref string, reporter gh.Reporter) ([]map[string]any, error) {
return nil, nil
}
func (fakeGitHub) ListWorkflowRuns(ctx context.Context, owner, repo string, options gh.ListWorkflowRunsOptions, reporter gh.Reporter) ([]map[string]any, error) {
return nil, nil
}
type sinceCaptureGitHub struct {
fakeGitHub
since string
}
func (f *sinceCaptureGitHub) ListRepositoryIssues(ctx context.Context, owner, repo string, options gh.ListIssuesOptions, reporter gh.Reporter) ([]map[string]any, error) {
f.since = options.Since
return nil, nil
}
type stateCaptureGitHub struct {
fakeGitHub
state string
}
func (f *stateCaptureGitHub) ListRepositoryIssues(ctx context.Context, owner, repo string, options gh.ListIssuesOptions, reporter gh.Reporter) ([]map[string]any, error) {
f.state = options.State
return nil, nil
}
type closedSweepGitHub struct {
fakeGitHub
}
func (closedSweepGitHub) ListRepositoryIssues(ctx context.Context, owner, repo string, options gh.ListIssuesOptions, reporter gh.Reporter) ([]map[string]any, error) {
if options.State == "closed" {
return []map[string]any{{
"id": 1,
"number": 7,
"state": "closed",
"title": "download stalls",
"body": "large file download stalls",
"html_url": "https://github.com/openclaw/gitcrawl/issues/7",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z",
"closed_at": "2026-04-27T00:00:00Z",
"labels": []map[string]any{{"name": "bug"}},
"assignees": []map[string]any{},
"user": map[string]any{"login": "vincentkoc", "type": "User"},
}}, nil
}
return nil, nil
}
type targetedGitHub struct {
fakeGitHub
listCalled bool
numbers []int
}
func (f *targetedGitHub) GetIssue(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) (map[string]any, error) {
f.numbers = append(f.numbers, number)
return f.fakeGitHub.GetIssue(ctx, owner, repo, number, reporter)
}
func (f *targetedGitHub) ListRepositoryIssues(ctx context.Context, owner, repo string, options gh.ListIssuesOptions, reporter gh.Reporter) ([]map[string]any, error) {
f.listCalled = true
return nil, nil
}
type pullCommentGitHub struct {
fakeGitHub
}
func (pullCommentGitHub) ListPullReviews(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
if number != 8 {
return nil, nil
}
return []map[string]any{{
"id": 81,
"body": "review body",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:01:00Z",
"user": map[string]any{"login": "reviewbot[bot]", "type": "User"},
}}, nil
}
func (pullCommentGitHub) ListPullReviewComments(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
if number != 8 {
return nil, nil
}
return []map[string]any{{
"id": 82,
"body": "line comment",
"created_at": "2026-04-26T00:02:00Z",
"updated_at": "2026-04-26T00:03:00Z",
"user": map[string]any{"login": "alice", "type": "Bot"},
}}, nil
}
type pullDetailsGitHub struct {
fakeGitHub
}
func (pullDetailsGitHub) ListPullFiles(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
return []map[string]any{{
"filename": "internal/cache.go",
"status": "modified",
"additions": 10,
"deletions": 2,
"changes": 12,
"patch": "@@ cache",
}}, nil
}
func (pullDetailsGitHub) ListPullCommits(ctx context.Context, owner, repo string, number int, reporter gh.Reporter) ([]map[string]any, error) {
return []map[string]any{{
"sha": "commit-sha",
"html_url": "https://github.com/openclaw/gitcrawl/commit/commit-sha",
"author": map[string]any{"login": "alice"},
"commit": map[string]any{
"message": "feat: cache",
"author": map[string]any{"name": "Alice", "date": "2026-04-26T00:00:00Z"},
},
}}, nil
}
func (pullDetailsGitHub) ListCommitCheckRuns(ctx context.Context, owner, repo, ref string, reporter gh.Reporter) ([]map[string]any, error) {
return []map[string]any{{
"name": "test",
"status": "completed",
"conclusion": "success",
"details_url": "https://github.com/openclaw/gitcrawl/actions/runs/99",
"check_suite": map[string]any{"app": map[string]any{"name": "GitHub Actions"}},
}}, nil
}
func (pullDetailsGitHub) ListWorkflowRuns(ctx context.Context, owner, repo string, options gh.ListWorkflowRunsOptions, reporter gh.Reporter) ([]map[string]any, error) {
return []map[string]any{{
"id": 99,
"run_number": 7,
"head_branch": "feature",
"head_sha": options.HeadSHA,
"status": "completed",
"conclusion": "success",
"name": "CI",
"event": "pull_request",
"html_url": "https://github.com/openclaw/gitcrawl/actions/runs/99",
"created_at": "2026-04-26T00:00:00Z",
"updated_at": "2026-04-26T00:01:00Z",
}}, nil
}
func TestSyncPersistsIssuesAndPullRequests(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
s := New(fakeGitHub{}, st)
s.now = func() time.Time { return time.Date(2026, 4, 26, 0, 0, 0, 0, time.UTC) }
var progressLogs bytes.Buffer
stats, err := s.Sync(ctx, Options{
Owner: "openclaw",
Repo: "gitcrawl",
IncludeComments: true,
Logger: testProgressLogger(&progressLogs),
})
if err != nil {
t.Fatalf("sync: %v", err)
}
if stats.ThreadsSynced != 2 || stats.IssuesSynced != 1 || stats.PullRequestsSynced != 1 {
t.Fatalf("unexpected stats: %#v", stats)
}
if stats.CommentsSynced != 1 {
t.Fatalf("comments synced: got %d want 1", stats.CommentsSynced)
}
if stats.MetadataOnly {
t.Fatal("metadata only: got true want false")
}
repo, err := st.RepositoryByFullName(ctx, "openclaw/gitcrawl")
if err != nil {
t.Fatalf("repo: %v", err)
}
threads, err := st.ListThreads(ctx, repo.ID, false)
if err != nil {
t.Fatalf("threads: %v", err)
}
if len(threads) != 2 {
t.Fatalf("threads: got %d want 2", len(threads))
}
if threads[1].Kind != "pull_request" {
t.Fatalf("second thread kind: %s", threads[1].Kind)
}
var documentCount int
if err := st.DB().QueryRowContext(ctx, `select count(*) from documents_fts where documents_fts match 'failure OR bug'`).Scan(&documentCount); err != nil {
t.Fatalf("query document index: %v", err)
}
if documentCount != 1 {
t.Fatalf("document count: got %d want 1", documentCount)
}
for _, want := range []string{
`msg="sync progress"`,
`state=finished`,
`unit=threads`,
`percent=100.0`,
`completion=100.0%`,
`repository=openclaw/gitcrawl`,
} {
if !strings.Contains(progressLogs.String(), want) {
t.Fatalf("missing %q in progress logs:\n%s", want, progressLogs.String())
}
}
}
func TestSyncHydratesPullReviewComments(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
s := New(pullCommentGitHub{}, st)
s.now = func() time.Time { return time.Date(2026, 4, 26, 0, 0, 0, 0, time.UTC) }
stats, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", Numbers: []int{8}, IncludeComments: true})
if err != nil {
t.Fatalf("sync: %v", err)
}
if stats.CommentsSynced != 2 {
t.Fatalf("comments synced = %d, want 2", stats.CommentsSynced)
}
repo, err := st.RepositoryByFullName(ctx, "openclaw/gitcrawl")
if err != nil {
t.Fatalf("repo: %v", err)
}
threads, err := st.ListThreads(ctx, repo.ID, true)
if err != nil {
t.Fatalf("threads: %v", err)
}
if len(threads) != 1 || threads[0].Kind != "pull_request" {
t.Fatalf("threads = %+v", threads)
}
}
func TestSyncHydratesPullRequestDetails(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
s := New(pullDetailsGitHub{}, st)
s.now = func() time.Time { return time.Date(2026, 4, 26, 0, 0, 0, 0, time.UTC) }
stats, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", Numbers: []int{8}, IncludePRDetails: true})
if err != nil {
t.Fatalf("sync: %v", err)
}
if stats.PRDetailsSynced != 1 || stats.PRFilesSynced != 1 || stats.PRCommitsSynced != 1 || stats.PRChecksSynced != 1 || stats.WorkflowRunsSynced != 1 {
t.Fatalf("stats = %#v", stats)
}
repo, err := st.RepositoryByFullName(ctx, "openclaw/gitcrawl")
if err != nil {
t.Fatalf("repo: %v", err)
}
cache, err := st.PullRequestCache(ctx, repo.ID, 8)
if err != nil {
t.Fatalf("pr cache: %v", err)
}
if cache.Detail.HeadSHA != "head-sha" || len(cache.Files) != 1 || len(cache.Commits) != 1 || len(cache.Checks) != 1 {
t.Fatalf("cache = %+v", cache)
}
runs, err := st.ListWorkflowRuns(ctx, repo.ID, store.WorkflowRunListOptions{HeadSHA: "head-sha", Limit: 10})
if err != nil {
t.Fatalf("workflow runs: %v", err)
}
if len(runs) != 1 || runs[0].RunID != "99" {
t.Fatalf("runs = %+v", runs)
}
}
func TestSyncCanTargetIssueNumbers(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
client := &targetedGitHub{}
s := New(client, st)
s.now = func() time.Time { return time.Date(2026, 4, 26, 0, 0, 0, 0, time.UTC) }
stats, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", Numbers: []int{7, 7, 8}, IncludeComments: true})
if err != nil {
t.Fatalf("sync: %v", err)
}
if client.listCalled {
t.Fatal("targeted sync should not call repository issue listing")
}
if got, want := client.numbers, []int{7, 8}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("targeted numbers: got %#v want %#v", got, want)
}
if stats.ThreadsSynced != 2 || stats.IssuesSynced != 1 || stats.PullRequestsSynced != 1 {
t.Fatalf("unexpected stats: %#v", stats)
}
if stats.CommentsSynced != 1 {
t.Fatalf("comments synced: got %d want 1", stats.CommentsSynced)
}
repo, err := st.RepositoryByFullName(ctx, "openclaw/gitcrawl")
if err != nil {
t.Fatalf("repo: %v", err)
}
threads, err := st.ListThreads(ctx, repo.ID, false)
if err != nil {
t.Fatalf("threads: %v", err)
}
if len(threads) != 2 {
t.Fatalf("threads: got %d want 2", len(threads))
}
}
func TestSyncNormalizesRelativeSince(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
client := &sinceCaptureGitHub{}
s := New(client, st)
s.now = func() time.Time { return time.Date(2026, 4, 27, 8, 30, 0, 0, time.UTC) }
stats, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", Since: "15m"})
if err != nil {
t.Fatalf("sync: %v", err)
}
want := "2026-04-27T08:15:00Z"
if client.since != want || stats.RequestedSince != want {
t.Fatalf("since: client=%q stats=%q want %q", client.since, stats.RequestedSince, want)
}
}
func TestSyncRejectsInvalidSince(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
s := New(fakeGitHub{}, st)
if _, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", Since: "yesterday"}); err == nil {
t.Fatal("expected invalid since to fail")
}
}
func TestSyncPassesRequestedState(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
client := &stateCaptureGitHub{}
s := New(client, st)
if _, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", State: "all"}); err != nil {
t.Fatalf("sync: %v", err)
}
if client.state != "all" {
t.Fatalf("state = %q, want all", client.state)
}
}
func TestSyncDefaultsToOpenState(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
client := &stateCaptureGitHub{}
s := New(client, st)
if _, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl"}); err != nil {
t.Fatalf("sync: %v", err)
}
if client.state != "open" {
t.Fatalf("default state = %q, want open", client.state)
}
}
func TestSyncRejectsInvalidState(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
s := New(fakeGitHub{}, st)
if _, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", State: "merged"}); err == nil {
t.Fatal("expected invalid state to fail")
}
}
func TestSyncOpenSinceAppliesClosedOverlapSweep(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "gitcrawl",
FullName: "openclaw/gitcrawl",
RawJSON: "{}",
UpdatedAt: "2026-04-26T00:00:00Z",
})
if err != nil {
t.Fatalf("seed repo: %v", err)
}
if _, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: "1",
Number: 7,
Kind: "issue",
State: "open",
Title: "download stalls",
Body: "large file download stalls",
HTMLURL: "https://github.com/openclaw/gitcrawl/issues/7",
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: "old",
CreatedAtGitHub: "2026-04-26T00:00:00Z",
UpdatedAtGitHub: "2026-04-26T00:00:00Z",
FirstPulledAt: "2026-04-26T00:00:00Z",
LastPulledAt: "2026-04-26T00:00:00Z",
UpdatedAt: "2026-04-26T00:00:00Z",
}); err != nil {
t.Fatalf("seed thread: %v", err)
}
s := New(closedSweepGitHub{}, st)
s.now = func() time.Time { return time.Date(2026, 4, 27, 1, 0, 0, 0, time.UTC) }
stats, err := s.Sync(ctx, Options{Owner: "openclaw", Repo: "gitcrawl", Since: "1h"})
if err != nil {
t.Fatalf("sync: %v", err)
}
if stats.ThreadsClosed != 1 {
t.Fatalf("threads closed = %d, want 1", stats.ThreadsClosed)
}
threads, err := st.ListThreads(ctx, repoID, true)
if err != nil {
t.Fatalf("threads: %v", err)
}
if len(threads) != 1 || threads[0].State != "closed" || threads[0].ClosedAtGitHub == "" {
t.Fatalf("thread not closed from overlap sweep: %#v", threads)
}
}
func TestExpectedIssueTotal(t *testing.T) {
cases := []struct {
name string
repo map[string]any
state string
since string
limit int
want int
}{
{name: "open no filters", repo: map[string]any{"open_issues_count": float64(666)}, state: "open", want: 666},
{name: "open with limit below count", repo: map[string]any{"open_issues_count": float64(666)}, state: "open", limit: 100, want: 100},
{name: "open with limit above count", repo: map[string]any{"open_issues_count": float64(50)}, state: "open", limit: 200, want: 50},
{name: "open with since", repo: map[string]any{"open_issues_count": float64(666)}, state: "open", since: "2026-04-26T00:00:00Z", want: 0},
{name: "closed state", repo: map[string]any{"open_issues_count": float64(666)}, state: "closed", want: 0},
{name: "all state", repo: map[string]any{"open_issues_count": float64(666)}, state: "all", want: 0},
{name: "missing count", repo: map[string]any{}, state: "open", want: 0},
{name: "zero count", repo: map[string]any{"open_issues_count": float64(0)}, state: "open", want: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := expectedIssueTotal(tc.repo, tc.state, tc.since, tc.limit); got != tc.want {
t.Fatalf("expectedIssueTotal = %d, want %d", got, tc.want)
}
})
}
}
func TestMappingHelperBranches(t *testing.T) {
if got := jsonID("abc"); got != "abc" {
t.Fatalf("string json id = %q", got)
}
if got := jsonID(float64(12)); got != "12" {
t.Fatalf("float json id = %q", got)
}
if got := jsonID(int64(13)); got != "13" {
t.Fatalf("int64 json id = %q", got)
}
if got := jsonID(json.Number("14")); got != "14" {
t.Fatalf("json number id = %q", got)
}
if got := jsonID(struct{}{}); got != "" {
t.Fatalf("unknown json id = %q", got)
}
if got := intValue(float64(22)); got != 22 {
t.Fatalf("float int value = %d", got)
}
if got := intValue(int64(23)); got != 23 {
t.Fatalf("int64 int value = %d", got)
}
if got := intValue(json.Number("24")); got != 24 {
t.Fatalf("json number int value = %d", got)
}
if got := intValue("bad"); got != 0 {
t.Fatalf("bad int value = %d", got)
}
if got := stringValue(time.Unix(0, 0).UTC()); got == "" {
t.Fatal("Stringer value should render")
}
if loginFromUser("not-user") != "" || typeFromUser("not-user") != "" {
t.Fatal("non-map user should return empty fields")
}
comment := mapComment(77, "review", map[string]any{
"id": json.Number("88"),
"body": time.Unix(0, 0).UTC(),
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:01:00Z",
"user": map[string]any{"login": "dependabot[bot]", "type": "User"},
})
if comment.GitHubID != "88" || !comment.IsBot || comment.Body == "" {
t.Fatalf("comment = %+v", comment)
}
}
func TestMappingFallbackBranches(t *testing.T) {
now := time.Date(2026, 5, 5, 12, 0, 0, 123, time.UTC)
normalized, err := normalizeSince("2026-05-05T12:00:00+02:00", now)
if err != nil {
t.Fatalf("normalize iso since: %v", err)
}
if normalized != "2026-05-05T10:00:00Z" {
t.Fatalf("normalized iso since = %q", normalized)
}
if got, err := normalizeSince("2w", now); err != nil || got != "2026-04-21T12:00:00.000000123Z" {
t.Fatalf("normalize weeks = %q, %v", got, err)
}
if got := mustJSON(map[string]any{"bad": make(chan int)}); got != "{}" {
t.Fatalf("mustJSON marshal fallback = %q", got)
}
thread := mapIssueToThread(99, map[string]any{
"id": int64(123),
"number": 456,
"state": "closed",
"title": "fallbacks",
"body": "body",
"html_url": "https://github.com/openclaw/gitcrawl/issues/456",
"labels": nil,
"assignees": nil,
"created_at": "2026-05-05T10:00:00Z",
"updated_at": "2026-05-05T11:00:00Z",
"closed_at": "2026-05-05T12:00:00Z",
}, "2026-05-05T12:00:00Z")
if thread.LabelsJSON != "[]" || thread.AssigneesJSON != "[]" {
t.Fatalf("nullable label defaults: labels=%s assignees=%s", thread.LabelsJSON, thread.AssigneesJSON)
}
if thread.GitHubID != "123" || thread.Number != 456 || thread.AuthorLogin != "" || thread.ClosedAtGitHub == "" {
t.Fatalf("thread = %+v", thread)
}
}
func testProgressLogger(out *bytes.Buffer) *slog.Logger {
return slog.New(slog.NewTextHandler(out, &slog.HandlerOptions{
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
if attr.Key == slog.TimeKey {
return slog.Attr{}
}
return attr
},
}))
}