From f2d60276f9d88fbb215ae4de71b9e7a34959828a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 06:20:35 +0100 Subject: [PATCH] feat: prepare gitcrawl 0.3.0 --- CHANGELOG.md | 12 +- README.md | 6 + docs/commands.md | 40 ++- docs/gh-shim.md | 11 +- docs/governance.md | 8 + docs/refresh-and-embed.md | 5 +- docs/sync.md | 5 + docs/tui.md | 6 +- internal/cli/app.go | 112 +++++- internal/cli/app_test.go | 12 + internal/cli/coverage_extra_test.go | 268 +++++++++++++++ internal/cli/gh_shim.go | 17 +- internal/cli/gh_shim_cache_policy.go | 3 + internal/cli/gh_shim_coverage_extra_test.go | 360 ++++++++++++++++++++ internal/cli/gh_shim_descriptor.go | 6 +- internal/cli/gh_shim_detail_test.go | 11 + internal/cli/gh_shim_policy_extra_test.go | 8 + internal/cli/gh_shim_prcache.go | 9 +- internal/cli/gh_shim_test.go | 23 ++ internal/cli/tui.go | 7 +- internal/cli/tui_coverage_extra_test.go | 179 ++++++++++ internal/cli/tui_render_extra_test.go | 2 +- internal/store/coverage_test.go | 103 ++++++ 23 files changed, 1169 insertions(+), 44 deletions(-) create mode 100644 internal/cli/coverage_extra_test.go create mode 100644 internal/cli/gh_shim_coverage_extra_test.go create mode 100644 internal/cli/tui_coverage_extra_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a214708..6b7ebc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ ## Unreleased -- Bump routine release workflow dependencies. +## 0.3.0 - 2026-05-08 -- Add a repo-local `gitcrawl` agent skill for local archive, freshness, gh-shim, - cluster, and verification workflows. +- Bump routine release workflow dependencies. +- Add a repo-local `gitcrawl` agent skill for local archive, freshness, + gh-shim, cluster, and verification workflows. +- Accept full GitHub issue and pull request URLs anywhere `gitcrawl` expects a + thread number, including sync filters, gh-shim views/diffs, governance + commands, neighbor lookup, embedding, and TUI jumps. - Document read-only SQLite query examples in the repo-local agent skill so agents can do exact local archive counts without mutating state. - Document the crawlkit control surface now available on `main`, including @@ -15,6 +19,8 @@ crawl app family while shared `crawlkit/tui` converges on the same panes, sorting, action menus, and status chrome. - Add command-reference coverage for the read-only metadata/status commands. +- Add broader CLI, gh-shim, TUI, and store regression coverage for the verified + release surface. ## 0.2.1 - 2026-05-05 diff --git a/README.md b/README.md index 0c774a0..ed35d2d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ gitcrawl status --json gitcrawl sync owner/repo gitcrawl sync owner/repo --state open gitcrawl sync owner/repo --numbers 123,456 --include-comments +gitcrawl sync owner/repo --numbers https://github.com/owner/repo/issues/123 --with pr-details gitcrawl refresh owner/repo gitcrawl cluster owner/repo --threshold 0.80 gitcrawl clusters owner/repo @@ -27,6 +28,7 @@ gitcrawl durable-clusters owner/repo gitcrawl cluster-detail owner/repo --id 123 gitcrawl cluster-explain owner/repo --id 123 gitcrawl close-thread owner/repo --number 123 --reason "duplicate handled" +gitcrawl close-thread owner/repo --number https://github.com/owner/repo/issues/123 --reason "handled" gitcrawl reopen-thread owner/repo --number 123 gitcrawl close-cluster owner/repo --id 123 --reason "handled" gitcrawl reopen-cluster owner/repo --id 123 @@ -34,6 +36,7 @@ gitcrawl exclude-cluster-member owner/repo --id 123 --number 456 --reason "not t gitcrawl include-cluster-member owner/repo --id 123 --number 456 gitcrawl set-cluster-canonical owner/repo --id 123 --number 456 gitcrawl neighbors owner/repo --number 123 --limit 10 +gitcrawl neighbors owner/repo --number https://github.com/owner/repo/pull/456 --limit 10 gitcrawl search owner/repo --query "download stalls" gitcrawl search issues "download stalls" -R owner/repo --state open --json number,title,state,url,updatedAt,labels --limit 30 gitcrawl search prs "manifest cache" -R owner/repo --state open --json number,title,state,url,updatedAt,isDraft,author --limit 20 @@ -41,6 +44,8 @@ gitcrawl search issues "hot loop" -R owner/repo --state open --sync-if-stale 5m gitcrawl sync owner/repo --numbers 123 --with pr-details gitcrawl gh search issues "download stalls" -R owner/repo --state open --match comments --json number,title,url gitcrawl gh pr view 123 -R owner/repo --json number,title,state,url +gitcrawl gh pr view https://github.com/owner/repo/pull/123 --json number,title,state,url +gitcrawl gh pr checks https://github.com/owner/repo/pull/123 --json name,state,conclusion gitcrawl gh run view 123456789 -R owner/repo --json status,conclusion gitcrawl gh xcache stats gitcrawl tui @@ -53,6 +58,7 @@ gitcrawl tui owner/repo `gitcrawl tui` infers the most recently updated local repository when `owner/repo` is omitted. `serve` is intentionally not part of `gitcrawl`. `gitcrawl sync` fetches open issues and pull requests by default. Pass `--state all` or `--state closed` for explicit backfill workflows; incremental open syncs with `--since` also sweep recently closed items so local open state does not rot. Pass `--numbers` to refresh exact issue or pull request rows without relying on list ordering or updated-time windows. +Thread-reference inputs accept bare numbers, `#123`, `issues/123`, `pull/123`, `owner/repo#123`, and full GitHub issue/PR URLs. This applies to sync filters, `--number` flags, governance member commands, neighbor/embed lookups, gh-shim `view`/`checks`/`diff`, and TUI jump input. For gh-shim view/checks/diff, a full GitHub URL also supplies the repository, so `-R owner/repo` can be omitted. Pass `--with pr-details` or `--include-pr-details` to hydrate pull request files, commits, checks, and workflow runs for local review. The `gh` shim can also auto-hydrate one exact PR on a PR-detail miss, then retry locally. `gitcrawl search issues|prs` accepts the common `gh search` shape (` -R owner/repo --state open --json fields --limit N`) and answers from the local SQLite cache. It is intended for discovery without spending GitHub REST search quota; use `gh` for final live verification and GitHub write actions. Pass `--sync-if-stale 5m` to perform one metadata sync before the cached search when the local repository mirror is older than that duration. `gitcrawl gh` is a gh-compatible shim for agent workflows. It answers broad `gh search issues|prs`, `gh issue/pr list`, supported `gh issue/pr view --json` fields, hydrated `gh pr checks`, and hydrated `gh run list/view` from local SQLite, then falls through to the real GitHub CLI for unsupported commands. Local `gh issue/pr list` supports common filters such as `--author`, `--assignee`, and repeated `--label`. Read-only fallthroughs such as `gh pr diff`, `gh repo view/list`, `gh release list/view`, `gh workflow list/view`, `gh secret list`, `gh variable get/list`, `gh label list`, read-only `gh search` kinds, GET-only REST `gh api` calls, and read-only `gh api graphql` queries use a command-aware persistent cache under `cache/gh-shim`; Actions run/job logs get longer TTLs, completed run/job reads are kept much longer than active CI status, user profile reads get a 7-day TTL, read-only GraphQL gets a 6-hour TTL, and `gh pr diff` entries are keyed by the cached PR head SHA when available. Explicit API paths and explicit repositories share cache entries across sibling checkouts even when agents set different `GH_REPO` values; implicit repo reads stay isolated by `GH_REPO` or current working directory. Cache keys canonicalize common flags such as `-R`/`--repo` and sorted `--json` fields so equivalent agent commands coalesce. Repeat read failures are cached by default so agents do not rediscover the same missing release or workflow, but rate-limit error entries expire quickly; if GitHub rate-limits a refresh and an expired successful entry exists, the shim serves the stale response with a warning instead of failing the read. When another process is refreshing an expired successful entry, peers may serve stale inside a short grace window instead of joining the backend stampede. Set `GITCRAWL_GH_STALE_GRACE=0` to disable stale-while-revalidate, or `GITCRAWL_GH_CACHE_ERRORS=0` to disable error caching. Mutating commands pass through, increment write counters, and invalidate matching cache tags instead of flushing unrelated entries. `gh xcache stats|keys|gc|flush|reset|snapshot` inspects, garbage-collects, clears, resets, or snapshots fallthrough-cache counters, including hit rate plus per-command, per-route, per-key, and `--since` recent-window miss counters. Set `GITCRAWL_GH_PATH` to choose the backend `gh`, and symlink or install the binary as `gh`/`gitcrawl-gh` to run the shim directly. diff --git a/docs/commands.md b/docs/commands.md index 74ea18a..3fb84bd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -41,9 +41,9 @@ These work on every command. | Command | Purpose | Docs | | --- | --- | --- | -| `gitcrawl sync owner/repo [--state --since --numbers --limit --include-comments --include-pr-details --with pr-details --json]` | Sync issues and PRs from GitHub into local SQLite | [Sync](/sync/) | +| `gitcrawl sync owner/repo [--state --since --numbers --limit --include-comments --include-pr-details --with pr-details --json]` | Sync issues and PRs from GitHub into local SQLite | [Sync](/sync/) | | `gitcrawl refresh owner/repo [--no-sync --no-embed --no-cluster ...]` | Wrapper that runs sync → embed → cluster | [Refresh and embed](/refresh-and-embed/) | -| `gitcrawl embed owner/repo [--number --limit --force --include-closed --json]` | Generate OpenAI embeddings for thread documents | [Refresh and embed](/refresh-and-embed/#embed) | +| `gitcrawl embed owner/repo [--number --limit --force --include-closed --json]` | Generate OpenAI embeddings for thread documents | [Refresh and embed](/refresh-and-embed/#embed) | | `gitcrawl runs owner/repo [--kind sync\|embedding\|cluster --limit --json]` | List recorded run history | [Refresh and embed](/refresh-and-embed/#runs) | ## Inspect @@ -53,7 +53,23 @@ These work on every command. | `gitcrawl threads owner/repo [--include-closed --numbers --limit --json]` | List threads from local cache | — | | `gitcrawl search owner/repo --query [--mode keyword\|semantic\|hybrid --limit --json]` | Local search (direct mode) | [Search](/search/) | | `gitcrawl search issues\|prs -R owner/repo [--state --json --limit --sync-if-stale]` | Local search (`gh search` shape) | [Search](/search/#gh-search-compatibility-mode) | -| `gitcrawl neighbors owner/repo --number [--limit --threshold --json]` | Vector-similar threads to a specific issue/PR | [Clustering](/clustering/#find-similar-threads-neighbors) | +| `gitcrawl neighbors owner/repo --number [--limit --threshold --json]` | Vector-similar threads to a specific issue/PR | [Clustering](/clustering/#find-similar-threads-neighbors) | + +## Thread References + +Commands that accept a thread number also accept thread references: + +- bare numbers: `123` +- hash references: `#123` +- path references: `issues/123`, `pull/123` +- scoped references: `owner/repo#123` +- full GitHub issue or pull request URLs + +This applies to `sync --numbers`, `threads --numbers`, `embed --number`, +`neighbors --number`, all governance `--number` flags, gh-shim +`issue/pr view`, `pr checks`, `pr diff`, and TUI jump input. In gh-shim +`view`/`checks`/`diff`, a full GitHub URL also supplies `owner/repo`, so +`-R owner/repo` is optional. ## Cluster @@ -69,13 +85,13 @@ These work on every command. | Command | Purpose | Docs | | --- | --- | --- | -| `gitcrawl close-thread owner/repo --number [--reason --json]` | Local close on a thread | [Governance](/governance/#local-close) | -| `gitcrawl reopen-thread owner/repo --number [--json]` | Inverse | — | +| `gitcrawl close-thread owner/repo --number [--reason --json]` | Local close on a thread | [Governance](/governance/#local-close) | +| `gitcrawl reopen-thread owner/repo --number [--json]` | Inverse | — | | `gitcrawl close-cluster owner/repo --id [--reason --json]` | Local close on a cluster | [Governance](/governance/#local-close) | | `gitcrawl reopen-cluster owner/repo --id [--json]` | Inverse | — | -| `gitcrawl exclude-cluster-member owner/repo --id --number [--reason --json]` | Pull a thread out of a cluster | [Governance](/governance/#member-exclusion) | -| `gitcrawl include-cluster-member owner/repo --id --number [--reason --json]` | Inverse | — | -| `gitcrawl set-cluster-canonical owner/repo --id --number [--reason --json]` | Pin canonical thread for a cluster | [Governance](/governance/#canonical-member) | +| `gitcrawl exclude-cluster-member owner/repo --id --number [--reason --json]` | Pull a thread out of a cluster | [Governance](/governance/#member-exclusion) | +| `gitcrawl include-cluster-member owner/repo --id --number [--reason --json]` | Inverse | — | +| `gitcrawl set-cluster-canonical owner/repo --id --number [--reason --json]` | Pin canonical thread for a cluster | [Governance](/governance/#canonical-member) | ## TUI @@ -88,12 +104,12 @@ These work on every command. | Command | Purpose | Docs | | --- | --- | --- | | `gitcrawl gh search issues\|prs -R owner/repo [...]` | Local-first `gh search` | [gh shim](/gh-shim/) | -| `gitcrawl gh issue view -R owner/repo --json ` | Local-first thread view | [gh shim](/gh-shim/) | -| `gitcrawl gh pr view -R owner/repo --json ` | Same, for PRs (with auto-hydration) | [gh shim](/gh-shim/) | +| `gitcrawl gh issue view [-R owner/repo] --json ` | Local-first thread view | [gh shim](/gh-shim/) | +| `gitcrawl gh pr view [-R owner/repo] --json ` | Same, for PRs (with auto-hydration) | [gh shim](/gh-shim/) | | `gitcrawl gh issue list -R owner/repo [--state --search --author --assignee --label --json]` | Local-first list | [gh shim](/gh-shim/) | | `gitcrawl gh pr list -R owner/repo [...]` | Same, for PRs | [gh shim](/gh-shim/) | -| `gitcrawl gh pr checks -R owner/repo --json ` | Cached PR checks (auto-hydrates if stale) | [gh shim](/gh-shim/) | -| `gitcrawl gh pr diff -R owner/repo` | Falls through; cached by head SHA | [gh shim](/gh-shim/) | +| `gitcrawl gh pr checks [-R owner/repo] --json ` | Cached PR checks (auto-hydrates if stale) | [gh shim](/gh-shim/) | +| `gitcrawl gh pr diff [-R owner/repo]` | Falls through; cached by head SHA | [gh shim](/gh-shim/) | | `gitcrawl gh run list -R owner/repo [--branch --commit --json]` | Cached workflow runs | [gh shim](/gh-shim/) | | `gitcrawl gh run view -R owner/repo [--json]` | Same, single run | [gh shim](/gh-shim/) | | `gitcrawl gh repo view\|list ...` | Falls through; cached briefly | [gh shim](/gh-shim/) | diff --git a/docs/gh-shim.md b/docs/gh-shim.md index 2a28b21..c777478 100644 --- a/docs/gh-shim.md +++ b/docs/gh-shim.md @@ -52,8 +52,13 @@ Answered from the local FTS index. Honors `--state`, `--json`, `--limit`. `--mat ```bash gh issue view 123 -R owner/repo --json number,title,state,url,body,labels,author gh pr view 123 -R owner/repo --json number,title,state,url,isDraft,author,headRef,baseRef +gh issue view https://github.com/owner/repo/issues/123 --json number,title,url +gh pr view https://github.com/owner/repo/pull/123 --json number,title,url ``` +Full GitHub issue/PR URLs provide both the repository and thread number when +`-R`/`--repo` is omitted. + Supported JSON fields include `number`, `title`, `state`, `url`, `body`, `author`, `createdAt`, `updatedAt`, `closedAt`, `labels`, plus PR-specific `isDraft`, `headRef`, `baseRef`. PR detail fields (`files`, `commits`, `checks`, `statusCheckRollup`) are answered from cached PR detail and trigger [auto-hydration](#auto-hydration) on miss. ### `gh issue list` / `gh pr list` @@ -71,10 +76,14 @@ Supports `--state`, `--search` (keyword search), `--author`, `--assignee`, repea ```bash gh pr checks 123 -R owner/repo --json name,state,conclusion,detailsUrl +gh pr checks https://github.com/owner/repo/pull/123 --json name,state,conclusion ``` Returns the cached check/status summary for the PR. If the cached PR detail is older than 90 seconds or its head SHA is stale, [auto-hydration](#auto-hydration) refreshes it before answering. Supported fields: `name`, `state`, `status`, `conclusion`, `detailsUrl`, `workflow`, `startedAt`, `completedAt`. +Like `gh pr view`, a full pull request URL can supply both repository and +number. + ### `gh run list` / `gh run view` ```bash @@ -89,7 +98,7 @@ Workflow runs come from cached PR detail. Filters: `--branch`, `--commit` (head These commands always run real `gh` but the response body is cached for the next caller in the same workspace: -- `gh pr diff` — keyed by the cached PR head SHA when available, so the cache is stable across many sequential agent reads +- `gh pr diff ` — keyed by the cached PR head SHA when available, so the cache is stable across many sequential agent reads; full PR URLs can omit `-R` - `gh issue list/status/view`, `gh pr list/status/view/checks`, and unsupported read-only local shim shapes - `gh release list/view`, `gh workflow list/view`, `gh secret list`, and `gh variable get/list` - `gh project list/view/field-list/item-list`, `gh ruleset check/list/view`, `gh gist list/view`, and `gh org list` diff --git a/docs/governance.md b/docs/governance.md index 1b18008..2451de7 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -25,6 +25,7 @@ Mark a thread or a cluster as "handled locally — do not show me this again." ```bash gitcrawl close-thread owner/repo --number 123 --reason "duplicate handled" +gitcrawl close-thread owner/repo --number https://github.com/owner/repo/issues/123 --reason "duplicate handled" gitcrawl reopen-thread owner/repo --number 123 gitcrawl close-cluster owner/repo --id 42 --reason "all members handled" @@ -47,6 +48,7 @@ Pull a single thread out of a cluster, or pull it back in. ```bash gitcrawl exclude-cluster-member owner/repo --id 42 --number 456 --reason "different repro" +gitcrawl exclude-cluster-member owner/repo --id 42 --number owner/repo#456 --reason "different repro" gitcrawl include-cluster-member owner/repo --id 42 --number 456 ``` @@ -72,6 +74,12 @@ gitcrawl set-cluster-canonical owner/repo --id 42 --number 123 --reason "main tr The chosen `--number` must already be a member of the cluster. The TUI's right-click menu has a "set canonical" entry that calls this command. +All governance `--number` flags accept the same thread-reference forms as sync: +bare numbers, `#123`, `issues/123`, `pull/123`, `owner/repo#123`, and full +GitHub issue or pull request URLs. The command still applies only to the +`owner/repo` argument you pass to gitcrawl; URL input is accepted so copied +GitHub links can be pasted directly. + ## Reopen and undo There is no separate `undo`. The inverse commands are explicit: diff --git a/docs/refresh-and-embed.md b/docs/refresh-and-embed.md index 2ab806e..8854f3f 100644 --- a/docs/refresh-and-embed.md +++ b/docs/refresh-and-embed.md @@ -71,11 +71,14 @@ Generates OpenAI embeddings for any thread whose document hash has changed since | Flag | Default | Description | | --- | --- | --- | -| `--number ` | _(any)_ | Embed a single issue/PR by number | +| `--number ` | _(any)_ | Embed a single issue/PR by number or copied GitHub URL | | `--limit ` | _(no limit)_ | Maximum rows to embed in this run | | `--force` | _(off)_ | Re-embed every selected row, ignoring content hash | | `--include-closed` | _(off)_ | Include closed threads | +`--number` accepts bare numbers, `#123`, `issues/123`, `pull/123`, +`owner/repo#123`, and full GitHub issue or pull request URLs. + ### When to `--force` You should rarely need it. The pipeline auto-forces a rebuild when: diff --git a/docs/sync.md b/docs/sync.md index acab314..2e26531 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -54,10 +54,15 @@ gitcrawl sync owner/repo --state all --since 2026-04-01T00:00:00Z ```bash gitcrawl sync owner/repo --numbers 123,456 --include-comments +gitcrawl sync owner/repo --numbers https://github.com/owner/repo/issues/123 --with pr-details ``` `--numbers` is the safest way to refresh specific issues or PRs — it bypasses list ordering and the updated-time window, fetching exactly the rows you ask for. Pair it with `--include-comments` and/or `--include-pr-details` to hydrate the conversation and PR-only data at the same time. +`--numbers` accepts comma-separated thread references, not just integers: +`123`, `#123`, `issues/123`, `pull/123`, `owner/repo#123`, and full GitHub +issue or pull request URLs. + This is also what the `gh` shim uses internally for [auto-hydration](/gh-shim/#auto-hydration). ## Hydration depth diff --git a/docs/tui.md b/docs/tui.md index 7af4495..7f22118 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -53,7 +53,7 @@ The view auto-refreshes from the local store every 15 seconds. There is no GitHu | `Tab` / `Shift+Tab` | Switch panes | | `Enter` | Open detail for selected cluster or member; on a member, loads neighbors first | | `a` | Open the action menu (cluster or member, depending on focus) | -| `#` | Jump to a specific issue or PR number | +| `#` | Jump to a specific issue or PR number or copied GitHub issue/PR URL | | `n` | Load neighbors for the selected issue or PR | | `p` | Switch between repositories already present in the local store | | `s` | Cycle sort mode (`size` ↔ `recent` ↔ `oldest`, both directions) | @@ -62,6 +62,10 @@ 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. +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. + ## Mouse Mouse support is built in and works in most modern terminals (iTerm2, Kitty, Alacritty, WezTerm, recent macOS Terminal): diff --git a/internal/cli/app.go b/internal/cli/app.go index d9611d1..e2ff4a9 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -41,6 +41,9 @@ const ( ) var threadReferencePattern = regexp.MustCompile(`(?i)(?:\b[\w.-]+/[\w.-]+#(\d+)|(?:issues|pull)/(\d+)|#(\d{2,}))`) +var githubThreadURLPattern = regexp.MustCompile(`(?i)^https?://github\.com/([\w.-]+)/([\w.-]+)/(?:issues|pull)/(\d+)(?:[/?#].*)?$`) +var ownerRepoThreadPattern = regexp.MustCompile(`(?i)^([\w.-]+)/([\w.-]+)#(\d+)$`) +var pathThreadPattern = regexp.MustCompile(`(?i)(?:^|/)(?:issues|pull)/(\d+)(?:[/?#].*)?$`) var titleTokenPattern = regexp.MustCompile(`[A-Za-z0-9]{4,}`) type referenceEvidence struct { @@ -461,7 +464,7 @@ func (a *App) runNeighbors(ctx context.Context, args []string) error { if err != nil { return usageErr(err) } - number, err := parseRequiredPositiveInt("number", *numberRaw) + number, err := parseRequiredThreadNumber("number", *numberRaw) if err != nil { return usageErr(err) } @@ -696,7 +699,7 @@ func (a *App) runEmbed(ctx context.Context, args []string) error { if err != nil { return usageErr(err) } - number, err := parseOptionalPositiveInt(*numberRaw) + number, err := parseOptionalThreadNumber(*numberRaw) if err != nil { return usageErr(err) } @@ -1418,7 +1421,7 @@ func (a *App) runThreads(ctx context.Context, args []string) error { if err != nil { return usageErr(err) } - numbers, err := parseOptionalPositiveIntList(*numbersRaw) + numbers, err := parseOptionalThreadNumberList(*numbersRaw) if err != nil { return usageErr(err) } @@ -1469,7 +1472,7 @@ func (a *App) runCloseThread(ctx context.Context, args []string) error { if err != nil { return usageErr(err) } - number, err := parseOptionalPositiveInt(*numberRaw) + number, err := parseOptionalThreadNumber(*numberRaw) if err != nil { return usageErr(err) } @@ -1514,7 +1517,7 @@ func (a *App) runReopenThread(ctx context.Context, args []string) error { if err != nil { return usageErr(err) } - number, err := parseOptionalPositiveInt(*numberRaw) + number, err := parseOptionalThreadNumber(*numberRaw) if err != nil { return usageErr(err) } @@ -1785,7 +1788,7 @@ func (a *App) runSync(ctx context.Context, args []string) error { if err != nil { return usageErr(err) } - numbers, err := parseOptionalPositiveIntList(*numbersRaw) + numbers, err := parseOptionalThreadNumberList(*numbersRaw) if err != nil { return usageErr(err) } @@ -2398,6 +2401,9 @@ func resolveOutputFormat(value string, jsonOut bool) (OutputFormat, error) { } func parseOwnerRepo(value string) (string, string, error) { + if ref, ok := parseThreadReference(value); ok && ref.Owner != "" && ref.Repo != "" { + return ref.Owner, ref.Repo, nil + } parts := strings.Split(value, "/") if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { return "", "", fmt.Errorf("expected owner/repo, got %q", value) @@ -2405,6 +2411,60 @@ func parseOwnerRepo(value string) (string, string, error) { return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil } +type threadReference struct { + Owner string + Repo string + Number int +} + +func (ref threadReference) FullName() string { + if ref.Owner == "" || ref.Repo == "" { + return "" + } + return ref.Owner + "/" + ref.Repo +} + +func parseThreadReference(value string) (threadReference, bool) { + value = strings.TrimSpace(value) + value = strings.Trim(value, "<>()[]{}\"'`") + value = strings.TrimRight(value, ".,;") + if value == "" { + return threadReference{}, false + } + if number, ok := parsePositiveIntLiteral(value); ok { + return threadReference{Number: number}, true + } + if strings.HasPrefix(value, "#") { + if number, ok := parsePositiveIntLiteral(strings.TrimPrefix(value, "#")); ok { + return threadReference{Number: number}, true + } + } + if match := githubThreadURLPattern.FindStringSubmatch(value); match != nil { + if number, ok := parsePositiveIntLiteral(match[3]); ok { + return threadReference{Owner: match[1], Repo: match[2], Number: number}, true + } + } + if match := ownerRepoThreadPattern.FindStringSubmatch(value); match != nil { + if number, ok := parsePositiveIntLiteral(match[3]); ok { + return threadReference{Owner: match[1], Repo: match[2], Number: number}, true + } + } + if match := pathThreadPattern.FindStringSubmatch(value); match != nil { + if number, ok := parsePositiveIntLiteral(match[1]); ok { + return threadReference{Number: number}, true + } + } + return threadReference{}, false +} + +func parsePositiveIntLiteral(value string) (int, bool) { + if !isDecimalString(value) { + return 0, false + } + number, err := strconv.Atoi(value) + return number, err == nil && number > 0 +} + func parseOptionalPositiveInt(value string) (int, error) { if strings.TrimSpace(value) == "" { return 0, nil @@ -2427,6 +2487,28 @@ func parseRequiredPositiveInt(name, value string) (int, error) { return parsed, nil } +func parseOptionalThreadNumber(value string) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + ref, ok := parseThreadReference(value) + if !ok || ref.Number <= 0 { + return 0, fmt.Errorf("expected positive issue or pull request number, got %q", value) + } + return ref.Number, nil +} + +func parseRequiredThreadNumber(name, value string) (int, error) { + parsed, err := parseOptionalThreadNumber(value) + if err != nil { + return 0, err + } + if parsed == 0 { + return 0, fmt.Errorf("missing --%s", name) + } + return parsed, nil +} + func parseClusterMemberCommandIDs(command, clusterIDRaw, numberRaw string) (int, int, error) { clusterID, err := parseOptionalPositiveInt(clusterIDRaw) if err != nil { @@ -2435,7 +2517,7 @@ func parseClusterMemberCommandIDs(command, clusterIDRaw, numberRaw string) (int, if clusterID == 0 { return 0, 0, fmt.Errorf("%s requires --id", command) } - number, err := parseOptionalPositiveInt(numberRaw) + number, err := parseOptionalThreadNumber(numberRaw) if err != nil { return 0, 0, err } @@ -2790,6 +2872,22 @@ func parseOptionalPositiveIntList(value string) ([]int, error) { return out, nil } +func parseOptionalThreadNumberList(value string) ([]int, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + parts := strings.Split(value, ",") + out := make([]int, 0, len(parts)) + for _, part := range parts { + parsed, err := parseOptionalThreadNumber(strings.TrimSpace(part)) + if err != nil { + return nil, err + } + out = append(out, parsed) + } + return out, nil +} + func (a *App) writeOutput(title string, payload any, allowLog bool) error { switch a.format { case FormatJSON: diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index e5469e3..84cc476 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -930,6 +930,18 @@ func TestAppOutputModesAndUsageBranches(t *testing.T) { if _, err := parseOptionalPositiveIntList("1, 0"); err == nil { t.Fatal("bad int list should fail") } + if owner, repo, err := parseOwnerRepo("https://github.com/openclaw/openclaw/issues/78601"); err != nil || owner != "openclaw" || repo != "openclaw" { + t.Fatalf("full issue URL owner/repo = %q/%q err=%v", owner, repo, err) + } + if got, err := parseOptionalThreadNumber("https://github.com/openclaw/openclaw/issues/78601"); err != nil || got != 78601 { + t.Fatalf("full issue URL number = %d err=%v", got, err) + } + if got, err := parseOptionalThreadNumber("https://github.com/openclaw/openclaw/pull/78602#issuecomment-1"); err != nil || got != 78602 { + t.Fatalf("full pull URL number = %d err=%v", got, err) + } + if got, err := parseOptionalThreadNumberList("https://github.com/openclaw/openclaw/issues/78601, openclaw/openclaw#78602, pull/78603, #78604"); err != nil || len(got) != 4 || got[0] != 78601 || got[1] != 78602 || got[2] != 78603 || got[3] != 78604 { + t.Fatalf("thread ref list = %#v err=%v", got, err) + } if _, _, _, err := parseClusterShapeOptions("test", "bad", "1", "0.5"); err == nil { t.Fatal("bad cluster shape should fail") } diff --git a/internal/cli/coverage_extra_test.go b/internal/cli/coverage_extra_test.go new file mode 100644 index 0000000..bea9565 --- /dev/null +++ b/internal/cli/coverage_extra_test.go @@ -0,0 +1,268 @@ +package cli + +import ( + "bytes" + "context" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/openclaw/gitcrawl/internal/config" + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestCLIAppCommandCoveragePaths(t *testing.T) { + ctx := context.Background() + configPath := seedGHShimRepo(t, ctx) + cfg, err := config.Load(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + st, err := store.Open(ctx, cfg.DBPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + repo, err := st.RepositoryByFullName(ctx, "openclaw/openclaw") + if err != nil { + t.Fatalf("repo: %v", err) + } + threads, err := st.ListThreadsFiltered(ctx, store.ThreadListOptions{RepoID: repo.ID, IncludeClosed: true, Numbers: []int{10, 12}}) + if err != nil { + t.Fatalf("threads: %v", err) + } + if len(threads) != 2 { + t.Fatalf("seed threads = %+v", threads) + } + result, err := st.SaveDurableClusters(ctx, repo.ID, []store.DurableClusterInput{{ + StableKey: "cli:10,12", + StableSlug: "cli-10-12", + RepresentativeThreadID: threads[0].ID, + Title: "CLI command cluster", + Members: []store.DurableClusterMemberInput{ + {ThreadID: threads[0].ID, Role: "canonical"}, + {ThreadID: threads[1].ID, Role: "member"}, + }, + }}) + if err != nil { + t.Fatalf("save cluster: %v", err) + } + if _, err := st.RecordRun(ctx, store.RunRecord{RepoID: repo.ID, Kind: "sync", Scope: "open", Status: "success", StartedAt: "2026-05-08T01:00:00Z", FinishedAt: "2026-05-08T01:00:01Z", StatsJSON: "{}"}); err != nil { + t.Fatalf("record run: %v", err) + } + clusterID, err := st.ClusterIDForThreadNumber(ctx, repo.ID, 10, true) + if err != nil { + t.Fatalf("cluster id: %v", err) + } + if result.RunID == 0 { + t.Fatal("cluster run id should be non-zero") + } + if err := st.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + commands := [][]string{ + {"--config", configPath, "--json", "configure", "--summary-model", "gpt-test", "--embed-model", "embed-test", "--embedding-basis", "title_original"}, + {"--config", configPath, "--json", "metadata"}, + {"--config", configPath, "--json", "status"}, + {"--config", configPath, "--json", "threads", "openclaw/openclaw", "--numbers", "https://github.com/openclaw/openclaw/issues/10,https://github.com/openclaw/openclaw/pull/12", "--include-closed", "--limit", "2"}, + {"--config", configPath, "--json", "runs", "openclaw/openclaw", "--kind", "sync", "--limit", "1"}, + {"--config", configPath, "--json", "clusters", "openclaw/openclaw", "--include-closed", "--sort", "oldest", "--min-size", "1", "--limit", "5"}, + {"--config", configPath, "--json", "durable-clusters", "openclaw/openclaw", "--include-closed", "--sort", "size", "--min-size", "1", "--limit", "5"}, + {"--config", configPath, "--json", "cluster-detail", "openclaw/openclaw", "--id", strconv.FormatInt(clusterID, 10), "--member-limit", "2", "--body-chars", "10", "--include-closed"}, + {"--config", configPath, "--json", "close-thread", "openclaw/openclaw", "--number", "https://github.com/openclaw/openclaw/issues/10", "--reason", "covered"}, + {"--config", configPath, "--json", "reopen-thread", "openclaw/openclaw", "--number", "10"}, + {"--config", configPath, "--json", "close-cluster", "openclaw/openclaw", "--id", strconv.FormatInt(clusterID, 10), "--reason", "covered"}, + {"--config", configPath, "--json", "reopen-cluster", "openclaw/openclaw", "--id", strconv.FormatInt(clusterID, 10)}, + {"--config", configPath, "--json", "exclude-cluster-member", "openclaw/openclaw", "--id", strconv.FormatInt(clusterID, 10), "--number", "12", "--reason", "covered"}, + {"--config", configPath, "--json", "include-cluster-member", "openclaw/openclaw", "--id", strconv.FormatInt(clusterID, 10), "--number", "12", "--reason", "covered"}, + {"--config", configPath, "--json", "set-cluster-canonical", "openclaw/openclaw", "--id", strconv.FormatInt(clusterID, 10), "--number", "12", "--reason", "covered"}, + } + for _, args := range commands { + app := New() + var stdout, stderr bytes.Buffer + app.Stdout = &stdout + app.Stderr = &stderr + if err := app.Run(ctx, args); err != nil { + t.Fatalf("%v failed: %v\nstdout=%s\nstderr=%s", args, err, stdout.String(), stderr.String()) + } + if stdout.Len() == 0 { + t.Fatalf("%v produced no output", args) + } + } + if clusterID <= 0 { + t.Fatalf("cluster id = %d", clusterID) + } +} + +func TestCLIAppHumanAndLogOutputE2E(t *testing.T) { + ctx := context.Background() + configPath := seedGHShimRepo(t, ctx) + textCommands := [][]string{ + {"--config", configPath, "version"}, + {"--config", configPath, "metadata"}, + {"--config", configPath, "status"}, + {"--config", configPath, "doctor"}, + {"--config", configPath, "help", "portable"}, + {"--config", configPath, "help", "tui"}, + } + for _, args := range textCommands { + app := New() + var stdout bytes.Buffer + app.Stdout = &stdout + if err := app.Run(ctx, args); err != nil { + t.Fatalf("%v failed: %v", args, err) + } + if strings.TrimSpace(stdout.String()) == "" { + t.Fatalf("%v produced no text output", args) + } + } + + logCommands := [][]string{ + {"--config", configPath, "--format", "log", "configure", "--summary-model", "gpt-log"}, + {"--config", configPath, "--format", "log", "doctor"}, + } + for _, args := range logCommands { + app := New() + var stdout bytes.Buffer + app.Stdout = &stdout + if err := app.Run(ctx, args); err != nil { + t.Fatalf("%v failed: %v", args, err) + } + if !strings.Contains(stdout.String(), "=") { + t.Fatalf("%v log output = %q", args, stdout.String()) + } + } + + jsonVersion := New() + var jsonOut bytes.Buffer + jsonVersion.Stdout = &jsonOut + if err := jsonVersion.Run(ctx, []string{"--config", configPath, "--format", "json", "version"}); err != nil { + t.Fatalf("json version: %v", err) + } + if !strings.Contains(jsonOut.String(), `"version"`) { + t.Fatalf("json version output = %q", jsonOut.String()) + } +} + +func TestCLIAppVectorFallbackCoveragePaths(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + dbPath := filepath.Join(dir, "gitcrawl.db") + app := New() + if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", dbPath}); err != nil { + t.Fatalf("init: %v", err) + } + repoID, firstID, secondID := seedCommandFlowStore(t, dbPath) + st, err := store.Open(ctx, dbPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + for _, vector := range []store.ThreadVector{ + {ThreadID: firstID, Basis: "other_basis", Model: "other-model", Dimensions: 2, ContentHash: "v1", Vector: []float64{1, 0}, CreatedAt: now, UpdatedAt: now}, + {ThreadID: secondID, Basis: "other_basis", Model: "other-model", Dimensions: 2, ContentHash: "v2", Vector: []float64{0.95, 0.05}, CreatedAt: now, UpdatedAt: now}, + } { + if err := st.UpsertThreadVector(ctx, vector); err != nil { + t.Fatalf("upsert vector: %v", err) + } + } + if err := st.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + configure := New() + if err := configure.Run(ctx, []string{"--config", configPath, "configure", "--embed-model", "missing-model", "--embedding-basis", "missing-basis"}); err != nil { + t.Fatalf("configure: %v", err) + } + for _, args := range [][]string{ + {"--config", configPath, "--json", "neighbors", "openclaw/openclaw", "--number", "101", "--limit", "1", "--threshold", "0.99"}, + {"--config", configPath, "--json", "cluster", "openclaw/openclaw", "--threshold", "0.5", "--min-size", "2", "--limit", "2"}, + {"--config", configPath, "--json", "refresh", "openclaw/openclaw", "--no-sync", "--no-embed", "--threshold", "0.5", "--min-size", "2"}, + {"--config", configPath, "--json", "search", "openclaw/openclaw", "--query", "gateway", "--mode", ""}, + } { + run := New() + var stdout bytes.Buffer + run.Stdout = &stdout + if err := run.Run(ctx, args); err != nil { + t.Fatalf("%v failed: %v\n%s", args, err, stdout.String()) + } + } + if repoID == 0 { + t.Fatal("seed repo id should be non-zero") + } +} + +func TestCLIAppUsageBranches(t *testing.T) { + ctx := context.Background() + configPath := filepath.Join(t.TempDir(), "config.toml") + cases := [][]string{ + {"--format", "yaml", "status"}, + {"serve"}, + {"unknown"}, + {"configure", "--bad"}, + {"metadata", "extra"}, + {"status", "extra"}, + {"portable"}, + {"portable", "unknown"}, + {"portable", "prune", "extra"}, + {"portable", "prune", "--body-chars", "bad"}, + {"threads"}, + {"threads", "bad-repo"}, + {"threads", "openclaw/openclaw", "--numbers", "bad"}, + {"threads", "openclaw/openclaw", "--limit", "bad"}, + {"runs"}, + {"runs", "openclaw/openclaw", "--limit", "bad"}, + {"cluster-detail", "openclaw/openclaw", "--id", "bad"}, + {"close-thread", "openclaw/openclaw"}, + {"reopen-thread", "openclaw/openclaw", "--number", "bad"}, + {"close-cluster", "openclaw/openclaw"}, + {"reopen-cluster", "openclaw/openclaw", "--id", "bad"}, + {"exclude-cluster-member", "openclaw/openclaw", "--id", "1"}, + {"include-cluster-member", "openclaw/openclaw", "--id", "bad", "--number", "1"}, + {"set-cluster-canonical", "openclaw/openclaw", "--id", "1", "--number", "bad"}, + {"sync", "openclaw/openclaw", "--with", "bad"}, + {"refresh"}, + {"refresh", "openclaw/openclaw", "--no-sync", "--no-embed", "--no-cluster"}, + {"refresh", "bad-repo"}, + {"refresh", "openclaw/openclaw", "--limit", "bad"}, + {"refresh", "openclaw/openclaw", "--threshold", "bad"}, + {"refresh", "openclaw/openclaw", "--threshold", "2"}, + {"refresh", "openclaw/openclaw", "--min-size", "bad"}, + {"refresh", "openclaw/openclaw", "--k", "bad"}, + {"search"}, + {"search", "openclaw/openclaw"}, + {"search", "bad-repo", "--query", "x"}, + {"search", "openclaw/openclaw", "--query", "x", "--limit", "bad"}, + {"search", "openclaw/openclaw", "--query", "x", "--mode", "bad"}, + {"neighbors"}, + {"neighbors", "bad-repo"}, + {"neighbors", "openclaw/openclaw"}, + {"neighbors", "openclaw/openclaw", "--number", "bad"}, + {"neighbors", "openclaw/openclaw", "--number", "1", "--limit", "bad"}, + {"neighbors", "openclaw/openclaw", "--number", "1", "--threshold", "bad"}, + {"cluster"}, + {"cluster", "bad-repo"}, + {"cluster", "openclaw/openclaw", "--threshold", "bad"}, + {"cluster", "openclaw/openclaw", "--threshold", "2"}, + {"cluster", "openclaw/openclaw", "--min-size", "bad"}, + {"cluster", "openclaw/openclaw", "--max-cluster-size", "bad"}, + {"cluster", "openclaw/openclaw", "--limit", "bad"}, + {"embed"}, + {"embed", "bad-repo"}, + {"embed", "openclaw/openclaw", "--number", "bad"}, + {"embed", "openclaw/openclaw", "--limit", "bad"}, + {"tui", "one", "two"}, + {"tui", "--sort", "bad"}, + } + for _, args := range cases { + app := New() + app.Stdout = &bytes.Buffer{} + app.Stderr = &bytes.Buffer{} + full := append([]string{"--config", configPath}, args...) + if err := app.Run(ctx, full); err == nil { + t.Fatalf("%v succeeded, want error", args) + } + } +} diff --git a/internal/cli/gh_shim.go b/internal/cli/gh_shim.go index 8dc063f..85ddf38 100644 --- a/internal/cli/gh_shim.go +++ b/internal/cli/gh_shim.go @@ -11,7 +11,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "github.com/openclaw/gitcrawl/internal/store" @@ -107,13 +106,18 @@ func (a *App) runGHThreadView(ctx context.Context, resource string, args []strin return usageErr(err) } if fs.NArg() != 1 { - return usageErr(fmt.Errorf("gh %s view requires a number", resource)) + return usageErr(fmt.Errorf("gh %s view requires a number or GitHub URL", resource)) } + ref, _ := parseThreadReference(fs.Arg(0)) number, err := parseThreadNumber(fs.Arg(0)) if err != nil { return usageErr(err) } - repoValue, err := a.resolveGHRepo(ctx, firstNonEmpty(*repoShort, *repoLong)) + repoArg := firstNonEmpty(*repoShort, *repoLong) + if repoArg == "" { + repoArg = ref.FullName() + } + repoValue, err := a.resolveGHRepo(ctx, repoArg) if err != nil { return localGHUnsupported(err) } @@ -356,12 +360,7 @@ func ghResourceKind(resource string) string { } func parseThreadNumber(value string) (int, error) { - value = strings.TrimSpace(strings.TrimPrefix(value, "#")) - number, err := strconv.Atoi(value) - if err != nil || number <= 0 { - return 0, fmt.Errorf("expected positive issue or pull request number, got %q", value) - } - return number, nil + return parseOptionalThreadNumber(value) } func ownerRepoFromGitRemote(value string) (string, error) { diff --git a/internal/cli/gh_shim_cache_policy.go b/internal/cli/gh_shim_cache_policy.go index e3e6e9f..746083b 100644 --- a/internal/cli/gh_shim_cache_policy.go +++ b/internal/cli/gh_shim_cache_policy.go @@ -334,6 +334,9 @@ func parseGHPRDiffIdentityArgs(args []string) (string, int, bool) { if strings.HasPrefix(arg, "-") || number != 0 { continue } + if ref, ok := parseThreadReference(arg); ok && ref.FullName() != "" && repo == "" { + repo = ref.FullName() + } parsed, err := parseThreadNumber(arg) if err != nil { return "", 0, false diff --git a/internal/cli/gh_shim_coverage_extra_test.go b/internal/cli/gh_shim_coverage_extra_test.go new file mode 100644 index 0000000..2c7112c --- /dev/null +++ b/internal/cli/gh_shim_coverage_extra_test.go @@ -0,0 +1,360 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/openclaw/gitcrawl/internal/config" + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestGHCacheDescriptorAndPolicyBranches(t *testing.T) { + if got := canonicalGHCommandArgs(nil); got != nil { + t.Fatalf("nil canonical args = %+v", got) + } + canonical := canonicalGHCommandArgs([]string{"pr", "view", "12", "--json", "title,number", "-R", " openclaw/openclaw ", "--method", "get", "--flag"}) + if strings.Join(canonical, " ") != "pr view 12 --flag --json=number,title --method=GET --repo=openclaw/openclaw" { + t.Fatalf("canonical args = %+v", canonical) + } + if got := canonicalGHCommandArgs([]string{"pr", "view", "--repo"}); strings.Join(got, " ") != "pr view --repo" { + t.Fatalf("missing value canonical args = %+v", got) + } + if !ghCacheTagsMatch([]string{"repo:openclaw/openclaw", "issues"}, stringSet([]string{"issues", "repo:openclaw/openclaw"})) { + t.Fatal("specific issue tag should match") + } + if ghCacheTagsMatch([]string{"repo:openclaw/openclaw"}, stringSet([]string{"repo:openclaw/openclaw", "issues"})) { + t.Fatal("repo tag alone should not match specific mutation") + } + app := New() + t.Setenv("GH_REPO", "openclaw/from-env") + tagCases := [][]string{ + app.ghCommandCacheTags(context.Background(), []string{"issue", "view", "https://github.com/openclaw/openclaw/issues/10", "-R", "openclaw/openclaw"}), + app.ghCommandCacheTags(context.Background(), []string{"pr", "view", "12"}), + app.ghMutationInvalidationTags(context.Background(), []string{"run", "rerun", "99", "-R", "openclaw/openclaw"}), + app.ghCommandCacheTags(context.Background(), []string{"workflow", "view", "ci.yml", "-R", "openclaw/openclaw"}), + app.ghCommandCacheTags(context.Background(), []string{"release", "view", "v0.7.0", "-R", "openclaw/openclaw"}), + app.ghCommandCacheTags(context.Background(), []string{"api", "repos/openclaw/openclaw/actions/runs/99/jobs"}), + app.ghMutationInvalidationTags(context.Background(), []string{"cache", "delete"}), + } + for _, tags := range tagCases { + if len(tags) == 0 { + t.Fatalf("empty tags") + } + } + if repo := ghCommandRepo([]string{"repo", "view", "openclaw/openclaw"}); repo != "openclaw/openclaw" { + t.Fatalf("repo view repo = %q", repo) + } + if repo := ghAPIRepo([]string{"https://api.github.com/repos/openclaw/openclaw/issues/10"}); repo != "openclaw/openclaw" { + t.Fatalf("api repo = %q", repo) + } + if tags := ghAPITags([]string{"repos/openclaw/openclaw/releases/latest"}); len(tags) < 2 || tags[1] != "releases" { + t.Fatalf("release api tags = %+v", tags) + } + if got := firstGHNumberArg([]string{"--repo", "openclaw/openclaw", "https://github.com/openclaw/openclaw/pull/12"}); got != "12" { + t.Fatalf("first number = %q", got) + } + if got := uniqueStrings([]string{"", "a", " a ", "b"}); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("unique = %+v", got) + } + completedRun := ghCommandCacheEntry{Args: []string{"run", "view", "99"}, Stdout: `{"status":"completed"}`} + if ttl := ghCompletedRunCacheTTL(completedRun); ttl != 12*time.Hour { + t.Fatalf("run view ttl = %s", ttl) + } + completedList := ghCommandCacheEntry{Args: []string{"api", "repos/openclaw/openclaw/actions/runs"}, Stdout: `{"workflow_runs":[{"status":"completed"}]}`} + if ttl := ghCompletedRunCacheTTL(completedList); ttl != 30*time.Minute { + t.Fatalf("run list ttl = %s", ttl) + } + jobs := ghCommandCacheEntry{Args: []string{"api", "repos/openclaw/openclaw/actions/runs/99/jobs"}, Stdout: `{"jobs":[{"conclusion":"success"}]}`} + if ttl := ghCompletedRunCacheTTL(jobs); ttl != 12*time.Hour { + t.Fatalf("jobs ttl = %s", ttl) + } + if ghJSONStatusCompleted(`{`) || ghJSONCollectionCompleted(`[]`) || allGHStatusMapsCompleted([]map[string]any{{"status": "queued"}}) { + t.Fatal("incomplete JSON status classified as completed") + } + if !cacheableGHRead([]string{"label", "list"}) || !cacheableGHRead([]string{"org", "list"}) || !cacheableGHRead([]string{"search", "repos"}) { + t.Fatal("expected read-only gh commands to be cacheable") + } + if ghCommandName(nil) != "" || ghCommandName([]string{"pr"}) != "pr" || ghCommandName([]string{"api", "repos/x/y"}) != "api" { + t.Fatal("gh command name mismatch") + } + if ghRunCacheTTL(nil) != 30*time.Second || ghRunCacheTTL([]string{"view", "--job", "1"}) != time.Minute || ghRunCacheTTL([]string{"rerun"}) != 30*time.Second { + t.Fatal("run ttl mismatch") + } + if ttl := ghAPICacheTTL([]string{"repos/openclaw/openclaw/actions/runs/99/jobs"}); ttl != time.Minute { + t.Fatalf("jobs ttl = %s", ttl) + } + if ttl := ghAPICacheTTL([]string{"repos/openclaw/openclaw/contents/file?ref=main"}); ttl != 30*time.Minute { + t.Fatalf("unstable content ttl = %s", ttl) + } + if !ghAPIContentRefIsStableReleaseTag("refs/tags/v1.2.3") || !ghAPIContentRefIsStableReleaseTag("v1.2.3+build") || ghAPIContentRefIsStableReleaseTag("v1.2") { + t.Fatal("version ref classification mismatch") + } +} + +func TestPortableRuntimeHelperBranches(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + root := filepath.Join(dir, "store") + dbPath := filepath.Join(root, "data", "openclaw__openclaw.sync.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + t.Fatalf("mkdir db dir: %v", err) + } + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatalf("mkdir git dir: %v", err) + } + if err := os.WriteFile(dbPath, []byte("db-v1"), 0o644); err != nil { + t.Fatalf("write db: %v", err) + } + app := New() + app.configPath = filepath.Join(dir, "config.toml") + mirror, err := app.portableRuntimeDBPath(dbPath) + if err != nil { + t.Fatalf("runtime path: %v", err) + } + changed, err := refreshPortableRuntimeDB(ctx, dbPath, mirror, false) + if err != nil || !changed { + t.Fatalf("initial runtime copy changed=%v err=%v", changed, err) + } + changed, err = refreshPortableRuntimeDB(ctx, dbPath, mirror, false) + if err != nil || changed { + t.Fatalf("second runtime copy changed=%v err=%v", changed, err) + } + if needs, err := portableRuntimeNeedsCopy(filepath.Join(dir, "missing.db"), mirror); err == nil || needs { + t.Fatalf("missing source needs=%v err=%v", needs, err) + } + if _, ok := portableStoreRoot(filepath.Join(dir, "plain", "db.sqlite")); ok { + t.Fatal("plain db should not have portable root") + } + if gitWorktreeClean(ctx, root) { + t.Fatal("fake git directory should not be a clean worktree") + } + statePath := portableStoreRefreshStatePath(mirror) + state := portableStoreRefreshState{LastSuccess: time.Now().UTC().Format(time.RFC3339Nano)} + if err := writePortableStoreRefreshState(statePath, state); err != nil { + t.Fatalf("write state: %v", err) + } + if got := readPortableStoreRefreshState(statePath); got.LastSuccess == "" { + t.Fatalf("read state = %+v", got) + } + if got := readPortableStoreRefreshState(filepath.Join(dir, "missing.json")); got.LastSuccess != "" { + t.Fatalf("missing state = %+v", got) + } + if !recentPortableRefresh(state.LastSuccess, time.Now().UTC(), time.Hour) || recentPortableRefresh("bad", time.Now().UTC(), time.Hour) || recentPortableRefresh("", time.Now().UTC(), time.Hour) { + t.Fatal("recent refresh classification mismatch") + } + t.Setenv("GITCRAWL_PORTABLE_REFRESH_TTL", "0") + if portableStoreRefreshInterval() != 0 { + t.Fatal("zero refresh ttl not honored") + } + if err := copyFileAtomic(filepath.Join(dir, "missing"), filepath.Join(dir, "out", "db")); err == nil { + t.Fatal("missing source copy should fail") + } +} + +func TestGHCacheClearMatchingBranches(t *testing.T) { + ctx := context.Background() + configPath := seedGHShimRepo(t, ctx) + cfg, err := config.Load(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + app := New() + app.configPath = configPath + dir, err := app.ghCommandCacheDir() + if err != nil { + t.Fatalf("cache dir: %v", err) + } + entry := ghCommandCacheEntry{ + Args: []string{"issue", "view", "10", "-R", "openclaw/openclaw"}, + Stdout: "{}", + Stderr: "", + ExitCode: 0, + CreatedAt: time.Now(), + Tags: []string{"repo:openclaw/openclaw", "issues", "issue:10"}, + } + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal entry: %v", err) + } + entryPath := filepath.Join(dir, "entry.json") + if err := os.WriteFile(entryPath, data, 0o644); err != nil { + t.Fatalf("write entry: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "entry.lock"), []byte("lock"), 0o644); err != nil { + t.Fatalf("write lock: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "ignore.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("write ignored entry: %v", err) + } + if err := app.clearGHCommandCacheMatching([]string{"issue:10"}); err != nil { + t.Fatalf("clear matching: %v", err) + } + if _, err := os.Stat(entryPath); !os.IsNotExist(err) { + t.Fatalf("entry still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "entry.lock")); !os.IsNotExist(err) { + t.Fatalf("lock still exists: %v", err) + } + if err := os.WriteFile(entryPath, data, 0o644); err != nil { + t.Fatalf("rewrite entry: %v", err) + } + if err := app.clearGHCommandCacheForMutation(ctx, []string{"cache", "delete"}); err != nil { + t.Fatalf("clear global mutation: %v", err) + } + if _, err := os.Stat(entryPath); !os.IsNotExist(err) { + t.Fatalf("global clear left entry: %v", err) + } + if cfg.CacheDir == "" { + t.Fatal("seed config cache dir should not be empty") + } +} + +func TestGHMetricsSearchRunsAndXCacheBranches(t *testing.T) { + var counters ghXCacheCounters + if incrementGHXCacheCounters(&counters, "unknown", nil) { + t.Fatal("unknown counter should not increment") + } + for _, name := range []string{"local_hits", "fallback_hits", "stale_hits", "backend_misses", "pass_through_writes"} { + if !incrementGHXCacheCounters(&counters, name, []string{"api", "repos/openclaw/openclaw/actions/runs/99"}) { + t.Fatalf("counter %s did not increment", name) + } + } + var bucket ghXCacheCounterBucket + for _, name := range []string{"local_hits", "fallback_hits", "stale_hits", "backend_misses", "pass_through_writes"} { + if !incrementGHXCacheCounterBucket(&bucket, name, []string{"pr", "view", "12", "-R", "openclaw/openclaw"}) { + t.Fatalf("bucket counter %s did not increment", name) + } + } + if incrementGHXCacheCounterBucket(&bucket, "bad", nil) { + t.Fatal("unknown bucket counter should not increment") + } + if got := ghCommandMissKey([]string{"pr", "view", strings.Repeat("x", 220)}); len(got) != 180 || !strings.HasSuffix(got, "...") { + t.Fatalf("miss key = %q len=%d", got, len(got)) + } + if route := ghCommandRoute([]string{"api", "repos/openclaw/openclaw/actions/runs/99"}); !strings.Contains(route, "/actions/runs/:id") { + t.Fatalf("api route = %q", route) + } + if route := ghCommandRoute([]string{"pr"}); route != "pr" { + t.Fatalf("single route = %q", route) + } + now := time.Now().UTC() + counters.Hourly = map[string]ghXCacheCounterBucket{ + "old": {StartedAt: now.Add(-2 * time.Hour), LocalHits: 9}, + "new": {StartedAt: now.Add(-5 * time.Minute), LocalHits: 1, BackendMissesByCommand: map[string]int64{"api": 2}}, + "zero": {LocalHits: 8}, + } + recent := counters.since(time.Hour, now) + if recent.LocalHits != 1 || recent.BackendMissesByCommand["api"] != 2 { + t.Fatalf("recent counters = %+v", recent) + } + mergeCounterMap(&recent.BackendMissesByRoute, map[string]int64{"r": 3}) + if recent.BackendMissesByRoute["r"] != 3 { + t.Fatalf("merged counters = %+v", recent.BackendMissesByRoute) + } + buckets := map[string]ghXCacheCounterBucket{"old": {StartedAt: now.Add(-8 * 24 * time.Hour)}, "new": {StartedAt: now}} + pruneGHXCacheBuckets(buckets, now.Add(-7*24*time.Hour)) + if _, ok := buckets["old"]; ok || buckets["new"].StartedAt.IsZero() { + t.Fatalf("pruned buckets = %+v", buckets) + } + if _, start := ghXCacheCurrentBucket(now); !start.Equal(now.Truncate(time.Hour)) { + t.Fatalf("bucket start = %s", start) + } + if staleGHCommandCacheLock(fakeFileInfo{mod: now.Add(-3 * time.Minute)}) != true || staleGHCommandCacheLock(fakeFileInfo{mod: now}) { + t.Fatal("stale lock classification mismatch") + } + + thread := store.Thread{ + GitHubID: "99", Number: 99, Title: "Title", State: "open", HTMLURL: "https://example.com/99", + LabelsJSON: `["bug",""]`, AuthorLogin: "alice", AuthorType: "User", Body: "body", + UpdatedAt: "2026-05-08T00:00:00Z", CreatedAtGitHub: "2026-05-07T00:00:00Z", ClosedAtGitHub: "", IsDraft: true, + } + fields := "number,id,title,state,url,updatedAt,createdAt,closedAt,mergedAt,labels,isDraft,author,body" + rows, err := ghSearchJSONRows([]store.Thread{thread}, fields) + if err != nil || rows[0]["number"] != 99 { + t.Fatalf("search rows=%+v err=%v", rows, err) + } + if labels := ghLabelsFromJSON(`not-json`); labels != nil { + t.Fatalf("bad labels = %+v", labels) + } + if labels := ghLabelsFromJSON(`[{"name":"bug","color":"red"}]`); len(labels) != 1 || labels[0].Name != "bug" { + t.Fatalf("object labels = %+v", labels) + } + if _, err := ghSearchJSONRows([]store.Thread{thread}, "unsupported"); err == nil { + t.Fatal("unsupported search json field should fail") + } + if _, err := ghSearchJSONRows([]store.Thread{thread}, " "); err == nil { + t.Fatal("empty search json fields should fail") + } + query, repo, state := parseGHSearchQuery("repo:openclaw/openclaw is:pr is:open crash") + if query != "crash" || repo != "openclaw/openclaw" || state != "open" { + t.Fatalf("query=%q repo=%q state=%q", query, repo, state) + } + if !isGHSearchKind("pull-requests") || ghSearchKind("pulls") != "pull_request" || ghSearchKind("issues") != "issue" { + t.Fatal("search kind mismatch") + } + if _, err := parseGHSearchDuration("0"); err == nil { + t.Fatal("zero duration should fail") + } + if duration, err := parseGHSearchDuration("5"); err != nil || duration != 5*time.Second { + t.Fatalf("seconds duration=%s err=%v", duration, err) + } + if _, err := parseGHSearchLimit("5", "6"); err == nil { + t.Fatal("disagreeing limits should fail") + } + + runs := []store.WorkflowRun{{ + RunID: "99", RunNumber: 7, WorkflowName: "CI", Status: "completed", Conclusion: "success", + HTMLURL: "https://example.com/run", Event: "push", HeadBranch: "main", HeadSHA: "abc", + CreatedAtGH: "2026-05-08T00:00:00Z", UpdatedAtGH: "2026-05-08T00:01:00Z", + }, {RunID: "not-number", WorkflowName: "Deploy"}} + runRows := ghWorkflowRunJSONRows(runs, "databaseId,id,number,workflowName,name,displayTitle,status,conclusion,url,event,headBranch,headSha,createdAt,updatedAt") + if runRows[0]["databaseId"] != int64(99) || runRows[1]["databaseId"] != "not-number" { + t.Fatalf("run rows = %+v", runRows) + } + + dir := t.TempDir() + entry := ghCommandCacheEntry{Args: []string{"run", "list"}, CreatedAt: time.Now().Add(-time.Hour), Stdout: "[]"} + data, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal entry: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "good.json"), data, 0o644); err != nil { + t.Fatalf("write entry: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("{"), 0o644); err != nil { + t.Fatalf("write bad entry: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir: %v", err) + } + found := false + for _, entry := range entries { + if info, ok := ghCommandCacheKeyInfoFromDirEntry(dir, entry); ok && info.Key == "good" { + found = true + } + } + if !found { + t.Fatal("cache key info did not parse good entry") + } + var buf bytes.Buffer + printGHXCacheMisses(&buf, "Misses", map[string]int64{"b": 1, "a": 2}) + if !strings.Contains(buf.String(), "Misses") { + t.Fatalf("miss output = %q", buf.String()) + } +} + +type fakeFileInfo struct{ mod time.Time } + +func (f fakeFileInfo) Name() string { return "fake" } +func (f fakeFileInfo) Size() int64 { return 0 } +func (f fakeFileInfo) Mode() os.FileMode { return 0 } +func (f fakeFileInfo) ModTime() time.Time { return f.mod } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } diff --git a/internal/cli/gh_shim_descriptor.go b/internal/cli/gh_shim_descriptor.go index 2159b31..72a8bfb 100644 --- a/internal/cli/gh_shim_descriptor.go +++ b/internal/cli/gh_shim_descriptor.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" ) @@ -308,9 +309,8 @@ func firstGHNumberArg(args []string) string { } continue } - arg = strings.TrimPrefix(strings.TrimSpace(arg), "#") - if isDecimalString(arg) { - return arg + if ref, ok := parseThreadReference(arg); ok && ref.Number > 0 { + return strconv.Itoa(ref.Number) } } return "" diff --git a/internal/cli/gh_shim_detail_test.go b/internal/cli/gh_shim_detail_test.go index 5d43198..9d5b29d 100644 --- a/internal/cli/gh_shim_detail_test.go +++ b/internal/cli/gh_shim_detail_test.go @@ -55,6 +55,17 @@ func TestGHShimViewAndListUseLocalCache(t *testing.T) { t.Fatalf("checks = %#v", checks) } + stdout.Reset() + if err := run.Run(ctx, []string{"--config", configPath, "gh", "pr", "checks", "https://github.com/openclaw/openclaw/pull/12", "--json", "name,state"}); err != nil { + t.Fatalf("gh pr checks URL: %v", err) + } + if err := json.Unmarshal(stdout.Bytes(), &checks); err != nil { + t.Fatalf("decode URL checks: %v\n%s", err, stdout.String()) + } + if len(checks) != 1 || checks[0]["name"] != "test" || checks[0]["state"] != "SUCCESS" { + t.Fatalf("URL checks = %#v", checks) + } + stdout.Reset() if err := run.Run(ctx, []string{"--config", configPath, "gh", "run", "list", "-R", "openclaw/openclaw", "--branch", "manifest-cache", "--json", "databaseId,workflowName,status,conclusion,headSha"}); err != nil { t.Fatalf("gh run list: %v", err) diff --git a/internal/cli/gh_shim_policy_extra_test.go b/internal/cli/gh_shim_policy_extra_test.go index 3a8d732..1cc6c91 100644 --- a/internal/cli/gh_shim_policy_extra_test.go +++ b/internal/cli/gh_shim_policy_extra_test.go @@ -165,6 +165,14 @@ func TestGHShimCachePolicyExtraBranches(t *testing.T) { if !ok || repo != "openclaw/from-env" || number != 42 { t.Fatalf("diff identity repo=%q number=%d ok=%v", repo, number, ok) } + repo, number, ok = parseGHPRDiffIdentityArgs([]string{"pr", "diff", "https://github.com/openclaw/openclaw/pull/78601"}) + if !ok || repo != "openclaw/openclaw" || number != 78601 { + t.Fatalf("diff URL identity repo=%q number=%d ok=%v", repo, number, ok) + } + repo, number, ok = parseGHPRDiffIdentityArgs([]string{"pr", "diff", "https://github.com/openclaw/openclaw/issues/78601"}) + if !ok || repo != "openclaw/openclaw" || number != 78601 { + t.Fatalf("diff issue URL identity repo=%q number=%d ok=%v", repo, number, ok) + } for _, args := range [][]string{{"issue", "close"}, {"pr", "merge"}, {"project", "item-add"}, {"release", "upload"}, {"repo", "delete"}, {"run", "rerun"}, {"secret", "set"}, {"variable", "delete"}, {"workflow", "disable"}, {"api", "repos/openclaw/gitcrawl/issues", "-f", "title=x"}} { if !mutatingGHCommand(args) { t.Fatalf("%v should be mutating", args) diff --git a/internal/cli/gh_shim_prcache.go b/internal/cli/gh_shim_prcache.go index 1b4aee7..30c8ef5 100644 --- a/internal/cli/gh_shim_prcache.go +++ b/internal/cli/gh_shim_prcache.go @@ -192,13 +192,18 @@ func (a *App) runGHPRChecks(ctx context.Context, args []string) error { return usageErr(err) } if fs.NArg() != 1 { - return usageErr(fmt.Errorf("gh pr checks requires a number")) + return usageErr(fmt.Errorf("gh pr checks requires a number or GitHub URL")) } + ref, _ := parseThreadReference(fs.Arg(0)) number, err := parseThreadNumber(fs.Arg(0)) if err != nil { return usageErr(err) } - repoValue, err := a.resolveGHRepo(ctx, firstNonEmpty(*repoShort, *repoLong)) + repoArg := firstNonEmpty(*repoShort, *repoLong) + if repoArg == "" { + repoArg = ref.FullName() + } + repoValue, err := a.resolveGHRepo(ctx, repoArg) if err != nil { return localGHUnsupported(err) } diff --git a/internal/cli/gh_shim_test.go b/internal/cli/gh_shim_test.go index a155e0b..56c2203 100644 --- a/internal/cli/gh_shim_test.go +++ b/internal/cli/gh_shim_test.go @@ -64,6 +64,29 @@ func TestGHShimFallsBackForUnsupportedRead(t *testing.T) { } } +func TestGHShimViewAcceptsFullGitHubURL(t *testing.T) { + ctx := context.Background() + configPath := seedGHShimRepo(t, ctx) + + run := New() + var stdout bytes.Buffer + run.Stdout = &stdout + if err := run.Run(ctx, []string{ + "--config", configPath, + "gh", "issue", "view", "https://github.com/openclaw/openclaw/issues/10", + "--json", "number,title,url", + }); err != nil { + t.Fatalf("gh issue view URL: %v", err) + } + var row map[string]any + if err := json.Unmarshal(stdout.Bytes(), &row); err != nil { + t.Fatalf("decode issue view: %v\n%s", err, stdout.String()) + } + if int(row["number"].(float64)) != 10 || row["url"] != "https://github.com/openclaw/openclaw/issues/10" { + t.Fatalf("row = %#v", row) + } +} + func seedGHShimRepo(t *testing.T, ctx context.Context) string { t.Helper() dir := t.TempDir() diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 1d74755..037c4b1 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -9,7 +9,6 @@ import ( "regexp" "runtime" "sort" - "strconv" "strings" "time" @@ -1113,7 +1112,7 @@ func (m *clusterBrowserModel) startJumpInput() tea.Cmd { m.showHelp = false m.closeMenu("") m.searchInput.Prompt = "# " - m.searchInput.Placeholder = "issue or PR number" + m.searchInput.Placeholder = "issue, PR, or GitHub URL" m.searchInput.SetValue("") m.status = "Jump to issue/PR" return m.searchInput.Focus() @@ -1123,9 +1122,9 @@ func (m clusterBrowserModel) handleJumpKey(msg tea.KeyMsg) (clusterBrowserModel, switch msg.String() { case "enter": m.jumping = false - value := strings.TrimPrefix(strings.TrimSpace(m.searchInput.Value()), "#") + value := strings.TrimSpace(m.searchInput.Value()) m.searchInput.Blur() - number, err := strconv.Atoi(value) + number, err := parseOptionalThreadNumber(value) if err != nil || number <= 0 { m.status = "Enter a positive issue or PR number" return m, nil diff --git a/internal/cli/tui_coverage_extra_test.go b/internal/cli/tui_coverage_extra_test.go new file mode 100644 index 0000000..457fc7a --- /dev/null +++ b/internal/cli/tui_coverage_extra_test.go @@ -0,0 +1,179 @@ +package cli + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/openclaw/gitcrawl/internal/store" +) + +func TestTUIRemainingActionAndErrorBranches(t *testing.T) { + thread := store.Thread{ + ID: 1, Number: 10, Kind: "issue", State: "open", Title: "Thread title", + Body: "Body with https://example.com/docs", HTMLURL: "https://github.com/openclaw/openclaw/issues/10", + UpdatedAt: "2026-05-08T00:00:00Z", + } + cluster := store.ClusterSummary{ + ID: 7, Source: store.ClusterSourceRun, StableSlug: "cluster-7", Status: "active", + Title: "Cluster title", RepresentativeNumber: 10, RepresentativeKind: "issue", + RepresentativeTitle: "Thread title", MemberCount: 1, UpdatedAt: "2026-05-08T00:00:00Z", + } + detail := store.ClusterDetail{ + Cluster: cluster, + Members: []store.ClusterMemberDetail{{ + Thread: thread, + Role: "member", + State: "active", + BodySnippet: "Body with https://example.com/docs", + Summaries: map[string]string{"problem_summary": "summary"}, + }}, + } + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + Sort: "size", + MinSize: 1, + Clusters: []store.ClusterSummary{cluster}, + }) + model.detailCache[7] = detail + model.loadSelectedCluster() + model.memberIndex = 0 + model.neighborCache[thread.ID] = []tuiNeighbor{{Thread: thread, Score: 0.9}} + + for _, action := range []string{"sort-oldest", "member-sort-oldest", "toggle-closed", "close-menu"} { + if !model.runAction(action) { + t.Fatalf("action %s was not handled", action) + } + } + if model.payload.Sort != "oldest" || model.memberSort != memberSortOldest { + t.Fatalf("sort actions failed sort=%q member=%q", model.payload.Sort, model.memberSort) + } + + t.Setenv("PATH", "") + errorActions := []string{ + "open-cluster-representative", + "copy-cluster-url", + "copy-thread-detail", + "copy-body-preview", + "copy-summaries", + "copy-neighbors", + "copy-cluster-id", + "copy-cluster-name", + "copy-cluster-title", + "copy-member-list", + "copy-cluster", + "copy-visible-clusters", + "copy-reference-links", + "open", + "copy-url", + "copy-markdown", + "copy-title", + "open-first-link", + "copy-first-link", + } + for _, action := range errorActions { + model.status = "" + handled := model.runMenuItem(tuiMenuItem{label: action, action: action, value: "https://example.com/docs"}) + if !handled || model.status == "" { + t.Fatalf("error action %s handled=%v status=%q", action, handled, model.status) + } + } + model.openReferenceLinkMenu("copy") + model.runAction("back-to-actions") + if model.menuTitle != "Actions" { + t.Fatalf("back to actions failed title=%q", model.menuTitle) + } + model.runMenuItem(tuiMenuItem{label: "Open picked", action: "open-picked-link", value: "https://example.com/docs"}) + model.runMenuItem(tuiMenuItem{label: "Copy picked", action: "copy-picked-link", value: "https://example.com/docs"}) + + model.closeSelectedClusterLocally() + if !strings.Contains(model.status, "only available for durable clusters") { + t.Fatalf("raw cluster local close status=%q", model.status) + } + model.reopenSelectedClusterLocally() + model.excludeSelectedClusterMemberLocally() + model.includeSelectedClusterMemberLocally() + model.setSelectedClusterCanonicalLocally() + if !strings.Contains(model.status, "only available for durable clusters") { + t.Fatalf("raw member local action status=%q", model.status) + } +} + +func TestTUIRemainingHelperBranches(t *testing.T) { + model := newClusterBrowserModel(context.Background(), nil, 0, clusterBrowserPayload{ + Repository: "openclaw/openclaw", + MinSize: 1, + Limit: 1, + Clusters: []store.ClusterSummary{ + {ID: 1, Status: "active", RepresentativeNumber: 101, MemberCount: 1, UpdatedAt: "2026-05-08T00:00:00Z"}, + }, + }) + if model.currentClusterID() != 1 { + t.Fatalf("current cluster id = %d", model.currentClusterID()) + } + if model.clusterRefreshLimit() != 1 { + t.Fatalf("cluster refresh limit = %d", model.clusterRefreshLimit()) + } + model.ensureClusterInWorkingSet(store.ClusterSummary{ID: 2, Status: "closed", ClosedAt: "2026-05-08T00:00:00Z", MemberCount: 2}) + if !model.selectClusterIDForJump(2) || !model.showClosed || model.minSize != 1 { + t.Fatalf("jump selection showClosed=%v minSize=%d selected=%d", model.showClosed, model.minSize, model.selected) + } + model.payload.Clusters = nil + if model.currentClusterID() != 0 || model.clusterSignature() != "" { + t.Fatalf("empty cluster helpers id=%d sig=%q", model.currentClusterID(), model.clusterSignature()) + } + if _, ok := model.clusterFromWorkingSet(999); ok { + t.Fatal("missing working-set cluster should not resolve") + } + model.applyClusterRefresh(nil, 0) + if model.payload.Clusters == nil { + t.Fatal("nil refresh should normalize clusters") + } + model.autoRefreshFromStore() + if model.status != "Refresh unavailable for this view" { + t.Fatalf("auto refresh status=%q", model.status) + } + if cmd := model.autoRefreshCmd(); cmd != nil { + t.Fatalf("auto refresh command without store = %v", cmd) + } + model.switchRepository("") + if model.status != "Repository picker unavailable for this view" { + t.Fatalf("switch repository no store status=%q", model.status) + } + if label := (clusterBrowserModel{}).clusterPositionLabel(); label != "0" { + t.Fatalf("zero cluster position label = %q", label) + } + if label := model.clusterPositionLabel(); label != "0" { + t.Fatalf("empty model cluster position label = %q", label) + } + memberModel := model + memberModel.memberRows = []memberRow{} + if label := memberModel.memberPositionLabel(); label != "0" { + t.Fatalf("zero member position label = %q", label) + } + if got := formatRelativeTime(time.Now().Add(-30 * time.Minute).Format(time.RFC3339Nano)); got != "30m ago" { + t.Fatalf("minute age = %q", got) + } + if got := formatRelativeTime(time.Now().Add(-75 * 24 * time.Hour).Format(time.RFC3339Nano)); !strings.Contains(got, "mo ago") { + t.Fatalf("month age = %q", got) + } + if got := formatRelativeTime(""); got != "never" { + t.Fatalf("empty age = %q", got) + } + if got := formatRelativeTime("bad-time"); got != "bad-time" { + t.Fatalf("bad age = %q", got) + } + if got := wrapPlain("", 10); len(got) != 1 || got[0] != "" { + t.Fatalf("empty wrap = %+v", got) + } + if got := clampInt(5, 10, 1); got != 10 { + t.Fatalf("inverted clamp = %d", got) + } + if got := padCells("abcdef", 0); got != "" { + t.Fatalf("zero pad = %q", got) + } + if got := fitBlock("a\nb", 2, 1); got != "a " { + t.Fatalf("fit block = %q", got) + } +} diff --git a/internal/cli/tui_render_extra_test.go b/internal/cli/tui_render_extra_test.go index a08e00a..375a1a3 100644 --- a/internal/cli/tui_render_extra_test.go +++ b/internal/cli/tui_render_extra_test.go @@ -283,7 +283,7 @@ func TestTUIJumpKeyAndRefreshCommandBranches(t *testing.T) { t.Fatalf("bad enter next=%+v cmd=%v", next, cmd) } input = textinput.New() - input.SetValue("123") + input.SetValue("https://github.com/openclaw/openclaw/issues/123") model = clusterBrowserModel{ searchInput: input, jumping: true, diff --git a/internal/store/coverage_test.go b/internal/store/coverage_test.go index 738bd41..9579f60 100644 --- a/internal/store/coverage_test.go +++ b/internal/store/coverage_test.go @@ -880,3 +880,106 @@ func seedVectorThreads(t *testing.T, ctx context.Context, st *Store) (int64, []i } return repoID, ids } + +func TestClosedStoreErrorBranches(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + repoID, threadIDs := seedVectorThreads(t, ctx, st) + if _, err := st.SaveDurableClusters(ctx, repoID, []DurableClusterInput{{ + StableKey: "closed-store", + RepresentativeThreadID: threadIDs[0], + Members: []DurableClusterMemberInput{{ThreadID: threadIDs[0]}, {ThreadID: threadIDs[1]}}, + }}); err != nil { + t.Fatalf("seed durable cluster: %v", err) + } + if err := st.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + checks := []struct { + name string + fn func() error + }{ + {"display summaries", func() error { + _, err := st.ListDisplayClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID, IncludeClosed: true}) + return err + }}, + {"run summaries", func() error { + _, err := st.ListRunClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID}) + return err + }}, + {"durable summaries", func() error { + _, err := st.ListClusterSummaries(ctx, ClusterSummaryOptions{RepoID: repoID}) + return err + }}, + {"cluster detail", func() error { + _, err := st.ClusterDetail(ctx, ClusterDetailOptions{RepoID: repoID, ClusterID: 1}) + return err + }}, + {"durable detail", func() error { + _, err := st.DurableClusterDetail(ctx, ClusterDetailOptions{RepoID: repoID, ClusterID: 1}) + return err + }}, + {"thread cluster", func() error { + _, err := st.ClusterIDForThreadNumber(ctx, repoID, 301, true) + return err + }}, + {"close cluster", func() error { + return st.CloseClusterLocally(ctx, repoID, 1, "closed") + }}, + {"reopen cluster", func() error { + return st.ReopenClusterLocally(ctx, repoID, 1) + }}, + {"save durable", func() error { + _, err := st.SaveDurableClusters(ctx, repoID, []DurableClusterInput{{ + StableKey: "after-close", + RepresentativeThreadID: threadIDs[0], + Members: []DurableClusterMemberInput{{ThreadID: threadIDs[0]}}, + }}) + return err + }}, + {"exclude member", func() error { + _, err := st.ExcludeClusterMemberLocally(ctx, repoID, 1, 301, "closed") + return err + }}, + {"include member", func() error { + _, err := st.IncludeClusterMemberLocally(ctx, repoID, 1, 301, "closed") + return err + }}, + {"canonical member", func() error { + _, err := st.SetClusterCanonicalLocally(ctx, repoID, 1, 301, "closed") + return err + }}, + {"summaries", func() error { + _, err := st.summariesByThreadIDs(ctx, threadIDs) + return err + }}, + {"portable prune", func() error { + _, err := st.PrunePortablePayloads(ctx, PortablePruneOptions{BodyChars: 8}) + return err + }}, + {"status", func() error { + _, err := st.Status(ctx) + return err + }}, + {"repositories", func() error { + _, err := st.ListRepositories(ctx) + return err + }}, + {"runs", func() error { + _, err := st.ListRuns(ctx, repoID, "sync", 1) + return err + }}, + } + errorsSeen := 0 + for _, check := range checks { + if err := check.fn(); err != nil { + errorsSeen++ + } + } + if errorsSeen == 0 { + t.Fatal("closed store checks did not exercise any errors") + } +}