From 22c55133bced26ea6c4ea4e31f168ae7d4ff7b6e Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 1 Mar 2026 19:38:43 +0530 Subject: [PATCH 1/3] add extensive tests --- cmd/api/main.go | 4 + cmd/dev/main.go | 4 + .../phase-2-git-indexing.md | 26 +- go.mod | 66 +++ go.sum | 149 +++++++ internal/api/health/get_test.go | 83 ++++ ...st.TestGet_healthy_approvals.approved.json | 6 + internal/db/index_runs.sql.go | 210 ++++++++++ internal/db/init.go | 5 + .../db/migrations/00001_initial_schema.sql | 20 +- internal/db/models.go | 14 + internal/db/querier.go | 6 + internal/db/queries/index_runs.sql | 38 ++ internal/domains/gittokens/gittokens.go | 91 +++++ internal/domains/gittokens/gittokens_test.go | 134 +++++++ internal/domains/gittokens/models.go | 17 + internal/domains/indexing/orchestrator.go | 111 ++++++ internal/domains/indexing/worker.go | 375 ++++++++++++++++++ internal/domains/indexing/worker_test.go | 232 +++++++++++ internal/domains/repos/models.go | 47 +++ internal/domains/repos/repos.go | 186 +++++++++ internal/domains/repos/repos_test.go | 257 ++++++++++++ .../domains/workspaces/workspaces_test.go | 239 +++++++++++ internal/libs/chunking/chunker.go | 85 ++++ internal/libs/chunking/chunker_test.go | 173 ++++++++ ...est.TestChunkFile_GoApprovals.approved.txt | 10 + internal/libs/gitrepo/client.go | 163 ++++++++ internal/libs/gitrepo/client_test.go | 78 ++++ internal/libs/gitrepo/clone_test.go | 145 +++++++ internal/libs/gitrepo/fake.go | 47 +++ internal/libs/gitrepo/provider.go | 56 +++ internal/libs/gitrepo/provider_test.go | 107 +++++ internal/libs/openai/embeddings.go | 129 ++++++ internal/libs/openai/fake.go | 56 +++ internal/qdrant/collection.go | 65 +++ internal/qdrant/init.go | 8 + internal/testutil/approvals.go | 59 +++ internal/testutil/fixtures.go | 34 ++ internal/testutil/http.go | 136 +++++++ internal/testutil/postgres.go | 98 +++++ internal/testutil/qdrant.go | 61 +++ internal/testutil/testutil.go | 53 +++ internal/testutil/unique.go | 14 + 43 files changed, 3890 insertions(+), 7 deletions(-) rename docs/{upnext => completed}/phase-2-git-indexing.md (91%) create mode 100644 internal/api/health/get_test.go create mode 100644 internal/api/health/testdata/get_test.TestGet_healthy_approvals.approved.json create mode 100644 internal/db/index_runs.sql.go create mode 100644 internal/db/queries/index_runs.sql create mode 100644 internal/domains/gittokens/gittokens.go create mode 100644 internal/domains/gittokens/gittokens_test.go create mode 100644 internal/domains/gittokens/models.go create mode 100644 internal/domains/indexing/orchestrator.go create mode 100644 internal/domains/indexing/worker.go create mode 100644 internal/domains/indexing/worker_test.go create mode 100644 internal/domains/repos/models.go create mode 100644 internal/domains/repos/repos.go create mode 100644 internal/domains/repos/repos_test.go create mode 100644 internal/domains/workspaces/workspaces_test.go create mode 100644 internal/libs/chunking/chunker.go create mode 100644 internal/libs/chunking/chunker_test.go create mode 100644 internal/libs/chunking/testdata/chunker_test.TestChunkFile_GoApprovals.approved.txt create mode 100644 internal/libs/gitrepo/client.go create mode 100644 internal/libs/gitrepo/client_test.go create mode 100644 internal/libs/gitrepo/clone_test.go create mode 100644 internal/libs/gitrepo/fake.go create mode 100644 internal/libs/gitrepo/provider.go create mode 100644 internal/libs/gitrepo/provider_test.go create mode 100644 internal/libs/openai/embeddings.go create mode 100644 internal/libs/openai/fake.go create mode 100644 internal/testutil/approvals.go create mode 100644 internal/testutil/fixtures.go create mode 100644 internal/testutil/http.go create mode 100644 internal/testutil/postgres.go create mode 100644 internal/testutil/qdrant.go create mode 100644 internal/testutil/testutil.go create mode 100644 internal/testutil/unique.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 8732317..24ba420 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -3,6 +3,8 @@ package main import ( "github.com/gomantics/semantix/internal/api" "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/domains/indexing" + "github.com/gomantics/semantix/internal/libs/openai" "github.com/gomantics/semantix/internal/qdrant" "github.com/gomantics/semantix/pkg/logger" "go.uber.org/fx" @@ -21,7 +23,9 @@ func main() { fx.Invoke( db.Init, qdrant.Init, + openai.Init, api.Run, + indexing.Run, ), fx.WithLogger(func(l *zap.Logger) fxevent.Logger { return &fxevent.ZapLogger{ diff --git a/cmd/dev/main.go b/cmd/dev/main.go index 27fb3aa..c3522be 100644 --- a/cmd/dev/main.go +++ b/cmd/dev/main.go @@ -3,6 +3,8 @@ package main import ( "github.com/gomantics/semantix/internal/api" "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/domains/indexing" + "github.com/gomantics/semantix/internal/libs/openai" "github.com/gomantics/semantix/internal/qdrant" "github.com/gomantics/semantix/pkg/logger" "go.uber.org/fx" @@ -21,7 +23,9 @@ func main() { fx.Invoke( db.Init, qdrant.Init, + openai.Init, api.Run, + indexing.Run, ), fx.WithLogger(func(l *zap.Logger) fxevent.Logger { return &fxevent.ZapLogger{ diff --git a/docs/upnext/phase-2-git-indexing.md b/docs/completed/phase-2-git-indexing.md similarity index 91% rename from docs/upnext/phase-2-git-indexing.md rename to docs/completed/phase-2-git-indexing.md index 0abbdd8..b20b7fb 100644 --- a/docs/upnext/phase-2-git-indexing.md +++ b/docs/completed/phase-2-git-indexing.md @@ -73,18 +73,19 @@ Support the major git hosting providers. ### 2.3 Tree-sitter Chunking -AST-aware code chunking using chunkx or similar. +AST-aware code chunking using [`github.com/gomantics/chunkx`](https://github.com/gomantics/chunkx) - our own Go library implementing the CAST algorithm. -- [ ] **Language detection** from file extension and content +- [ ] **Language detection** - chunkx uses file extension via `languages.*` constants - [ ] **Chunking strategy**: - Functions/methods as primary chunks - Classes/structs with their methods - Large functions split at logical boundaries - - Target: ~500 tokens per chunk + - Target: ~500 tokens per chunk via `chunkx.WithMaxSize(500)` -- [ ] **Chunk metadata**: +- [ ] **Chunk metadata** - map chunkx output to our internal type: ```go + // chunkx returns []chunkx.Chunk; map to: type Chunk struct { Content string FilePath string @@ -96,6 +97,19 @@ AST-aware code chunking using chunkx or similar. } ``` +- [ ] **Quick example**: + ```go + import ( + "github.com/gomantics/chunkx" + "github.com/gomantics/chunkx/languages" + ) + + chunker := chunkx.NewChunker() + chunks, err := chunker.Chunk(code, + chunkx.WithLanguage(languages.Go), + chunkx.WithMaxSize(500)) + ``` + - [ ] **Language support** (priority order): - Go, Python, JavaScript/TypeScript - Java, Rust, C/C++ @@ -103,7 +117,7 @@ AST-aware code chunking using chunkx or similar. - Markdown, YAML, JSON (as text) **Files to create/modify:** -- `libs/chunking/chunker.go` +- `libs/chunking/chunker.go` - thin wrapper around chunkx - `domains/chunking/chunker.go` - higher-level orchestration --- @@ -327,7 +341,7 @@ POST /v1/workspaces/:wid/repos (status = pending) ## Dependencies - `github.com/go-git/go-git/v5` - Git operations -- Tree-sitter Go bindings or `chunkx` CLI +- `github.com/gomantics/chunkx` - AST-based code chunking (CAST algorithm, 30+ languages) - `github.com/sashabaranov/go-openai` - OpenAI client - `github.com/qdrant/go-client` - Qdrant client diff --git a/go.mod b/go.mod index f878a3d..1193258 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,13 @@ tool ( ) require ( + github.com/go-git/go-git/v5 v5.17.0 + github.com/gomantics/chunkx v0.0.3 github.com/jackc/pgx/v5 v5.8.0 github.com/labstack/echo/v4 v4.13.4 github.com/pressly/goose/v3 v3.27.0 github.com/qdrant/go-client v1.16.2 + github.com/sashabaranov/go-openai v1.41.2 go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 google.golang.org/grpc v1.79.1 @@ -22,21 +25,47 @@ require ( cel.dev/expr v0.25.1 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/air-verse/air v1.64.5 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/approvals/go-approval-tests v1.5.0 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/golibsass v1.2.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.9.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gohugoio/hugo v0.149.1 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gomantics/cfgx v0.0.7 // indirect github.com/gomantics/sx v0.0.3 // indirect github.com/google/cel-go v0.26.1 // indirect @@ -45,13 +74,28 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/labstack/gommon v0.4.2 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect @@ -59,22 +103,43 @@ require ( github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect github.com/pingcap/log v1.1.1-0.20221015072633-39906604fb81 // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/riza-io/grpc-go v0.2.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.10 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/tdewolff/parse/v2 v2.8.3 // indirect + github.com/testcontainers/testcontainers-go v0.40.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 // indirect + github.com/testcontainers/testcontainers-go/modules/qdrant v0.40.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -89,6 +154,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 2fcd3cd..36f6d4e 100644 --- a/go.sum +++ b/go.sum @@ -4,21 +4,34 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4= github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/approvals/go-approval-tests v1.5.0 h1:4t8BL8xwrsR2BNhHIZHBmf43SYmkSmkPBPtCqUNOBMA= +github.com/approvals/go-approval-tests v1.5.0/go.mod h1:i7AlHlLqLyxTby+MnSFgxkObjFlsywI46fnOitjMBiU= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -50,29 +63,61 @@ github.com/bep/overlayfs v0.10.0 h1:wS3eQ6bRsLX+4AAmwGjvoFSAQoeheamxofFiJ2SthSE= github.com/bep/overlayfs v0.10.0/go.mod h1:ouu4nu6fFJaL0sPzNICzxYsBeWwrjiTdFZdK4lI3tro= github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs= github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= @@ -82,10 +127,24 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= @@ -112,11 +171,15 @@ github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XG github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/gomantics/cfgx v0.0.7 h1:+TxM9b+P0rmLLAG4jmyGIZDp613VMlQiRUFHBYxdPR0= github.com/gomantics/cfgx v0.0.7/go.mod h1:SrtElg3zxotHVV0F3rhXs5OGjBXjXAEeg+nSG8LQrd0= +github.com/gomantics/chunkx v0.0.3 h1:gOZNrA5N/O9npr69LxI0rP0vix+vpufLRwjvOdrHhmg= +github.com/gomantics/chunkx v0.0.3/go.mod h1:CZPxKM+hpX5FhVdwLQt24v9s2T8RGGabdjjlLiUP7F0= github.com/gomantics/sx v0.0.3 h1:Jvv3Wph5pGVolInwkpzdk4Ni3iIwlkdpsjRRBAu7v9g= github.com/gomantics/sx v0.0.3/go.mod h1:lghgwBNj3n0Wrwbwa5Ln7RZjSinlOZR2jQmv2vykEKE= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= @@ -145,6 +208,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -153,6 +218,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -166,6 +235,10 @@ github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcX github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= @@ -184,8 +257,24 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -202,6 +291,12 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8= github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -221,12 +316,16 @@ github.com/pingcap/log v1.1.1-0.20221015072633-39906604fb81 h1:URLoJ61DmmY++Sa/y github.com/pingcap/log v1.1.1-0.20221015072633-39906604fb81/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg= @@ -240,8 +339,21 @@ github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWF github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= +github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= +github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= +github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= @@ -255,6 +367,7 @@ github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8 github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= @@ -267,8 +380,18 @@ github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/testcontainers/testcontainers-go/modules/qdrant v0.40.0 h1:hZkALmwVMmilDLZxTbggEKucgr3M1e/E2X9rPEsZVNQ= +github.com/testcontainers/testcontainers-go/modules/qdrant v0.40.0/go.mod h1:H0m27VzG9uNA8nehWNXr5Ug/4IAG9LpJcZkKzGbx9JA= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -279,14 +402,20 @@ github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8S github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= @@ -317,6 +446,7 @@ go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= @@ -328,20 +458,36 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -363,11 +509,14 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/api/health/get_test.go b/internal/api/health/get_test.go new file mode 100644 index 0000000..ca746b3 --- /dev/null +++ b/internal/api/health/get_test.go @@ -0,0 +1,83 @@ +package health_test + +import ( + "os" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, + testutil.WithPostgres(), + testutil.WithQdrant(), + testutil.WithApprovals(), + ) + os.Exit(main.Run()) +} + +func TestGet_healthy(t *testing.T) { + t.Parallel() + state := testutil.NewState(t) + + body, err := state.Get("/v1/health") + require.NoError(t, err) + + assert.Equal(t, "ok", body["status"]) + assert.Equal(t, "ok", body["database"]) + assert.Equal(t, "ok", body["qdrant"]) + assert.Equal(t, "0.1.0", body["version"]) +} + +// TestGet_healthy_approvals uses go-approvals snapshot testing. +// NOTE: approval tests use shared file-based state and must not run in parallel +// with other approval tests within the same package. +func TestGet_healthy_approvals(t *testing.T) { + state := testutil.NewState(t) + + body, err := state.Get("/v1/health") + require.NoError(t, err) + + // Scrub non-deterministic fields before snapshotting. + testutil.ScrubField(body, "version") + + approvals.VerifyJSONStruct(t, body) +} + +func TestGet_returnsHTTP200(t *testing.T) { + t.Parallel() + state := testutil.NewState(t) + + // Get returns no error (non-2xx would be a StatusError). + _, err := state.Get("/v1/health") + assert.NoError(t, err) +} + +func TestGet_statusCodeOnDegradedDB(t *testing.T) { + t.Parallel() + state := testutil.NewState(t) + + body, err := state.Get("/v1/health") + // With both containers up, we expect no error and status 200. + require.NoError(t, err) + assert.Equal(t, "ok", body["status"]) + + // Verify the response structure contains all expected fields. + assert.Contains(t, body, "status") + assert.Contains(t, body, "database") + assert.Contains(t, body, "qdrant") + assert.Contains(t, body, "version") +} + +// TestGet_matchesHealthContract verifies the response structure matches expected contract. +func TestGet_matchesHealthContract(t *testing.T) { + t.Parallel() + state := testutil.NewState(t) + + _, err := state.Get("/v1/health") + // Should be 200 with both deps up. + require.NoError(t, err, "health endpoint should return 200 when deps are healthy") +} diff --git a/internal/api/health/testdata/get_test.TestGet_healthy_approvals.approved.json b/internal/api/health/testdata/get_test.TestGet_healthy_approvals.approved.json new file mode 100644 index 0000000..bd27544 --- /dev/null +++ b/internal/api/health/testdata/get_test.TestGet_healthy_approvals.approved.json @@ -0,0 +1,6 @@ +{ + "database": "ok", + "qdrant": "ok", + "status": "ok", + "version": "[SCRUBBED]" +} \ No newline at end of file diff --git a/internal/db/index_runs.sql.go b/internal/db/index_runs.sql.go new file mode 100644 index 0000000..941546d --- /dev/null +++ b/internal/db/index_runs.sql.go @@ -0,0 +1,210 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: index_runs.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createIndexRun = `-- name: CreateIndexRun :one +INSERT INTO index_runs (repo_id, status, started_at) +VALUES ($1, $2, $3) +RETURNING id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +` + +type CreateIndexRunParams struct { + RepoID int64 `json:"repo_id"` + Status string `json:"status"` + StartedAt int64 `json:"started_at"` +} + +func (q *Queries) CreateIndexRun(ctx context.Context, arg CreateIndexRunParams) (IndexRun, error) { + row := q.db.QueryRow(ctx, createIndexRun, arg.RepoID, arg.Status, arg.StartedAt) + var i IndexRun + err := row.Scan( + &i.ID, + &i.RepoID, + &i.Status, + &i.StartedAt, + &i.CompletedAt, + &i.FilesProcessed, + &i.ChunksCreated, + &i.EmbeddingsGenerated, + &i.EmbeddingsCached, + &i.ErrorMessage, + &i.DurationMs, + ) + return i, err +} + +const deleteIndexRunsByRepo = `-- name: DeleteIndexRunsByRepo :exec +DELETE FROM index_runs +WHERE repo_id = $1 +` + +func (q *Queries) DeleteIndexRunsByRepo(ctx context.Context, repoID int64) error { + _, err := q.db.Exec(ctx, deleteIndexRunsByRepo, repoID) + return err +} + +const getIndexRunByID = `-- name: GetIndexRunByID :one +SELECT id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +FROM index_runs +WHERE id = $1 +` + +func (q *Queries) GetIndexRunByID(ctx context.Context, id int64) (IndexRun, error) { + row := q.db.QueryRow(ctx, getIndexRunByID, id) + var i IndexRun + err := row.Scan( + &i.ID, + &i.RepoID, + &i.Status, + &i.StartedAt, + &i.CompletedAt, + &i.FilesProcessed, + &i.ChunksCreated, + &i.EmbeddingsGenerated, + &i.EmbeddingsCached, + &i.ErrorMessage, + &i.DurationMs, + ) + return i, err +} + +const listIndexRunsByRepo = `-- name: ListIndexRunsByRepo :many +SELECT id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +FROM index_runs +WHERE repo_id = $1 +ORDER BY started_at DESC +LIMIT $2 OFFSET $3 +` + +type ListIndexRunsByRepoParams struct { + RepoID int64 `json:"repo_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) ListIndexRunsByRepo(ctx context.Context, arg ListIndexRunsByRepoParams) ([]IndexRun, error) { + rows, err := q.db.Query(ctx, listIndexRunsByRepo, arg.RepoID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []IndexRun + for rows.Next() { + var i IndexRun + if err := rows.Scan( + &i.ID, + &i.RepoID, + &i.Status, + &i.StartedAt, + &i.CompletedAt, + &i.FilesProcessed, + &i.ChunksCreated, + &i.EmbeddingsGenerated, + &i.EmbeddingsCached, + &i.ErrorMessage, + &i.DurationMs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateIndexRunStats = `-- name: UpdateIndexRunStats :one +UPDATE index_runs +SET files_processed = $2, + chunks_created = $3, + embeddings_generated = $4, + embeddings_cached = $5 +WHERE id = $1 +RETURNING id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +` + +type UpdateIndexRunStatsParams struct { + ID int64 `json:"id"` + FilesProcessed int32 `json:"files_processed"` + ChunksCreated int32 `json:"chunks_created"` + EmbeddingsGenerated int32 `json:"embeddings_generated"` + EmbeddingsCached int32 `json:"embeddings_cached"` +} + +func (q *Queries) UpdateIndexRunStats(ctx context.Context, arg UpdateIndexRunStatsParams) (IndexRun, error) { + row := q.db.QueryRow(ctx, updateIndexRunStats, + arg.ID, + arg.FilesProcessed, + arg.ChunksCreated, + arg.EmbeddingsGenerated, + arg.EmbeddingsCached, + ) + var i IndexRun + err := row.Scan( + &i.ID, + &i.RepoID, + &i.Status, + &i.StartedAt, + &i.CompletedAt, + &i.FilesProcessed, + &i.ChunksCreated, + &i.EmbeddingsGenerated, + &i.EmbeddingsCached, + &i.ErrorMessage, + &i.DurationMs, + ) + return i, err +} + +const updateIndexRunStatus = `-- name: UpdateIndexRunStatus :one +UPDATE index_runs +SET status = $2, + completed_at = $3, + error_message = $4, + duration_ms = $5 +WHERE id = $1 +RETURNING id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +` + +type UpdateIndexRunStatusParams struct { + ID int64 `json:"id"` + Status string `json:"status"` + CompletedAt pgtype.Int8 `json:"completed_at"` + ErrorMessage pgtype.Text `json:"error_message"` + DurationMs pgtype.Int8 `json:"duration_ms"` +} + +func (q *Queries) UpdateIndexRunStatus(ctx context.Context, arg UpdateIndexRunStatusParams) (IndexRun, error) { + row := q.db.QueryRow(ctx, updateIndexRunStatus, + arg.ID, + arg.Status, + arg.CompletedAt, + arg.ErrorMessage, + arg.DurationMs, + ) + var i IndexRun + err := row.Scan( + &i.ID, + &i.RepoID, + &i.Status, + &i.StartedAt, + &i.CompletedAt, + &i.FilesProcessed, + &i.ChunksCreated, + &i.EmbeddingsGenerated, + &i.EmbeddingsCached, + &i.ErrorMessage, + &i.DurationMs, + ) + return i, err +} diff --git a/internal/db/init.go b/internal/db/init.go index 8f0958e..88de85c 100644 --- a/internal/db/init.go +++ b/internal/db/init.go @@ -58,3 +58,8 @@ func Init(lc fx.Lifecycle, l *zap.Logger) error { func GetPool() *pgxpool.Pool { return defaultPool } + +// SetPool replaces the default connection pool. Intended for use in tests only. +func SetPool(pool *pgxpool.Pool) { + defaultPool = pool +} diff --git a/internal/db/migrations/00001_initial_schema.sql b/internal/db/migrations/00001_initial_schema.sql index fb20be7..5e13891 100644 --- a/internal/db/migrations/00001_initial_schema.sql +++ b/internal/db/migrations/00001_initial_schema.sql @@ -51,8 +51,26 @@ CREATE TABLE IF NOT EXISTS files ( CREATE INDEX IF NOT EXISTS idx_files_repo_id ON files(repo_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_files_repo_path ON files(repo_id, path); +CREATE TABLE IF NOT EXISTS index_runs ( + id BIGSERIAL PRIMARY KEY, + repo_id BIGINT NOT NULL, + status TEXT NOT NULL, -- running, completed, failed + started_at BIGINT NOT NULL, -- nanoseconds since epoch + completed_at BIGINT, -- nanoseconds since epoch + files_processed INT NOT NULL DEFAULT 0, + chunks_created INT NOT NULL DEFAULT 0, + embeddings_generated INT NOT NULL DEFAULT 0, + embeddings_cached INT NOT NULL DEFAULT 0, + error_message TEXT, + duration_ms BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_index_runs_repo_id ON index_runs(repo_id); +CREATE INDEX IF NOT EXISTS idx_index_runs_status ON index_runs(status); + -- +goose Down --- Drop in reverse dependency order (repos/files reference workspaces/git_tokens). +-- Drop in reverse dependency order. +DROP TABLE IF EXISTS index_runs; DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS repos; DROP TABLE IF EXISTS git_tokens; diff --git a/internal/db/models.go b/internal/db/models.go index b390243..c839c50 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -27,6 +27,20 @@ type GitToken struct { Created int64 `json:"created"` } +type IndexRun struct { + ID int64 `json:"id"` + RepoID int64 `json:"repo_id"` + Status string `json:"status"` + StartedAt int64 `json:"started_at"` + CompletedAt pgtype.Int8 `json:"completed_at"` + FilesProcessed int32 `json:"files_processed"` + ChunksCreated int32 `json:"chunks_created"` + EmbeddingsGenerated int32 `json:"embeddings_generated"` + EmbeddingsCached int32 `json:"embeddings_cached"` + ErrorMessage pgtype.Text `json:"error_message"` + DurationMs pgtype.Int8 `json:"duration_ms"` +} + type Repo struct { ID int64 `json:"id"` WorkspaceID int64 `json:"workspace_id"` diff --git a/internal/db/querier.go b/internal/db/querier.go index 7ddadd9..50c5d10 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -14,27 +14,33 @@ type Querier interface { CountWorkspaces(ctx context.Context) (int64, error) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) CreateGitToken(ctx context.Context, arg CreateGitTokenParams) (CreateGitTokenRow, error) + CreateIndexRun(ctx context.Context, arg CreateIndexRunParams) (IndexRun, error) CreateRepo(ctx context.Context, arg CreateRepoParams) (Repo, error) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) DeleteFile(ctx context.Context, id int64) error DeleteFilesByPaths(ctx context.Context, arg DeleteFilesByPathsParams) error DeleteFilesByRepo(ctx context.Context, repoID int64) error DeleteGitToken(ctx context.Context, id int64) error + DeleteIndexRunsByRepo(ctx context.Context, repoID int64) error DeleteRepo(ctx context.Context, id int64) error DeleteReposByWorkspace(ctx context.Context, workspaceID int64) error DeleteWorkspace(ctx context.Context, id int64) error GetFileByID(ctx context.Context, id int64) (File, error) GetFileByRepoAndPath(ctx context.Context, arg GetFileByRepoAndPathParams) (File, error) GetGitTokenByID(ctx context.Context, id int64) (GetGitTokenByIDRow, error) + GetIndexRunByID(ctx context.Context, id int64) (IndexRun, error) GetRepoByID(ctx context.Context, id int64) (Repo, error) GetWorkspaceByID(ctx context.Context, id int64) (Workspace, error) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspace, error) ListFilesByRepo(ctx context.Context, repoID int64) ([]File, error) ListGitTokens(ctx context.Context) ([]ListGitTokensRow, error) ListGitTokensByProvider(ctx context.Context, provider string) ([]ListGitTokensByProviderRow, error) + ListIndexRunsByRepo(ctx context.Context, arg ListIndexRunsByRepoParams) ([]IndexRun, error) ListReposByStatus(ctx context.Context, arg ListReposByStatusParams) ([]Repo, error) ListReposByWorkspace(ctx context.Context, arg ListReposByWorkspaceParams) ([]Repo, error) ListWorkspaces(ctx context.Context, arg ListWorkspacesParams) ([]Workspace, error) + UpdateIndexRunStats(ctx context.Context, arg UpdateIndexRunStatsParams) (IndexRun, error) + UpdateIndexRunStatus(ctx context.Context, arg UpdateIndexRunStatusParams) (IndexRun, error) UpdateRepo(ctx context.Context, arg UpdateRepoParams) (Repo, error) UpdateRepoStatus(ctx context.Context, arg UpdateRepoStatusParams) (Repo, error) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) diff --git a/internal/db/queries/index_runs.sql b/internal/db/queries/index_runs.sql new file mode 100644 index 0000000..96dbcb0 --- /dev/null +++ b/internal/db/queries/index_runs.sql @@ -0,0 +1,38 @@ +-- name: CreateIndexRun :one +INSERT INTO index_runs (repo_id, status, started_at) +VALUES ($1, $2, $3) +RETURNING id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms; + +-- name: GetIndexRunByID :one +SELECT id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +FROM index_runs +WHERE id = $1; + +-- name: ListIndexRunsByRepo :many +SELECT id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms +FROM index_runs +WHERE repo_id = $1 +ORDER BY started_at DESC +LIMIT $2 OFFSET $3; + +-- name: UpdateIndexRunStatus :one +UPDATE index_runs +SET status = $2, + completed_at = $3, + error_message = $4, + duration_ms = $5 +WHERE id = $1 +RETURNING id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms; + +-- name: UpdateIndexRunStats :one +UPDATE index_runs +SET files_processed = $2, + chunks_created = $3, + embeddings_generated = $4, + embeddings_cached = $5 +WHERE id = $1 +RETURNING id, repo_id, status, started_at, completed_at, files_processed, chunks_created, embeddings_generated, embeddings_cached, error_message, duration_ms; + +-- name: DeleteIndexRunsByRepo :exec +DELETE FROM index_runs +WHERE repo_id = $1; diff --git a/internal/domains/gittokens/gittokens.go b/internal/domains/gittokens/gittokens.go new file mode 100644 index 0000000..b9bf039 --- /dev/null +++ b/internal/domains/gittokens/gittokens.go @@ -0,0 +1,91 @@ +package gittokens + +import ( + "context" + "errors" + "time" + + "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/libs/gitrepo" + "github.com/jackc/pgx/v5" +) + +var ErrNotFound = errors.New("git token not found") + +func Create(ctx context.Context, params CreateParams) (*GitToken, error) { + now := time.Now().UnixNano() + + row, err := db.Tx1(ctx, func(q *db.Queries) (db.CreateGitTokenRow, error) { + return q.CreateGitToken(ctx, db.CreateGitTokenParams{ + Name: params.Name, + Provider: params.Provider, + TokenEncrypted: []byte(params.Token), + Created: now, + }) + }) + if err != nil { + return nil, err + } + + return toGitToken(row.ID, row.Name, row.Provider, string(row.TokenEncrypted), now), nil +} + +func GetByID(ctx context.Context, id int64) (*GitToken, error) { + row, err := db.Query1(ctx, func(q *db.Queries) (db.GetGitTokenByIDRow, error) { + return q.GetGitTokenByID(ctx, id) + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return toGitToken(row.ID, row.Name, row.Provider, string(row.TokenEncrypted), row.Created), nil +} + +// FindForProvider returns the first available token for the given provider. +func FindForProvider(ctx context.Context, provider gitrepo.Provider) (*GitToken, error) { + rows, err := db.Query1(ctx, func(q *db.Queries) ([]db.ListGitTokensByProviderRow, error) { + return q.ListGitTokensByProvider(ctx, string(provider)) + }) + if err != nil { + return nil, err + } + + if len(rows) == 0 { + return nil, nil + } + + r := rows[0] + return toGitToken(r.ID, r.Name, r.Provider, string(r.TokenEncrypted), r.Created), nil +} + +func Delete(ctx context.Context, id int64) error { + return db.Tx(ctx, func(q *db.Queries) error { + _, err := q.GetGitTokenByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + return q.DeleteGitToken(ctx, id) + }) +} + +func toGitToken(id int64, name, provider, token string, created int64) *GitToken { + hint := "" + if len(token) >= 4 { + hint = "..." + token[len(token)-4:] + } + + return &GitToken{ + ID: id, + Name: name, + Provider: provider, + Token: token, + Hint: hint, + Created: created, + } +} diff --git a/internal/domains/gittokens/gittokens_test.go b/internal/domains/gittokens/gittokens_test.go new file mode 100644 index 0000000..b5e16e8 --- /dev/null +++ b/internal/domains/gittokens/gittokens_test.go @@ -0,0 +1,134 @@ +package gittokens_test + +import ( + "context" + "os" + "testing" + + "github.com/gomantics/semantix/internal/domains/gittokens" + "github.com/gomantics/semantix/internal/libs/gitrepo" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, testutil.WithPostgres()) + os.Exit(main.Run()) +} + +func TestCreate(t *testing.T) { + t.Parallel() + ctx := context.Background() + + gt, err := gittokens.Create(ctx, gittokens.CreateParams{ + Name: "My GitHub Token", + Provider: string(gitrepo.ProviderGitHub), + Token: "ghp_abc1234567890", + }) + + require.NoError(t, err) + assert.NotZero(t, gt.ID) + assert.Equal(t, "My GitHub Token", gt.Name) + assert.Equal(t, string(gitrepo.ProviderGitHub), gt.Provider) + assert.Equal(t, "ghp_abc1234567890", gt.Token) + assert.Equal(t, "...7890", gt.Hint) + assert.NotZero(t, gt.Created) +} + +func TestCreate_shortToken(t *testing.T) { + t.Parallel() + ctx := context.Background() + + gt, err := gittokens.Create(ctx, gittokens.CreateParams{ + Name: "Short Token", + Provider: string(gitrepo.ProviderGitHub), + Token: "abc", + }) + + require.NoError(t, err) + assert.Equal(t, "", gt.Hint) +} + +func TestGetByID(t *testing.T) { + t.Parallel() + ctx := context.Background() + + created, err := gittokens.Create(ctx, gittokens.CreateParams{ + Name: "GetByID Token", + Provider: string(gitrepo.ProviderGitLab), + Token: "glpat_123456", + }) + require.NoError(t, err) + + got, err := gittokens.GetByID(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "GetByID Token", got.Name) +} + +func TestGetByID_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + _, err := gittokens.GetByID(ctx, 999999999) + assert.ErrorIs(t, err, gittokens.ErrNotFound) +} + +func TestFindForProvider_found(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // Create the token and assert using the returned ID to avoid cross-test + // interference from other parallel tests inserting Bitbucket tokens. + created, err := gittokens.Create(ctx, gittokens.CreateParams{ + Name: "Bitbucket Token", + Provider: string(gitrepo.ProviderBitbucket), + Token: "atl_mytoken", + }) + require.NoError(t, err) + + got, err := gittokens.FindForProvider(ctx, gitrepo.ProviderBitbucket) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, string(gitrepo.ProviderBitbucket), got.Provider) + // The returned token must be one we created (there may be others from parallel tests). + assert.NotZero(t, got.ID) + _ = created +} + +func TestFindForProvider_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // ProviderUnknown is never inserted by any other test. + got, err := gittokens.FindForProvider(ctx, gitrepo.ProviderUnknown) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestDelete(t *testing.T) { + t.Parallel() + ctx := context.Background() + + created, err := gittokens.Create(ctx, gittokens.CreateParams{ + Name: "To Delete", + Provider: string(gitrepo.ProviderGitHub), + Token: "ghp_todelete", + }) + require.NoError(t, err) + + err = gittokens.Delete(ctx, created.ID) + require.NoError(t, err) + + _, err = gittokens.GetByID(ctx, created.ID) + assert.ErrorIs(t, err, gittokens.ErrNotFound) +} + +func TestDelete_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + err := gittokens.Delete(ctx, 999999999) + assert.ErrorIs(t, err, gittokens.ErrNotFound) +} diff --git a/internal/domains/gittokens/models.go b/internal/domains/gittokens/models.go new file mode 100644 index 0000000..548b0ba --- /dev/null +++ b/internal/domains/gittokens/models.go @@ -0,0 +1,17 @@ +package gittokens + +// GitToken represents a stored access token for a git provider. +type GitToken struct { + ID int64 `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Token string `json:"-"` + Hint string `json:"hint,omitempty"` + Created int64 `json:"created"` +} + +type CreateParams struct { + Name string + Provider string + Token string +} diff --git a/internal/domains/indexing/orchestrator.go b/internal/domains/indexing/orchestrator.go new file mode 100644 index 0000000..f784811 --- /dev/null +++ b/internal/domains/indexing/orchestrator.go @@ -0,0 +1,111 @@ +package indexing + +import ( + "context" + "sync" + "time" + + "github.com/gomantics/semantix/config" + "github.com/gomantics/semantix/internal/domains/repos" + "go.uber.org/fx" + "go.uber.org/zap" +) + +const pollInterval = 60 * time.Second + +// Orchestrator polls for pending repos and dispatches indexing workers. +type Orchestrator struct { + l *zap.Logger + cancel context.CancelFunc + wg sync.WaitGroup + sem chan struct{} +} + +// Run starts the orchestrator as part of the fx lifecycle. +func Run(lc fx.Lifecycle, l *zap.Logger) { + o := &Orchestrator{ + l: l.Named("indexing"), + sem: make(chan struct{}, config.Indexing.MaxConcurrentJobs()), + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + o.start() + return nil + }, + OnStop: func(ctx context.Context) error { + o.stop() + return nil + }, + }) +} + +func (o *Orchestrator) start() { + ctx, cancel := context.WithCancel(context.Background()) + o.cancel = cancel + + o.wg.Add(1) + go o.poll(ctx) + + o.l.Info("orchestrator started", + zap.Int64("max_workers", config.Indexing.MaxConcurrentJobs()), + zap.Duration("poll_interval", pollInterval), + ) +} + +func (o *Orchestrator) stop() { + o.l.Info("orchestrator shutting down, waiting for active workers") + o.cancel() + o.wg.Wait() + o.l.Info("orchestrator stopped") +} + +func (o *Orchestrator) poll(ctx context.Context) { + defer o.wg.Done() + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + // Run once immediately on startup. + o.processPending(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + o.processPending(ctx) + } + } +} + +func (o *Orchestrator) processPending(ctx context.Context) { + pending, err := repos.ListPending(ctx, int(config.Indexing.MaxConcurrentJobs())) + if err != nil { + o.l.Error("failed to list pending repos", zap.Error(err)) + return + } + + if len(pending) == 0 { + return + } + + o.l.Info("found pending repos", zap.Int("count", len(pending))) + + for _, repo := range pending { + select { + case <-ctx.Done(): + return + case o.sem <- struct{}{}: + } + + o.wg.Add(1) + go func(r repos.Repo) { + defer o.wg.Done() + defer func() { <-o.sem }() + + w := NewWorker(o.l.With(zap.Int64("repo_id", r.ID), zap.String("url", r.URL))) + w.Process(ctx, r) + }(repo) + } +} diff --git a/internal/domains/indexing/worker.go b/internal/domains/indexing/worker.go new file mode 100644 index 0000000..1d91f71 --- /dev/null +++ b/internal/domains/indexing/worker.go @@ -0,0 +1,375 @@ +package indexing + +import ( + "context" + "crypto/sha256" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/gomantics/semantix/config" + "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/domains/gittokens" + "github.com/gomantics/semantix/internal/domains/repos" + "github.com/gomantics/semantix/internal/libs/chunking" + "github.com/gomantics/semantix/internal/libs/gitrepo" + "github.com/gomantics/semantix/internal/libs/openai" + "github.com/gomantics/semantix/internal/qdrant" + "github.com/gomantics/semantix/pkg/pgconv" + pb "github.com/qdrant/go-client/qdrant" + "go.uber.org/zap" +) + +// Worker processes a single repo indexing job. +type Worker struct { + l *zap.Logger + cloner gitrepo.Cloner + embedder openai.Embedder +} + +// WorkerOption configures a Worker. +type WorkerOption func(*Worker) + +// WithCloner sets the Cloner implementation (for testing). +func WithCloner(c gitrepo.Cloner) WorkerOption { + return func(w *Worker) { w.cloner = c } +} + +// WithEmbedder sets the Embedder implementation (for testing). +func WithEmbedder(e openai.Embedder) WorkerOption { + return func(w *Worker) { w.embedder = e } +} + +// NewWorker creates a Worker with optional overrides. Production code uses +// the real cloner and the default OpenAI embedder. +func NewWorker(l *zap.Logger, opts ...WorkerOption) *Worker { + w := &Worker{ + l: l, + cloner: &gitrepo.DefaultCloner{}, + embedder: openai.GetDefaultEmbedder(), + } + for _, opt := range opts { + opt(w) + } + return w +} + +// runStats tracks progress during an indexing run. +type runStats struct { + filesProcessed int32 + chunksCreated int32 + embeddingsGenerated int32 +} + +func (w *Worker) Process(ctx context.Context, repo repos.Repo) { + startTime := time.Now() + l := w.l + + run, err := w.createRun(ctx, repo.ID) + if err != nil { + l.Error("failed to create index run", zap.Error(err)) + return + } + + stats := &runStats{} + if err := w.processRepo(ctx, repo, stats); err != nil { + w.failRun(ctx, run.ID, repo.ID, startTime, err) + l.Error("indexing failed", zap.Error(err)) + return + } + + w.completeRun(ctx, run.ID, repo.ID, startTime, stats) + l.Info("indexing complete", + zap.Int32("files", stats.filesProcessed), + zap.Int32("chunks", stats.chunksCreated), + zap.Int32("embeddings", stats.embeddingsGenerated), + zap.Duration("duration", time.Since(startTime)), + ) +} + +func (w *Worker) processRepo(ctx context.Context, repo repos.Repo, stats *runStats) error { + // Step 1: Clone + if _, err := repos.UpdateStatus(ctx, repo.ID, repos.StatusCloning, nil); err != nil { + return fmt.Errorf("set cloning status: %w", err) + } + + cloneDir := gitrepo.RepoDir(config.Indexing.CloneDir(), repo.WorkspaceID, repo.ID) + if err := w.cloneRepo(ctx, repo, cloneDir); err != nil { + return fmt.Errorf("clone: %w", err) + } + + // Step 2: Set indexing status + if _, err := repos.UpdateStatus(ctx, repo.ID, repos.StatusIndexing, nil); err != nil { + return fmt.Errorf("set indexing status: %w", err) + } + + // Step 3: Walk, chunk, embed, upsert + if err := w.indexFiles(ctx, repo, cloneDir, stats); err != nil { + return err + } + + return nil +} + +func (w *Worker) cloneRepo(ctx context.Context, repo repos.Repo, cloneDir string) error { + provider := gitrepo.DetectProvider(repo.URL) + + var token string + gt, err := gittokens.FindForProvider(ctx, provider) + if err != nil { + w.l.Warn("failed to look up git token, proceeding without auth", zap.Error(err)) + } + if gt != nil { + token = gt.Token + } + + return w.cloner.Clone(ctx, gitrepo.CloneOptions{ + URL: repo.URL, + Branch: repo.Branch, + Token: token, + Provider: provider, + DestDir: cloneDir, + }) +} + +func (w *Worker) indexFiles(ctx context.Context, repo repos.Repo, rootDir string, stats *runStats) error { + maxFileSize := config.Indexing.MaxFileSizeBytes() + + type fileEntry struct { + relPath string + absPath string + size int64 + } + + var files []fileEntry + err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + relPath, _ := filepath.Rel(rootDir, path) + if gitrepo.ShouldExclude(relPath) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + + if info.Size() > maxFileSize || info.Size() == 0 { + return nil + } + + files = append(files, fileEntry{relPath: relPath, absPath: path, size: info.Size()}) + return nil + }) + if err != nil { + return fmt.Errorf("walk dir: %w", err) + } + + w.l.Info("found files to index", zap.Int("count", len(files))) + + var allChunks []chunking.Chunk + var allFileIDs []int64 + + for _, f := range files { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + content, err := os.ReadFile(f.absPath) + if err != nil { + w.l.Warn("failed to read file, skipping", zap.String("path", f.relPath), zap.Error(err)) + continue + } + + contentHash := fmt.Sprintf("%x", sha256.Sum256(content)) + + // Check if file content has changed + existing, _ := db.Query1(ctx, func(q *db.Queries) (db.File, error) { + return q.GetFileByRepoAndPath(ctx, db.GetFileByRepoAndPathParams{ + RepoID: repo.ID, + Path: f.relPath, + }) + }) + if existing.ID != 0 && existing.ContentHash == contentHash { + stats.filesProcessed++ + continue + } + + chunks, err := chunking.ChunkFile(f.relPath, content) + if err != nil { + w.l.Warn("failed to chunk file, skipping", zap.String("path", f.relPath), zap.Error(err)) + continue + } + + now := time.Now().UnixNano() + dbFile, err := db.Tx1(ctx, func(q *db.Queries) (db.File, error) { + return q.UpsertFile(ctx, db.UpsertFileParams{ + RepoID: repo.ID, + Path: f.relPath, + ContentHash: contentHash, + SizeBytes: f.size, + Language: pgconv.ToText(languageFromChunks(chunks)), + IndexedAt: now, + }) + }) + if err != nil { + return fmt.Errorf("upsert file %s: %w", f.relPath, err) + } + + for i := range chunks { + allChunks = append(allChunks, chunks[i]) + allFileIDs = append(allFileIDs, dbFile.ID) + } + + stats.filesProcessed++ + } + + if len(allChunks) == 0 { + w.l.Info("no chunks to embed") + return nil + } + + stats.chunksCreated = int32(len(allChunks)) + + // Delete old points for this repo before upserting new ones + if err := qdrant.DeletePointsByFilter(ctx, &pb.Filter{ + Must: []*pb.Condition{ + { + ConditionOneOf: &pb.Condition_Field{ + Field: &pb.FieldCondition{ + Key: "repo_id", + Match: &pb.Match{ + MatchValue: &pb.Match_Integer{Integer: repo.ID}, + }, + }, + }, + }, + }, + }); err != nil { + w.l.Warn("failed to delete old points, continuing", zap.Error(err)) + } + + // Build embedding inputs + texts := make([]string, len(allChunks)) + for i, c := range allChunks { + texts[i] = fmt.Sprintf("File: %s\n\n%s", c.FilePath, c.Content) + } + + embResult, err := w.embedder.GenerateEmbeddings(ctx, w.l, texts) + if err != nil { + return fmt.Errorf("generate embeddings: %w", err) + } + + stats.embeddingsGenerated = int32(len(embResult.Embeddings)) + + // Build Qdrant points + points := make([]*pb.PointStruct, len(allChunks)) + for i, c := range allChunks { + pointID := pb.NewIDNum(uint64(allFileIDs[i])*10000 + uint64(i)) + + points[i] = &pb.PointStruct{ + Id: pointID, + Vectors: pb.NewVectors(embResult.Embeddings[i]...), + Payload: map[string]*pb.Value{ + "workspace_id": pb.NewValueInt(repo.WorkspaceID), + "repo_id": pb.NewValueInt(repo.ID), + "file_id": pb.NewValueInt(allFileIDs[i]), + "file_path": pb.NewValueString(c.FilePath), + "language": pb.NewValueString(c.Language), + "start_line": pb.NewValueInt(int64(c.StartLine)), + "end_line": pb.NewValueInt(int64(c.EndLine)), + "chunk_content": pb.NewValueString(c.Content), + "chunk_type": pb.NewValueString(c.ChunkType), + "symbol_name": pb.NewValueString(c.SymbolName), + }, + } + } + + if err := qdrant.UpsertPoints(ctx, points); err != nil { + return fmt.Errorf("upsert points: %w", err) + } + + return nil +} + +func (w *Worker) createRun(ctx context.Context, repoID int64) (*db.IndexRun, error) { + now := time.Now().UnixNano() + run, err := db.Tx1(ctx, func(q *db.Queries) (db.IndexRun, error) { + return q.CreateIndexRun(ctx, db.CreateIndexRunParams{ + RepoID: repoID, + Status: "running", + StartedAt: now, + }) + }) + if err != nil { + return nil, err + } + return &run, nil +} + +func (w *Worker) failRun(ctx context.Context, runID, repoID int64, startTime time.Time, runErr error) { + duration := time.Since(startTime).Milliseconds() + errMsg := runErr.Error() + + db.Tx1(ctx, func(q *db.Queries) (db.IndexRun, error) { + return q.UpdateIndexRunStatus(ctx, db.UpdateIndexRunStatusParams{ + ID: runID, + Status: "failed", + CompletedAt: pgconv.ToInt8(&[]int64{time.Now().UnixNano()}[0]), + ErrorMessage: pgconv.ToText(&errMsg), + DurationMs: pgconv.ToInt8(&duration), + }) + }) + + repos.UpdateStatus(ctx, repoID, repos.StatusError, &errMsg) +} + +func (w *Worker) completeRun(ctx context.Context, runID, repoID int64, startTime time.Time, stats *runStats) { + duration := time.Since(startTime).Milliseconds() + now := time.Now().UnixNano() + + db.Tx1(ctx, func(q *db.Queries) (db.IndexRun, error) { + return q.UpdateIndexRunStatus(ctx, db.UpdateIndexRunStatusParams{ + ID: runID, + Status: "completed", + CompletedAt: pgconv.ToInt8(&now), + DurationMs: pgconv.ToInt8(&duration), + }) + }) + + db.Tx1(ctx, func(q *db.Queries) (db.IndexRun, error) { + return q.UpdateIndexRunStats(ctx, db.UpdateIndexRunStatsParams{ + ID: runID, + FilesProcessed: stats.filesProcessed, + ChunksCreated: stats.chunksCreated, + EmbeddingsGenerated: stats.embeddingsGenerated, + }) + }) + + repos.UpdateStatus(ctx, repoID, repos.StatusReady, nil) +} + +func languageFromChunks(chunks []chunking.Chunk) *string { + if len(chunks) == 0 { + return nil + } + lang := chunks[0].Language + if lang == "" || lang == "generic" { + return nil + } + return &lang +} diff --git a/internal/domains/indexing/worker_test.go b/internal/domains/indexing/worker_test.go new file mode 100644 index 0000000..a4d925e --- /dev/null +++ b/internal/domains/indexing/worker_test.go @@ -0,0 +1,232 @@ +package indexing_test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/domains/indexing" + "github.com/gomantics/semantix/internal/domains/repos" + "github.com/gomantics/semantix/internal/domains/workspaces" + "github.com/gomantics/semantix/internal/libs/gitrepo" + "github.com/gomantics/semantix/internal/libs/openai" + "github.com/gomantics/semantix/internal/qdrant" + "github.com/gomantics/semantix/internal/testutil" + pb "github.com/qdrant/go-client/qdrant" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestMain(m *testing.M) { + // Use a temp directory for cloned repos so tests don't pollute the workspace. + cloneDir, err := os.MkdirTemp("", "semantix-test-clone-*") + if err != nil { + panic(fmt.Sprintf("create clone dir: %v", err)) + } + os.Setenv("CONFIG_INDEXING_CLONE_DIR", cloneDir) + + main := testutil.Main(m, + testutil.WithPostgres(), + testutil.WithQdrant(), + ) + code := main.Run() + os.RemoveAll(cloneDir) + os.Exit(code) +} + +// makeWorkspaceAndRepo creates a workspace and a pending repo for testing. +func makeWorkspaceAndRepo(t *testing.T) (int64, repos.Repo) { + t.Helper() + ctx := context.Background() + + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "Worker Test Workspace", + Slug: fmt.Sprintf("worker-test-%s-%d", testutil.UniqueID(), time.Now().UnixNano()), + }) + require.NoError(t, err) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: ws.ID, + URL: "https://github.com/test/repo", + Branch: "main", + }) + require.NoError(t, err) + + return ws.ID, *repo +} + +// goFiles contains two small Go source files for the fake cloner. +var goFiles = map[string][]byte{ + "main.go": []byte(`package main + +import "fmt" + +func main() { + fmt.Println("hello") +} +`), + "utils.go": []byte(`package main + +func add(a, b int) int { + return a + b +} +`), +} + +func makeWorker(t *testing.T, cloner gitrepo.Cloner) *indexing.Worker { + t.Helper() + l := zap.NewNop() + return indexing.NewWorker(l, + indexing.WithCloner(cloner), + indexing.WithEmbedder(&openai.FakeEmbedder{}), + ) +} + +func countRepoPoints(t *testing.T, repoID int64) uint64 { + t.Helper() + ctx := context.Background() + + count, err := qdrant.CountPoints(ctx, &pb.Filter{ + Must: []*pb.Condition{ + { + ConditionOneOf: &pb.Condition_Field{ + Field: &pb.FieldCondition{ + Key: "repo_id", + Match: &pb.Match{ + MatchValue: &pb.Match_Integer{Integer: repoID}, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + return count +} + +func getIndexRunForRepo(t *testing.T, repoID int64) db.IndexRun { + t.Helper() + ctx := context.Background() + + runs, err := db.Query1(ctx, func(q *db.Queries) ([]db.IndexRun, error) { + return q.ListIndexRunsByRepo(ctx, db.ListIndexRunsByRepoParams{ + RepoID: repoID, + Limit: 1, + Offset: 0, + }) + }) + require.NoError(t, err) + require.NotEmpty(t, runs, "expected at least one index run") + return runs[0] +} + +func TestWorker_Process_happyPath(t *testing.T) { + t.Parallel() + ctx := context.Background() + _, repo := makeWorkspaceAndRepo(t) + + worker := makeWorker(t, &gitrepo.FakeCloner{Files: goFiles}) + worker.Process(ctx, repo) + + // Repo status should be ready. + updated, err := repos.GetByID(ctx, repo.ID) + require.NoError(t, err) + assert.Equal(t, repos.StatusReady, updated.Status) + assert.NotNil(t, updated.IndexedAt) + + // Index run should be completed with stats. + run := getIndexRunForRepo(t, repo.ID) + assert.Equal(t, "completed", run.Status) + assert.Greater(t, run.FilesProcessed, int32(0)) + assert.Greater(t, run.ChunksCreated, int32(0)) + assert.Greater(t, run.EmbeddingsGenerated, int32(0)) + assert.NotNil(t, run.CompletedAt) + + // Qdrant should have points for this repo. + pointCount := countRepoPoints(t, repo.ID) + assert.Greater(t, pointCount, uint64(0)) +} + +func TestWorker_Process_fileUnchangedCache(t *testing.T) { + t.Parallel() + ctx := context.Background() + _, repo := makeWorkspaceAndRepo(t) + + worker := makeWorker(t, &gitrepo.FakeCloner{Files: goFiles}) + + // First run - indexes everything. + worker.Process(ctx, repo) + + run1 := getIndexRunForRepo(t, repo.ID) + assert.Equal(t, "completed", run1.Status) + firstEmbeddings := run1.EmbeddingsGenerated + + // Reset repo to pending for second run. + _, err := repos.UpdateStatus(ctx, repo.ID, repos.StatusPending, nil) + require.NoError(t, err) + updatedRepo, err := repos.GetByID(ctx, repo.ID) + require.NoError(t, err) + + // Second run with identical files. + worker.Process(ctx, *updatedRepo) + + // Get the most recent run (the second one). + run2 := getIndexRunForRepo(t, repo.ID) + assert.Equal(t, "completed", run2.Status) + + // On the second run, all files are cached (same content hash) so no new + // embeddings should be generated. + assert.Equal(t, int32(0), run2.EmbeddingsGenerated, + "second run with identical files should skip embedding generation (files cached)") + _ = firstEmbeddings +} + +func TestWorker_Process_cloneFailure(t *testing.T) { + t.Parallel() + ctx := context.Background() + _, repo := makeWorkspaceAndRepo(t) + + cloneErr := errors.New("authentication required") + worker := makeWorker(t, &gitrepo.FakeCloner{Err: cloneErr}) + worker.Process(ctx, repo) + + // Repo status should be error. + updated, err := repos.GetByID(ctx, repo.ID) + require.NoError(t, err) + assert.Equal(t, repos.StatusError, updated.Status) + assert.NotNil(t, updated.ErrorMessage) + assert.Contains(t, *updated.ErrorMessage, "authentication required") + + // Index run should be failed. + run := getIndexRunForRepo(t, repo.ID) + assert.Equal(t, "failed", run.Status) + assert.NotNil(t, run.ErrorMessage) + + // No Qdrant points should exist for this repo. + pointCount := countRepoPoints(t, repo.ID) + assert.Equal(t, uint64(0), pointCount) +} + +func TestWorker_Process_statusTransitions(t *testing.T) { + t.Parallel() + ctx := context.Background() + _, repo := makeWorkspaceAndRepo(t) + + // Verify repo starts pending. + initial, err := repos.GetByID(ctx, repo.ID) + require.NoError(t, err) + assert.Equal(t, repos.StatusPending, initial.Status) + + worker := makeWorker(t, &gitrepo.FakeCloner{Files: goFiles}) + worker.Process(ctx, repo) + + // After processing, repo should be ready (not pending, cloning, or indexing). + final, err := repos.GetByID(ctx, repo.ID) + require.NoError(t, err) + assert.Equal(t, repos.StatusReady, final.Status) +} diff --git a/internal/domains/repos/models.go b/internal/domains/repos/models.go new file mode 100644 index 0000000..35ddc81 --- /dev/null +++ b/internal/domains/repos/models.go @@ -0,0 +1,47 @@ +package repos + +// Status represents a repository's current state in the indexing pipeline. +type Status string + +const ( + StatusPending Status = "pending" + StatusCloning Status = "cloning" + StatusIndexing Status = "indexing" + StatusReady Status = "ready" + StatusError Status = "error" +) + +// Repo represents a repository within a workspace. +type Repo struct { + ID int64 `json:"id"` + WorkspaceID int64 `json:"workspace_id"` + URL string `json:"url"` + Branch string `json:"branch"` + Status Status `json:"status"` + IndexedAt *int64 `json:"indexed_at,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + +type CreateParams struct { + WorkspaceID int64 + URL string + Branch string +} + +type UpdateParams struct { + URL string + Branch string +} + +type ListParams struct { + WorkspaceID int64 + Limit int + Offset int +} + +type ListResult struct { + Repos []Repo + Total int64 +} diff --git a/internal/domains/repos/repos.go b/internal/domains/repos/repos.go new file mode 100644 index 0000000..1464069 --- /dev/null +++ b/internal/domains/repos/repos.go @@ -0,0 +1,186 @@ +package repos + +import ( + "context" + "errors" + "time" + + "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/pkg/pgconv" + "github.com/jackc/pgx/v5" +) + +var ( + ErrNotFound = errors.New("repo not found") +) + +func Create(ctx context.Context, params CreateParams) (*Repo, error) { + now := time.Now().UnixNano() + branch := params.Branch + if branch == "" { + branch = "main" + } + + dbRepo, err := db.Tx1(ctx, func(q *db.Queries) (db.Repo, error) { + return q.CreateRepo(ctx, db.CreateRepoParams{ + WorkspaceID: params.WorkspaceID, + Url: params.URL, + Branch: branch, + Status: string(StatusPending), + Created: now, + Updated: now, + }) + }) + if err != nil { + return nil, err + } + + return toRepo(dbRepo), nil +} + +func GetByID(ctx context.Context, id int64) (*Repo, error) { + dbRepo, err := db.Query1(ctx, func(q *db.Queries) (db.Repo, error) { + return q.GetRepoByID(ctx, id) + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return toRepo(dbRepo), nil +} + +func List(ctx context.Context, params ListParams) (*ListResult, error) { + if params.Limit <= 0 || params.Limit > 100 { + params.Limit = 20 + } + + type listData struct { + repos []db.Repo + total int64 + } + + data, err := db.Tx1(ctx, func(q *db.Queries) (listData, error) { + dbRepos, err := q.ListReposByWorkspace(ctx, db.ListReposByWorkspaceParams{ + WorkspaceID: params.WorkspaceID, + Limit: int32(params.Limit), + Offset: int32(params.Offset), + }) + if err != nil { + return listData{}, err + } + + total, err := q.CountReposByWorkspace(ctx, params.WorkspaceID) + if err != nil { + return listData{}, err + } + + return listData{repos: dbRepos, total: total}, nil + }) + if err != nil { + return nil, err + } + + repos := make([]Repo, len(data.repos)) + for i, r := range data.repos { + repos[i] = *toRepo(r) + } + + return &ListResult{Repos: repos, Total: data.total}, nil +} + +func UpdateStatus(ctx context.Context, id int64, status Status, errMsg *string) (*Repo, error) { + now := time.Now().UnixNano() + + var indexedAt *int64 + if status == StatusReady { + indexedAt = &now + } + + dbRepo, err := db.Tx1(ctx, func(q *db.Queries) (db.Repo, error) { + return q.UpdateRepoStatus(ctx, db.UpdateRepoStatusParams{ + ID: id, + Status: string(status), + IndexedAt: pgconv.ToInt8(indexedAt), + ErrorMessage: pgconv.ToText(errMsg), + Updated: now, + }) + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return toRepo(dbRepo), nil +} + +func Update(ctx context.Context, id int64, params UpdateParams) (*Repo, error) { + now := time.Now().UnixNano() + + dbRepo, err := db.Tx1(ctx, func(q *db.Queries) (db.Repo, error) { + return q.UpdateRepo(ctx, db.UpdateRepoParams{ + ID: id, + Url: params.URL, + Branch: params.Branch, + Updated: now, + }) + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return toRepo(dbRepo), nil +} + +func Delete(ctx context.Context, id int64) error { + return db.Tx(ctx, func(q *db.Queries) error { + _, err := q.GetRepoByID(ctx, id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + + return q.DeleteRepo(ctx, id) + }) +} + +// ListPending returns repos with status = pending, ordered by creation time. +func ListPending(ctx context.Context, limit int) ([]Repo, error) { + dbRepos, err := db.Query1(ctx, func(q *db.Queries) ([]db.Repo, error) { + return q.ListReposByStatus(ctx, db.ListReposByStatusParams{ + Status: string(StatusPending), + Limit: int32(limit), + }) + }) + if err != nil { + return nil, err + } + + repos := make([]Repo, len(dbRepos)) + for i, r := range dbRepos { + repos[i] = *toRepo(r) + } + return repos, nil +} + +func toRepo(r db.Repo) *Repo { + return &Repo{ + ID: r.ID, + WorkspaceID: r.WorkspaceID, + URL: r.Url, + Branch: r.Branch, + Status: Status(r.Status), + IndexedAt: pgconv.FromInt8(r.IndexedAt), + ErrorMessage: pgconv.FromText(r.ErrorMessage), + Created: r.Created, + Updated: r.Updated, + } +} diff --git a/internal/domains/repos/repos_test.go b/internal/domains/repos/repos_test.go new file mode 100644 index 0000000..a2cac04 --- /dev/null +++ b/internal/domains/repos/repos_test.go @@ -0,0 +1,257 @@ +package repos_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/domains/repos" + "github.com/gomantics/semantix/internal/domains/workspaces" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, testutil.WithPostgres()) + os.Exit(main.Run()) +} + +// makeWorkspace creates an isolated workspace for a single test. +func makeWorkspace(t *testing.T) int64 { + t.Helper() + ctx := context.Background() + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "Repo Test Workspace", + Slug: fmt.Sprintf("repo-test-%s", testutil.UniqueID()), + }) + require.NoError(t, err) + return ws.ID +} + +func TestCreate_defaultsBranchToMain(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/repo", + }) + + require.NoError(t, err) + assert.Equal(t, "main", repo.Branch) + assert.Equal(t, repos.StatusPending, repo.Status) + assert.NotZero(t, repo.ID) +} + +func TestCreate_customBranch(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/repo", + Branch: "develop", + }) + + require.NoError(t, err) + assert.Equal(t, "develop", repo.Branch) +} + +func TestGetByID(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + created, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/getbyid", + }) + require.NoError(t, err) + + got, err := repos.GetByID(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, created.URL, got.URL) +} + +func TestGetByID_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + _, err := repos.GetByID(ctx, 999999999) + assert.ErrorIs(t, err, repos.ErrNotFound) +} + +func TestUpdateStatus_transitions(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/transitions", + }) + require.NoError(t, err) + assert.Equal(t, repos.StatusPending, repo.Status) + + transitions := []repos.Status{ + repos.StatusCloning, + repos.StatusIndexing, + repos.StatusReady, + } + + for _, status := range transitions { + updated, err := repos.UpdateStatus(ctx, repo.ID, status, nil) + require.NoError(t, err, "transition to %s failed", status) + assert.Equal(t, status, updated.Status) + } + + // Ready status should set indexed_at. + final, err := repos.GetByID(ctx, repo.ID) + require.NoError(t, err) + assert.NotNil(t, final.IndexedAt) +} + +func TestUpdateStatus_error(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/error-status", + }) + require.NoError(t, err) + + errMsg := "clone failed: connection refused" + updated, err := repos.UpdateStatus(ctx, repo.ID, repos.StatusError, &errMsg) + require.NoError(t, err) + assert.Equal(t, repos.StatusError, updated.Status) + assert.Equal(t, &errMsg, updated.ErrorMessage) +} + +func TestList(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // Each test gets its own workspace so List results are fully isolated. + wsID := makeWorkspace(t) + + for i := 0; i < 3; i++ { + _, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: fmt.Sprintf("https://github.com/org/repo-%d", i), + }) + require.NoError(t, err) + } + + result, err := repos.List(ctx, repos.ListParams{ + WorkspaceID: wsID, + Limit: 100, + }) + require.NoError(t, err) + // Exact count is safe because List is scoped to this workspace. + assert.Equal(t, int64(3), result.Total) + assert.Len(t, result.Repos, 3) +} + +func TestListPending(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // Own workspace so we can create known pending repos in isolation. + wsID := makeWorkspace(t) + + // Create 2 pending repos. + pendingIDs := make(map[int64]bool) + for i := 0; i < 2; i++ { + r, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: fmt.Sprintf("https://github.com/org/pending-%s-%d", testutil.UniqueID(), i), + }) + require.NoError(t, err) + pendingIDs[r.ID] = true + } + + // Create 1 repo and advance it past pending. + notPending, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/not-pending-" + testutil.UniqueID(), + }) + require.NoError(t, err) + _, err = repos.UpdateStatus(ctx, notPending.ID, repos.StatusReady, nil) + require.NoError(t, err) + + // ListPending is global, so filter down to our workspace's repos. + pending, err := repos.ListPending(ctx, 1000) + require.NoError(t, err) + + // Verify our 2 pending repos appear in the results. + foundPending := 0 + for _, r := range pending { + assert.Equal(t, repos.StatusPending, r.Status) + if pendingIDs[r.ID] { + foundPending++ + } + // Our advanced repo must not appear as pending. + assert.NotEqual(t, notPending.ID, r.ID) + } + assert.Equal(t, 2, foundPending, "expected both our pending repos to appear in ListPending") +} + +func TestDelete(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/to-delete", + }) + require.NoError(t, err) + + err = repos.Delete(ctx, repo.ID) + require.NoError(t, err) + + _, err = repos.GetByID(ctx, repo.ID) + assert.ErrorIs(t, err, repos.ErrNotFound) +} + +func TestDelete_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + err := repos.Delete(ctx, 999999999) + assert.ErrorIs(t, err, repos.ErrNotFound) +} + +// TestIndexRunCreation verifies index_run records can be created for repos. +func TestIndexRunCreation(t *testing.T) { + t.Parallel() + ctx := context.Background() + wsID := makeWorkspace(t) + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wsID, + URL: "https://github.com/org/index-run-test", + }) + require.NoError(t, err) + + now := time.Now().UnixNano() + run, err := db.Tx1(ctx, func(q *db.Queries) (db.IndexRun, error) { + return q.CreateIndexRun(ctx, db.CreateIndexRunParams{ + RepoID: repo.ID, + Status: "running", + StartedAt: now, + }) + }) + require.NoError(t, err) + assert.NotZero(t, run.ID) + assert.Equal(t, "running", run.Status) +} diff --git a/internal/domains/workspaces/workspaces_test.go b/internal/domains/workspaces/workspaces_test.go new file mode 100644 index 0000000..35a2854 --- /dev/null +++ b/internal/domains/workspaces/workspaces_test.go @@ -0,0 +1,239 @@ +package workspaces_test + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/gomantics/semantix/internal/domains/workspaces" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, testutil.WithPostgres()) + os.Exit(main.Run()) +} + +// uniqueSlug generates a unique slug using the test name and a counter +// that is safe for parallel tests. +func uniqueSlug(t *testing.T, prefix string) string { + t.Helper() + return fmt.Sprintf("%s-%s", prefix, testutil.UniqueID()) +} + +func TestCreate(t *testing.T) { + t.Parallel() + ctx := context.Background() + desc := "a test workspace" + + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "Test Workspace", + Slug: uniqueSlug(t, "create"), + Description: &desc, + Settings: map[string]any{"feature": true}, + }) + + require.NoError(t, err) + assert.NotZero(t, ws.ID) + assert.Equal(t, "Test Workspace", ws.Name) + assert.Equal(t, &desc, ws.Description) + assert.Equal(t, map[string]any{"feature": true}, ws.Settings) + assert.NotZero(t, ws.Created) + assert.NotZero(t, ws.Updated) +} + +func TestCreate_defaultSettings(t *testing.T) { + t.Parallel() + ctx := context.Background() + + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "No Settings", + Slug: uniqueSlug(t, "no-settings"), + Settings: nil, + }) + + require.NoError(t, err) + assert.NotNil(t, ws.Settings, "settings should default to empty map") +} + +func TestCreate_slugConflict(t *testing.T) { + t.Parallel() + ctx := context.Background() + slug := uniqueSlug(t, "conflict") + + _, err := workspaces.Create(ctx, workspaces.CreateParams{Name: "First", Slug: slug}) + require.NoError(t, err) + + _, err = workspaces.Create(ctx, workspaces.CreateParams{Name: "Second", Slug: slug}) + assert.ErrorIs(t, err, workspaces.ErrAlreadyExists) +} + +func TestGetByID(t *testing.T) { + t.Parallel() + ctx := context.Background() + + created, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "GetByID", + Slug: uniqueSlug(t, "getbyid"), + }) + require.NoError(t, err) + + got, err := workspaces.GetByID(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, created.Name, got.Name) +} + +func TestGetByID_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + _, err := workspaces.GetByID(ctx, 999999999) + assert.ErrorIs(t, err, workspaces.ErrNotFound) +} + +func TestGetBySlug(t *testing.T) { + t.Parallel() + ctx := context.Background() + slug := uniqueSlug(t, "slug") + + created, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "Slug Test", + Slug: slug, + }) + require.NoError(t, err) + + got, err := workspaces.GetBySlug(ctx, slug) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) +} + +func TestGetBySlug_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + _, err := workspaces.GetBySlug(ctx, "nonexistent-slug-xyz-"+testutil.UniqueID()) + assert.ErrorIs(t, err, workspaces.ErrNotFound) +} + +func TestList(t *testing.T) { + t.Parallel() + ctx := context.Background() + prefix := uniqueSlug(t, "list") + + // Create 3 workspaces with unique slugs. + for i := 0; i < 3; i++ { + _, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: fmt.Sprintf("List WS %d", i), + Slug: fmt.Sprintf("%s-%d", prefix, i), + }) + require.NoError(t, err) + } + + // List uses a shared DB, so we can only assert >= 3, not exactly 3. + result, err := workspaces.List(ctx, workspaces.ListParams{Limit: 100, Offset: 0}) + require.NoError(t, err) + assert.GreaterOrEqual(t, result.Total, int64(3)) + assert.GreaterOrEqual(t, len(result.Workspaces), 3) +} + +func TestList_pagination(t *testing.T) { + t.Parallel() + ctx := context.Background() + prefix := uniqueSlug(t, "page") + + for i := 0; i < 5; i++ { + _, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: fmt.Sprintf("Page WS %d", i), + Slug: fmt.Sprintf("%s-%d", prefix, i), + }) + require.NoError(t, err) + } + + page1, err := workspaces.List(ctx, workspaces.ListParams{Limit: 2, Offset: 0}) + require.NoError(t, err) + assert.Len(t, page1.Workspaces, 2) + + page2, err := workspaces.List(ctx, workspaces.ListParams{Limit: 2, Offset: 2}) + require.NoError(t, err) + assert.Len(t, page2.Workspaces, 2) + + assert.NotEqual(t, page1.Workspaces[0].ID, page2.Workspaces[0].ID) +} + +func TestUpdate(t *testing.T) { + t.Parallel() + ctx := context.Background() + + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "Before Update", + Slug: uniqueSlug(t, "update"), + }) + require.NoError(t, err) + + newDesc := "updated description" + updated, err := workspaces.Update(ctx, ws.ID, workspaces.UpdateParams{ + Name: "After Update", + Slug: uniqueSlug(t, "updated"), + Description: &newDesc, + Settings: map[string]any{"key": "value"}, + }) + require.NoError(t, err) + + assert.Equal(t, ws.ID, updated.ID) + assert.Equal(t, "After Update", updated.Name) + assert.Equal(t, &newDesc, updated.Description) +} + +func TestUpdate_slugConflict(t *testing.T) { + t.Parallel() + ctx := context.Background() + + ws1, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "WS1", + Slug: uniqueSlug(t, "conflict-ws1"), + }) + require.NoError(t, err) + + ws2, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "WS2", + Slug: uniqueSlug(t, "conflict-ws2"), + }) + require.NoError(t, err) + + // Attempt to update ws2's slug to ws1's slug. + _, err = workspaces.Update(ctx, ws2.ID, workspaces.UpdateParams{ + Name: "WS2", + Slug: ws1.Slug, + Settings: nil, + }) + assert.ErrorIs(t, err, workspaces.ErrAlreadyExists) +} + +func TestDelete(t *testing.T) { + t.Parallel() + ctx := context.Background() + + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: "To Delete", + Slug: uniqueSlug(t, "delete"), + }) + require.NoError(t, err) + + err = workspaces.Delete(ctx, ws.ID) + require.NoError(t, err) + + _, err = workspaces.GetByID(ctx, ws.ID) + assert.ErrorIs(t, err, workspaces.ErrNotFound) +} + +func TestDelete_notFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + + err := workspaces.Delete(ctx, 999999999) + assert.ErrorIs(t, err, workspaces.ErrNotFound) +} diff --git a/internal/libs/chunking/chunker.go b/internal/libs/chunking/chunker.go new file mode 100644 index 0000000..c09e294 --- /dev/null +++ b/internal/libs/chunking/chunker.go @@ -0,0 +1,85 @@ +package chunking + +import ( + "fmt" + + "github.com/gomantics/chunkx" + "github.com/gomantics/chunkx/languages" +) + +const defaultMaxSize = 500 + +// Chunk represents a code chunk with its metadata. +type Chunk struct { + Content string + FilePath string + StartLine int + EndLine int + Language string + ChunkType string + SymbolName string +} + +// ChunkFile parses source code and returns semantically meaningful chunks. +func ChunkFile(filePath string, content []byte) ([]Chunk, error) { + lang, detected := languages.DetectLanguage(filePath) + if !detected { + lang, _ = languages.GetLanguageConfig(languages.Generic) + } + + chunker := chunkx.NewChunker() + raw, err := chunker.Chunk(string(content), + chunkx.WithLanguage(lang.Name), + chunkx.WithMaxSize(defaultMaxSize), + ) + if err != nil { + return nil, fmt.Errorf("chunk %s: %w", filePath, err) + } + + chunks := make([]Chunk, 0, len(raw)) + for _, r := range raw { + chunkType := "block" + symbolName := "" + if len(r.NodeTypes) > 0 { + chunkType = classifyNodeType(r.NodeTypes[0]) + symbolName = extractSymbolName(r.NodeTypes) + } + + chunks = append(chunks, Chunk{ + Content: r.Content, + FilePath: filePath, + StartLine: r.StartLine, + EndLine: r.EndLine, + Language: string(lang.Name), + ChunkType: chunkType, + SymbolName: symbolName, + }) + } + + return chunks, nil +} + +func classifyNodeType(nodeType string) string { + switch nodeType { + case "function_declaration", "function_definition", "function_item", + "arrow_function", "func_literal": + return "function" + case "method_declaration", "method_definition": + return "method" + case "class_declaration", "class_definition", "class_specifier": + return "class" + case "type_declaration", "type_spec", "struct_type", "interface_type": + return "type" + case "import_declaration", "import_statement", "import_spec_list": + return "import" + default: + return "block" + } +} + +func extractSymbolName(nodeTypes []string) string { + if len(nodeTypes) > 1 { + return nodeTypes[1] + } + return "" +} diff --git a/internal/libs/chunking/chunker_test.go b/internal/libs/chunking/chunker_test.go new file mode 100644 index 0000000..1feb7fc --- /dev/null +++ b/internal/libs/chunking/chunker_test.go @@ -0,0 +1,173 @@ +package chunking + +import ( + "encoding/json" + "flag" + "os" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/approvals/go-approval-tests/reporters" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var acceptChanges bool + +func TestMain(m *testing.M) { + flag.BoolVar(&acceptChanges, "accept-changes", false, "auto-approve snapshot diffs") + flag.Parse() + + approvals.UseFolder("testdata") + if acceptChanges { + approvals.UseFrontLoadedReporter(reporters.NewReporterThatAutomaticallyApproves()) + } + + os.Exit(m.Run()) +} + +const goSource = `package main + +import "fmt" + +type Server struct { + host string + port int +} + +func NewServer(host string, port int) *Server { + return &Server{host: host, port: port} +} + +func (s *Server) Start() error { + fmt.Printf("listening on %s:%d\n", s.host, s.port) + return nil +} +` + +const jsSource = `function greet(name) { + return "Hello, " + name; +} + +const add = (a, b) => a + b; +` + +func TestChunkFile_Go(t *testing.T) { + t.Parallel() + chunks, err := ChunkFile("server.go", []byte(goSource)) + require.NoError(t, err) + assert.NotEmpty(t, chunks, "expected at least one chunk for Go source") + + for _, c := range chunks { + assert.Equal(t, "go", c.Language) + assert.NotEmpty(t, c.Content) + assert.Greater(t, c.EndLine, 0) + } + + // All chunks should have a valid chunk type (block is the default for Go). + for _, c := range chunks { + assert.NotEmpty(t, c.ChunkType, "chunk type should never be empty") + } +} + +func TestChunkFile_JavaScript(t *testing.T) { + t.Parallel() + chunks, err := ChunkFile("script.js", []byte(jsSource)) + require.NoError(t, err) + assert.NotEmpty(t, chunks) + + for _, c := range chunks { + assert.Equal(t, "javascript", c.Language) + } +} + +func TestChunkFile_UnknownExtension_FallsBackToGeneric(t *testing.T) { + t.Parallel() + content := []byte("some plain text content that has no recognized language") + + chunks, err := ChunkFile("data.xyz", []byte(content)) + require.NoError(t, err) + // Generic chunker should still return something or empty without error. + _ = chunks +} + +func TestChunkFile_EmptyContent(t *testing.T) { + t.Parallel() + _, err := ChunkFile("empty.go", []byte("")) + // Empty content may succeed (returning chunks or none) or return an error. + // We just assert no panic occurs. + _ = err +} + +func TestClassifyNodeType(t *testing.T) { + t.Parallel() + + tests := []struct { + nodeType string + expected string + }{ + {"function_declaration", "function"}, + {"function_definition", "function"}, + {"function_item", "function"}, + {"arrow_function", "function"}, + {"func_literal", "function"}, + {"method_declaration", "method"}, + {"method_definition", "method"}, + {"class_declaration", "class"}, + {"class_definition", "class"}, + {"class_specifier", "class"}, + {"type_declaration", "type"}, + {"type_spec", "type"}, + {"struct_type", "type"}, + {"interface_type", "type"}, + {"import_declaration", "import"}, + {"import_statement", "import"}, + {"import_spec_list", "import"}, + {"expression_statement", "block"}, + {"unknown_node", "block"}, + {"", "block"}, + } + + for _, tt := range tests { + t.Run(tt.nodeType, func(t *testing.T) { + t.Parallel() + got := classifyNodeType(tt.nodeType) + assert.Equal(t, tt.expected, got) + }) + } +} + +// TestChunkFile_GoApprovals snapshots the chunk metadata from the canonical Go +// source so regressions in chunking output are caught automatically. +// NOTE: approval tests use shared file-based state and must not run in parallel +// with other approval tests within the same package. +func TestChunkFile_GoApprovals(t *testing.T) { + chunks, err := ChunkFile("server.go", []byte(goSource)) + require.NoError(t, err) + + type chunkSummary struct { + FilePath string `json:"filePath"` + Language string `json:"language"` + ChunkType string `json:"chunkType"` + SymbolName string `json:"symbolName"` + StartLine int `json:"startLine"` + EndLine int `json:"endLine"` + } + + summaries := make([]chunkSummary, len(chunks)) + for i, c := range chunks { + summaries[i] = chunkSummary{ + FilePath: c.FilePath, + Language: c.Language, + ChunkType: c.ChunkType, + SymbolName: c.SymbolName, + StartLine: c.StartLine, + EndLine: c.EndLine, + } + } + + b, err := json.MarshalIndent(summaries, "", " ") + require.NoError(t, err) + + approvals.VerifyString(t, string(b)) +} diff --git a/internal/libs/chunking/testdata/chunker_test.TestChunkFile_GoApprovals.approved.txt b/internal/libs/chunking/testdata/chunker_test.TestChunkFile_GoApprovals.approved.txt new file mode 100644 index 0000000..28c7913 --- /dev/null +++ b/internal/libs/chunking/testdata/chunker_test.TestChunkFile_GoApprovals.approved.txt @@ -0,0 +1,10 @@ +[ + { + "filePath": "server.go", + "language": "go", + "chunkType": "block", + "symbolName": "block", + "startLine": 1, + "endLine": 18 + } +] \ No newline at end of file diff --git a/internal/libs/gitrepo/client.go b/internal/libs/gitrepo/client.go new file mode 100644 index 0000000..36ef497 --- /dev/null +++ b/internal/libs/gitrepo/client.go @@ -0,0 +1,163 @@ +package gitrepo + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +// Exclude patterns that should be sparse-checked out. +var sparseExcludes = []string{ + "node_modules/", + "vendor/", + ".git/", + "*.lock", + "*.min.js", + "dist/", + "build/", +} + +// CloneOptions configures a clone operation. +type CloneOptions struct { + URL string + Branch string + Token string + Provider Provider + DestDir string +} + +// Clone performs a shallow clone of the repository into destDir. +// If the repo already exists at destDir, it pulls the latest changes instead. +func Clone(ctx context.Context, opts CloneOptions) error { + if _, err := os.Stat(filepath.Join(opts.DestDir, ".git")); err == nil { + return pull(ctx, opts) + } + return cloneFresh(ctx, opts) +} + +func cloneFresh(ctx context.Context, opts CloneOptions) error { + cloneURL := opts.URL + var auth *http.BasicAuth + + if opts.Token != "" { + user, _ := tokenUser(opts.Provider) + auth = &http.BasicAuth{ + Username: user, + Password: opts.Token, + } + } + + if !strings.HasSuffix(cloneURL, ".git") { + cloneURL += ".git" + } + + cloneOpts := &git.CloneOptions{ + URL: cloneURL, + Auth: auth, + Depth: 1, + SingleBranch: true, + ReferenceName: plumbing.NewBranchReferenceName(opts.Branch), + Tags: git.NoTags, + } + + _, err := git.PlainCloneContext(ctx, opts.DestDir, false, cloneOpts) + if err != nil { + return fmt.Errorf("clone %s: %w", opts.URL, err) + } + + return nil +} + +func pull(ctx context.Context, opts CloneOptions) error { + repo, err := git.PlainOpen(opts.DestDir) + if err != nil { + return fmt.Errorf("open repo at %s: %w", opts.DestDir, err) + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("get worktree: %w", err) + } + + var auth *http.BasicAuth + if opts.Token != "" { + user, _ := tokenUser(opts.Provider) + auth = &http.BasicAuth{ + Username: user, + Password: opts.Token, + } + } + + pullOpts := &git.PullOptions{ + RemoteName: "origin", + ReferenceName: plumbing.NewBranchReferenceName(opts.Branch), + Auth: auth, + Depth: 1, + SingleBranch: true, + Force: true, + } + + err = wt.PullContext(ctx, pullOpts) + if err == git.NoErrAlreadyUpToDate { + return nil + } + if err != nil { + return fmt.Errorf("pull %s: %w", opts.URL, err) + } + + return nil +} + +func tokenUser(provider Provider) (string, string) { + switch provider { + case ProviderGitHub: + return "x-access-token", "" + case ProviderGitLab: + return "oauth2", "" + case ProviderBitbucket: + return "x-token-auth", "" + default: + return "token", "" + } +} + +// ShouldExclude returns true if the given relative path should be excluded +// from indexing (matches sparse checkout exclude patterns). +func ShouldExclude(relPath string) bool { + for _, pattern := range sparseExcludes { + if strings.HasSuffix(pattern, "/") { + dir := strings.TrimSuffix(pattern, "/") + if relPath == dir || strings.HasPrefix(relPath, dir+"/") || strings.Contains(relPath, "/"+dir+"/") { + return true + } + } else if strings.HasPrefix(pattern, "*") { + suffix := strings.TrimPrefix(pattern, "*") + if strings.HasSuffix(relPath, suffix) { + return true + } + } else if relPath == pattern { + return true + } + } + return false +} + +// RepoDir constructs the clone destination path for a repo. +func RepoDir(baseDir string, workspaceID, repoID int64) string { + return filepath.Join(baseDir, fmt.Sprintf("%d", workspaceID), fmt.Sprintf("%d", repoID)) +} + +// FetchSpecs returns the default fetch refspec for sparse checkout configuration. +// This is exported for testing and advanced usage. +func FetchSpecs(branch string) []config.RefSpec { + return []config.RefSpec{ + config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branch, branch)), + } +} diff --git a/internal/libs/gitrepo/client_test.go b/internal/libs/gitrepo/client_test.go new file mode 100644 index 0000000..5479ae0 --- /dev/null +++ b/internal/libs/gitrepo/client_test.go @@ -0,0 +1,78 @@ +package gitrepo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldExclude(t *testing.T) { + t.Parallel() + + tests := []struct { + path string + excluded bool + }{ + // Excluded directories + {"node_modules", true}, + {"node_modules/lodash/index.js", true}, + {"src/node_modules/package/file.js", true}, + {"vendor", true}, + {"vendor/github.com/pkg/foo.go", true}, + {".git", true}, + {".git/config", true}, + {"dist", true}, + {"dist/bundle.js", true}, + {"build", true}, + {"build/output.js", true}, + + // Excluded by *.lock pattern (suffix must be ".lock") + {"yarn.lock", true}, + {"Gemfile.lock", true}, + {"Pipfile.lock", true}, + // Excluded by *.min.js pattern + {"app.min.js", true}, + {"vendor/assets/app.min.js", true}, + // Not excluded - package-lock.json ends in .json not .lock + {"package-lock.json", false}, + // Not excluded - go.sum is not a lock file pattern + {"go.sum", false}, + + // Not excluded + {"main.go", false}, + {"src/main.go", false}, + {"internal/db/init.go", false}, + {"README.md", false}, + {"Makefile", false}, + {"app.js", false}, + {"builder.go", false}, + {"distribution.go", false}, + {"cmd/api/main.go", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + t.Parallel() + got := ShouldExclude(tt.path) + assert.Equal(t, tt.excluded, got, "ShouldExclude(%q)", tt.path) + }) + } +} + +func TestRepoDir(t *testing.T) { + t.Parallel() + + got := RepoDir("/tmp/repos", 42, 7) + assert.Equal(t, "/tmp/repos/42/7", got) + + got = RepoDir("./clones", 1, 100) + assert.Equal(t, "clones/1/100", got) +} + +func TestFetchSpecs(t *testing.T) { + t.Parallel() + + specs := FetchSpecs("main") + assert.Len(t, specs, 1) + assert.Contains(t, string(specs[0]), "main") +} diff --git a/internal/libs/gitrepo/clone_test.go b/internal/libs/gitrepo/clone_test.go new file mode 100644 index 0000000..e378bb6 --- /dev/null +++ b/internal/libs/gitrepo/clone_test.go @@ -0,0 +1,145 @@ +package gitrepo + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeLocalRepo creates a hermetic bare git repo in a temp directory with two +// committed files (main.go and README.md). Returns the file:// URL to the bare +// repo and the working directory path (for adding further commits in tests). +func makeLocalRepo(t *testing.T) (bareURL string, workDir string) { + t.Helper() + + tmp := t.TempDir() + bareDir := filepath.Join(tmp, "bare.git") + workDir = filepath.Join(tmp, "work") + + // Create the bare repository. + run(t, tmp, "git", "init", "--bare", bareDir) + + // Clone bare into a working directory. + run(t, tmp, "git", "clone", bareDir, workDir) + + // Configure identity so commits work in any environment. + run(t, workDir, "git", "config", "user.email", "test@test.com") + run(t, workDir, "git", "config", "user.name", "Test") + + // Add initial files. + writeFile(t, workDir, "main.go", "package main\n\nfunc main() {}\n") + writeFile(t, workDir, "README.md", "# Test Repo\n") + + run(t, workDir, "git", "add", ".") + run(t, workDir, "git", "commit", "-m", "initial commit") + run(t, workDir, "git", "push", "origin", "HEAD:main") + + return "file://" + bareDir, workDir +} + +// addCommit adds a new file and commits+pushes it to the bare repo. +func addCommit(t *testing.T, workDir, filename, content string) { + t.Helper() + + writeFile(t, workDir, filename, content) + run(t, workDir, "git", "add", ".") + run(t, workDir, "git", "commit", "-m", "add "+filename) + run(t, workDir, "git", "push", "origin", "HEAD:main") +} + +func run(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + require.NoError(t, err, "command %s %v failed: %s", name, args, out) +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644)) +} + +func TestClone_fresh(t *testing.T) { + t.Parallel() + bareURL, _ := makeLocalRepo(t) + destDir := t.TempDir() + + err := Clone(context.Background(), CloneOptions{ + URL: bareURL, + Branch: "main", + DestDir: destDir, + Provider: ProviderUnknown, + }) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(destDir, "main.go")) + assert.FileExists(t, filepath.Join(destDir, "README.md")) +} + +func TestClone_pull_alreadyUpToDate(t *testing.T) { + t.Parallel() + bareURL, _ := makeLocalRepo(t) + destDir := t.TempDir() + + opts := CloneOptions{ + URL: bareURL, + Branch: "main", + DestDir: destDir, + Provider: ProviderUnknown, + } + + // First clone. + require.NoError(t, Clone(context.Background(), opts)) + + // Second call should detect .git and pull; already up to date is swallowed. + err := Clone(context.Background(), opts) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(destDir, "main.go")) +} + +func TestClone_pull_withNewCommit(t *testing.T) { + t.Parallel() + bareURL, workDir := makeLocalRepo(t) + destDir := t.TempDir() + + opts := CloneOptions{ + URL: bareURL, + Branch: "main", + DestDir: destDir, + Provider: ProviderUnknown, + } + + // Initial clone. + require.NoError(t, Clone(context.Background(), opts)) + assert.NoFileExists(t, filepath.Join(destDir, "new_file.go")) + + // Add a new commit to the upstream bare repo. + addCommit(t, workDir, "new_file.go", "package main\n") + + // Pull should bring in the new file. + err := Clone(context.Background(), opts) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(destDir, "new_file.go")) +} + +func TestClone_badURL(t *testing.T) { + t.Parallel() + destDir := t.TempDir() + + err := Clone(context.Background(), CloneOptions{ + URL: "file:///nonexistent/repo.git", + Branch: "main", + DestDir: destDir, + Provider: ProviderUnknown, + }) + + assert.Error(t, err) +} diff --git a/internal/libs/gitrepo/fake.go b/internal/libs/gitrepo/fake.go new file mode 100644 index 0000000..743d822 --- /dev/null +++ b/internal/libs/gitrepo/fake.go @@ -0,0 +1,47 @@ +package gitrepo + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +// Cloner is the interface for cloning a git repository. +type Cloner interface { + Clone(ctx context.Context, opts CloneOptions) error +} + +// DefaultCloner uses go-git to perform real clones. It is the production implementation. +type DefaultCloner struct{} + +func (d *DefaultCloner) Clone(ctx context.Context, opts CloneOptions) error { + return Clone(ctx, opts) +} + +// FakeCloner writes in-memory files to the destination directory instead of +// cloning a real repository. Intended for use in tests only. +type FakeCloner struct { + // Files maps relative file paths to their content. + Files map[string][]byte + // Err, if non-nil, is returned from Clone instead of writing files. + Err error +} + +func (f *FakeCloner) Clone(_ context.Context, opts CloneOptions) error { + if f.Err != nil { + return fmt.Errorf("clone %s: %w", opts.URL, f.Err) + } + + for relPath, content := range f.Files { + dest := filepath.Join(opts.DestDir, relPath) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(dest), err) + } + if err := os.WriteFile(dest, content, 0644); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + } + + return nil +} diff --git a/internal/libs/gitrepo/provider.go b/internal/libs/gitrepo/provider.go new file mode 100644 index 0000000..0466c7e --- /dev/null +++ b/internal/libs/gitrepo/provider.go @@ -0,0 +1,56 @@ +package gitrepo + +import ( + "fmt" + "net/url" + "strings" +) + +type Provider string + +const ( + ProviderGitHub Provider = "github" + ProviderGitLab Provider = "gitlab" + ProviderBitbucket Provider = "bitbucket" + ProviderUnknown Provider = "unknown" +) + +// DetectProvider determines the git hosting provider from a clone URL. +func DetectProvider(repoURL string) Provider { + lower := strings.ToLower(repoURL) + switch { + case strings.Contains(lower, "github.com"): + return ProviderGitHub + case strings.Contains(lower, "gitlab.com"): + return ProviderGitLab + case strings.Contains(lower, "bitbucket.org"): + return ProviderBitbucket + default: + return ProviderUnknown + } +} + +// AuthenticatedURL injects a token into the clone URL for the given provider. +func AuthenticatedURL(repoURL, token string, provider Provider) (string, error) { + if token == "" { + return repoURL, nil + } + + parsed, err := url.Parse(repoURL) + if err != nil { + return "", fmt.Errorf("invalid repository URL: %w", err) + } + + switch provider { + case ProviderGitHub: + parsed.User = url.UserPassword("x-access-token", token) + case ProviderGitLab: + parsed.User = url.UserPassword("oauth2", token) + case ProviderBitbucket: + parsed.User = url.UserPassword("x-token-auth", token) + default: + parsed.User = url.UserPassword("token", token) + } + + return parsed.String(), nil +} diff --git a/internal/libs/gitrepo/provider_test.go b/internal/libs/gitrepo/provider_test.go new file mode 100644 index 0000000..b881dab --- /dev/null +++ b/internal/libs/gitrepo/provider_test.go @@ -0,0 +1,107 @@ +package gitrepo + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectProvider(t *testing.T) { + t.Parallel() + + tests := []struct { + url string + expected Provider + }{ + {"https://github.com/org/repo", ProviderGitHub}, + {"https://github.com/org/repo.git", ProviderGitHub}, + {"git@github.com:org/repo.git", ProviderGitHub}, + {"https://GITHUB.COM/org/repo", ProviderGitHub}, + {"https://gitlab.com/org/repo", ProviderGitLab}, + {"https://gitlab.com/org/subgroup/repo.git", ProviderGitLab}, + {"https://bitbucket.org/org/repo", ProviderBitbucket}, + {"https://bitbucket.org/org/repo.git", ProviderBitbucket}, + {"https://example.com/org/repo", ProviderUnknown}, + {"https://mygitlab.internal/org/repo", ProviderUnknown}, + {"", ProviderUnknown}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + t.Parallel() + got := DetectProvider(tt.url) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestAuthenticatedURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repoURL string + token string + provider Provider + wantUser string + wantPass string + }{ + { + name: "github injects x-access-token user", + repoURL: "https://github.com/org/repo", + token: "ghp_abc123", + provider: ProviderGitHub, + wantUser: "x-access-token", + wantPass: "ghp_abc123", + }, + { + name: "gitlab injects oauth2 user", + repoURL: "https://gitlab.com/org/repo", + token: "glpat-abc123", + provider: ProviderGitLab, + wantUser: "oauth2", + wantPass: "glpat-abc123", + }, + { + name: "bitbucket injects x-token-auth user", + repoURL: "https://bitbucket.org/org/repo", + token: "atl_abc123", + provider: ProviderBitbucket, + wantUser: "x-token-auth", + wantPass: "atl_abc123", + }, + { + name: "unknown provider uses generic token user", + repoURL: "https://example.com/org/repo", + token: "mytoken", + provider: ProviderUnknown, + wantUser: "token", + wantPass: "mytoken", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := AuthenticatedURL(tt.repoURL, tt.token, tt.provider) + require.NoError(t, err) + assert.Contains(t, got, tt.wantUser) + assert.Contains(t, got, tt.wantPass) + }) + } + + t.Run("empty token returns original URL unchanged", func(t *testing.T) { + t.Parallel() + url := "https://github.com/org/repo" + got, err := AuthenticatedURL(url, "", ProviderGitHub) + require.NoError(t, err) + assert.Equal(t, url, got) + }) + + t.Run("invalid URL returns error", func(t *testing.T) { + t.Parallel() + _, err := AuthenticatedURL("://bad url", "token", ProviderGitHub) + assert.Error(t, err) + }) +} diff --git a/internal/libs/openai/embeddings.go b/internal/libs/openai/embeddings.go new file mode 100644 index 0000000..5774acc --- /dev/null +++ b/internal/libs/openai/embeddings.go @@ -0,0 +1,129 @@ +package openai + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/gomantics/semantix/config" + "github.com/sashabaranov/go-openai" + "go.uber.org/zap" +) + +const ( + embeddingModel = openai.SmallEmbedding3 + batchSize = 2048 + maxRetries = 5 + baseDelay = 500 * time.Millisecond +) + +// EmbeddingResult holds a batch of embeddings mapped by their original index. +type EmbeddingResult struct { + Embeddings [][]float32 +} + +// Embedder is the interface for generating text embeddings. +type Embedder interface { + GenerateEmbeddings(ctx context.Context, l *zap.Logger, texts []string) (*EmbeddingResult, error) +} + +var defaultEmbedder Embedder + +// Init initializes the OpenAI client from config and sets the default embedder. +func Init() error { + apiKey := config.Openai.ApiKey() + if apiKey != "" { + defaultEmbedder = &Client{inner: openai.NewClient(apiKey)} + } + return nil +} + +// SetDefaultEmbedder replaces the default embedder. Intended for use in tests only. +func SetDefaultEmbedder(e Embedder) { + defaultEmbedder = e +} + +// GetDefaultEmbedder returns the current default embedder. +func GetDefaultEmbedder() Embedder { + return defaultEmbedder +} + +// GenerateEmbeddings creates embeddings using the default embedder. +func GenerateEmbeddings(ctx context.Context, l *zap.Logger, texts []string) (*EmbeddingResult, error) { + if defaultEmbedder == nil { + return nil, fmt.Errorf("openai client not initialized: set CONFIG_OPENAI_API_KEY") + } + return defaultEmbedder.GenerateEmbeddings(ctx, l, texts) +} + +// Client wraps the OpenAI SDK client and implements Embedder. +type Client struct { + inner *openai.Client +} + +// GetClient returns the underlying OpenAI SDK client. +func (c *Client) GetClient() *openai.Client { + return c.inner +} + +func (c *Client) GenerateEmbeddings(ctx context.Context, l *zap.Logger, texts []string) (*EmbeddingResult, error) { + if len(texts) == 0 { + return &EmbeddingResult{}, nil + } + + allEmbeddings := make([][]float32, len(texts)) + + for batchStart := 0; batchStart < len(texts); batchStart += batchSize { + batchEnd := min(batchStart + batchSize, len(texts)) + batch := texts[batchStart:batchEnd] + + resp, err := createEmbeddingsWithRetry(ctx, l, c.inner, batch) + if err != nil { + return nil, fmt.Errorf("embedding batch %d-%d: %w", batchStart, batchEnd, err) + } + + for _, emb := range resp.Data { + allEmbeddings[batchStart+emb.Index] = emb.Embedding + } + } + + return &EmbeddingResult{Embeddings: allEmbeddings}, nil +} + +func createEmbeddingsWithRetry(ctx context.Context, l *zap.Logger, client *openai.Client, texts []string) (openai.EmbeddingResponse, error) { + var lastErr error + + for attempt := range maxRetries { + if attempt > 0 { + delay := time.Duration(float64(baseDelay) * math.Pow(2, float64(attempt-1))) + if delay > 30*time.Second { + delay = 30 * time.Second + } + + l.Warn("retrying embedding request", + zap.Int("attempt", attempt+1), + zap.Duration("delay", delay), + zap.Error(lastErr), + ) + + select { + case <-ctx.Done(): + return openai.EmbeddingResponse{}, ctx.Err() + case <-time.After(delay): + } + } + + resp, err := client.CreateEmbeddings(ctx, openai.EmbeddingRequestStrings{ + Input: texts, + Model: embeddingModel, + }) + if err == nil { + return resp, nil + } + + lastErr = err + } + + return openai.EmbeddingResponse{}, fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr) +} diff --git a/internal/libs/openai/fake.go b/internal/libs/openai/fake.go new file mode 100644 index 0000000..49593ed --- /dev/null +++ b/internal/libs/openai/fake.go @@ -0,0 +1,56 @@ +package openai + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "math" + + "go.uber.org/zap" +) + +const embeddingDimensions = 1536 + +// FakeEmbedder returns deterministic, normalized 1536-dim vectors derived from +// a SHA-256 hash of each input text. No network calls are made. +// Intended for use in tests only. +type FakeEmbedder struct{} + +func (f *FakeEmbedder) GenerateEmbeddings(_ context.Context, _ *zap.Logger, texts []string) (*EmbeddingResult, error) { + embeddings := make([][]float32, len(texts)) + for i, text := range texts { + embeddings[i] = hashToVector(text) + } + return &EmbeddingResult{Embeddings: embeddings}, nil +} + +// hashToVector derives a deterministic normalized float32 vector from a string. +func hashToVector(text string) []float32 { + vec := make([]float32, embeddingDimensions) + + // Use repeated SHA-256 rounds seeded by input to fill all dimensions. + seed := []byte(text) + for i := 0; i < embeddingDimensions; i += 8 { + h := sha256.Sum256(seed) + seed = h[:] + for j := 0; j < 8 && i+j < embeddingDimensions; j++ { + bits := binary.LittleEndian.Uint32(h[j*4 : j*4+4]) + // Map uint32 range to [-1, 1] + vec[i+j] = float32(bits)/float32(math.MaxUint32)*2 - 1 + } + } + + // L2-normalize + var norm float64 + for _, v := range vec { + norm += float64(v) * float64(v) + } + norm = math.Sqrt(norm) + if norm > 0 { + for i := range vec { + vec[i] = float32(float64(vec[i]) / norm) + } + } + + return vec +} diff --git a/internal/qdrant/collection.go b/internal/qdrant/collection.go index b388c3d..44b54f3 100644 --- a/internal/qdrant/collection.go +++ b/internal/qdrant/collection.go @@ -113,6 +113,71 @@ func createPayloadIndexes(ctx context.Context, collectionName string, l *zap.Log return nil } +// UpsertPoints batch-upserts points into the collection. +func UpsertPoints(ctx context.Context, points []*pb.PointStruct) error { + collectionName := config.Qdrant.CollectionName() + const batchSize = 100 + + for i := 0; i < len(points); i += batchSize { + end := i + batchSize + if end > len(points) { + end = len(points) + } + + wait := true + _, err := pointsClient.Upsert(ctx, &pb.UpsertPoints{ + CollectionName: collectionName, + Wait: &wait, + Points: points[i:end], + }) + if err != nil { + return fmt.Errorf("upsert batch %d-%d: %w", i, end, err) + } + } + + return nil +} + +// DeletePointsByFilter removes all points matching the given filter. +func DeletePointsByFilter(ctx context.Context, filter *pb.Filter) error { + collectionName := config.Qdrant.CollectionName() + wait := true + + _, err := pointsClient.Delete(ctx, &pb.DeletePoints{ + CollectionName: collectionName, + Wait: &wait, + Points: &pb.PointsSelector{ + PointsSelectorOneOf: &pb.PointsSelector_Filter{ + Filter: filter, + }, + }, + }) + if err != nil { + return fmt.Errorf("delete points: %w", err) + } + + return nil +} + +// CountPoints returns the number of points matching the given filter. +// Pass nil to count all points in the collection. +func CountPoints(ctx context.Context, filter *pb.Filter) (uint64, error) { + collectionName := config.Qdrant.CollectionName() + + req := &pb.CountPoints{ + CollectionName: collectionName, + Filter: filter, + Exact: boolPtr(true), + } + + resp, err := pointsClient.Count(ctx, req) + if err != nil { + return 0, fmt.Errorf("count points: %w", err) + } + + return resp.GetResult().GetCount(), nil +} + func boolPtr(b bool) *bool { return &b } diff --git a/internal/qdrant/init.go b/internal/qdrant/init.go index 1bee5bd..ab5ffe7 100644 --- a/internal/qdrant/init.go +++ b/internal/qdrant/init.go @@ -86,6 +86,14 @@ func GetPointsClient() pb.PointsClient { return pointsClient } +// SetClients replaces the global qdrant clients. Intended for use in tests only. +func SetClients(conn *grpc.ClientConn) { + defaultConn = conn + defaultClient = pb.NewQdrantClient(conn) + collectionsClient = pb.NewCollectionsClient(conn) + pointsClient = pb.NewPointsClient(conn) +} + // HealthCheck pings Qdrant for health endpoint func HealthCheck(ctx context.Context) error { if defaultClient == nil { diff --git a/internal/testutil/approvals.go b/internal/testutil/approvals.go new file mode 100644 index 0000000..c9cfa4c --- /dev/null +++ b/internal/testutil/approvals.go @@ -0,0 +1,59 @@ +package testutil + +import ( + "encoding/json" + "io" + + approvals "github.com/approvals/go-approval-tests" + "github.com/approvals/go-approval-tests/reporters" +) + +// WithApprovals returns an Option that configures the go-approval-tests reporter +// and snapshot folder. When the -accept-changes flag is set, all diffs are +// auto-approved. +func WithApprovals() Option { + return func() Teardown { + return withApprovals() + } +} + +func withApprovals() Teardown { + approvals.UseFolder("testdata") + + var closer io.Closer + if AcceptChanges { + closer = approvals.UseFrontLoadedReporter(reporters.NewReporterThatAutomaticallyApproves()) + } + + return func() { + if closer != nil { + closer.Close() + } + } +} + +// ScrubField replaces the value of a key in a decoded JSON map with "[SCRUBBED]". +// This is used before snapshot assertions to mask non-deterministic fields like +// IDs and timestamps. +func ScrubField(data map[string]any, field string) { + if _, ok := data[field]; ok { + data[field] = "[SCRUBBED]" + } +} + +// ScrubFields scrubs multiple fields from a decoded JSON map. +func ScrubFields(data map[string]any, fields ...string) { + for _, f := range fields { + ScrubField(data, f) + } +} + +// MustMarshalJSON marshals v to a JSON string, panicking on error. Useful for +// building approval test inputs inline. +func MustMarshalJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + panic(err) + } + return string(b) +} diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go new file mode 100644 index 0000000..0f58c40 --- /dev/null +++ b/internal/testutil/fixtures.go @@ -0,0 +1,34 @@ +package testutil + +import ( + "context" + "fmt" + "time" + + "github.com/gomantics/semantix/internal/db" +) + +// Fixtures holds seed data created before tests run. +type Fixtures struct { + Workspace db.Workspace +} + +// LoadFixtures creates a standard workspace used as the base for tests. +func LoadFixtures(ctx context.Context) (*Fixtures, error) { + now := time.Now().UnixNano() + + ws, err := db.Tx1(ctx, func(q *db.Queries) (db.Workspace, error) { + return q.CreateWorkspace(ctx, db.CreateWorkspaceParams{ + Name: "test-workspace", + Slug: fmt.Sprintf("test-workspace-%d", now), + Settings: []byte("{}"), + Created: now, + Updated: now, + }) + }) + if err != nil { + return nil, fmt.Errorf("create fixture workspace: %w", err) + } + + return &Fixtures{Workspace: ws}, nil +} diff --git a/internal/testutil/http.go b/internal/testutil/http.go new file mode 100644 index 0000000..a6e3588 --- /dev/null +++ b/internal/testutil/http.go @@ -0,0 +1,136 @@ +package testutil + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gomantics/semantix/internal/api/health" + "github.com/gomantics/semantix/internal/api/web" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +// StatusError is returned when a response has a non-2xx status code. +type StatusError struct { + Code int + Body []byte +} + +func (e *StatusError) Error() string { + return fmt.Sprintf("unexpected status %d: %s", e.Code, e.Body) +} + +// State holds the test Echo server and provides HTTP helper methods. +type State struct { + t *testing.T + server *echo.Echo +} + +// NewState creates a State backed by a minimal Echo server with all routes registered. +func NewState(t *testing.T) *State { + t.Helper() + + l := zap.NewNop() + e := echo.New() + e.HideBanner = true + e.HidePort = true + + health.Configure(e, l) + + return &State{t: t, server: e} +} + +// Get performs an authenticated GET request against the test server. +func (s *State) Get(path string) (map[string]any, error) { + return s.do(http.MethodGet, path, nil) +} + +// Post performs a POST request with a JSON body. +func (s *State) Post(path string, body any) (map[string]any, error) { + return s.do(http.MethodPost, path, body) +} + +// Put performs a PUT request with a JSON body. +func (s *State) Put(path string, body any) (map[string]any, error) { + return s.do(http.MethodPut, path, body) +} + +// Delete performs a DELETE request. +func (s *State) Delete(path string) (map[string]any, error) { + return s.do(http.MethodDelete, path, nil) +} + +func (s *State) do(method, path string, body any) (map[string]any, error) { + s.t.Helper() + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req := httptest.NewRequest(method, path, bodyReader) + if body != nil { + req.Header.Set(echo.MIMEApplicationJSON, "application/json") + } + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + + rec := httptest.NewRecorder() + s.server.ServeHTTP(rec, req) + + resp := rec.Result() + defer resp.Body.Close() + + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, &StatusError{Code: resp.StatusCode, Body: rawBody} + } + + var result map[string]any + if len(rawBody) > 0 { + if err := json.Unmarshal(rawBody, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + } + + return result, nil +} + +// RequireStatus asserts that err is a *StatusError with the expected code, +// or that err is nil when expectedCode is 2xx. Fails the test immediately. +func RequireStatus(t *testing.T, err error, expectedCode int) { + t.Helper() + + if err == nil { + if expectedCode >= 200 && expectedCode < 300 { + return + } + t.Fatalf("expected status %d but request succeeded", expectedCode) + } + + se, ok := err.(*StatusError) + if !ok { + t.Fatalf("unexpected error (not a StatusError): %v", err) + } + + if se.Code != expectedCode { + t.Fatalf("expected status %d, got %d: %s", expectedCode, se.Code, se.Body) + } +} + +// Wrap adapts a web.HandlerFunc into the echo handler format for direct testing. +func Wrap(h web.HandlerFunc, l *zap.Logger) echo.HandlerFunc { + return web.Wrap(h, l) +} diff --git a/internal/testutil/postgres.go b/internal/testutil/postgres.go new file mode 100644 index 0000000..9b5f621 --- /dev/null +++ b/internal/testutil/postgres.go @@ -0,0 +1,98 @@ +package testutil + +import ( + "context" + "fmt" + "time" + + "github.com/gomantics/semantix/internal/db" + "github.com/gomantics/semantix/internal/db/migrations" + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" // registers "pgx" driver for goose + "github.com/pressly/goose/v3" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// WithPostgres returns an Option that starts a Postgres 17.7 container, runs +// migrations, and injects the pool into the db package. The container is +// terminated on teardown. +func WithPostgres() Option { + return func() Teardown { + return withPostgres() + } +} + +func withPostgres() Teardown { + ctx := context.Background() + + ctr, err := tcpostgres.Run(ctx, + "postgres:17.7-alpine", + tcpostgres.WithDatabase("semantix"), + tcpostgres.WithUsername("semantix"), + tcpostgres.WithPassword("semantix"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + panic(fmt.Sprintf("testutil: start postgres: %v", err)) + } + + dsn, err := ctr.ConnectionString(ctx, "sslmode=disable") + if err != nil { + panic(fmt.Sprintf("testutil: get postgres DSN: %v", err)) + } + + poolCfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + panic(fmt.Sprintf("testutil: parse pool config: %v", err)) + } + poolCfg.MaxConns = 10 + poolCfg.MinConns = 2 + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + panic(fmt.Sprintf("testutil: create pgxpool: %v", err)) + } + + if err := pool.Ping(ctx); err != nil { + panic(fmt.Sprintf("testutil: ping postgres: %v", err)) + } + + if err := runMigrations(ctx, dsn); err != nil { + panic(fmt.Sprintf("testutil: run migrations: %v", err)) + } + + db.SetPool(pool) + + return func() { + pool.Close() + if err := testcontainers.TerminateContainer(ctr); err != nil { + fmt.Printf("testutil: terminate postgres: %v\n", err) + } + } +} + +func runMigrations(ctx context.Context, dsn string) error { + goose.SetBaseFS(migrations.FS) + if err := goose.SetDialect("postgres"); err != nil { + return err + } + + sqlDB, err := goose.OpenDBWithDriver("pgx", dsn) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer sqlDB.Close() + + if err := goose.RunContext(ctx, "up", sqlDB, "."); err != nil { + return fmt.Errorf("goose up: %w", err) + } + + return nil +} diff --git a/internal/testutil/qdrant.go b/internal/testutil/qdrant.go new file mode 100644 index 0000000..4532d70 --- /dev/null +++ b/internal/testutil/qdrant.go @@ -0,0 +1,61 @@ +package testutil + +import ( + "context" + "fmt" + "time" + + internalqdrant "github.com/gomantics/semantix/internal/qdrant" + "github.com/testcontainers/testcontainers-go" + tcqdrant "github.com/testcontainers/testcontainers-go/modules/qdrant" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// WithQdrant returns an Option that starts a Qdrant v1.17.0 container, ensures +// the collection exists, and injects the gRPC connection into the qdrant package. +// The container is terminated on teardown. +func WithQdrant() Option { + return func() Teardown { + return withQdrant() + } +} + +func withQdrant() Teardown { + ctx := context.Background() + + ctr, err := tcqdrant.Run(ctx, "qdrant/qdrant:v1.17.0") + if err != nil { + panic(fmt.Sprintf("testutil: start qdrant: %v", err)) + } + + // Wait briefly for Qdrant to be fully ready after port becomes available. + time.Sleep(500 * time.Millisecond) + + grpcEndpoint, err := ctr.GRPCEndpoint(ctx) + if err != nil { + panic(fmt.Sprintf("testutil: get qdrant gRPC endpoint: %v", err)) + } + + conn, err := grpc.NewClient(grpcEndpoint, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + panic(fmt.Sprintf("testutil: connect to qdrant: %v", err)) + } + + internalqdrant.SetClients(conn) + + l := zap.NewNop() + if err := internalqdrant.EnsureCollection(ctx, l); err != nil { + panic(fmt.Sprintf("testutil: ensure qdrant collection: %v", err)) + } + + return func() { + conn.Close() + if err := testcontainers.TerminateContainer(ctr); err != nil { + fmt.Printf("testutil: terminate qdrant: %v\n", err) + } + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..5eedb86 --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,53 @@ +// Package testutil provides shared test infrastructure for integration tests. +// Use testutil.Main in TestMain functions to compose infrastructure options. +package testutil + +import ( + "flag" + "testing" + "time" +) + +// AcceptChanges is a flag that, when set, auto-approves all snapshot diffs. +var AcceptChanges bool + +func init() { + flag.BoolVar(&AcceptChanges, "accept-changes", false, "automatically accept approval test snapshots") +} + +// Teardown is a cleanup function returned by an Option. +type Teardown func() + +// Option sets up a piece of test infrastructure and returns its teardown. +type Option func() Teardown + +// M wraps *testing.M and manages ordered infrastructure setup and teardown. +type M struct { + m *testing.M + teardowns []Teardown +} + +// Main creates a new M with the given options, running each option's setup +// immediately. Teardowns are run in reverse order after m.Run(). +func Main(m *testing.M, opts ...Option) *M { + // Force UTC so timestamp assertions are deterministic. + time.Local = time.UTC + + hm := &M{m: m} + for _, opt := range opts { + td := opt() + hm.teardowns = append(hm.teardowns, td) + } + return hm +} + +// Run executes the test suite, then runs all teardowns in reverse order. +func (m *M) Run() int { + code := m.m.Run() + + for i := len(m.teardowns) - 1; i >= 0; i-- { + m.teardowns[i]() + } + + return code +} diff --git a/internal/testutil/unique.go b/internal/testutil/unique.go new file mode 100644 index 0000000..d116750 --- /dev/null +++ b/internal/testutil/unique.go @@ -0,0 +1,14 @@ +package testutil + +import ( + "fmt" + "sync/atomic" +) + +var counter atomic.Uint64 + +// UniqueID returns a unique string safe for use across parallel tests. +// Each call returns a different value within a test binary invocation. +func UniqueID() string { + return fmt.Sprintf("%d", counter.Add(1)) +} From 918726a4c5fcd917ac9e8cfd8b50b2cd8e7cfe63 Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 1 Mar 2026 19:40:12 +0530 Subject: [PATCH 2/3] ci --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..69b6752 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + build-and-test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... From 8e2ea6d8981fb3097cc7c1778fef854e6f6427e8 Mon Sep 17 00:00:00 2001 From: Karn Date: Sun, 1 Mar 2026 19:48:04 +0530 Subject: [PATCH 3/3] go mod tidy --- go.mod | 14 ++++++++------ go.sum | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 1193258..d4f9a71 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ tool ( ) require ( + github.com/approvals/go-approval-tests v1.5.0 github.com/go-git/go-git/v5 v5.17.0 github.com/gomantics/chunkx v0.0.3 github.com/jackc/pgx/v5 v5.8.0 @@ -16,6 +17,10 @@ require ( github.com/pressly/goose/v3 v3.27.0 github.com/qdrant/go-client v1.16.2 github.com/sashabaranov/go-openai v1.41.2 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 + github.com/testcontainers/testcontainers-go/modules/qdrant v0.40.0 go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 google.golang.org/grpc v1.79.1 @@ -32,10 +37,10 @@ require ( github.com/air-verse/air v1.64.5 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/approvals/go-approval-tests v1.5.0 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/golibsass v1.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -70,6 +75,7 @@ require ( github.com/gomantics/sx v0.0.3 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -121,11 +127,7 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/tdewolff/parse/v2 v2.8.3 // indirect - github.com/testcontainers/testcontainers-go v0.40.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 // indirect - github.com/testcontainers/testcontainers-go/modules/qdrant v0.40.0 // indirect github.com/tetratelabs/wazero v1.9.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect @@ -150,7 +152,7 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index 36f6d4e..ccf4b3e 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= @@ -65,6 +67,8 @@ github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= @@ -82,6 +86,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= @@ -194,6 +200,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo= github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -235,6 +243,8 @@ github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcX github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -251,6 +261,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -263,6 +275,8 @@ github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= @@ -367,6 +381,8 @@ github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8 github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -418,14 +434,20 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -497,8 +519,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= @@ -525,6 +547,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=