Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## 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.

## 0.10.0 - 2026-09-13

**Highlights:** Explicit cloud archive admission (thanks @vincentkoc), `last_export_at` for portable publication time, and sync that no longer loses completed items when one fails.
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ These work on every command.

| Command | Purpose | Docs |
| --- | --- | --- |
| `gitcrawl sync owner/repo [--state --since --numbers <refs> --limit --include-comments --include-pr-details --with pr-details --progress-file <absolute-path> --json]` | Sync issues and PRs from GitHub into local SQLite | [Sync](/sync/) |
| `gitcrawl sync owner/repo [--state --since --numbers <refs> --limit --include-comments --include-pr-details --with pr-details --force --progress-file <absolute-path> --json]` | Sync issues and PRs from GitHub into local SQLite | [Sync](/sync/) |
| `gitcrawl sync-failures owner/repo [--include-resolved --limit N --json]` | List failed issue, comment, and PR hydration attempts and optional resolved history | [Sync](/sync/#hydration-depth) |
| `gitcrawl coverage [owner/repo \| --repos owner/a,owner/b] [--min-missing-pr-details N --json]` | Report archive, PR-detail, and enrichment coverage/freshness | — |
| `gitcrawl fill-pr-details owner/repo [--limit --order --batch-size --reserve-rate-limit --include-comments --json-progress --json]` | Hydrate locally missing pull request detail rows in bounded batches | — |
Expand Down
2 changes: 1 addition & 1 deletion docs/refresh-and-embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Disable any stage with `--no-sync`, `--no-embed`, `--no-cluster`. The remaining

| Forwarded to | Flag |
| --- | --- |
| sync | `--since`, `--state`, `--limit`, `--include-comments`, `--include-pr-details`, `--with pr-details` |
| sync | `--since`, `--state`, `--limit`, `--include-comments`, `--include-pr-details`, `--with pr-details`, `--force` (bypass unchanged issue-comment reuse) |
| embed | `--limit` |
| cluster | `--threshold` (0.80), `--min-size` (1), `--max-cluster-size` (40), `--k` (16), `--cross-kind-threshold` (0.93), `--strict-vectors` |

Expand Down
21 changes: 21 additions & 0 deletions docs/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ issue or pull request URLs.
| `--with pr-metadata` | PR object, including merge attribution, head/base references, and diff counts |
| `--include-pr-details` | PR object, files, commits, status checks, workflow runs, review threads |
| `--with pr-details` | Same as `--include-pr-details` (gh-style flag) |
| `--force` | Download selected data again, bypassing unchanged issue-comment reuse |
| `--progress-file <absolute-path>` | Atomically publish sanitized machine-readable activity |

`pr-metadata` writes only `pull_request_details`, using the normal per-thread
Expand All @@ -96,6 +97,26 @@ child collections.
Full PR details also populate `pull_request_files`, `pull_request_commits`,
`pull_request_checks`, and `github_workflow_runs` for local review and search.

With `--include-comments`, sync reuses issue comments (including the general
discussion on PRs) when the freshly fetched parent `updated_at` matches the last
completed comment observation and its `comments` count matches the exact saved
issue-comment membership. Missing or pruned payloads, observations, membership, timestamps or counts,
changed timestamps or counts, and unresolved issue-comment failures require a
download. Historical comments omitted from the last observation are never reused.
Reused comments remain in the archive and revision evidence; they do not count
toward `comments_synced` or progress `comments_received`. The saved observation
is checked again in the item transaction; concurrent replacement requires a retry.

PR reviews, inline review comments, review-thread resolution, and all selected
PR details are still fetched on every sync: a parent timestamp or head SHA alone
does not prove those collections are unchanged. This preserves check and workflow
transitions in fully hydrated revision evidence.

To refresh issue comments that may have changed without moving either parent
signal, use `gitcrawl sync owner/repo --include-comments --force` (or the same
flags with `refresh`). `--force` bypasses reuse within the selected state, time
window, or `--numbers`; it does not expand that scope or select extra families.

`fill-pr-details` selects PRs without a metadata row; it does not upgrade existing
metadata-only rows. To upgrade them, use
`gitcrawl sync owner/repo --numbers 123,456 --with pr-details`
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/comment_reuse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cli

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)

func TestSyncAndRefreshForceCommentDownload(t *testing.T) {
for _, command := range []string{"sync", "refresh"} {
t.Run(command, func(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
configPath, dbPath := filepath.Join(dir, "config.toml"), filepath.Join(dir, "archive.db")
if err := New().Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil {
t.Fatal(err)
}
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/fixture/repo":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123})
case "/repos/fixture/repo/issues":
row := githubIssueJSON(1, "issue", "Discussion")
row["comments"] = 1
_ = json.NewEncoder(w).Encode([]map[string]any{row})
case "/repos/fixture/repo/issues/1/comments":
calls++
_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 11, "body": "saved discussion"}})
default:
t.Errorf("unexpected request: %s", r.URL.Path)
http.Error(w, "unexpected", http.StatusBadRequest)
}
}))
defer server.Close()
t.Setenv("GITHUB_TOKEN", "test-gh-token")
t.Setenv("GITCRAWL_GITHUB_BASE_URL", server.URL)
args := []string{"--config", configPath, command, "fixture/repo", "--include-comments", "--limit", "1", "--json"}
if command == "refresh" {
args = append(args, "--no-embed", "--no-cluster")
}
for run, wantCalls := range []int{1, 1, 2} {
var stdout bytes.Buffer
app := New()
app.Stdout = &stdout
if run == 2 {
args = append(args, "--force")
}
if err := app.Run(ctx, args); err != nil {
t.Fatal(err)
}
if calls != wantCalls {
t.Fatalf("run=%d calls=%d want=%d", run, calls, wantCalls)
}
if run == 1 && !strings.Contains(stdout.String(), `"comments_synced": 0`) {
t.Fatalf("reused comments counted as downloaded: %s", stdout.String())
}
}
})
}
}
8 changes: 6 additions & 2 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,12 @@ Usage:
"sync": `gitcrawl sync mirrors GitHub issue and pull request metadata.

Usage:
gitcrawl sync owner/repo [--state open|closed|all] [--numbers refs] [--with pr-metadata|pr-details] [--include-pr-details] [--json]
gitcrawl sync owner/repo [--state open|closed|all] [--numbers refs] [--with pr-metadata|pr-details] [--include-comments] [--include-pr-details] [--force] [--json]

pr-metadata fetches only the pull request object; pr-details also hydrates files,
commits, checks, workflows, and review threads. Comments are selected separately.
Unchanged issue comments are reused; --force downloads them again. PR reviews
and PR details are always fetched when selected.
`,
"sync-failures": `gitcrawl sync-failures lists failed sync hydration attempts.

Expand All @@ -172,7 +174,9 @@ choose a different floor.
"refresh": `gitcrawl refresh runs sync, enrichment, embedding, and clustering.

Usage:
gitcrawl refresh owner/repo [--state open|closed|all] [--with pr-metadata|pr-details] [--include-pr-details] [--no-sync] [--no-embed] [--no-cluster] [--strict-vectors] [--json]
gitcrawl refresh owner/repo [--state open|closed|all] [--with pr-metadata|pr-details] [--include-pr-details] [--force] [--no-sync] [--no-embed] [--no-cluster] [--strict-vectors] [--json]

--force bypasses unchanged issue-comment reuse during sync.
`,
"summarize": `gitcrawl summarize generates key summaries for current thread revisions.

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func (a *App) runRefresh(ctx context.Context, args []string) error {
noEmbed := fs.Bool("no-embed", false, "skip embedding stage")
noCluster := fs.Bool("no-cluster", false, "skip clustering stage")
includeComments := fs.Bool("include-comments", false, "hydrate comments during sync")
force := fs.Bool("force", false, "download selected sync data even when issue comments are unchanged")
includePRDetails := fs.Bool("include-pr-details", false, "hydrate PR files, commits, checks, workflow runs, and review threads")
withRaw := fs.String("with", "", "additional sync hydration: pr-metadata, pr-details")
fs.Bool("include-code", false, "accepted for compatibility; code hydration is not implemented yet")
Expand Down Expand Up @@ -97,6 +98,7 @@ func (a *App) runRefresh(ctx context.Context, args []string) error {
State: strings.TrimSpace(*state),
Limit: limit,
IncludeComments: *includeComments,
Force: *force,
IncludePRMetadata: with["pr-metadata"],
IncludePRDetails: *includePRDetails || with["pr-details"],
})
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func (a *App) runSync(ctx context.Context, args []string) error {
limitRaw := fs.String("limit", "", "maximum issue/PR rows")
jsonOut := fs.Bool("json", false, "write JSON output")
includeComments := fs.Bool("include-comments", false, "hydrate issue comments, PR reviews, and PR review comments")
force := fs.Bool("force", false, "download selected data even when issue comments are unchanged")
includePRDetails := fs.Bool("include-pr-details", false, "hydrate PR files, commits, checks, and workflow runs")
withRaw := fs.String("with", "", "extra hydration: pr-metadata, pr-details")
progressFile := fs.String("progress-file", "", "write an atomic sanitized sync progress snapshot")
Expand Down Expand Up @@ -72,6 +73,7 @@ func (a *App) runSync(ctx context.Context, args []string) error {
Limit: limit,
Numbers: numbers,
IncludeComments: *includeComments,
Force: *force,
IncludePRMetadata: with["pr-metadata"],
IncludePRDetails: *includePRDetails || with["pr-details"],
Progress: progress.report,
Expand All @@ -97,6 +99,7 @@ type syncOptions struct {
Limit int
Numbers []int
IncludeComments bool
Force bool
IncludePRMetadata bool
IncludePRDetails bool
Quiet bool
Expand Down Expand Up @@ -429,6 +432,7 @@ func (a *App) syncRepository(ctx context.Context, owner, repo string, options sy
Limit: options.Limit,
Numbers: options.Numbers,
IncludeComments: options.IncludeComments,
Force: options.Force,
IncludePRMetadata: options.IncludePRMetadata,
IncludePRDetails: options.IncludePRDetails,
Reporter: reporter,
Expand Down
76 changes: 76 additions & 0 deletions internal/store/comment_reuse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package store

import (
"context"
"database/sql"
"fmt"
"time"
)

// IssueCommentReuse identifies the exact saved discussion validated by a fresh
// parent timestamp and comment count. PR reviews are deliberately excluded.
type IssueCommentReuse struct {
ThreadID int64
ObservationSequence int64
CommentIDs []int64
}

func (s *Store) ReusableIssueComments(ctx context.Context, repoID int64, number int, updatedAt string, count int) (*IssueCommentReuse, error) {
updated, err := time.Parse(time.RFC3339Nano, updatedAt)
if err != nil || count < 0 {
return nil, nil
}
var snapshot IssueCommentReuse
var source string
err = s.q().QueryRowContext(ctx, `
select t.id, r.source_updated_at, r.observation_sequence
from threads t
join thread_child_observation_reservations r on r.thread_id = t.id and r.family = 'comments'
where t.repo_id = ? and t.number = ?
and not exists (
select 1 from sync_attempt_failures f
where f.repo_id = t.repo_id and f.number = t.number
and f.operation = 'issue_comments' and f.resolved_at is null
)
`, repoID, number).Scan(&snapshot.ThreadID, &source, &snapshot.ObservationSequence)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read reusable issue comments: %w", err)
}
observed, err := time.Parse(time.RFC3339Nano, source)
if err != nil || !observed.Equal(updated) || snapshot.ObservationSequence <= 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test feeds the same instant in another form (+00:00 instead of Z, or fractional seconds), so replacing Equal with a string comparison keeps every test green. One invalidation-table row where the fresh updated_at is 2026-04-26T00:00:00+00:00 and reuse still happens would pin the Equal semantics.

return nil, nil
}
memberIDs, found, err := s.ThreadChildObservationMemberIDs(ctx, snapshot.ThreadID, ThreadChildComments, snapshot.ObservationSequence)
if err != nil || !found {
return nil, err
}
comments, err := s.ListComments(ctx, snapshot.ThreadID)
if err != nil {
return nil, err
}
byID := make(map[int64]Comment, len(comments))
for _, comment := range comments {
byID[comment.ID] = comment
}
for _, id := range memberIDs {
comment, found := byID[id]
if !found {
return nil, nil
}
if comment.CommentType == "issue_comment" {
// Portable pruning keeps observation membership but strips raw payloads
// and may truncate bodies. Fetch again before claiming full evidence.
if comment.RawJSON == "" || comment.Body == "" {
return nil, nil
}
snapshot.CommentIDs = append(snapshot.CommentIDs, id)
}
}
if len(snapshot.CommentIDs) != count {
return nil, nil
}
return &snapshot, nil
}
Loading