feat: prepare gitcrawl 0.3.0

This commit is contained in:
Peter Steinberger 2026-05-08 06:20:35 +01:00
parent a1be2e57c5
commit f2d60276f9
No known key found for this signature in database
23 changed files with 1169 additions and 44 deletions

View File

@ -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

View File

@ -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 (`<query> -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.

View File

@ -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 <refs> --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 <ref> --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 <text> [--mode keyword\|semantic\|hybrid --limit --json]` | Local search (direct mode) | [Search](/search/) |
| `gitcrawl search issues\|prs <query> -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 <n> [--limit --threshold --json]` | Vector-similar threads to a specific issue/PR | [Clustering](/clustering/#find-similar-threads-neighbors) |
| `gitcrawl neighbors owner/repo --number <ref> [--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 <n> [--reason --json]` | Local close on a thread | [Governance](/governance/#local-close) |
| `gitcrawl reopen-thread owner/repo --number <n> [--json]` | Inverse | — |
| `gitcrawl close-thread owner/repo --number <ref> [--reason --json]` | Local close on a thread | [Governance](/governance/#local-close) |
| `gitcrawl reopen-thread owner/repo --number <ref> [--json]` | Inverse | — |
| `gitcrawl close-cluster owner/repo --id <n> [--reason --json]` | Local close on a cluster | [Governance](/governance/#local-close) |
| `gitcrawl reopen-cluster owner/repo --id <n> [--json]` | Inverse | — |
| `gitcrawl exclude-cluster-member owner/repo --id <n> --number <m> [--reason --json]` | Pull a thread out of a cluster | [Governance](/governance/#member-exclusion) |
| `gitcrawl include-cluster-member owner/repo --id <n> --number <m> [--reason --json]` | Inverse | — |
| `gitcrawl set-cluster-canonical owner/repo --id <n> --number <m> [--reason --json]` | Pin canonical thread for a cluster | [Governance](/governance/#canonical-member) |
| `gitcrawl exclude-cluster-member owner/repo --id <n> --number <ref> [--reason --json]` | Pull a thread out of a cluster | [Governance](/governance/#member-exclusion) |
| `gitcrawl include-cluster-member owner/repo --id <n> --number <ref> [--reason --json]` | Inverse | — |
| `gitcrawl set-cluster-canonical owner/repo --id <n> --number <ref> [--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 <query> -R owner/repo [...]` | Local-first `gh search` | [gh shim](/gh-shim/) |
| `gitcrawl gh issue view <n> -R owner/repo --json <fields>` | Local-first thread view | [gh shim](/gh-shim/) |
| `gitcrawl gh pr view <n> -R owner/repo --json <fields>` | Same, for PRs (with auto-hydration) | [gh shim](/gh-shim/) |
| `gitcrawl gh issue view <n-or-url> [-R owner/repo] --json <fields>` | Local-first thread view | [gh shim](/gh-shim/) |
| `gitcrawl gh pr view <n-or-url> [-R owner/repo] --json <fields>` | 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 <n> -R owner/repo --json <fields>` | Cached PR checks (auto-hydrates if stale) | [gh shim](/gh-shim/) |
| `gitcrawl gh pr diff <n> -R owner/repo` | Falls through; cached by head SHA | [gh shim](/gh-shim/) |
| `gitcrawl gh pr checks <n-or-url> [-R owner/repo] --json <fields>` | Cached PR checks (auto-hydrates if stale) | [gh shim](/gh-shim/) |
| `gitcrawl gh pr diff <n-or-url> [-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 <run-id> -R owner/repo [--json]` | Same, single run | [gh shim](/gh-shim/) |
| `gitcrawl gh repo view\|list ...` | Falls through; cached briefly | [gh shim](/gh-shim/) |

View File

@ -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 <number-or-url>` — 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`

View File

@ -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:

View File

@ -71,11 +71,14 @@ Generates OpenAI embeddings for any thread whose document hash has changed since
| Flag | Default | Description |
| --- | --- | --- |
| `--number <n>` | _(any)_ | Embed a single issue/PR by number |
| `--number <ref>` | _(any)_ | Embed a single issue/PR by number or copied GitHub URL |
| `--limit <n>` | _(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:

View File

@ -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

View File

@ -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):

View File

@ -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:

View File

@ -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")
}

View File

@ -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)
}
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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 }

View File

@ -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 ""

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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()

View File

@ -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

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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")
}
}