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
3 changes: 3 additions & 0 deletions docs/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions internal/cli/args_test.go
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
26 changes: 26 additions & 0 deletions internal/cli/gh_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions internal/cli/tui_neighbors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
43 changes: 43 additions & 0 deletions internal/cli/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
9 changes: 8 additions & 1 deletion internal/github/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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{}
Expand Down
38 changes: 38 additions & 0 deletions internal/github/history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"}
}
Expand Down
Loading