diff --git a/CHANGELOG.md b/CHANGELOG.md index 8045b203..80f169bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Skip unchanged issue-comment downloads on issues and PRs using parent timestamps, comment counts, and completed saved observations; keep PR review and detail data live, and add `sync`/`refresh --force` for a full selected refresh. Thanks @vlsi for the report. +- Order captured comments chronologically when source timestamps have different fractional-second precision, preserving deterministic ties by kind and stable ID. ## 0.10.0 - 2026-09-13 diff --git a/docs/capture.md b/docs/capture.md index e24f4a0a..6e53e3a4 100644 --- a/docs/capture.md +++ b/docs/capture.md @@ -71,6 +71,8 @@ The top-level object contains: Each thread contains its stable GitHub ID, number, kind, state, title, body, author, URL, labels, assignees, timestamps, comments, and semantic `content_hash`. Comments are ordered by source time, kind, and stable ID. +Source times are compared chronologically, including fractional seconds and +timezone offsets. Comments without a source time sort first. The semantic hash covers the exported thread before `content_hash` is set. It changes when exported thread or comment content changes. diff --git a/internal/capture/capture.go b/internal/capture/capture.go index bc7381ee..96bd1f8e 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -359,7 +359,14 @@ func buildThread( } sort.Slice(capturedComments, func(left, right int) bool { if capturedComments[left].CreatedAt != capturedComments[right].CreatedAt { - return capturedComments[left].CreatedAt < capturedComments[right].CreatedAt + leftCreated, rightCreated := capturedComments[left].CreatedAt, capturedComments[right].CreatedAt + if leftCreated == "" || rightCreated == "" { + return leftCreated == "" + } + // Validated RFC3339Nano strings are not ordered by fractional-second precision. + leftTime, _ := time.Parse(time.RFC3339Nano, leftCreated) + rightTime, _ := time.Parse(time.RFC3339Nano, rightCreated) + return leftTime.Before(rightTime) } if capturedComments[left].Kind != capturedComments[right].Kind { return capturedComments[left].Kind < capturedComments[right].Kind diff --git a/internal/capture/comment_order_test.go b/internal/capture/comment_order_test.go new file mode 100644 index 00000000..75823e6a --- /dev/null +++ b/internal/capture/comment_order_test.go @@ -0,0 +1,61 @@ +package capture + +import ( + "context" + "path/filepath" + "slices" + "testing" + + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestBuildThreadOrdersCommentsByInstant(t *testing.T) { + ctx := context.Background() + st, err := store.Open(ctx, filepath.Join(t.TempDir(), "archive.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + repoID, err := st.UpsertRepository(ctx, store.Repository{ + Owner: "example", Name: "archive", FullName: "example/archive", UpdatedAt: "2026-09-15T00:00:01Z", + }) + if err != nil { + t.Fatal(err) + } + thread := store.Thread{ + RepoID: repoID, GitHubID: "1", Number: 1, Kind: "pull_request", State: "open", + Title: "Synthetic conversation", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", + UpdatedAtGitHub: "2026-09-15T00:00:01Z", UpdatedAt: "2026-09-15T00:00:01Z", + } + thread.ID, err = st.UpsertThread(ctx, thread) + if err != nil { + t.Fatal(err) + } + for _, comment := range []store.Comment{ + {GitHubID: "missing", CommentType: "issue_comment"}, + {GitHubID: "whole", CommentType: "issue_comment", CreatedAtGitHub: "2026-09-15T00:00:00Z"}, + {GitHubID: "tenth", CommentType: "issue_comment", CreatedAtGitHub: "2026-09-15T00:00:00.1Z"}, + {GitHubID: "later", CommentType: "issue_comment", CreatedAtGitHub: "2026-09-15T00:00:00.11Z"}, + {GitHubID: "nano", CommentType: "issue_comment", CreatedAtGitHub: "2026-09-15T00:00:00.000000001Z"}, + {GitHubID: "review", CommentType: "pull_review", CreatedAtGitHub: "2026-09-15T01:00:00.1+01:00"}, + {GitHubID: "a-tenth", CommentType: "issue_comment", CreatedAtGitHub: "2026-09-15T00:00:00.100Z"}, + } { + comment.ThreadID, comment.RawJSON = thread.ID, "{}" + if _, err := st.UpsertComment(ctx, comment); err != nil { + t.Fatal(err) + } + } + reserveComments(t, ctx, st, thread.ID, thread.UpdatedAtGitHub) + got, err := buildThread(ctx, st, thread) + if err != nil { + t.Fatal(err) + } + var ids []string + for _, comment := range got.Comments { + ids = append(ids, comment.ID) + } + want := []string{"missing", "whole", "nano", "a-tenth", "tenth", "review", "later"} + if !slices.Equal(ids, want) { + t.Fatalf("comment order = %v, want %v", ids, want) + } +}