Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Steinberger
469d89bc1a
chore: prepare gitcrawl 0.3.1
Some checks failed
CI / Go / ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / Go / ${{ matrix.os }} (ubuntu-latest) (push) Has been cancelled
Pages / Deploy docs (push) Has been cancelled
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Has been cancelled
2026-05-08 09:56:02 +01:00
Peter Steinberger
a94a53217d
docs: update gitcrawl changelog and command docs 2026-05-08 09:50:20 +01:00
Peter Steinberger
7671a6b999
fix: harden gitcrawl command surface 2026-05-08 09:50:17 +01:00
Peter Steinberger
f2d60276f9
feat: prepare gitcrawl 0.3.0 2026-05-08 06:20:35 +01:00
Peter Steinberger
a1be2e57c5
docs: clarify gitcrawl skill paths 2026-05-08 01:13:01 +01:00
Vincent Koc
01d62c1afc
docs: note dependency updates
Some checks are pending
CI / Go / ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Go / ${{ matrix.os }} (ubuntu-latest) (push) Waiting to run
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Waiting to run
2026-05-07 02:52:17 -07:00
dependabot[bot]
fc7001e21e
chore(deps): bump goreleaser/goreleaser-action from 7.1.0 to 7.2.1 (#11)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.1.0 to 7.2.1.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v7.1.0...v7.2.1)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 02:41:45 -07:00
Peter Steinberger
025e92b858
ci: update homebrew tap on release
Some checks failed
CI / Go / ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Go / ${{ matrix.os }} (ubuntu-latest) (push) Waiting to run
Security Gate: Secret Scanning / Scan for Verified Secrets (push) Waiting to run
Pages / Deploy docs (push) Has been cancelled
2026-05-07 03:56:51 +01:00
Vincent Koc
eafeabf8fd
build(deps): bump crawlkit to v0.4.1 (#13) 2026-05-06 14:52:53 -07:00
Vincent Koc
fdc3f7473e
fix(docs): avoid regex tag stripping in toc (#12) 2026-05-06 02:10:03 -07:00
40 changed files with 1933 additions and 110 deletions

View File

@ -11,20 +11,19 @@ requested scope, or the user asks for current external context.
## Sources
- DB: `~/.config/gitcrawl/gitcrawl.db`
- Config: `~/.config/gitcrawl/config.toml`
- DB: resolve with `gitcrawl doctor --json`; portable-store installs may point at `~/.config/gitcrawl/stores/gitcrawl-store/data/openclaw__openclaw.sync.db` instead of the default local DB
- Cache: `~/.config/gitcrawl/cache`
- Vectors: `~/.config/gitcrawl/vectors`
- Repo: `~/GIT/_Perso/gitcrawl`
- Preferred CLI: `gitcrawl`; fallback to `go run ./cmd/gitcrawl` from the repo if the installed binary is stale
- Repo: `openclaw/gitcrawl`; on ClawSweeper this is checked out at `~/clawsweeper-workspace/gitcrawl`
- Preferred CLI: `gitcrawl`; fallback to `go run ./cmd/gitcrawl` from a verified repo checkout if the installed binary is stale
## Freshness
For recent/current questions, check freshness before analysis:
```bash
sqlite3 ~/.config/gitcrawl/gitcrawl.db \
"select coalesce(max(finished_at), '') from sync_runs where status = 'success';"
gitcrawl doctor --json
```
Routine refresh:
@ -71,11 +70,12 @@ configured DB and prefer CLI commands for normal reads.
Useful examples:
```bash
sqlite3 -readonly ~/.config/gitcrawl/gitcrawl.db \
db="$(gitcrawl doctor --json | jq -r .db_path)"
sqlite3 -readonly "$db" \
"select count(*) as threads from threads;"
sqlite3 -readonly ~/.config/gitcrawl/gitcrawl.db \
sqlite3 -readonly "$db" \
"select r.full_name, count(*) as threads from threads t join repositories r on r.id = t.repo_id group by r.full_name order by threads desc limit 20;"
sqlite3 -readonly ~/.config/gitcrawl/gitcrawl.db \
sqlite3 -readonly "$db" \
"select state, count(*) as threads from threads group by state;"
```
@ -83,7 +83,7 @@ Do not run mutating SQL against the archive. Use local maintainer commands for
overrides instead of writing database rows directly.
When the installed CLI lacks a new feature, build or run from
`~/GIT/_Perso/gitcrawl` before concluding the feature is missing.
a verified `openclaw/gitcrawl` checkout before concluding the feature is missing.
## Maintainer Boundaries

View File

@ -75,7 +75,7 @@ jobs:
printf '%s' "$output" | grep -q "gitcrawl tui"
- name: Snapshot release build
uses: goreleaser/goreleaser-action@v7.1.0
uses: goreleaser/goreleaser-action@v7.2.1
with:
distribution: goreleaser
version: "~> v2"

View File

@ -37,10 +37,69 @@ jobs:
run: git checkout ${{ inputs.tag }}
- name: GoReleaser
uses: goreleaser/goreleaser-action@v7.1.0
uses: goreleaser/goreleaser-action@v7.2.1
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Resolve release tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
fi
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to openclaw/homebrew-tap"
exit 1
fi
request_id="gitcrawl-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update gitcrawl for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo openclaw/homebrew-tap \
--ref main \
-f formula=gitcrawl \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/gitcrawl \
-f artifact_template="{formula}_{version}_{target}.tar.gz" \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo openclaw/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo openclaw/homebrew-tap \
--exit-status \
--interval 10

View File

@ -2,17 +2,21 @@
## Unreleased
- Add a repo-local `gitcrawl` agent skill for local archive, freshness, gh-shim,
cluster, and verification workflows.
- 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
`metadata --json`, `status --json`, and `doctor --json` for local launchers
and CI.
- Clarify that `gitcrawl tui` remains the reference terminal browser for the
crawl app family while shared `crawlkit/tui` converges on the same panes,
sorting, action menus, and status chrome.
## 0.3.1 - 2026-05-08
- Fix gh-shim portable-store auto-hydration so exact issue/PR refreshes write to the runtime mirror instead of dirtying the Git checkout, clear stale portable refresh locks, and make empty open issue discovery fall through when only targeted sync history exists.
- Keep `cluster-detail` aligned with the default cluster list by showing closed historical members unless `--hide-closed` is passed, and fail fast when `GITCRAWL_GH_PATH` points back at the `gitcrawl` shim.
## 0.3.0 - 2026-05-08
- 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 `metadata --json`, `status --json`, and `doctor --json` for local launchers and CI.
- Clarify that `gitcrawl tui` remains the reference terminal browser for the 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,9 +58,10 @@ 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.
`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`; empty open issue discovery falls through when the local repo only has targeted sync history. 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.
The TUI starts at `--min-size 5` and `--sort size`, like ghcrawl's saved default, so the first screen is the useful cluster workload instead of singleton noise. Pass `--min-size 1` when you intentionally want singleton clusters. Mouse support is built in: click rows, wheel panes, and right-click for copy, sort, filter, jump, link, neighbor, local close/reopen, and member triage actions. Press `a` to open the same action menu from the keyboard, `#` to jump directly to an issue or PR number, `p` to switch between repositories already present in the local store, or `n` to load neighbors for the selected issue or PR. Enter from the members pane also loads neighbors before opening detail. The TUI quietly refreshes from the local store every 15 seconds.
`gitcrawl tui` remains the reference terminal interaction model for the crawl app family: pane focus, sortable headers, mouse/right-click actions, detail rendering, and status chrome are the behavior the shared `crawlkit/tui` browser is converging on for Slack, Discord, and Notion archives.

View File

@ -88,7 +88,7 @@ gitcrawl cluster-explain owner/repo --id 123 # alias
| `--id <n>` | _(required)_ | Cluster ID |
| `--member-limit <n>` | _(no limit)_ | Maximum members to return |
| `--body-chars <n>` | `280` | Body snippet length per member |
| `--include-closed` | _(off)_ | Include closed members |
| `--hide-closed` | _(off)_ | Hide locally closed members |
`cluster-explain` is the same command — it exists so the verb reads naturally in agent prompts ("explain why these things ended up together").

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
@ -62,20 +78,20 @@ These work on every command.
| `gitcrawl cluster owner/repo [--threshold --min-size --max-cluster-size --k --cross-kind-threshold --limit --model --basis --include-closed --json]` | Build durable clusters from vectors | [Clustering](/clustering/#generate-clusters) |
| `gitcrawl clusters owner/repo [--sort size\|recent\|oldest --min-size --limit --hide-closed --json]` | Latest-run cluster summary, merged with closed durable rows | [Clustering](/clustering/#list-clusters) |
| `gitcrawl durable-clusters owner/repo [--include-closed --sort --min-size --limit --json]` | Strict durable-cluster audit view | [Clustering](/clustering/#list-clusters) |
| `gitcrawl cluster-detail owner/repo --id <n> [--member-limit --body-chars --include-closed --json]` | Cluster + members detail | [Clustering](/clustering/#inspect-a-cluster) |
| `gitcrawl cluster-detail owner/repo --id <n> [--member-limit --body-chars --hide-closed --json]` | Cluster + members detail | [Clustering](/clustering/#inspect-a-cluster) |
| `gitcrawl cluster-explain owner/repo --id <n> [...]` | Alias for `cluster-detail` | [Clustering](/clustering/#inspect-a-cluster) |
## Governance
| 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`
@ -116,6 +125,8 @@ When a local issue or PR read misses the cache, the shim can auto-hydrate exactl
This keeps `gh issue view`, `gh pr view`, `gh pr checks`, and `gh run` reads cheap and fresh without manual sync orchestration. Disable with `GITCRAWL_GH_AUTO_HYDRATE=0` if you want the shim to be strictly cache-or-fallthrough.
When the configured database comes from a portable store, auto-hydration writes to the local runtime mirror, not the Git checkout. Broad empty open-issue discovery is also guarded: if `gh issue list` or empty-query `gh search issues --state open` would return no rows but the repo only has targeted sync history, the shim falls through to the real `gh` instead of treating that incomplete local snapshot as authoritative.
## Cache inspection: `xcache`
```bash

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

@ -56,6 +56,7 @@ Write commands (`embed`, `refresh`, `cluster`, neighbor generation) need to pers
This separation means:
- You can `gitcrawl embed` against a portable store without dirtying the Git checkout
- gh-shim exact-thread auto-hydration writes into the same runtime mirror
- Local cluster overrides (`close-cluster`, exclusions, canonicals) live in the runtime mirror
- Only the publishing workflow writes back into the portable checkout

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

2
go.mod
View File

@ -8,7 +8,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.7
github.com/mattn/go-isatty v0.0.22
github.com/vincentkoc/crawlkit v0.4.0
github.com/vincentkoc/crawlkit v0.4.1
)
require (

4
go.sum
View File

@ -56,8 +56,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/vincentkoc/crawlkit v0.4.0 h1:1jQZAYbBivy6d7ewNdMZ8THgmJVwb+pQT0kH5Z9COHI=
github.com/vincentkoc/crawlkit v0.4.0/go.mod h1:/ioLA/tyZ/927kAOGg0M8Mrqk7pnTZLpCKWfpul9zoE=
github.com/vincentkoc/crawlkit v0.4.1 h1:qDUF+Kk7nqADmpGMcnWTHEQMiX3bSD2DdFywKyT3kWs=
github.com/vincentkoc/crawlkit v0.4.1/go.mod h1:/ioLA/tyZ/927kAOGg0M8Mrqk7pnTZLpCKWfpul9zoE=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=

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)
}
@ -1302,7 +1305,8 @@ func (a *App) runClusterDetail(ctx context.Context, args []string) error {
clusterIDRaw := fs.String("id", "", "cluster id")
memberLimitRaw := fs.String("member-limit", "", "maximum member rows")
bodyCharsRaw := fs.String("body-chars", "", "maximum body snippet characters")
includeClosed := fs.Bool("include-closed", false, "include closed clusters and members")
includeClosed := fs.Bool("include-closed", false, "deprecated; closed cluster members are shown by default")
hideClosed := fs.Bool("hide-closed", false, "hide locally closed members")
jsonOut := fs.Bool("json", false, "write JSON output")
if err := fs.Parse(normalizeCommandArgs(args, map[string]bool{"id": true, "member-limit": true, "body-chars": true})); err != nil {
return usageErr(err)
@ -1343,7 +1347,7 @@ func (a *App) runClusterDetail(ctx context.Context, args []string) error {
detail, err := rt.Store.ClusterDetail(ctx, store.ClusterDetailOptions{
RepoID: repo.ID,
ClusterID: int64(clusterID),
IncludeClosed: *includeClosed,
IncludeClosed: *includeClosed || !*hideClosed,
MemberLimit: memberLimit,
BodyChars: bodyChars,
})
@ -1418,7 +1422,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 +1473,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 +1518,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 +1789,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)
}
@ -1846,14 +1850,14 @@ func (a *App) syncRepository(ctx context.Context, owner, repo string, options sy
if err := config.EnsureRuntimeDirs(cfg); err != nil {
return syncer.Stats{}, err
}
st, err := store.Open(ctx, cfg.DBPath)
rt, err := a.openLocalRuntime(ctx)
if err != nil {
return syncer.Stats{}, err
}
defer st.Close()
defer rt.Store.Close()
client := gh.New(gh.Options{Token: token.Value, BaseURL: githubBaseURL()})
service := syncer.New(client, st)
service := syncer.New(client, rt.Store)
stats, err := service.Sync(ctx, syncer.Options{
Owner: owner,
Repo: repo,
@ -2398,6 +2402,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 +2412,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 +2488,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 +2518,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 +2873,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:
@ -2853,7 +2952,14 @@ func (a *App) printUsage() {
}
func (a *App) printCommandUsage(command string) error {
if text, ok := commandUsageTexts[command]; ok {
fmt.Fprint(a.Stdout, text)
return nil
}
switch command {
case "cluster-explain":
fmt.Fprint(a.Stdout, commandUsageTexts["cluster-detail"])
return nil
case "portable":
fmt.Fprint(a.Stdout, portableUsageText)
return nil
@ -2884,6 +2990,7 @@ Core commands:
doctor check config, token, and database readiness
sync sync GitHub issue and pull request metadata
refresh run sync, enrichment, embedding, and clustering pipeline
embed generate OpenAI embeddings for local thread documents
threads list local issue and pull request rows
cluster build durable clusters from local thread vectors
close-thread locally hide one issue or pull request row
@ -2909,6 +3016,131 @@ Core commands:
No API server is provided. There is intentionally no serve command.
`
var commandUsageTexts = map[string]string{
"metadata": `gitcrawl metadata prints crawlkit control metadata.
Usage:
gitcrawl metadata [--json]
`,
"status": `gitcrawl status prints fast read-only archive status.
Usage:
gitcrawl status [--json]
`,
"init": `gitcrawl init creates a local config and SQLite database.
Usage:
gitcrawl init [--db path] [--portable-store URL] [--json]
`,
"configure": `gitcrawl configure updates model fields in the config.
Usage:
gitcrawl configure [--summary-model name] [--embed-model name] [--embedding-basis title_original] [--json]
`,
"doctor": `gitcrawl doctor checks config, token, and database readiness.
Usage:
gitcrawl doctor [--json]
`,
"sync": `gitcrawl sync mirrors GitHub issue and pull request metadata.
Usage:
gitcrawl sync owner/repo [--state open|closed|all] [--numbers refs] [--with pr-details] [--include-pr-details] [--json]
`,
"refresh": `gitcrawl refresh runs sync, enrichment, embedding, and clustering.
Usage:
gitcrawl refresh owner/repo [--state open|closed|all] [--sync-if-stale duration] [--no-sync] [--no-embed] [--no-cluster] [--json]
`,
"embed": `gitcrawl embed generates OpenAI embeddings for local thread documents.
Usage:
gitcrawl embed owner/repo [--number ref] [--limit N] [--force] [--include-closed] [--json]
`,
"threads": `gitcrawl threads lists local issue and pull request rows.
Usage:
gitcrawl threads owner/repo [--include-closed] [--numbers refs] [--limit N] [--json]
`,
"search": `gitcrawl search queries local thread documents, or accepts gh-shaped issue and PR search.
Usage:
gitcrawl search owner/repo --query text [--mode keyword|semantic] [--limit N] [--json]
gitcrawl search issues|prs <query> -R owner/repo [--state open|closed|all] [--json fields] [--limit N]
`,
"cluster": `gitcrawl cluster builds durable clusters from local thread vectors.
Usage:
gitcrawl cluster owner/repo [--threshold N] [--min-size N] [--max-cluster-size N] [--k N] [--cross-kind-threshold N] [--limit N] [--model name] [--basis semantic|references|hybrid] [--include-closed] [--json]
`,
"clusters": `gitcrawl clusters lists latest display clusters with durable fallback.
Usage:
gitcrawl clusters owner/repo [--sort size|recent|oldest] [--min-size N] [--limit N] [--hide-closed] [--json]
`,
"durable-clusters": `gitcrawl durable-clusters lists governed durable cluster groups.
Usage:
gitcrawl durable-clusters owner/repo [--include-closed] [--sort size|recent|oldest] [--min-size N] [--limit N] [--json]
`,
"cluster-detail": `gitcrawl cluster-detail dumps one cluster and its member rows.
Usage:
gitcrawl cluster-detail owner/repo --id N [--member-limit N] [--body-chars N] [--hide-closed] [--json]
`,
"neighbors": `gitcrawl neighbors lists vector-nearest local issue and pull request rows.
Usage:
gitcrawl neighbors owner/repo --number ref [--limit N] [--json]
`,
"runs": `gitcrawl runs lists local pipeline run history.
Usage:
gitcrawl runs owner/repo [--kind sync|summary|embedding|cluster] [--limit N] [--json]
`,
"close-thread": `gitcrawl close-thread locally hides one issue or pull request row.
Usage:
gitcrawl close-thread owner/repo --number ref [--reason text] [--json]
`,
"reopen-thread": `gitcrawl reopen-thread clears a local thread hide.
Usage:
gitcrawl reopen-thread owner/repo --number ref [--json]
`,
"close-cluster": `gitcrawl close-cluster locally hides one durable cluster.
Usage:
gitcrawl close-cluster owner/repo --id N [--reason text] [--json]
`,
"reopen-cluster": `gitcrawl reopen-cluster clears a local cluster hide.
Usage:
gitcrawl reopen-cluster owner/repo --id N [--json]
`,
"exclude-cluster-member": `gitcrawl exclude-cluster-member locally removes one row from a durable cluster.
Usage:
gitcrawl exclude-cluster-member owner/repo --id N --number ref [--reason text] [--json]
`,
"include-cluster-member": `gitcrawl include-cluster-member restores one row to a durable cluster.
Usage:
gitcrawl include-cluster-member owner/repo --id N --number ref [--json]
`,
"set-cluster-canonical": `gitcrawl set-cluster-canonical sets the canonical row for a durable cluster.
Usage:
gitcrawl set-cluster-canonical owner/repo --id N --number ref [--reason text] [--json]
`,
"gh": `gitcrawl gh runs a gh-compatible local cache shim with fallback to real gh.
Usage:
gitcrawl gh <gh command>
gitcrawl gh xcache stats|keys|gc|flush|reset|snapshot [--json]
`,
}
const tuiUsageText = `gitcrawl tui opens the local terminal cluster browser.
Usage:

View File

@ -153,6 +153,15 @@ func TestMetadataStatusAndControlStatusJSON(t *testing.T) {
if !strings.Contains(helpOut.String(), "cluster browser") {
t.Fatalf("tui help output = %q", helpOut.String())
}
for _, topic := range []string{"metadata", "status", "init", "configure", "doctor", "sync", "refresh", "embed", "threads", "search", "cluster", "clusters", "durable-clusters", "cluster-detail", "cluster-explain", "neighbors", "runs", "close-thread", "reopen-thread", "close-cluster", "reopen-cluster", "exclude-cluster-member", "include-cluster-member", "set-cluster-canonical", "gh"} {
helpOut.Reset()
if err := help.printCommandUsage(topic); err != nil {
t.Fatalf("%s help: %v", topic, err)
}
if !strings.Contains(helpOut.String(), "Usage:") {
t.Fatalf("%s help output = %q", topic, helpOut.String())
}
}
if err := New().Run(ctx, []string{"--config", configPath, "status", "extra"}); err == nil {
t.Fatal("status extra arg should fail")
}
@ -930,6 +939,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")
}
@ -968,7 +989,7 @@ func TestGlobalCommandBranches(t *testing.T) {
}{
{args: []string{"--help"}, wantOut: "Usage:"},
{args: []string{"help"}, wantOut: "Usage:"},
{args: []string{"help", "sync"}, wantErr: true, exitCode: 2},
{args: []string{"help", "sync"}, wantOut: "gitcrawl sync"},
{args: []string{"--version"}, wantOut: "dev"},
{args: []string{"version"}, wantOut: "dev"},
{args: []string{"--json", "version"}, wantOut: `"version"`},
@ -2314,6 +2335,36 @@ func TestClustersDefaultShowsActivePrimaryMembers(t *testing.T) {
if len(all.Clusters) != 1 || all.Clusters[0].MemberCount != 1 {
t.Fatalf("hide-closed should focus active members, got %#v", all.Clusters)
}
stdout.Reset()
detail := New()
detail.Stdout = &stdout
if err := detail.Run(ctx, []string{"--config", configPath, "--json", "cluster-detail", "openclaw/openclaw", "--id", "90"}); err != nil {
t.Fatalf("cluster-detail: %v", err)
}
var detailPayload struct {
Members []store.ClusterMemberDetail `json:"members"`
}
if err := json.Unmarshal(stdout.Bytes(), &detailPayload); err != nil {
t.Fatalf("decode cluster detail: %v\n%s", err, stdout.String())
}
if len(detailPayload.Members) != 2 {
t.Fatalf("default cluster-detail should match visible cluster members, got %#v", detailPayload.Members)
}
stdout.Reset()
hideDetail := New()
hideDetail.Stdout = &stdout
if err := hideDetail.Run(ctx, []string{"--config", configPath, "--json", "cluster-detail", "openclaw/openclaw", "--id", "90", "--hide-closed"}); err != nil {
t.Fatalf("cluster-detail hide closed: %v", err)
}
detailPayload.Members = nil
if err := json.Unmarshal(stdout.Bytes(), &detailPayload); err != nil {
t.Fatalf("decode hide-closed cluster detail: %v\n%s", err, stdout.String())
}
if len(detailPayload.Members) != 1 || detailPayload.Members[0].Thread.Number != 90 {
t.Fatalf("hide-closed cluster-detail should focus open members, got %#v", detailPayload.Members)
}
}
func TestClusterMemberOverrideCommands(t *testing.T) {

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

67
internal/cli/gh_path.go Normal file
View File

@ -0,0 +1,67 @@
package cli
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func resolveRealGHPath() (string, error) {
envPath := strings.TrimSpace(os.Getenv("GITCRAWL_GH_PATH"))
candidates := []string{}
if envPath != "" {
candidates = append(candidates, envPath)
}
candidates = append(candidates,
"/opt/homebrew/opt/gh/bin/gh",
"/usr/local/opt/gh/bin/gh",
"/usr/local/bin/gh",
"/usr/bin/gh",
)
if lookPath, err := exec.LookPath("gh"); err == nil {
candidates = append(candidates, lookPath)
}
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
if envPath != "" && candidate == envPath {
return "", fmt.Errorf("real gh not found at GITCRAWL_GH_PATH %q", envPath)
}
continue
}
if isGitcrawlShimPath(candidate) {
if envPath != "" && candidate == envPath {
return "", fmt.Errorf("GITCRAWL_GH_PATH points to the gitcrawl shim (%s); set it to the real gh binary", envPath)
}
continue
}
return candidate, nil
}
return "", fmt.Errorf("real gh not found; set GITCRAWL_GH_PATH")
}
func isGitcrawlShimPath(path string) bool {
if path == "" {
return false
}
resolved := path
if eval, err := filepath.EvalSymlinks(path); err == nil {
resolved = eval
}
for _, value := range []string{path, resolved} {
base := strings.ToLower(filepath.Base(value))
if base == "gitcrawl" || base == "gitcrawl-gh" {
return true
}
}
return false
}

View File

@ -104,6 +104,15 @@ func (a *App) runGHSearch(ctx context.Context, args []string) error {
if err != nil {
return err
}
if len(threads) == 0 && ghSearchNeedsLiveEmptyCheck(kind, query, state) {
lastSync, err := rt.Store.LastSuccessfulListSyncAt(ctx, repo.ID, state)
if err != nil {
return err
}
if lastSync.IsZero() {
return localGHUnsupported(fmt.Errorf("empty local %s search has no broad %s sync", args[0], ghDefaultListState(state)))
}
}
jsonFields := strings.TrimSpace(*jsonFieldsRaw)
if jsonFields != "" || a.format == FormatJSON {
@ -126,7 +135,7 @@ func (a *App) runGHSearch(ctx context.Context, args []string) error {
}
func (a *App) syncGHSearchIfStale(ctx context.Context, owner, repoName, state string, maxAge time.Duration) error {
stale, lastSync, err := a.ghSearchCacheStale(ctx, owner, repoName, maxAge)
stale, lastSync, err := a.ghSearchCacheStale(ctx, owner, repoName, state, maxAge)
if err != nil {
return err
}
@ -142,7 +151,7 @@ func (a *App) syncGHSearchIfStale(ctx context.Context, owner, repoName, state st
return err
}
func (a *App) ghSearchCacheStale(ctx context.Context, owner, repoName string, maxAge time.Duration) (bool, time.Time, error) {
func (a *App) ghSearchCacheStale(ctx context.Context, owner, repoName, state string, maxAge time.Duration) (bool, time.Time, error) {
rt, err := a.openLocalRuntimeReadOnly(ctx)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
@ -158,7 +167,7 @@ func (a *App) ghSearchCacheStale(ctx context.Context, owner, repoName string, ma
}
return false, time.Time{}, err
}
lastSync, err := rt.Store.LastSuccessfulSyncAt(ctx, repo.ID)
lastSync, err := rt.Store.LastSuccessfulListSyncAt(ctx, repo.ID, state)
if err != nil {
return false, time.Time{}, err
}
@ -168,6 +177,20 @@ func (a *App) ghSearchCacheStale(ctx context.Context, owner, repoName string, ma
return time.Since(lastSync) > maxAge, lastSync, nil
}
func ghSearchNeedsLiveEmptyCheck(kind, query, state string) bool {
if strings.TrimSpace(query) != "" || kind != "issue" {
return false
}
return ghDefaultListState(state) == "open"
}
func ghDefaultListState(state string) string {
if strings.TrimSpace(state) == "" {
return "open"
}
return strings.TrimSpace(state)
}
func parseGHSearchQuery(value string) (query string, repo string, state string) {
var queryParts []string
for _, part := range strings.Fields(value) {

View File

@ -66,6 +66,16 @@ func TestGHSearchCacheStaleUsesRepoSyncRuns(t *testing.T) {
t.Fatalf("repo: %v", err)
}
finishedAt := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339Nano)
if _, err := st.RecordRun(ctx, store.RunRecord{
RepoID: repoID,
Kind: "sync",
Scope: "numbers:13",
Status: "success",
StartedAt: time.Now().UTC().Format(time.RFC3339Nano),
FinishedAt: time.Now().UTC().Format(time.RFC3339Nano),
}); err != nil {
t.Fatalf("record targeted sync: %v", err)
}
if _, err := st.RecordRun(ctx, store.RunRecord{
RepoID: repoID,
Kind: "sync",
@ -74,7 +84,7 @@ func TestGHSearchCacheStaleUsesRepoSyncRuns(t *testing.T) {
StartedAt: finishedAt,
FinishedAt: finishedAt,
}); err != nil {
t.Fatalf("record sync: %v", err)
t.Fatalf("record broad sync: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
@ -82,14 +92,14 @@ func TestGHSearchCacheStaleUsesRepoSyncRuns(t *testing.T) {
run := New()
run.configPath = configPath
stale, lastSync, err := run.ghSearchCacheStale(ctx, "openclaw", "openclaw", 2*time.Hour)
stale, lastSync, err := run.ghSearchCacheStale(ctx, "openclaw", "openclaw", "open", 2*time.Hour)
if err != nil {
t.Fatalf("freshness check: %v", err)
}
if stale || lastSync.IsZero() {
t.Fatalf("expected cache to be fresh, stale=%v lastSync=%s", stale, lastSync)
}
stale, _, err = run.ghSearchCacheStale(ctx, "openclaw", "openclaw", 30*time.Minute)
stale, _, err = run.ghSearchCacheStale(ctx, "openclaw", "openclaw", "open", 30*time.Minute)
if err != nil {
t.Fatalf("stale freshness check: %v", err)
}
@ -110,7 +120,7 @@ func TestGHSearchCacheStaleWhenRepoMissing(t *testing.T) {
run := New()
run.configPath = configPath
stale, lastSync, err := run.ghSearchCacheStale(ctx, "openclaw", "missing", time.Minute)
stale, lastSync, err := run.ghSearchCacheStale(ctx, "openclaw", "missing", "open", time.Minute)
if err != nil {
t.Fatalf("freshness check: %v", err)
}

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)
}
@ -202,6 +206,22 @@ func (a *App) runGHThreadList(ctx context.Context, resource string, args []strin
if err != nil {
return err
}
if len(threads) == 0 && ghThreadListNeedsLiveEmptyCheck(ghThreadListRequest{
Kind: ghResourceKind(resource),
State: strings.TrimSpace(*stateRaw),
Query: strings.TrimSpace(*searchRaw),
Author: strings.TrimSpace(*authorRaw),
Assignee: strings.TrimSpace(*assigneeRaw),
Labels: labels.Values(),
}) {
fresh, err := a.localGHThreadListHasBroadSync(ctx, repoValue, strings.TrimSpace(*stateRaw))
if err != nil {
return err
}
if !fresh {
return localGHUnsupported(fmt.Errorf("empty local %s list has no broad %s sync", resource, ghDefaultListState(*stateRaw)))
}
}
jsonFields := strings.TrimSpace(*jsonFieldsRaw)
if jsonFields != "" || strings.TrimSpace(*jqRaw) != "" || a.format == FormatJSON {
if jsonFields == "" {
@ -289,6 +309,34 @@ func (a *App) localGHThreads(ctx context.Context, req ghThreadListRequest) ([]st
})
}
func ghThreadListNeedsLiveEmptyCheck(req ghThreadListRequest) bool {
if req.Kind != "issue" || strings.TrimSpace(req.Query) != "" || strings.TrimSpace(req.Author) != "" || strings.TrimSpace(req.Assignee) != "" || len(req.Labels) > 0 {
return false
}
return ghDefaultListState(req.State) == "open"
}
func (a *App) localGHThreadListHasBroadSync(ctx context.Context, repoValue, state string) (bool, error) {
owner, repoName, err := parseOwnerRepo(repoValue)
if err != nil {
return false, err
}
rt, err := a.openLocalRuntimeReadOnly(ctx)
if err != nil {
return false, localGHUnsupported(err)
}
defer rt.Store.Close()
repo, err := rt.repository(ctx, owner, repoName)
if err != nil {
return false, localGHUnsupported(err)
}
lastSync, err := rt.Store.LastSuccessfulListSyncAt(ctx, repo.ID, state)
if err != nil {
return false, err
}
return !lastSync.IsZero(), nil
}
func (a *App) resolveGHRepo(ctx context.Context, explicit string) (string, error) {
if strings.TrimSpace(explicit) != "" {
return strings.TrimSpace(explicit), nil
@ -309,17 +357,9 @@ func (a *App) resolveGHRepo(ctx context.Context, explicit string) (string, error
}
func (a *App) execRealGH(ctx context.Context, args []string) error {
ghPath := strings.TrimSpace(os.Getenv("GITCRAWL_GH_PATH"))
if ghPath == "" {
if _, err := os.Stat("/opt/homebrew/opt/gh/bin/gh"); err == nil {
ghPath = "/opt/homebrew/opt/gh/bin/gh"
} else {
var err error
ghPath, err = exec.LookPath("gh")
if err != nil {
return fmt.Errorf("real gh not found; set GITCRAWL_GH_PATH")
}
}
ghPath, err := resolveRealGHPath()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, ghPath, args...)
cmd.Stdin = os.Stdin
@ -356,12 +396,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

@ -85,24 +85,16 @@ func cacheGHReadErrors() bool {
}
func (a *App) captureRealGH(ctx context.Context, args []string) (string, string, int, error) {
ghPath := strings.TrimSpace(os.Getenv("GITCRAWL_GH_PATH"))
if ghPath == "" {
if _, err := os.Stat("/opt/homebrew/opt/gh/bin/gh"); err == nil {
ghPath = "/opt/homebrew/opt/gh/bin/gh"
} else {
var err error
ghPath, err = exec.LookPath("gh")
if err != nil {
return "", "", 127, fmt.Errorf("real gh not found; set GITCRAWL_GH_PATH")
}
}
ghPath, err := resolveRealGHPath()
if err != nil {
return "", "", 127, err
}
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, ghPath, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
err = cmd.Run()
exitCode := 0
if err != nil {
exitCode = 1

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)
@ -220,6 +228,18 @@ func TestGHShimCachePolicyExtraBranches(t *testing.T) {
if strings.TrimSpace(ghOut.String()) != "real-gh:" {
t.Fatalf("empty gh shim output = %q", ghOut.String())
}
shimPath := filepath.Join(t.TempDir(), "gitcrawl-gh")
if err := os.WriteFile(shimPath, []byte("#!/bin/sh\necho shim\n"), 0o755); err != nil {
t.Fatalf("write fake shim: %v", err)
}
shimLink := filepath.Join(t.TempDir(), "gh")
if err := os.Symlink(shimPath, shimLink); err != nil {
t.Fatalf("symlink fake shim: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", shimLink)
if _, err := resolveRealGHPath(); err == nil || !strings.Contains(err.Error(), "gitcrawl shim") {
t.Fatalf("shim path should fail fast, err=%v", err)
}
t.Setenv("GITCRAWL_GH_STALE_GRACE", "3m")
if got := ghCommandCacheStaleGrace([]string{"api", "users/octocat"}); got != 3*time.Minute {
t.Fatalf("env stale grace = %s", got)

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

@ -4,6 +4,8 @@ import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@ -64,6 +66,213 @@ func TestGHShimFallsBackForUnsupportedRead(t *testing.T) {
}
}
func TestGHShimFallsBackForEmptyOpenIssueListWithoutBroadSync(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimEmptyRepo(t, ctx)
dir := t.TempDir()
ghPath := filepath.Join(dir, "gh")
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho fallback:$*\n"), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "list", "-R", "openclaw/openclaw", "--state", "open", "--json", "number"}); err != nil {
t.Fatalf("fallback: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "fallback:issue list -R openclaw/openclaw --state open --json number" {
t.Fatalf("fallback output = %q", got)
}
}
func TestGHShimSearchFallsBackForEmptyOpenRepoWithoutBroadSync(t *testing.T) {
ctx := context.Background()
configPath := seedGHShimEmptyRepo(t, ctx)
dir := t.TempDir()
ghPath := filepath.Join(dir, "gh")
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho fallback:$*\n"), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("GITCRAWL_GH_PATH", ghPath)
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "gh", "search", "issues", "-R", "openclaw/openclaw", "--state", "open", "--json", "number"}); err != nil {
t.Fatalf("fallback: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "fallback:search issues -R openclaw/openclaw --state open --json number" {
t.Fatalf("fallback output = %q", got)
}
}
func TestGHShimAutoHydratePortableStoreWritesRuntimeMirror(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remoteDir := filepath.Join(dir, "remote")
checkoutDir := filepath.Join(dir, "checkout")
dbRel := filepath.Join("data", "openclaw__openclaw.sync.db")
if err := os.MkdirAll(filepath.Join(remoteDir, "data"), 0o755); err != nil {
t.Fatalf("mkdir remote data: %v", err)
}
if err := runGit(ctx, remoteDir, "init", "-b", "main"); err != nil {
t.Fatalf("git init: %v", err)
}
seedPortableThread(t, filepath.Join(remoteDir, dbRel), 1, "portable issue")
if err := runGit(ctx, remoteDir, "add", dbRel); err != nil {
t.Fatalf("git add seed: %v", err)
}
if err := runGit(ctx, remoteDir, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "seed store"); err != nil {
t.Fatalf("git commit seed: %v", err)
}
if _, err := syncPortableStore(ctx, remoteDir, checkoutDir); err != nil {
t.Fatalf("clone portable store: %v", err)
}
configPath := filepath.Join(dir, "config.toml")
app := New()
if err := app.Run(ctx, []string{"--config", configPath, "init", "--db", filepath.Join(checkoutDir, dbRel)}); err != nil {
t.Fatalf("init config: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/repos/openclaw/openclaw":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 101, "full_name": "openclaw/openclaw"})
case "/repos/openclaw/openclaw/issues/2":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 502,
"number": 2,
"state": "open",
"title": "runtime-only issue",
"body": "hydrate into runtime mirror",
"html_url": "https://github.com/openclaw/openclaw/issues/2",
"created_at": "2026-05-08T00:00:00Z",
"updated_at": "2026-05-08T00:00:00Z",
"labels": []map[string]any{},
"assignees": []map[string]any{},
"user": map[string]any{"login": "alice", "type": "User"},
})
default:
t.Fatalf("unexpected path: %s", r.URL.String())
}
}))
defer server.Close()
t.Setenv("GITHUB_TOKEN", "test-token")
t.Setenv("GITCRAWL_GITHUB_BASE_URL", server.URL)
run := New()
var stdout bytes.Buffer
run.Stdout = &stdout
if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "view", "2", "-R", "openclaw/openclaw", "--json", "number,title"}); err != nil {
t.Fatalf("gh issue view: %v", err)
}
if !strings.Contains(stdout.String(), `"number": 2`) || !strings.Contains(stdout.String(), "runtime-only issue") {
t.Fatalf("view output = %q", stdout.String())
}
if !gitWorktreeClean(ctx, checkoutDir) {
t.Fatal("auto-hydrate dirtied portable checkout")
}
assertPortableThreadPresence(t, ctx, filepath.Join(checkoutDir, dbRel), 2, false)
mirrorPath, err := run.portableRuntimeDBPath(filepath.Join(checkoutDir, dbRel))
if err != nil {
t.Fatalf("runtime db path: %v", err)
}
assertPortableThreadPresence(t, ctx, mirrorPath, 2, true)
}
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 seedGHShimEmptyRepo(t *testing.T, ctx context.Context) string {
t.Helper()
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)
}
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
cfg.CacheDir = filepath.Join(dir, "cache")
if err := config.Save(configPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
st, err := store.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
repoID, err := st.UpsertRepository(ctx, store.Repository{
Owner: "openclaw",
Name: "openclaw",
FullName: "openclaw/openclaw",
RawJSON: "{}",
UpdatedAt: "2026-05-08T00:00:00Z",
})
if err != nil {
t.Fatalf("seed repository: %v", err)
}
if _, err := st.RecordRun(ctx, store.RunRecord{
RepoID: repoID,
Kind: "sync",
Scope: "numbers:13",
Status: "success",
StartedAt: "2026-05-08T00:00:00Z",
FinishedAt: "2026-05-08T00:00:01Z",
}); err != nil {
t.Fatalf("record targeted sync: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
return configPath
}
func assertPortableThreadPresence(t *testing.T, ctx context.Context, dbPath string, number int, want bool) {
t.Helper()
st, err := store.OpenReadOnly(ctx, dbPath)
if err != nil {
t.Fatalf("open store %s: %v", dbPath, err)
}
defer st.Close()
repo, err := st.RepositoryByFullName(ctx, "openclaw/openclaw")
if err != nil {
t.Fatalf("repository %s: %v", dbPath, err)
}
threads, err := st.ListThreadsFiltered(ctx, store.ThreadListOptions{RepoID: repo.ID, IncludeClosed: true, Numbers: []int{number}})
if err != nil {
t.Fatalf("list threads %s: %v", dbPath, err)
}
got := len(threads) > 0
if got != want {
t.Fatalf("thread %d presence in %s = %v, want %v", number, dbPath, got, want)
}
}
func seedGHShimRepo(t *testing.T, ctx context.Context) string {
t.Helper()
dir := t.TempDir()

View File

@ -64,7 +64,7 @@ func candidateRealGHPaths() []string {
seen := map[string]bool{}
unique := paths[:0]
for _, path := range paths {
if path = strings.TrimSpace(path); path != "" && !seen[path] {
if path = strings.TrimSpace(path); path != "" && !seen[path] && !isGitcrawlShimPath(path) {
seen[path] = true
unique = append(unique, path)
}

View File

@ -3,6 +3,7 @@ package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@ -26,6 +27,8 @@ const portableStoreRefreshTimeout = 15 * time.Second
const portableStoreRefreshTTL = 2 * time.Minute
const portableStoreRefreshFailureBackoff = time.Minute
var errPortableStoreDirty = errors.New("portable store checkout has local changes")
func (a *App) openLocalRuntime(ctx context.Context) (localRuntime, error) {
cfg, err := config.Load(a.configPath)
if err != nil {
@ -91,7 +94,7 @@ func refreshPortableStoreForDB(ctx context.Context, dbPath string) error {
return nil
}
if !gitWorktreeClean(ctx, root) {
return nil
return errPortableStoreDirty
}
pullCtx, cancel := context.WithTimeout(ctx, portableStoreRefreshTimeout)
defer cancel()
@ -169,6 +172,7 @@ func refreshPortableStoreForDBIfDue(ctx context.Context, sourceDBPath, mirrorPat
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
return err
}
removeStalePortableRefreshLock(lockPath, now)
lock, locked := tryGHCommandCacheLock(lockPath)
if !locked {
return nil
@ -196,6 +200,17 @@ func refreshPortableStoreForDBIfDue(ctx context.Context, sourceDBPath, mirrorPat
return writePortableStoreRefreshState(statePath, state)
}
func removeStalePortableRefreshLock(path string, now time.Time) {
info, err := os.Stat(path)
if err != nil {
return
}
if now.Sub(info.ModTime()) <= 2*portableStoreRefreshTimeout {
return
}
_ = os.Remove(path)
}
func portableStoreRefreshInterval() time.Duration {
if raw := strings.TrimSpace(os.Getenv("GITCRAWL_PORTABLE_REFRESH_TTL")); raw != "" {
if duration, err := time.ParseDuration(raw); err == nil && duration >= 0 {

View File

@ -66,6 +66,22 @@ func TestPortableRuntimeUtilityBranches(t *testing.T) {
if recentPortableRefresh("", now, time.Minute) || recentPortableRefresh("bad", now, time.Minute) || !recentPortableRefresh(now.Format(time.RFC3339Nano), now, time.Minute) {
t.Fatal("recent refresh classification mismatch")
}
lockPath := filepath.Join(dir, "refresh.lock")
if err := os.WriteFile(lockPath, []byte("123\n"), 0o600); err != nil {
t.Fatalf("write lock: %v", err)
}
removeStalePortableRefreshLock(lockPath, now)
if _, err := os.Stat(lockPath); err != nil {
t.Fatalf("fresh lock should remain: %v", err)
}
old := now.Add(-3 * portableStoreRefreshTimeout)
if err := os.Chtimes(lockPath, old, old); err != nil {
t.Fatalf("age lock: %v", err)
}
removeStalePortableRefreshLock(lockPath, now)
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Fatalf("stale lock should be removed, err=%v", err)
}
t.Setenv("GITCRAWL_PORTABLE_REFRESH_TTL", "0")
if got := portableStoreRefreshInterval(); got != 0 {
t.Fatalf("zero ttl = %s", got)

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

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
@ -94,6 +95,50 @@ func (s *Store) LastSuccessfulSyncAt(ctx context.Context, repoID int64) (time.Ti
return parsed, nil
}
func (s *Store) LastSuccessfulListSyncAt(ctx context.Context, repoID int64, state string) (time.Time, error) {
scopes := listSyncScopesForState(state)
if len(scopes) == 0 {
return time.Time{}, nil
}
placeholders := make([]string, len(scopes))
args := make([]any, 0, 1+len(scopes))
args = append(args, repoID)
for i, scope := range scopes {
placeholders[i] = "?"
args = append(args, scope)
}
var lastSync string
err := s.q().QueryRowContext(ctx, `
select coalesce(max(finished_at), '')
from sync_runs
where repo_id = ? and status in ('success', 'completed') and scope in (`+strings.Join(placeholders, ",")+`)
`, args...).Scan(&lastSync)
if err != nil {
return time.Time{}, fmt.Errorf("read last successful list sync: %w", err)
}
if lastSync == "" {
return time.Time{}, nil
}
parsed, err := time.Parse(time.RFC3339Nano, lastSync)
if err != nil {
return time.Time{}, fmt.Errorf("parse last successful list sync %q: %w", lastSync, err)
}
return parsed, nil
}
func listSyncScopesForState(state string) []string {
switch strings.TrimSpace(strings.ToLower(state)) {
case "", "open":
return []string{"open", "all"}
case "closed":
return []string{"closed", "all"}
case "all":
return []string{"all"}
default:
return nil
}
}
func runTable(kind string) (string, error) {
switch kind {
case "sync":

View File

@ -111,3 +111,42 @@ func TestLastSuccessfulSyncAt(t *testing.T) {
t.Fatalf("last sync = %s, want %s", lastSync, want)
}
}
func TestLastSuccessfulListSyncAtIgnoresTargetedRuns(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)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, Repository{
Owner: "openclaw", Name: "gitcrawl", FullName: "openclaw/gitcrawl", RawJSON: "{}", UpdatedAt: "2026-04-26T00:00:00Z",
})
if err != nil {
t.Fatalf("repo: %v", err)
}
if _, err := st.RecordRun(ctx, RunRecord{
RepoID: repoID, Kind: "sync", Scope: "numbers:13", Status: "success",
StartedAt: "2026-04-26T00:03:00Z", FinishedAt: "2026-04-26T00:03:30Z",
}); err != nil {
t.Fatalf("record targeted run: %v", err)
}
if lastSync, err := st.LastSuccessfulListSyncAt(ctx, repoID, "open"); err != nil || !lastSync.IsZero() {
t.Fatalf("targeted run should not count as broad list sync: last=%s err=%v", lastSync, err)
}
if _, err := st.RecordRun(ctx, RunRecord{
RepoID: repoID, Kind: "sync", Scope: "all", Status: "success",
StartedAt: "2026-04-26T00:04:00Z", FinishedAt: "2026-04-26T00:04:30Z",
}); err != nil {
t.Fatalf("record all run: %v", err)
}
lastSync, err := st.LastSuccessfulListSyncAt(ctx, repoID, "open")
if err != nil {
t.Fatalf("last broad sync: %v", err)
}
want, _ := time.Parse(time.RFC3339Nano, "2026-04-26T00:04:30Z")
if !lastSync.Equal(want) {
t.Fatalf("last broad sync = %s, want %s", lastSync, want)
}
}

View File

@ -431,10 +431,7 @@ function tocFromHtml(html) {
const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
let m;
while ((m = re.exec(html))) {
const text = m[3]
.replace(/<a class="anchor"[^>]*>.*?<\/a>/, "")
.replace(/<[^>]+>/g, "")
.trim();
const text = htmlTextContent(m[3]).replace(/^#/, "").trim();
items.push({ level: Number(m[1]), id: m[2], text });
}
if (items.length < 2) return "";
@ -585,6 +582,33 @@ function escapeAttr(value) {
return escapeHtml(value);
}
function htmlTextContent(fragment) {
let out = "";
let inTag = false;
for (const char of fragment) {
if (char === "<") {
inTag = true;
continue;
}
if (inTag) {
if (char === ">") inTag = false;
continue;
}
out += char;
}
return decodeHtmlText(out);
}
function decodeHtmlText(value) {
return String(value).replace(/&(amp|lt|gt|quot|#39);/g, (_, entity) => ({
amp: "&",
lt: "<",
gt: ">",
quot: '"',
"#39": "'",
})[entity]);
}
function validateLinks(outputDir) {
const failures = [];
for (const file of allHtml(outputDir)) {