From 0944878f69b78699ffd2da5d4d96d1df976c1108 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 02:21:09 -0700 Subject: [PATCH 1/3] fix(cli): preserve arguments after the end-of-options marker --- docs/search.md | 3 +++ internal/cli/args.go | 5 +++++ internal/cli/args_test.go | 24 ++++++++++++++++++++++++ internal/cli/gh_search_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/docs/search.md b/docs/search.md index cc89b8f1..8904d490 100644 --- a/docs/search.md +++ b/docs/search.md @@ -71,6 +71,9 @@ gitcrawl search prs "manifest cache" \ --limit 20 ``` +Put `--` before query terms that begin with a dash, with options before it: +`gitcrawl search issues -R owner/repo --json number,title -- --verbose`. + Recognized flags in this mode: | Flag | Description | diff --git a/internal/cli/args.go b/internal/cli/args.go index 6f1e1c24..889fdfe8 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -5,6 +5,11 @@ func normalizeCommandArgs(args []string, stringFlags map[string]bool) []string { var positionals []string for index := 0; index < len(args); index++ { arg := args[index] + if arg == "--" { + flags = append(flags, "--") + positionals = append(positionals, args[index+1:]...) + break + } name, ok := flagName(arg) if !ok { positionals = append(positionals, arg) diff --git a/internal/cli/args_test.go b/internal/cli/args_test.go index 6a156886..a6293a6b 100644 --- a/internal/cli/args_test.go +++ b/internal/cli/args_test.go @@ -1,10 +1,34 @@ package cli import ( + "flag" "reflect" "testing" ) +func TestNormalizeCommandArgsPreservesEndOfOptions(t *testing.T) { + for _, args := range [][]string{ + {"--limit", "5", "--", "--json"}, + {"first", "--limit", "5", "--", "--json", "-R", "last"}, + } { + t.Run(args[0], func(t *testing.T) { + fs := flag.NewFlagSet("search", flag.ContinueOnError) + limit := fs.String("limit", "", "") + jsonOut := fs.Bool("json", false, "") + if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"limit": true})); err != nil { + t.Fatal(err) + } + want := []string{"--json"} + if args[0] == "first" { + want = []string{"first", "--json", "-R", "last"} + } + if *limit != "5" || *jsonOut || !reflect.DeepEqual(fs.Args(), want) { + t.Fatalf("limit=%q json=%v args=%q; want limit=5 json=false args=%q", *limit, *jsonOut, fs.Args(), want) + } + }) + } +} + func TestNormalizeCommandArgsMovesFlagsBeforePositionals(t *testing.T) { got := normalizeCommandArgs([]string{"openclaw/openclaw", "--query", "download", "--json"}, map[string]bool{"query": true}) want := []string{"--query", "download", "--json", "openclaw/openclaw"} diff --git a/internal/cli/gh_search_test.go b/internal/cli/gh_search_test.go index aeb79dcc..9c9d7f80 100644 --- a/internal/cli/gh_search_test.go +++ b/internal/cli/gh_search_test.go @@ -42,6 +42,32 @@ func TestParseGHSearchDuration(t *testing.T) { } } +func TestGHSearchLiteralFlagQuery(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + dbPath := filepath.Join(dir, "gitcrawl.db") + seedPortableThread(t, dbPath, 42, "Broken --verbose flag") + seedPortableThread(t, dbPath, 43, "Unrelated issue") + app := New() + var stdout, stderr bytes.Buffer + app.Stdout, app.Stderr = &stdout, &stderr + if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { + t.Fatal(err) + } + stdout.Reset() + if err := app.Run(ctx, []string{"--config", configPath, "search", "issues", "-R", "openclaw/openclaw", "--json", "number", "--", "--verbose"}); err != nil { + t.Fatal(err) + } + var rows []struct{ Number int } + if err := json.Unmarshal(stdout.Bytes(), &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Number != 42 { + t.Fatalf("search returned %s; want only issue #42", stdout.String()) + } +} + func TestGHSearchCacheStaleUsesRepoSyncRuns(t *testing.T) { ctx := context.Background() dir := t.TempDir() From 0957ad35c7c25b0baa16885a6ee806a0dffed83c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 02:21:46 -0700 Subject: [PATCH 2/3] fix(sync): reject inconsistent GraphQL continuation counts --- docs/sync.md | 2 +- internal/github/history.go | 9 +++++++- internal/github/history_test.go | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/sync.md b/docs/sync.md index f3cc291d..155bf3bc 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -102,7 +102,7 @@ no REST requests or fallback. The regular sync path is unchanged. The profile requires the flags above; it rejects `--since`, `--limit` and full PR-detail hydration. It does not collect files, commit bodies, checks or Actions logs. An incomplete GraphQL response, unavailable parent, missing identity, -duplicate child within a connection, mismatched count or nonadvancing cursor +duplicate child within a connection, mismatched or changing count, or nonadvancing cursor fails the batch before archive writes. Supervisors should retry failed selections in isolation. Empty reviews remain retained, including approvals without bodies. Minimized comments and null diff --git a/internal/github/history.go b/internal/github/history.go index b0012a27..0135e11b 100644 --- a/internal/github/history.go +++ b/internal/github/history.go @@ -241,6 +241,10 @@ func (h *historySession) hydrate(ctx context.Context, node map[string]any) error if conn == nil { return fmt.Errorf("missing %s connection", key) } + total, ok := historyInt(conn["totalCount"]) + if !ok || total < 0 { + return fmt.Errorf("invalid history %s count", key) + } fields, err := historyFields(typ, key) if err != nil { return err @@ -270,6 +274,9 @@ func (h *historySession) hydrate(ctx context.Context, node map[string]any) error return fmt.Errorf("history pagination identity mismatch") } nxt := historyMap(parent[key]) + if nextTotal, ok := historyInt(nxt["totalCount"]); !ok || nextTotal != total { + return fmt.Errorf("changed or missing history %s count", key) + } a, ok := conn["nodes"].([]any) if !ok { return fmt.Errorf("missing history nodes") @@ -285,7 +292,7 @@ func (h *historySession) hydrate(ctx context.Context, node map[string]any) error if !ok { return fmt.Errorf("missing history nodes") } - if total, ok := historyInt(conn["totalCount"]); !ok || total != len(children) { + if total != len(children) { return fmt.Errorf("incomplete history %s count", key) } ids := map[string]bool{} diff --git a/internal/github/history_test.go b/internal/github/history_test.go index 3c3a88c8..5668e7d5 100644 --- a/internal/github/history_test.go +++ b/internal/github/history_test.go @@ -138,6 +138,43 @@ func TestGraphQLHistoryRejectsShortConnection(t *testing.T) { } } +func TestGraphQLHistoryRejectsChangedContinuationCount(t *testing.T) { + for _, total := range []any{1, 3, nil} { + t.Run(fmt.Sprint(total), func(t *testing.T) { + comment := func(id string) map[string]any { + return map[string]any{"id": id, "__typename": "IssueComment", "fullDatabaseId": id, "body": id} + } + node := historyTestNode() + first := historyTestConnection(comment("1")) + first["totalCount"] = 2 + first["pageInfo"] = map[string]any{"hasNextPage": true, "endCursor": "first"} + node["comments"] = first + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req graphqlEnvelope + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Error(err) + return + } + data := map[string]any{"rateLimit": map[string]any{"cost": 1, "remaining": 19000, "resetAt": "2099-01-01T01:00:00Z"}} + if strings.Contains(req.Query, "issueOrPullRequest") { + data["repository"] = map[string]any{"databaseId": 42, "nameWithOwner": "fixture/repo", "n0": node} + } + if strings.Contains(req.Query, "node(id:") { + last := historyTestConnection(comment("2")) + last["totalCount"] = total + data["node"] = map[string]any{"id": node["id"], "comments": last} + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + defer server.Close() + batch, err := New(Options{BaseURL: server.URL}).FetchGraphQLHistory(context.Background(), "fixture", "repo", []int{1}, nil) + if err == nil || len(batch.Items) != 0 { + t.Fatalf("accepted inconsistent continuation count %v: items=%d err=%v", total, len(batch.Items), err) + } + }) + } +} + func TestGraphQLHistoryIssueDiscussion(t *testing.T) { node := historyTestNode() node["__typename"] = "Issue" @@ -212,6 +249,7 @@ func TestGraphQLHistoryReviewThreadCompleteness(t *testing.T) { switch req.Variables["id"] { case "PR_fixture": conn := historyTestConnection(thread2) + conn["totalCount"] = 2 if mode == "repeated-thread" { conn["pageInfo"] = map[string]any{"hasNextPage": true, "endCursor": "thread-first"} } From dc43e15481ac189891969d9c5ef3d6910401f047 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Sep 2026 02:25:14 -0700 Subject: [PATCH 3/3] fix(tui): load neighbors for selected closed threads --- docs/tui.md | 3 +++ internal/cli/tui_neighbors.go | 9 ++++---- internal/cli/tui_test.go | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index cef5fea4..b7632077 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -65,6 +65,9 @@ The view auto-refreshes from the local store every 15 seconds. There is no GitHu The action menu opened with `a` mirrors the right-click menu, so every mouse action has a keyboard equivalent. +Neighbor loading can use the saved embedding of a selected closed thread as +well as an open thread. Results contain open, locally active neighbors. + Jump input accepts the same thread references as the CLI: bare numbers, `#123`, `issues/123`, `pull/123`, `owner/repo#123`, and full GitHub issue or pull request URLs. diff --git a/internal/cli/tui_neighbors.go b/internal/cli/tui_neighbors.go index c07d9a00..c3ced8a1 100644 --- a/internal/cli/tui_neighbors.go +++ b/internal/cli/tui_neighbors.go @@ -104,13 +104,14 @@ func loadThreadNeighbors(ctx context.Context, st *store.Store, repoID int64, pay threshold = 0.2 } targetThread, targetVector, err := st.ThreadVectorByNumber(ctx, store.ThreadVectorQuery{ - RepoID: repoID, - Model: payload.EmbedModel, - Basis: payload.EmbeddingBasis, + RepoID: repoID, + Model: payload.EmbedModel, + Basis: payload.EmbeddingBasis, + IncludeClosed: true, }, number) if err != nil { var fallbackErr error - targetThread, targetVector, fallbackErr = st.ThreadVectorByNumber(ctx, store.ThreadVectorQuery{RepoID: repoID}, number) + targetThread, targetVector, fallbackErr = st.ThreadVectorByNumber(ctx, store.ThreadVectorQuery{RepoID: repoID, IncludeClosed: true}, number) if fallbackErr != nil { return 0, 0, nil, err } diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 97eb16b4..434d3a49 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -2712,6 +2712,49 @@ func TestTUILoadNeighborsFromStore(t *testing.T) { } } +func TestTUILoadNeighborsForClosedSelection(t *testing.T) { + for _, modelName := range []string{"test", "missing-configured-model"} { + t.Run(modelName, func(t *testing.T) { + ctx := context.Background() + st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "fixture", Name: "repo", FullName: "fixture/repo", RawJSON: "{}"}) + if err != nil { + t.Fatal(err) + } + targetID, err := seedTUIThreadVector(ctx, st, repoID, 1, "Closed target", []float64{1, 0}) + if err != nil { + t.Fatal(err) + } + neighborID, err := seedTUIThreadVector(ctx, st, repoID, 2, "Open neighbor", []float64{0.9, 0.1}) + if err != nil { + t.Fatal(err) + } + if _, err := st.DB().ExecContext(ctx, `update threads set state = 'closed' where id = ?`, targetID); err != nil { + t.Fatal(err) + } + model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{Repository: "fixture/repo", EmbedModel: modelName, EmbeddingBasis: "title_original"}) + model.memberIndex = 0 + model.memberRows = []memberRow{{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{ + ID: targetID, Number: 1, State: "closed", HTMLURL: "https://github.com/fixture/repo/issues/1", + }}}} + cmd := model.requestSelectedThreadNeighbors(10, 0.2) + if cmd == nil { + t.Fatal("neighbor command missing") + } + updated, _ := model.Update(cmd()) + model = updated.(clusterBrowserModel) + neighbors := model.neighborCache[targetID] + if len(neighbors) != 1 || neighbors[0].Thread.ID != neighborID { + t.Fatalf("neighbors=%+v status=%q; want open neighbor %d", neighbors, model.status, neighborID) + } + }) + } +} + func TestTUILoadNeighborsUsesConfiguredBackend(t *testing.T) { ctx := context.Background() st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))