test: enforce 85 percent coverage gate
This commit is contained in:
parent
e5621d1b78
commit
54f7107df9
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -54,8 +54,12 @@ jobs:
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
- name: Test with coverage
|
||||
run: |
|
||||
go test ./... -covermode=atomic -coverprofile=coverage.out
|
||||
total="$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%/, "", $3); print $3 }')"
|
||||
echo "total coverage: ${total}%"
|
||||
awk -v total="$total" 'BEGIN { if (total + 0 < 85.0) { printf("coverage %.1f%% is below 85.0%%\n", total); exit 1 } }'
|
||||
|
||||
- name: Build
|
||||
run: go build -ldflags "-X github.com/openclaw/gitcrawl/internal/cli.version=${GITHUB_SHA:0:7}" -o bin/gitcrawl ./cmd/gitcrawl
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.0 - Unreleased
|
||||
## 0.2.1 - 2026-05-05
|
||||
|
||||
- Improve `gh` shim cache coordination and observability with stale-while-revalidate reads, finer Actions/API TTLs, recent-window stats, top miss keys, and `xcache snapshot`.
|
||||
|
||||
|
||||
8
Makefile
8
Makefile
@ -1,7 +1,7 @@
|
||||
BINARY := gitcrawl
|
||||
VERSION ?= dev
|
||||
|
||||
.PHONY: build test run clean
|
||||
.PHONY: build test test-coverage run clean
|
||||
|
||||
build:
|
||||
go build -ldflags "-X github.com/openclaw/gitcrawl/internal/cli.version=$(VERSION)" -o bin/$(BINARY) ./cmd/gitcrawl
|
||||
@ -9,6 +9,12 @@ build:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
test-coverage:
|
||||
go test ./... -covermode=atomic -coverprofile=coverage.out
|
||||
@total="$$(go tool cover -func=coverage.out | awk '/^total:/ { sub(/%/, "", $$3); print $$3 }')"; \
|
||||
echo "total coverage: $${total}%"; \
|
||||
awk -v total="$$total" 'BEGIN { if (total + 0 < 85.0) { printf("coverage %.1f%% is below 85.0%%\n", total); exit 1 } }'
|
||||
|
||||
run:
|
||||
go run ./cmd/gitcrawl $(ARGS)
|
||||
|
||||
|
||||
@ -121,6 +121,110 @@ echo "call-$count:$*"
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHXCacheCommandsReportAndCleanCacheState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
configPath := seedGHShimRepo(t, ctx)
|
||||
app := New()
|
||||
app.configPath = configPath
|
||||
var stdout bytes.Buffer
|
||||
app.Stdout = &stdout
|
||||
dir, err := app.ghCommandCacheDir()
|
||||
if err != nil {
|
||||
t.Fatalf("cache dir: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
freshPath := filepath.Join(dir, "fresh.json")
|
||||
expiredPath := filepath.Join(dir, "expired.json")
|
||||
if err := writeGHCommandCache(freshPath, ghCommandCacheEntry{CreatedAt: now.Add(-time.Minute), Args: []string{"api", "users/octocat"}, ExitCode: 0, Stdout: "{}"}); err != nil {
|
||||
t.Fatalf("write fresh cache: %v", err)
|
||||
}
|
||||
if err := writeGHCommandCache(expiredPath, ghCommandCacheEntry{CreatedAt: now.Add(-8 * 24 * time.Hour), Args: []string{"api", "users/octocat"}, ExitCode: 0, Stdout: "{}"}); err != nil {
|
||||
t.Fatalf("write expired cache: %v", err)
|
||||
}
|
||||
lockPath := filepath.Join(dir, "stale.lock")
|
||||
if err := os.WriteFile(lockPath, []byte("123\n"), 0o600); err != nil {
|
||||
t.Fatalf("write lock: %v", err)
|
||||
}
|
||||
old := now.Add(-3 * time.Minute)
|
||||
if err := os.Chtimes(lockPath, old, old); err != nil {
|
||||
t.Fatalf("age lock: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "broken.json"), []byte("{"), 0o600); err != nil {
|
||||
t.Fatalf("write broken entry: %v", err)
|
||||
}
|
||||
if _, ok := ghCommandCacheKeyInfoFromDirEntry(dir, mustDirEntry(t, dir, "broken.json")); ok {
|
||||
t.Fatal("broken cache entry should be ignored")
|
||||
}
|
||||
if err := app.incrementGHXCacheCounter("local_hits"); err != nil {
|
||||
t.Fatalf("increment hit: %v", err)
|
||||
}
|
||||
if err := app.incrementGHXCacheBackendMiss([]string{"api", "repos/openclaw/gitcrawl/actions/runs/1/jobs"}); err != nil {
|
||||
t.Fatalf("increment miss: %v", err)
|
||||
}
|
||||
if err := app.runGHXCache([]string{"stats", "--since", "2h"}); err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
statsText := stdout.String()
|
||||
if !strings.Contains(statsText, "hit rate") || !strings.Contains(statsText, "Backend Misses by Route") {
|
||||
t.Fatalf("stats output = %q", statsText)
|
||||
}
|
||||
stdout.Reset()
|
||||
if err := app.runGHXCache([]string{"keys"}); err != nil {
|
||||
t.Fatalf("keys: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "api users/octocat") {
|
||||
t.Fatalf("keys output = %q", stdout.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
if err := app.runGHXCache([]string{"snapshot", "--reset"}); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Reset xcache counters") {
|
||||
t.Fatalf("snapshot output = %q", stdout.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
if err := app.runGHXCache([]string{"gc"}); err != nil {
|
||||
t.Fatalf("gc: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Removed 1 expired entrie(s), 1 stale lock(s)") {
|
||||
t.Fatalf("gc output = %q", stdout.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
if err := app.runGHXCache([]string{"flush"}); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Flushed") {
|
||||
t.Fatalf("flush output = %q", stdout.String())
|
||||
}
|
||||
if err := app.clearGHCommandCache(); err != nil {
|
||||
t.Fatalf("clear cache: %v", err)
|
||||
}
|
||||
if err := app.runGHXCache([]string{}); err == nil {
|
||||
t.Fatal("missing xcache command should fail")
|
||||
}
|
||||
if err := app.runGHXCache([]string{"stats", "--since", "nope"}); err == nil {
|
||||
t.Fatal("invalid since should fail")
|
||||
}
|
||||
if err := app.runGHXCache([]string{"mystery"}); err == nil {
|
||||
t.Fatal("unknown xcache command should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func mustDirEntry(t *testing.T, dir, name string) os.DirEntry {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read dir: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == name {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing dir entry %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGHShimCachesGHXStyleReadOnlyFallbackCommands(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"gh", "release", "view", "v1.2.3", "-R", "openclaw/openclaw"},
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/openclaw/gitcrawl/internal/config"
|
||||
@ -112,6 +113,28 @@ func TestGHShimViewAndListUseLocalCache(t *testing.T) {
|
||||
if len(list) != 1 || int(list[0]["number"].(float64)) != 10 {
|
||||
t.Fatalf("filtered list = %#v", list)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "view", "10", "-R", "openclaw/openclaw"}); err != nil {
|
||||
t.Fatalf("gh issue human view: %v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "title:\tHot loop burns CPU") || !strings.Contains(got, "runtime has a hot loop") {
|
||||
t.Fatalf("human issue view = %q", got)
|
||||
}
|
||||
stdout.Reset()
|
||||
if err := run.Run(ctx, []string{"--config", configPath, "gh", "issue", "list", "-R", "openclaw/openclaw", "--limit", "1"}); err != nil {
|
||||
t.Fatalf("gh issue human list: %v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "10\tHot loop burns CPU") {
|
||||
t.Fatalf("human issue list = %q", got)
|
||||
}
|
||||
stdout.Reset()
|
||||
if err := run.Run(ctx, []string{"--config", configPath, "gh", "pr", "list", "-R", "openclaw/openclaw", "--limit", "1"}); err != nil {
|
||||
t.Fatalf("gh pr human list: %v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, "12\tManifest cache update") {
|
||||
t.Fatalf("human pr list = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHShimAutoHydratesPRDetailsOnMiss(t *testing.T) {
|
||||
|
||||
231
internal/cli/gh_shim_policy_extra_test.go
Normal file
231
internal/cli/gh_shim_policy_extra_test.go
Normal file
@ -0,0 +1,231 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/gitcrawl/internal/store"
|
||||
)
|
||||
|
||||
func TestGHShimPRCacheAndPolicyHelperBranches(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
configPath := seedGHShimRepo(t, ctx)
|
||||
app := New()
|
||||
app.configPath = configPath
|
||||
var stdout bytes.Buffer
|
||||
app.Stdout = &stdout
|
||||
|
||||
if err := app.Run(ctx, []string{"--config", configPath, "gh", "pr", "checks", "12", "-R", "openclaw/openclaw"}); err != nil {
|
||||
t.Fatalf("human pr checks: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "test\tcompleted\tsuccess") {
|
||||
t.Fatalf("human checks = %q", stdout.String())
|
||||
}
|
||||
cache, err := app.localGHPullRequestCache(ctx, "openclaw/openclaw", 12)
|
||||
if err != nil {
|
||||
t.Fatalf("local pr cache: %v", err)
|
||||
}
|
||||
if _, err := app.loadGHPullRequestCache(ctx, "openclaw/openclaw", 12, false); err != nil {
|
||||
t.Fatalf("load cached pr detail without freshness: %v", err)
|
||||
}
|
||||
if _, err := app.loadGHPullRequestCache(ctx, "openclaw/openclaw", 12, true); err != nil {
|
||||
t.Fatalf("load fresh cached pr detail: %v", err)
|
||||
}
|
||||
if !ghPullRequestCacheFresh(cache) {
|
||||
t.Fatalf("seeded cache should be fresh: %+v", cache.Detail)
|
||||
}
|
||||
cache.Detail.RawJSON = `{"head":{"sha":"different"}}`
|
||||
if ghPullRequestCacheFresh(cache) {
|
||||
t.Fatal("mismatched raw head sha should be stale")
|
||||
}
|
||||
cache.Detail.RawJSON = `{"head":{"sha":"abc123"}}`
|
||||
cache.Detail.FetchedAt = "bad"
|
||||
if ghPullRequestCacheFresh(cache) {
|
||||
t.Fatal("bad fetched timestamp should be stale")
|
||||
}
|
||||
if !app.shouldAutoHydrateGHPRDetails(localGHUnsupported(errors.New("pull request detail: sql: no rows in result set"))) {
|
||||
t.Fatal("missing local PR cache should auto-hydrate")
|
||||
}
|
||||
t.Setenv("GITCRAWL_GH_AUTO_HYDRATE", "0")
|
||||
if app.shouldAutoHydrateGHThread(nil) {
|
||||
t.Fatal("auto-hydrate env disable not honored")
|
||||
}
|
||||
if _, err := app.loadGHPullRequestCache(ctx, "openclaw/openclaw", 9999, true); err == nil {
|
||||
t.Fatal("missing PR cache with auto-hydrate disabled should fail")
|
||||
}
|
||||
t.Setenv("GITCRAWL_GH_AUTO_HYDRATE", "")
|
||||
if isMissingLocalPRCache(nil) || !isMissingLocalPRCache(localGHUnsupported(errors.New("cached PR branch \"x\" was not found"))) {
|
||||
t.Fatal("missing cache classification mismatch")
|
||||
}
|
||||
number, err := app.findGHPullRequestNumberByBranch(ctx, "openclaw/openclaw", "manifest-cache")
|
||||
if err != nil || number != 12 {
|
||||
t.Fatalf("branch lookup number=%d err=%v", number, err)
|
||||
}
|
||||
if _, err := app.findGHPullRequestNumberByBranch(ctx, "openclaw/openclaw", "missing"); err == nil {
|
||||
t.Fatal("missing branch lookup should fail")
|
||||
}
|
||||
if got := ghPRHeadRefFromRawJSON(`{"head":{"ref":" feature/cache "}}`); got != "feature/cache" {
|
||||
t.Fatalf("head ref = %q", got)
|
||||
}
|
||||
if got := ghPRHeadRefFromRawJSON(`{`); got != "" {
|
||||
t.Fatalf("invalid head ref = %q", got)
|
||||
}
|
||||
if !ghPRFieldsNeedFresh([]string{"number", "statusCheckRollup"}) || !ghPRFieldsNeedFresh([]string{"mergeStateStatus"}) || ghPRFieldsNeedFresh([]string{"files"}) {
|
||||
t.Fatal("fresh field detection mismatch")
|
||||
}
|
||||
thread := store.Thread{IsDraft: true}
|
||||
for _, field := range []string{"headRepositoryOwner", "headRepository", "mergeStateStatus", "additions", "deletions", "changedFiles", "isDraft"} {
|
||||
if _, err := ghPRDetailJSONValue(thread, cache, field); err != nil {
|
||||
t.Fatalf("field %s: %v", field, err)
|
||||
}
|
||||
}
|
||||
if _, err := ghPRDetailJSONValue(thread, cache, "unsupported"); err == nil {
|
||||
t.Fatal("unsupported PR detail field should fail")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
app.Stdout = &out
|
||||
if err := app.writeJSONValue(map[string]any{"value": 1}, ""); err != nil || !strings.Contains(out.String(), `"value": 1`) {
|
||||
t.Fatalf("write json out=%q err=%v", out.String(), err)
|
||||
}
|
||||
if err := app.writeJSONValue(make(chan int), ""); err == nil {
|
||||
t.Fatal("unmarshalable JSON value should fail")
|
||||
}
|
||||
out.Reset()
|
||||
if err := app.writeJSONValue(map[string]any{"value": 2}, ".value"); err != nil || strings.TrimSpace(out.String()) != "2" {
|
||||
t.Fatalf("write json jq out=%q err=%v", out.String(), err)
|
||||
}
|
||||
t.Setenv("PATH", "")
|
||||
if err := app.writeJSONValue(map[string]any{"value": 2}, ".value"); err == nil {
|
||||
t.Fatal("jq expression without jq executable should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHShimCachePolicyExtraBranches(t *testing.T) {
|
||||
if cacheableGHRead(nil) || cacheableGHRead([]string{"repo", "view", "--web"}) {
|
||||
t.Fatal("interactive or empty gh commands should not be cacheable")
|
||||
}
|
||||
if !cacheableGHRead([]string{"gist", "view", "1"}) || !cacheableGHRead([]string{"project", "item-list"}) || !cacheableGHRead([]string{"cache", "list"}) {
|
||||
t.Fatal("expected read-only command to be cacheable")
|
||||
}
|
||||
if ghAPIReadOnly([]string{"repos/openclaw/gitcrawl/issues", "-f", "title=x"}) || ghAPIReadOnly([]string{"repos/openclaw/gitcrawl", "-X"}) || ghAPIReadOnly([]string{"repos/openclaw/gitcrawl", "--method=PATCH"}) {
|
||||
t.Fatal("mutating or malformed API command should not be read-only")
|
||||
}
|
||||
if got := ghAPIPathArg([]string{"--paginate", "-H", "Accept: json", "--jq", ".[]", "--template", "{{.}}", "repos/openclaw/gitcrawl/issues"}); got != "repos/openclaw/gitcrawl/issues" {
|
||||
t.Fatalf("api path with skipped flags = %q", got)
|
||||
}
|
||||
if got := ghAPIPathArg([]string{"-f", "x=y"}); got != "" {
|
||||
t.Fatalf("api path with only fields = %q", got)
|
||||
}
|
||||
if !ghAPIReadOnly([]string{"repos/openclaw/gitcrawl", "--method=GET"}) {
|
||||
t.Fatal("GET API command should be read-only")
|
||||
}
|
||||
if ghGraphQLReadOnly([]string{"graphql"}) || ghGraphQLReadOnly([]string{"graphql", "-X"}) || ghGraphQLReadOnly([]string{"graphql", "-X", "PUT", "-f", "query={ viewer { login } }"}) || ghGraphQLReadOnly([]string{"graphql", "--field=query=@query.graphql"}) {
|
||||
t.Fatal("malformed or mutating GraphQL command should not be read-only")
|
||||
}
|
||||
if !ghGraphQLReadOnly([]string{"graphql", "--field=query=query { viewer { login } }"}) {
|
||||
t.Fatal("GraphQL query should be read-only")
|
||||
}
|
||||
t.Setenv("GITCRAWL_GH_CACHE_TTL", "2m")
|
||||
if got := ghCommandCacheTTL([]string{"repo", "view"}); got != 2*time.Minute {
|
||||
t.Fatalf("env ttl = %s", got)
|
||||
}
|
||||
t.Setenv("GITCRAWL_GH_CACHE_TTL", "")
|
||||
ttlCases := []struct {
|
||||
args []string
|
||||
want time.Duration
|
||||
}{
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/pages/builds/latest"}, 2 * time.Minute},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/pages/health"}, 15 * time.Minute},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/actions/jobs/123/logs"}, 12 * time.Hour},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/actions/jobs/123"}, time.Minute},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/actions/runs/123/pending_deployments"}, 30 * time.Second},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/actions/workflows/ci.yml"}, 15 * time.Minute},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/releases/latest"}, time.Hour},
|
||||
{[]string{"api", "repos/openclaw/gitcrawl/branches/main"}, 10 * time.Minute},
|
||||
{[]string{"workflow", "list"}, 15 * time.Minute},
|
||||
{[]string{"issue", "view"}, 5 * time.Minute},
|
||||
{[]string{"unknown"}, 5 * time.Minute},
|
||||
}
|
||||
for _, tc := range ttlCases {
|
||||
if got := ghCommandCacheTTL(tc.args); got != tc.want {
|
||||
t.Fatalf("ttl %v = %s, want %s", tc.args, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !ghAPIContentRefIsStable([]string{"repos/openclaw/gitcrawl/contents/a?ref=v1.2.3-beta+1"}) || ghAPIContentRefIsStable([]string{"repos/openclaw/gitcrawl/contents/a?ref=refs/heads/v1.2.3"}) || ghAPIContentRefIsStable([]string{"repos/openclaw/gitcrawl/contents/a?ref=v1.2"}) {
|
||||
t.Fatal("stable content ref classification mismatch")
|
||||
}
|
||||
t.Setenv("GH_REPO", "openclaw/from-env")
|
||||
repo, number, ok := parseGHPRDiffIdentityArgs([]string{"pr", "diff", "42"})
|
||||
if !ok || repo != "openclaw/from-env" || number != 42 {
|
||||
t.Fatalf("diff 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)
|
||||
}
|
||||
}
|
||||
if mutatingGHCommand([]string{"pr", "checkout"}) || mutatingGHCommand([]string{"repo", "view"}) || mutatingGHCommand([]string{"api", "repos/openclaw/gitcrawl"}) {
|
||||
t.Fatal("read-only commands classified as mutating")
|
||||
}
|
||||
for _, remote := range []string{"git@github.com:openclaw/gitcrawl.git", "https://github.com/openclaw/gitcrawl.git", "ssh://git@github.com/openclaw/gitcrawl.git"} {
|
||||
if got, err := ownerRepoFromGitRemote(remote); err != nil || got != "openclaw/gitcrawl" {
|
||||
t.Fatalf("remote %q => %q err=%v", remote, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := ownerRepoFromGitRemote("not-a-github-remote"); err == nil {
|
||||
t.Fatal("bad remote should fail")
|
||||
}
|
||||
app := New()
|
||||
if got, err := app.resolveGHRepo(context.Background(), " openclaw/explicit "); err != nil || got != "openclaw/explicit" {
|
||||
t.Fatalf("explicit repo = %q err=%v", got, err)
|
||||
}
|
||||
if got, err := app.resolveGHRepo(context.Background(), ""); err != nil || got != "openclaw/from-env" {
|
||||
t.Fatalf("env repo = %q err=%v", got, err)
|
||||
}
|
||||
t.Setenv("GH_REPO", "")
|
||||
repoDir := t.TempDir()
|
||||
if err := runGit(context.Background(), repoDir, "init", "-b", "main"); err != nil {
|
||||
t.Fatalf("init git repo: %v", err)
|
||||
}
|
||||
if err := runGit(context.Background(), repoDir, "remote", "add", "origin", "https://github.com/openclaw/gitcrawl.git"); err != nil {
|
||||
t.Fatalf("add origin: %v", err)
|
||||
}
|
||||
original, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(original) }()
|
||||
if err := os.Chdir(repoDir); err != nil {
|
||||
t.Fatalf("chdir repo: %v", err)
|
||||
}
|
||||
if got, err := app.resolveGHRepo(context.Background(), ""); err != nil || got != "openclaw/gitcrawl" {
|
||||
t.Fatalf("git remote repo = %q err=%v", got, err)
|
||||
}
|
||||
ghPath := filepath.Join(t.TempDir(), "gh")
|
||||
if err := os.WriteFile(ghPath, []byte("#!/bin/sh\necho real-gh:$*\n"), 0o755); err != nil {
|
||||
t.Fatalf("write fake gh: %v", err)
|
||||
}
|
||||
t.Setenv("GITCRAWL_GH_PATH", ghPath)
|
||||
var ghOut bytes.Buffer
|
||||
app.Stdout = &ghOut
|
||||
if err := app.runGHShim(context.Background(), nil); err != nil {
|
||||
t.Fatalf("empty gh shim fallback: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(ghOut.String()) != "real-gh:" {
|
||||
t.Fatalf("empty gh shim output = %q", ghOut.String())
|
||||
}
|
||||
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)
|
||||
}
|
||||
t.Setenv("GITCRAWL_GH_STALE_GRACE", "")
|
||||
if got := ghCommandCacheStaleGrace([]string{"api", "users/octocat"}); got != 24*time.Hour {
|
||||
t.Fatalf("user stale grace = %s", got)
|
||||
}
|
||||
}
|
||||
80
internal/cli/runtime_extra_test.go
Normal file
80
internal/cli/runtime_extra_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPortableRuntimeUtilityBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.db")
|
||||
mirror := filepath.Join(dir, "runtime", "source.db")
|
||||
if _, err := portableRuntimeNeedsCopy(source, mirror); err == nil {
|
||||
t.Fatal("missing source should fail")
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("v1"), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
needs, err := portableRuntimeNeedsCopy(source, mirror)
|
||||
if err != nil || !needs {
|
||||
t.Fatalf("missing mirror needs copy=%v err=%v", needs, err)
|
||||
}
|
||||
if err := copyFileAtomic(source, mirror); err != nil {
|
||||
t.Fatalf("copy mirror: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(mirror+"-wal", []byte("wal"), 0o644); err != nil {
|
||||
t.Fatalf("write wal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(mirror+"-shm", []byte("shm"), 0o644); err != nil {
|
||||
t.Fatalf("write shm: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(mirror, time.Now().Add(time.Hour), time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("age mirror: %v", err)
|
||||
}
|
||||
needs, err = portableRuntimeNeedsCopy(source, mirror)
|
||||
if err != nil || needs {
|
||||
t.Fatalf("fresh mirror needs copy=%v err=%v", needs, err)
|
||||
}
|
||||
if err := copyFileAtomic(source, mirror); err != nil {
|
||||
t.Fatalf("recopy mirror: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(mirror + "-wal"); !os.IsNotExist(err) {
|
||||
t.Fatalf("wal sidecar should be removed, err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(mirror + "-shm"); !os.IsNotExist(err) {
|
||||
t.Fatalf("shm sidecar should be removed, err=%v", err)
|
||||
}
|
||||
|
||||
statePath := portableStoreRefreshStatePath(mirror)
|
||||
state := portableStoreRefreshState{LastAttempt: "attempt", 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.LastAttempt != "attempt" || got.LastSuccess == "" {
|
||||
t.Fatalf("state = %+v", got)
|
||||
}
|
||||
if err := os.WriteFile(statePath, []byte("{"), 0o600); err != nil {
|
||||
t.Fatalf("write invalid state: %v", err)
|
||||
}
|
||||
if got := readPortableStoreRefreshState(statePath); got.LastAttempt != "" {
|
||||
t.Fatalf("invalid state should decode empty, got %+v", got)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if recentPortableRefresh("", now, time.Minute) || recentPortableRefresh("bad", now, time.Minute) || !recentPortableRefresh(now.Format(time.RFC3339Nano), now, time.Minute) {
|
||||
t.Fatal("recent refresh classification mismatch")
|
||||
}
|
||||
t.Setenv("GITCRAWL_PORTABLE_REFRESH_TTL", "0")
|
||||
if got := portableStoreRefreshInterval(); got != 0 {
|
||||
t.Fatalf("zero ttl = %s", got)
|
||||
}
|
||||
t.Setenv("GITCRAWL_PORTABLE_REFRESH_TTL", "bad")
|
||||
if got := portableStoreRefreshInterval(); got != portableStoreRefreshTTL {
|
||||
t.Fatalf("bad ttl fallback = %s", got)
|
||||
}
|
||||
if err := refreshPortableStoreForDB(context.Background(), source); err != nil {
|
||||
t.Fatalf("non-portable refresh should be no-op: %v", err)
|
||||
}
|
||||
}
|
||||
326
internal/cli/tui_render_extra_test.go
Normal file
326
internal/cli/tui_render_extra_test.go
Normal file
@ -0,0 +1,326 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/openclaw/gitcrawl/internal/store"
|
||||
)
|
||||
|
||||
func TestFloatingMenuRenderingBranches(t *testing.T) {
|
||||
base := strings.Join([]string{
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
"01234567890123456789",
|
||||
}, "\n")
|
||||
model := clusterBrowserModel{
|
||||
width: 20,
|
||||
height: 6,
|
||||
menuTitle: "Actions",
|
||||
menuContext: focusClusters,
|
||||
menuIndex: 2,
|
||||
menuOff: 1,
|
||||
menuFloating: true,
|
||||
menuRect: tuiRect{x: 2, y: 1, w: 16, h: 8},
|
||||
menuItems: []tuiMenuItem{
|
||||
tuiMenuSection("Hidden"),
|
||||
{label: "Open", action: "open"},
|
||||
{label: "Close", action: "close"},
|
||||
{label: "Skip", action: ""},
|
||||
{label: "Refresh", action: "refresh"},
|
||||
},
|
||||
}
|
||||
rendered := model.renderFloatingMenu(base)
|
||||
if rendered == base || !strings.Contains(rendered, "Actions") || !strings.Contains(rendered, "Open") {
|
||||
t.Fatalf("rendered menu = %q", rendered)
|
||||
}
|
||||
if got := (clusterBrowserModel{}).renderFloatingMenu(base); got != base {
|
||||
t.Fatalf("empty rect should keep base view")
|
||||
}
|
||||
submenu := model
|
||||
submenu.menuTitle = "Repository"
|
||||
if lines := submenu.menuLines(14); !strings.Contains(strings.Join(lines, "\n"), "b back") {
|
||||
t.Fatalf("submenu lines = %#v", lines)
|
||||
}
|
||||
if got := actionMenuSubtitle(focusMembers); got != "selected member scope" {
|
||||
t.Fatalf("member subtitle = %q", got)
|
||||
}
|
||||
if got := actionMenuSubtitle(focusDetail); got != "detail scope" {
|
||||
t.Fatalf("detail subtitle = %q", got)
|
||||
}
|
||||
if got := actionMenuSubtitle(""); got != "current selection" {
|
||||
t.Fatalf("default subtitle = %q", got)
|
||||
}
|
||||
if palette := actionMenuColors(focusMembers); palette.accent == "" || palette.background == "" {
|
||||
t.Fatalf("member palette = %+v", palette)
|
||||
}
|
||||
if style := floatingMenuStyle(1, 1, actionMenuColors("")); style.GetWidth() != 1 || style.GetHeight() != 1 {
|
||||
t.Fatalf("minimum style size width=%d height=%d", style.GetWidth(), style.GetHeight())
|
||||
}
|
||||
if index, ok := visibleMenuShortcutIndex("2", model.menuItems, 1, 4); !ok || index != 2 {
|
||||
t.Fatalf("shortcut index=%d ok=%v", index, ok)
|
||||
}
|
||||
if _, ok := visibleMenuShortcutIndex("x", model.menuItems, 1, 4); ok {
|
||||
t.Fatal("non-numeric shortcut should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIMenuNavigationAndWheelBranches(t *testing.T) {
|
||||
model := clusterBrowserModel{
|
||||
width: 100,
|
||||
height: 30,
|
||||
menuIndex: 0,
|
||||
menuOff: 4,
|
||||
menuFloating: true,
|
||||
menuRect: tuiRect{x: 0, y: 0, w: 20, h: 8},
|
||||
menuItems: []tuiMenuItem{
|
||||
tuiMenuSection("top"),
|
||||
{label: "one", action: "one"},
|
||||
{label: "two", action: "two"},
|
||||
tuiMenuSection("middle"),
|
||||
{label: "three", action: "three"},
|
||||
{label: "four", action: "four"},
|
||||
},
|
||||
payload: clusterBrowserPayload{Clusters: []store.ClusterSummary{
|
||||
{ID: 10, Title: "first"},
|
||||
{ID: 11, Title: "second"},
|
||||
}},
|
||||
}
|
||||
if model.firstSelectableMenuIndex() != 1 || model.lastSelectableMenuIndex() != 5 {
|
||||
t.Fatalf("selectable bounds first=%d last=%d", model.firstSelectableMenuIndex(), model.lastSelectableMenuIndex())
|
||||
}
|
||||
if got := model.nextSelectableMenuIndex(1); got != 1 {
|
||||
t.Fatalf("next selectable = %d", got)
|
||||
}
|
||||
if got := model.nearestSelectableMenuIndex(3, 1); got != 4 {
|
||||
t.Fatalf("nearest forward = %d", got)
|
||||
}
|
||||
if got := model.nearestSelectableMenuIndex(3, -1); got != 2 {
|
||||
t.Fatalf("nearest backward = %d", got)
|
||||
}
|
||||
empty := clusterBrowserModel{}
|
||||
if got := empty.nearestSelectableMenuIndex(10, 1); got != 0 {
|
||||
t.Fatalf("empty nearest = %d", got)
|
||||
}
|
||||
model.menuIndex = 5
|
||||
model.keepMenuVisible()
|
||||
if model.menuOff > model.menuIndex {
|
||||
t.Fatalf("menu off=%d index=%d", model.menuOff, model.menuIndex)
|
||||
}
|
||||
layout := tuiLayout{
|
||||
clusters: tuiRect{x: 0, y: 2, w: 20, h: 8},
|
||||
members: tuiRect{x: 20, y: 2, w: 20, h: 8},
|
||||
detail: tuiRect{x: 40, y: 2, w: 20, h: 8},
|
||||
}
|
||||
if got := model.actionMenuContextAt(layout, 1, 3); got != focusClusters {
|
||||
t.Fatalf("cluster context = %q", got)
|
||||
}
|
||||
if got := model.actionMenuContextAt(layout, 21, 3); got != focusMembers {
|
||||
t.Fatalf("member context = %q", got)
|
||||
}
|
||||
if got := model.actionMenuContextAt(layout, 41, 3); got != focusDetail {
|
||||
t.Fatalf("detail context = %q", got)
|
||||
}
|
||||
if got := model.actionMenuContextAt(layout, 99, 99); got != "" {
|
||||
t.Fatalf("outside context = %q", got)
|
||||
}
|
||||
if index, ok := model.menuIndexAtMouse(layout, 1, 4); !ok || index != 6 {
|
||||
t.Fatalf("menu index at mouse index=%d ok=%v", index, ok)
|
||||
}
|
||||
model.menuFloating = false
|
||||
if index, ok := model.menuIndexAtMouse(layout, 41, 6); !ok || index != 5 {
|
||||
t.Fatalf("detail menu index at mouse index=%d ok=%v", index, ok)
|
||||
}
|
||||
if _, ok := model.menuIndexAtMouse(layout, 99, 99); ok {
|
||||
t.Fatal("outside mouse should not hit menu")
|
||||
}
|
||||
if step := (clusterBrowserModel{width: 100, height: 30}).pageStep(); step <= 0 {
|
||||
t.Fatalf("cluster page step = %d", step)
|
||||
}
|
||||
detailModel := clusterBrowserModel{focus: focusDetail}
|
||||
detailModel.detailView.Height = 3
|
||||
if step := detailModel.pageStep(); step != 3 {
|
||||
t.Fatalf("detail page step = %d", step)
|
||||
}
|
||||
model.selected = 0
|
||||
cmd := model.moveClusterByWheel(1)
|
||||
if cmd == nil || model.selected != 1 || model.status != "Cluster 11" {
|
||||
t.Fatalf("wheel move selected=%d status=%q cmd=%v", model.selected, model.status, cmd)
|
||||
}
|
||||
if cmd := model.moveClusterByWheel(1); cmd != nil {
|
||||
t.Fatalf("boundary wheel move should not tick: %v", cmd)
|
||||
}
|
||||
model.wheelDelta = -1
|
||||
model.wheelFocus = focusClusters
|
||||
if cmd := model.applyQueuedWheelScroll(); cmd == nil || model.focus != focusClusters {
|
||||
t.Fatalf("queued wheel cmd=%v focus=%q", cmd, model.focus)
|
||||
}
|
||||
model.wheelDelta = 0
|
||||
if cmd := model.applyQueuedWheelScroll(); cmd != nil {
|
||||
t.Fatalf("zero queued wheel should be nil: %v", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUISelectionAndVisibilityHelperBranches(t *testing.T) {
|
||||
model := clusterBrowserModel{
|
||||
payload: clusterBrowserPayload{Limit: 2, Clusters: []store.ClusterSummary{
|
||||
{ID: 1, RepresentativeNumber: 101, MemberCount: 2, UpdatedAt: "2026-05-05T10:00:00Z"},
|
||||
{ID: 2, RepresentativeNumber: 202, MemberCount: 1, UpdatedAt: "2026-05-05T11:00:00Z"},
|
||||
}},
|
||||
allClusters: []store.ClusterSummary{
|
||||
{ID: 3, RepresentativeNumber: 303, MemberCount: 5, UpdatedAt: "2026-05-05T12:00:00Z"},
|
||||
},
|
||||
hasDetail: true,
|
||||
detail: store.ClusterDetail{
|
||||
Cluster: store.ClusterSummary{ID: 9, RepresentativeNumber: 909},
|
||||
Members: []store.ClusterMemberDetail{{
|
||||
Thread: store.Thread{Number: 909, State: "open"},
|
||||
}},
|
||||
},
|
||||
detailCache: map[int64]store.ClusterDetail{
|
||||
8: {Cluster: store.ClusterSummary{ID: 8}, Members: []store.ClusterMemberDetail{{Thread: store.Thread{Number: 808, State: "open"}}}},
|
||||
},
|
||||
memberRows: []memberRow{
|
||||
{label: "header"},
|
||||
{selectable: true, member: store.ClusterMemberDetail{Thread: store.Thread{Number: 202, State: "open"}}},
|
||||
},
|
||||
}
|
||||
if got := model.currentClusterID(); got != 1 {
|
||||
t.Fatalf("current cluster = %d", got)
|
||||
}
|
||||
if got := model.clusterRefreshLimit(); got != 2 {
|
||||
t.Fatalf("refresh limit = %d", got)
|
||||
}
|
||||
if got := model.findLoadedClusterIDForThreadNumber(909); got != 9 {
|
||||
t.Fatalf("detail cluster lookup = %d", got)
|
||||
}
|
||||
if got := model.findLoadedClusterIDForThreadNumber(808); got != 8 {
|
||||
t.Fatalf("cache cluster lookup = %d", got)
|
||||
}
|
||||
if got := model.findLoadedClusterIDForThreadNumber(303); got != 3 {
|
||||
t.Fatalf("working-set cluster lookup = %d", got)
|
||||
}
|
||||
if _, ok := model.clusterFromWorkingSet(404); ok {
|
||||
t.Fatal("missing cluster should not be found")
|
||||
}
|
||||
if !model.selectMemberByNumber(202) || model.memberIndex != 1 {
|
||||
t.Fatalf("member selection index = %d", model.memberIndex)
|
||||
}
|
||||
if model.selectMemberByNumber(999) {
|
||||
t.Fatal("missing member should not be selected")
|
||||
}
|
||||
openThread := store.Thread{State: "open"}
|
||||
closedThread := store.Thread{State: "closed"}
|
||||
localClosedThread := store.Thread{State: "open", ClosedAtLocal: "2026-05-05T00:00:00Z"}
|
||||
if !threadVisible(openThread, false) || threadVisible(closedThread, false) || threadVisible(localClosedThread, false) || !threadVisible(closedThread, true) {
|
||||
t.Fatal("thread visibility mismatch")
|
||||
}
|
||||
if got := memberDisplayState(store.ClusterMemberDetail{State: "removed", Thread: openThread}); got != "removed" {
|
||||
t.Fatalf("member state = %q", got)
|
||||
}
|
||||
if got := memberDisplayState(store.ClusterMemberDetail{Thread: localClosedThread}); got != "local" {
|
||||
t.Fatalf("local member state = %q", got)
|
||||
}
|
||||
if memberVisible(store.ClusterMemberDetail{State: "removed", Thread: openThread}, false) || !memberVisible(store.ClusterMemberDetail{State: "removed", Thread: closedThread}, true) {
|
||||
t.Fatal("member visibility mismatch")
|
||||
}
|
||||
noLimit := clusterBrowserModel{payload: clusterBrowserPayload{Clusters: model.payload.Clusters}, allClusters: model.allClusters}
|
||||
if got := noLimit.clusterRefreshLimit(); got < len(model.allClusters) {
|
||||
t.Fatalf("no-limit refresh limit = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIJumpToThreadNumberLoadsClusterFromStore(t *testing.T) {
|
||||
st, repoID, clusterID := seedTUIDurableStore(t)
|
||||
defer st.Close()
|
||||
model := clusterBrowserModel{
|
||||
ctx: context.Background(),
|
||||
store: st,
|
||||
repoID: repoID,
|
||||
detailCache: map[int64]store.ClusterDetail{},
|
||||
payload: clusterBrowserPayload{Limit: 1, Sort: "recent"},
|
||||
minSize: 99,
|
||||
}
|
||||
model.jumpToThreadNumber(0)
|
||||
if model.status != "Enter a positive issue or PR number" {
|
||||
t.Fatalf("bad jump status = %q", model.status)
|
||||
}
|
||||
model.jumpToThreadNumber(202)
|
||||
if model.focus != focusMembers || !strings.Contains(model.status, "Jumped to #202") {
|
||||
t.Fatalf("jump focus=%q status=%q", model.focus, model.status)
|
||||
}
|
||||
if len(model.payload.Clusters) == 0 || model.payload.Clusters[model.selected].ID != clusterID {
|
||||
t.Fatalf("selected clusters = %+v selected=%d want cluster %d", model.payload.Clusters, model.selected, clusterID)
|
||||
}
|
||||
if model.memberIndex < 0 || model.memberRows[model.memberIndex].thread().Number != 202 {
|
||||
t.Fatalf("member rows index=%d rows=%+v", model.memberIndex, model.memberRows)
|
||||
}
|
||||
if _, ok := model.detailCache[clusterID]; !ok {
|
||||
t.Fatalf("detail cache missing cluster %d", clusterID)
|
||||
}
|
||||
model.jumpToThreadNumber(999)
|
||||
if model.status == "" || strings.Contains(model.status, "Jumped") {
|
||||
t.Fatalf("missing jump status = %q", model.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIJumpKeyAndRefreshCommandBranches(t *testing.T) {
|
||||
input := textinput.New()
|
||||
input.SetValue("#0")
|
||||
model := clusterBrowserModel{searchInput: input, jumping: true}
|
||||
next, cmd := model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil || next.jumping || next.status != "Enter a positive issue or PR number" {
|
||||
t.Fatalf("bad enter next=%+v cmd=%v", next, cmd)
|
||||
}
|
||||
input = textinput.New()
|
||||
input.SetValue("123")
|
||||
model = clusterBrowserModel{
|
||||
searchInput: input,
|
||||
jumping: true,
|
||||
payload: clusterBrowserPayload{Clusters: []store.ClusterSummary{{ID: 1, RepresentativeNumber: 123}}},
|
||||
allClusters: []store.ClusterSummary{{ID: 1, RepresentativeNumber: 123}},
|
||||
detailCache: map[int64]store.ClusterDetail{},
|
||||
}
|
||||
next, cmd = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if cmd != nil || next.jumping || !strings.Contains(next.status, "outside loaded members") {
|
||||
t.Fatalf("valid enter next status=%q cmd=%v", next.status, cmd)
|
||||
}
|
||||
model = clusterBrowserModel{searchInput: textinput.New(), jumping: true}
|
||||
next, cmd = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if cmd != nil || next.jumping || next.status != "Jump cancelled" {
|
||||
t.Fatalf("esc next=%+v cmd=%v", next, cmd)
|
||||
}
|
||||
next, cmd = model.handleJumpKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("4")})
|
||||
if next.jumping != true {
|
||||
t.Fatalf("rune input should keep jump mode, next=%+v cmd=%v", next, cmd)
|
||||
}
|
||||
if (clusterBrowserModel{}).remoteRefreshTickCmd() == nil || (clusterBrowserModel{}).autoRefreshCmd() != nil || (clusterBrowserModel{store: &store.Store{}, repoID: 1}).autoRefreshCmd() == nil {
|
||||
t.Fatal("refresh tick commands should be scheduled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInteractiveTUIFallsBackToJSONForNonFileOutput(t *testing.T) {
|
||||
app := New()
|
||||
var out bytes.Buffer
|
||||
app.Stdout = &out
|
||||
if app.canRunInteractiveTUI() {
|
||||
t.Fatal("buffer stdout should not be interactive")
|
||||
}
|
||||
payload := clusterBrowserPayload{Repository: "openclaw/openclaw", Mode: "clusters", Clusters: []store.ClusterSummary{{ID: 1, MemberCount: 2}}}
|
||||
if err := app.runInteractiveTUI(context.Background(), nil, 0, payload); err != nil {
|
||||
t.Fatalf("run tui fallback: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"repository": "openclaw/openclaw"`) || !strings.Contains(out.String(), `"clusters"`) {
|
||||
t.Fatalf("fallback tui output = %q", out.String())
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@ -13,6 +14,12 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestEmbedAcceptsLargeBatchResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request embeddingRequest
|
||||
@ -357,3 +364,79 @@ func TestEmbedRetryAfterDateForm(t *testing.T) {
|
||||
t.Fatalf("expected ~3s sleep from HTTP-date Retry-After, got %v", slept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIErrorAndRetryHelpers(t *testing.T) {
|
||||
apiErr := &APIError{Status: http.StatusBadGateway, Type: "overloaded_error", Code: "overloaded", Message: "try later"}
|
||||
if got := apiErr.Error(); !strings.Contains(got, "status=502") || !strings.Contains(got, "message=try later") {
|
||||
t.Fatalf("error string = %q", got)
|
||||
}
|
||||
if !apiErr.Retryable() || !apiErr.IsOverloaded() {
|
||||
t.Fatalf("retryable/overloaded = %v/%v", apiErr.Retryable(), apiErr.IsOverloaded())
|
||||
}
|
||||
if (*APIError)(nil).Retryable() || !(&APIError{Status: http.StatusGatewayTimeout}).Retryable() || (&APIError{Status: http.StatusTooManyRequests, Type: "insufficient_quota"}).Retryable() {
|
||||
t.Fatal("unexpected retryable classification")
|
||||
}
|
||||
if AsAPIError(nil) != nil || AsAPIError(errors.New("plain")) != nil {
|
||||
t.Fatal("unexpected APIError extraction")
|
||||
}
|
||||
now := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
|
||||
if got := parseRetryAfter("1.5", now); got != 1500*time.Millisecond {
|
||||
t.Fatalf("float retry-after = %s", got)
|
||||
}
|
||||
if got := parseRetryAfter("-1", now); got != 0 {
|
||||
t.Fatalf("negative retry-after = %s", got)
|
||||
}
|
||||
if got := parseRetryAfter(now.Add(-time.Minute).Format(http.TimeFormat), now); got != 0 {
|
||||
t.Fatalf("past retry-after = %s", got)
|
||||
}
|
||||
retry := RetryConfig{MaxAttempts: -1, BaseDelay: 0, MaxDelay: 50 * time.Millisecond, MaxElapsed: 0, Jitter: 0}
|
||||
client := New(Options{APIKey: "test", Retry: &retry})
|
||||
if client.retry.MaxAttempts != 1 {
|
||||
t.Fatalf("max attempts = %d, want normalized 1", client.retry.MaxAttempts)
|
||||
}
|
||||
if got := client.backoff(10, 0, time.Second); got != 50*time.Millisecond {
|
||||
t.Fatalf("retry-after should be clamped to max delay, got %s", got)
|
||||
}
|
||||
if got := client.backoff(10, 0, 0); got != 50*time.Millisecond {
|
||||
t.Fatalf("exponential backoff should be clamped to max delay, got %s", got)
|
||||
}
|
||||
if !client.canSleep(now, 24*time.Hour) {
|
||||
t.Fatal("max elapsed <= 0 should allow sleeping")
|
||||
}
|
||||
if err := sleepCtx(context.Background(), 0); err != nil {
|
||||
t.Fatalf("zero sleep: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled sleep err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedRetriesTransportError(t *testing.T) {
|
||||
var calls int
|
||||
client := New(Options{
|
||||
APIKey: "test",
|
||||
BaseURL: "https://example.invalid",
|
||||
Retry: &RetryConfig{MaxAttempts: 2, BaseDelay: time.Millisecond, MaxDelay: time.Millisecond, MaxElapsed: time.Hour, Jitter: 0},
|
||||
Sleep: func(context.Context, time.Duration) error { return nil },
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errors.New("temporary network break")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.5]}]}`)),
|
||||
}, nil
|
||||
})},
|
||||
})
|
||||
vectors, err := client.Embed(context.Background(), "model", []string{"hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("embed: %v", err)
|
||||
}
|
||||
if calls != 2 || len(vectors) != 1 || vectors[0][0] != 0.5 {
|
||||
t.Fatalf("calls=%d vectors=%v", calls, vectors)
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,4 +36,18 @@ func TestUpsertComment(t *testing.T) {
|
||||
if id == 0 {
|
||||
t.Fatal("expected comment id")
|
||||
}
|
||||
if _, err := st.UpsertComment(ctx, Comment{
|
||||
ThreadID: threadID, GitHubID: "c0", CommentType: "issue_comment",
|
||||
AuthorLogin: "octobot", AuthorType: "Bot", Body: "earlier bot note", IsBot: true, RawJSON: "{}",
|
||||
CreatedAtGitHub: "2026-04-25T00:00:00Z", UpdatedAtGitHub: "2026-04-25T00:01:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("second comment: %v", err)
|
||||
}
|
||||
comments, err := st.ListComments(ctx, threadID)
|
||||
if err != nil {
|
||||
t.Fatalf("list comments: %v", err)
|
||||
}
|
||||
if len(comments) != 2 || comments[0].GitHubID != "c0" || !comments[0].IsBot || comments[1].GitHubID != "c1" {
|
||||
t.Fatalf("comments = %+v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
@ -254,6 +254,70 @@ func TestPortablePruneCanonicalizesSchemaAndMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortablePruneClearsPRRawJSONBlobPointersAndFingerprints(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, threadIDs := seedVectorThreads(t, ctx, st)
|
||||
threadID := threadIDs[1]
|
||||
if _, err := st.DB().ExecContext(ctx, `
|
||||
insert into blobs(id, sha256, media_type, compression, size_bytes, storage_kind, inline_text, created_at)
|
||||
values(1, 'sha', 'application/json', 'none', 2, 'inline', '{}', '2026-05-05T00:00:00Z');
|
||||
insert into thread_revisions(id, thread_id, source_updated_at, content_hash, title_hash, body_hash, labels_hash, raw_json_blob_id, created_at)
|
||||
values(1, ?, '2026-05-05T00:00:00Z', 'content', 'title', 'body', 'labels', 1, '2026-05-05T00:00:00Z');
|
||||
insert into thread_fingerprints(thread_revision_id, algorithm_version, fingerprint_hash, fingerprint_slug, title_tokens_json, body_token_hash, linked_refs_json, file_set_hash, module_buckets_json, simhash64, feature_json, created_at)
|
||||
values(1, 'v1', 'hash', 'slug', '["token"]', 'body', '["#1"]', 'files', '["module"]', '1', '{"x":1}', '2026-05-05T00:00:00Z');
|
||||
`, threadID); err != nil {
|
||||
t.Fatalf("seed revision/fingerprint: %v", err)
|
||||
}
|
||||
if _, err := st.UpsertComment(ctx, Comment{ThreadID: threadID, GitHubID: "raw-comment", CommentType: "issue_comment", Body: "comment body that is long", RawJSON: `{"raw":true}`, CreatedAtGitHub: "2026-05-05T00:00:00Z"}); err != nil {
|
||||
t.Fatalf("seed comment: %v", err)
|
||||
}
|
||||
if _, err := st.DB().ExecContext(ctx, `update comments set raw_json_blob_id = 1 where github_id = 'raw-comment'`); err != nil {
|
||||
t.Fatalf("link comment blob: %v", err)
|
||||
}
|
||||
if err := st.UpsertPullRequestCache(ctx,
|
||||
PullRequestDetail{ThreadID: threadID, RepoID: repoID, Number: 302, HeadSHA: "head", RawJSON: `{"detail":true}`, FetchedAt: "2026-05-05T00:00:00Z", UpdatedAt: "2026-05-05T00:00:00Z"},
|
||||
[]PullRequestFile{{Path: "a.go", RawJSON: `{"file":true}`, FetchedAt: "2026-05-05T00:00:00Z"}},
|
||||
[]PullRequestCommit{{SHA: "abc", RawJSON: `{"commit":true}`, FetchedAt: "2026-05-05T00:00:00Z"}},
|
||||
[]PullRequestCheck{{Name: "ci", RawJSON: `{"check":true}`, FetchedAt: "2026-05-05T00:00:00Z"}},
|
||||
[]WorkflowRun{{RepoID: repoID, RunID: "1", RawJSON: `{"run":true}`, FetchedAt: "2026-05-05T00:00:00Z"}},
|
||||
); err != nil {
|
||||
t.Fatalf("seed pr cache: %v", err)
|
||||
}
|
||||
stats, err := st.PrunePortablePayloads(ctx, PortablePruneOptions{BodyChars: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("prune portable: %v", err)
|
||||
}
|
||||
if stats.RawJSONPruned < 6 || stats.FingerprintsPruned != 1 || stats.CommentsPruned != 1 {
|
||||
t.Fatalf("portable stats = %+v", stats)
|
||||
}
|
||||
var commentRaw string
|
||||
var commentBlob, revisionBlob any
|
||||
if err := st.DB().QueryRowContext(ctx, `select raw_json, raw_json_blob_id from comments where github_id = 'raw-comment'`).Scan(&commentRaw, &commentBlob); err != nil {
|
||||
t.Fatalf("read pruned comment: %v", err)
|
||||
}
|
||||
if commentRaw != "" || commentBlob != nil {
|
||||
t.Fatalf("comment raw=%q blob=%v", commentRaw, commentBlob)
|
||||
}
|
||||
if err := st.DB().QueryRowContext(ctx, `select raw_json_blob_id from thread_revisions where id = 1`).Scan(&revisionBlob); err != nil {
|
||||
t.Fatalf("read pruned revision: %v", err)
|
||||
}
|
||||
if revisionBlob != nil {
|
||||
t.Fatalf("revision blob=%v", revisionBlob)
|
||||
}
|
||||
var titleTokens, linkedRefs, modules, features string
|
||||
if err := st.DB().QueryRowContext(ctx, `select title_tokens_json, linked_refs_json, module_buckets_json, feature_json from thread_fingerprints where id = 1`).Scan(&titleTokens, &linkedRefs, &modules, &features); err != nil {
|
||||
t.Fatalf("read pruned fingerprint: %v", err)
|
||||
}
|
||||
if titleTokens != "[]" || linkedRefs != "[]" || modules != "[]" || features != "{}" {
|
||||
t.Fatalf("fingerprint title=%q refs=%q modules=%q features=%q", titleTokens, linkedRefs, modules, features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterHelperBranches(t *testing.T) {
|
||||
summaries := []ClusterSummary{
|
||||
{ID: 1, MemberCount: 1, UpdatedAt: "2026-04-30T01:00:00Z"},
|
||||
@ -267,6 +331,23 @@ func TestClusterHelperBranches(t *testing.T) {
|
||||
if summaries[0].ID != 1 {
|
||||
t.Fatalf("recent sort = %+v", summaries)
|
||||
}
|
||||
summaries = []ClusterSummary{
|
||||
{ID: 3, MemberCount: 2, UpdatedAt: "2026-04-30T01:00:00Z"},
|
||||
{ID: 2, MemberCount: 2, UpdatedAt: "2026-04-30T01:00:00Z"},
|
||||
{ID: 1, MemberCount: 3, UpdatedAt: "2026-04-30T00:00:00Z"},
|
||||
}
|
||||
sortClusterSummaries(summaries, "size")
|
||||
if summaries[0].ID != 1 || summaries[1].ID != 2 {
|
||||
t.Fatalf("size tie sort = %+v", summaries)
|
||||
}
|
||||
sortClusterSummaries(summaries, "oldest")
|
||||
if summaries[0].ID != 1 || summaries[1].ID != 2 {
|
||||
t.Fatalf("oldest tie sort = %+v", summaries)
|
||||
}
|
||||
sortClusterSummaries(summaries, "recent")
|
||||
if summaries[0].ID != 2 || summaries[1].ID != 3 {
|
||||
t.Fatalf("recent tie sort = %+v", summaries)
|
||||
}
|
||||
if ids := parseIDSet(`1, 2, 0, bad, 3`); len(ids) != 3 || !ids[2] {
|
||||
t.Fatalf("parse id set = %+v", ids)
|
||||
}
|
||||
@ -276,6 +357,15 @@ func TestClusterHelperBranches(t *testing.T) {
|
||||
if got := snippetRunes("abcdef", 3); got != "abc" {
|
||||
t.Fatalf("snippet = %q", got)
|
||||
}
|
||||
if got := rowsAffected(errorResult{}); got != 0 {
|
||||
t.Fatalf("error rows affected = %d", got)
|
||||
}
|
||||
if got := nullString(""); got.Valid {
|
||||
t.Fatalf("empty null string = %+v", got)
|
||||
}
|
||||
if got := nullString("x"); !got.Valid || got.String != "x" {
|
||||
t.Fatalf("non-empty null string = %+v", got)
|
||||
}
|
||||
if func() (panicked bool) {
|
||||
defer func() { panicked = recover() != nil }()
|
||||
_ = sqliteIdentifier(`bad"name`)
|
||||
@ -756,6 +846,16 @@ func TestPortableVacuumAndVectorQueryBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type errorResult struct{}
|
||||
|
||||
func (errorResult) LastInsertId() (int64, error) {
|
||||
return 0, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (errorResult) RowsAffected() (int64, error) {
|
||||
return 0, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func seedVectorThreads(t *testing.T, ctx context.Context, st *Store) (int64, []int64) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
87
internal/store/pull_requests_test.go
Normal file
87
internal/store/pull_requests_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPullRequestCacheRoundTripAndWorkflowFilters(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, threadIDs := seedVectorThreads(t, ctx, st)
|
||||
threadID := threadIDs[1]
|
||||
fetchedAt := "2026-05-05T10:00:00Z"
|
||||
|
||||
detail := PullRequestDetail{
|
||||
ThreadID: threadID, RepoID: repoID, Number: 302,
|
||||
BaseSHA: "base", HeadSHA: "head", HeadRef: "feature/cache", HeadRepoFullName: "openclaw/gitcrawl-fork",
|
||||
MergeableState: "clean", Additions: 12, Deletions: 3, ChangedFiles: 2,
|
||||
RawJSON: "{}", FetchedAt: fetchedAt, UpdatedAt: "2026-05-05T09:59:00Z",
|
||||
}
|
||||
files := []PullRequestFile{
|
||||
{Path: "z.go", Status: "modified", Additions: 2, Deletions: 1, Changes: 3, Patch: "@@", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
{Path: "a.go", Status: "renamed", Additions: 10, Changes: 10, PreviousPath: "old.go", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
}
|
||||
commits := []PullRequestCommit{
|
||||
{SHA: "abc", Message: "feat: cache", AuthorLogin: "alice", AuthorName: "Alice", CommittedAt: "2026-05-05T08:00:00Z", HTMLURL: "https://example.invalid/commit/abc", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
}
|
||||
checks := []PullRequestCheck{
|
||||
{Name: "z-check", Status: "completed", Conclusion: "success", DetailsURL: "https://example.invalid/z", WorkflowName: "CI", StartedAt: "2026-05-05T09:00:00Z", CompletedAt: "2026-05-05T09:05:00Z", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
{Name: "a-check", Status: "queued", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
}
|
||||
runs := []WorkflowRun{
|
||||
{RepoID: repoID, RunID: "100", RunNumber: 7, HeadBranch: "main", HeadSHA: "head", Status: "completed", Conclusion: "success", WorkflowName: "CI", Event: "push", HTMLURL: "https://example.invalid/run/100", CreatedAtGH: "2026-05-05T09:00:00Z", UpdatedAtGH: "2026-05-05T09:05:00Z", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
{RepoID: repoID, RunID: "101", RunNumber: 8, HeadBranch: "release", HeadSHA: "other", Status: "in_progress", WorkflowName: "release", Event: "workflow_dispatch", CreatedAtGH: "2026-05-05T09:10:00Z", UpdatedAtGH: "2026-05-05T09:11:00Z", RawJSON: "{}", FetchedAt: fetchedAt},
|
||||
}
|
||||
if err := st.UpsertPullRequestCache(ctx, detail, files, commits, checks, runs); err != nil {
|
||||
t.Fatalf("upsert pr cache: %v", err)
|
||||
}
|
||||
cache, err := st.PullRequestCache(ctx, repoID, 302)
|
||||
if err != nil {
|
||||
t.Fatalf("pull request cache: %v", err)
|
||||
}
|
||||
if cache.Detail.HeadSHA != "head" || cache.Detail.MergeableState != "clean" {
|
||||
t.Fatalf("detail = %+v", cache.Detail)
|
||||
}
|
||||
if len(cache.Files) != 2 || cache.Files[0].Path != "a.go" || cache.Files[0].PreviousPath != "old.go" {
|
||||
t.Fatalf("files = %+v", cache.Files)
|
||||
}
|
||||
if len(cache.Commits) != 1 || cache.Commits[0].SHA != "abc" || cache.Commits[0].AuthorName != "Alice" {
|
||||
t.Fatalf("commits = %+v", cache.Commits)
|
||||
}
|
||||
if len(cache.Checks) != 2 || cache.Checks[0].Name != "a-check" || cache.Checks[1].Conclusion != "success" {
|
||||
t.Fatalf("checks = %+v", cache.Checks)
|
||||
}
|
||||
|
||||
mainRuns, err := st.ListWorkflowRuns(ctx, repoID, WorkflowRunListOptions{Branch: "main", HeadSHA: "head", Limit: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("list filtered runs: %v", err)
|
||||
}
|
||||
if len(mainRuns) != 1 || mainRuns[0].RunID != "100" || mainRuns[0].HTMLURL == "" {
|
||||
t.Fatalf("main runs = %+v", mainRuns)
|
||||
}
|
||||
allRuns, err := st.ListWorkflowRuns(ctx, repoID, WorkflowRunListOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("list default runs: %v", err)
|
||||
}
|
||||
if len(allRuns) != 2 || allRuns[0].RunID != "101" {
|
||||
t.Fatalf("all runs = %+v", allRuns)
|
||||
}
|
||||
|
||||
detail.HeadSHA = "head-v2"
|
||||
if err := st.UpsertPullRequestCache(ctx, detail, files[:1], nil, nil, []WorkflowRun{{RepoID: repoID, RunID: "100", RunNumber: 9, HeadBranch: "main", HeadSHA: "head-v2", Status: "completed", Conclusion: "failure", UpdatedAtGH: "2026-05-05T10:00:00Z", RawJSON: "{}", FetchedAt: fetchedAt}}); err != nil {
|
||||
t.Fatalf("update pr cache: %v", err)
|
||||
}
|
||||
cache, err = st.PullRequestCache(ctx, repoID, 302)
|
||||
if err != nil {
|
||||
t.Fatalf("updated pull request cache: %v", err)
|
||||
}
|
||||
if cache.Detail.HeadSHA != "head-v2" || len(cache.Files) != 1 || len(cache.Commits) != 0 || len(cache.Checks) != 0 {
|
||||
t.Fatalf("updated cache = %+v", cache)
|
||||
}
|
||||
}
|
||||
@ -644,3 +644,40 @@ func TestMappingHelperBranches(t *testing.T) {
|
||||
t.Fatalf("comment = %+v", comment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingFallbackBranches(t *testing.T) {
|
||||
now := time.Date(2026, 5, 5, 12, 0, 0, 123, time.UTC)
|
||||
normalized, err := normalizeSince("2026-05-05T12:00:00+02:00", now)
|
||||
if err != nil {
|
||||
t.Fatalf("normalize iso since: %v", err)
|
||||
}
|
||||
if normalized != "2026-05-05T10:00:00Z" {
|
||||
t.Fatalf("normalized iso since = %q", normalized)
|
||||
}
|
||||
if got, err := normalizeSince("2w", now); err != nil || got != "2026-04-21T12:00:00.000000123Z" {
|
||||
t.Fatalf("normalize weeks = %q, %v", got, err)
|
||||
}
|
||||
if got := mustJSON(map[string]any{"bad": make(chan int)}); got != "{}" {
|
||||
t.Fatalf("mustJSON marshal fallback = %q", got)
|
||||
}
|
||||
|
||||
thread := mapIssueToThread(99, map[string]any{
|
||||
"id": int64(123),
|
||||
"number": 456,
|
||||
"state": "closed",
|
||||
"title": "fallbacks",
|
||||
"body": "body",
|
||||
"html_url": "https://github.com/openclaw/gitcrawl/issues/456",
|
||||
"labels": nil,
|
||||
"assignees": nil,
|
||||
"created_at": "2026-05-05T10:00:00Z",
|
||||
"updated_at": "2026-05-05T11:00:00Z",
|
||||
"closed_at": "2026-05-05T12:00:00Z",
|
||||
}, "2026-05-05T12:00:00Z")
|
||||
if thread.LabelsJSON != "[]" || thread.AssigneesJSON != "[]" {
|
||||
t.Fatalf("nullable label defaults: labels=%s assignees=%s", thread.LabelsJSON, thread.AssigneesJSON)
|
||||
}
|
||||
if thread.GitHubID != "123" || thread.Number != 456 || thread.AuthorLogin != "" || thread.ClosedAtGitHub == "" {
|
||||
t.Fatalf("thread = %+v", thread)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user