From 3d4d908d544e715de932b1af96e0c0487337ad9e Mon Sep 17 00:00:00 2001 From: u80864958 Date: Thu, 12 Feb 2026 16:02:38 +0100 Subject: [PATCH] init --- .gitignore | 3 + .vscode/launch.json | 15 +++ README.md | 78 +++++++++++++ cmd/pierre/main.go | 86 ++++++++++++++ config.example.yaml | 12 ++ go.mod | 52 +++++++++ go.sum | 187 ++++++++++++++++++++++++++++++ internal/chatter/gemini.go | 158 +++++++++++++++++++++++++ internal/chatter/model.go | 23 ++++ internal/chatter/ollama.go | 158 +++++++++++++++++++++++++ internal/gitadapters/base.go | 31 +++++ internal/gitadapters/bitbucket.go | 38 ++++++ internal/gitadapters/resource.go | 1 + internal/pierre/judge.go | 43 +++++++ 14 files changed, 885 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 cmd/pierre/main.go create mode 100644 config.example.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/chatter/gemini.go create mode 100644 internal/chatter/model.go create mode 100644 internal/chatter/ollama.go create mode 100644 internal/gitadapters/base.go create mode 100644 internal/gitadapters/bitbucket.go create mode 100644 internal/gitadapters/resource.go create mode 100644 internal/pierre/judge.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ad139b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/pierre +/pierre-cli +/config.yaml diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ff3e31d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Pierre", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/pierre/main.go", + "cwd": "${workspaceFolder}", + "backend": "dlv", + "showLog": true + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b15345e --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +## Pierre Bot + +**Pierre Bot** is an AI-powered code review companion designed for Bitbucket Server/Data Center. +It fetches pull request diffs, analyzes them using Google's Gemini 2.0 Flash model, and identifies potential bugs, style issues, or logic errors. + +## Installation + +Ensure you have [Go](https://go.dev/) installed, then clone the repository and build the binary: + +```bash +git clone https://bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot.git +cd pierre-bot +go build -o pierre ./cmd/pierre/main.go + +``` + +## Configuration + +Pierre Bot can be configured via **Command Line Flags**, **Environment Variables**, or a **YAML Config File**. + +### Configuration Hierarchy + +1. Command line flags +2. Environment variables +3. `config.yaml` in the current directory +4. `~/.pierre.yaml` + +### Required Environment Variables + +To avoid passing sensitive tokens in plain text, it you cann export these: + +```bash +export BITBUCKET_URL="https://your-bitbucket-instance.com" +export BITBUCKET_TOKEN="your-personal-access-token" +export LLM_API_KEY="your-gemini-api-key" + +``` + +## Usage Examples + +The tool uses **positional arguments** for the specific Pull Request you want to target: +`pierre ` + +### 1. Basic Command Line Usage + +If you have your environment variables set, simply run: + +```bash +./pierre-bot PROJ-KEY my-app-repo 125 + +``` + +### 2. Using a Configuration File + +You can create a `config.yaml` file to store persistent settings: + +```yaml +# config.yaml +bitbucket-url: "https://bitbucket.example.com" +bitbucket-token: "np-mY-S3cr3t-T0k3n" +``` + +Then run the bot: + +```bash +./pierre-bot --config config.yaml PROJ-KEY my-app-repo 125 + +``` + +### 3. Overriding Everything via Flags + +```bash +./pierre \ + --bitbucket-url="https://bitbucket.example.com" \ + --bitbucket-token="token" \ + MYPROJECT my-repo 42 + +``` diff --git a/cmd/pierre/main.go b/cmd/pierre/main.go new file mode 100644 index 0000000..cb45a21 --- /dev/null +++ b/cmd/pierre/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + "log" + + "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/chatter" + "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/gitadapters" + "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/pierre" + "github.com/alecthomas/kong" + kongyaml "github.com/alecthomas/kong-yaml" +) + +type BitbucketConfig struct { + BaseURL string `help:"Bitbucket Base URL (e.g. https://bitbucket.example.com)" required:"" env:"BITBUCKET_URL"` + Token string `help:"Bearer Token" env:"BITBUCKET_TOKEN"` + // Positional arguments + Project string `arg:"" help:"Project Key (e.g. PROJ)" env:"BITBUCKET_PROJECT"` + Repo string `arg:"" help:"Repository Slug" env:"BITBUCKET_REPO"` + PRID int `arg:"" help:"Pull Request ID" name:"pr"` +} + +type LLMConfig struct { + Provider string `help:"Provider for llm (ollama or gemini)" required:"" env:"LLM_PROVIDER"` + Endpoint string `help:"Endpoint for provider (only for ollama)" env:"LLM_ENDPOINT"` + APIKey string `help:"APIKey for provider" env:"LLM_API_KEY"` + Model string `help:"Model to use" env:"LLM_MODEL"` +} + +type Config struct { + Bitbucket BitbucketConfig `embed:"" prefix:"bitbucket-"` + LLM LLMConfig `embed:"" prefix:"llm-"` + Config kong.ConfigFlag `help:"Path to a YAML config file"` +} + +func main() { + cfg := &Config{} + + // Parse flags, env vars, and config files + kong.Parse(cfg, + kong.Name("pierre"), + kong.Description("AI-powered Pull Request reviewer for Bitbucket"), + kong.UsageOnError(), + kong.Configuration(kongyaml.Loader, "config.yaml", "~/.config/pierre/config.yaml"), + ) + + // Initialize Bitbucket Adapter + bitbucket := gitadapters.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token) + + // Fetch Diff using positional args + diff, err := bitbucket.GetDiff(cfg.Bitbucket.Project, cfg.Bitbucket.Repo, cfg.Bitbucket.PRID) + if err != nil { + log.Fatalf("Error fetching diff: %v", err) + } + + // Initialize AI Adapter + + var ai pierre.ChatAdapter + + switch cfg.LLM.Provider { + case "gemini": + ai, err = chatter.NewGeminiAdapter(context.Background(), cfg.LLM.APIKey, cfg.LLM.Model) + case "ollama": + ai, err = chatter.NewOllamaAdapter(cfg.LLM.Endpoint, cfg.LLM.Model) + default: + log.Fatalf("%s is not a valid llm provider", cfg.LLM.Provider) + } + + if err != nil { + log.Fatalf("Error initializing AI: %v", err) + } + + // Run Logic + comments, err := pierre.JudgePR(context.Background(), ai, diff) + if err != nil { + log.Fatalf("Error judging PR: %v", err) + } + + fmt.Printf("Analysis complete. Found %d issues.\n---\n", len(comments)) + + for _, c := range comments { + fmt.Printf("File: %s\nLine: %d\nMessage: %s\n%s\n", + c.File, c.Line, c.Message, "---") + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..14f5a30 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,12 @@ +bitbucket: + base-url: "https://bitbucket.your-org.ch" + token: "BMTY4OTU0NjU3OTo..." + # Positional defaults (optional) + project: "APP" + repo: "api-gateway" + prid: 45 + +llm: + provider: "gemini" + api-key: "AIzaSy..." + model: "gemini-2.0-flash" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..122402f --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot + +go 1.25.0 + +require ( + github.com/google/generative-ai-go v0.20.1 + github.com/ollama/ollama v0.16.0 + google.golang.org/api v0.186.0 +) + +require ( + cloud.google.com/go v0.115.0 // indirect + cloud.google.com/go/ai v0.8.0 // indirect + cloud.google.com/go/auth v0.6.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect + cloud.google.com/go/compute/metadata v0.3.0 // indirect + cloud.google.com/go/longrunning v0.5.7 // indirect + github.com/alecthomas/kong v1.14.0 // indirect + github.com/alecthomas/kong-yaml v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.5 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect + go.opentelemetry.io/otel v1.26.0 // indirect + go.opentelemetry.io/otel/metric v1.26.0 // indirect + go.opentelemetry.io/otel/trace v1.26.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect + google.golang.org/grpc v1.64.1 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cf8de47 --- /dev/null +++ b/go.sum @@ -0,0 +1,187 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= +cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= +cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g= +cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g= +cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= +cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= +github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/kong-yaml v0.2.0 h1:iiVVqVttmOsHKawlaW/TljPsjaEv1O4ODx6dloSA58Y= +github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfRP03Q1StN69/g= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/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/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ= +github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= +github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/ollama/ollama v0.16.0 h1:wDrjgZvx+ej1iYrD//q7crGRA4b4482WZodRYc7oQTI= +github.com/ollama/ollama v0.16.0/go.mod h1:FEk95NbAJJZk+t7cLh+bPGTul72j1O3PLLlYNV3FVZ0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= +go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= +go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= +go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= +go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= +go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= +go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug= +google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc= +google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/chatter/gemini.go b/internal/chatter/gemini.go new file mode 100644 index 0000000..d07ae16 --- /dev/null +++ b/internal/chatter/gemini.go @@ -0,0 +1,158 @@ +package chatter + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/google/generative-ai-go/genai" + "google.golang.org/api/option" +) + +type GeminiAdapter struct { + client *genai.Client + model string +} + +func NewGeminiAdapter(ctx context.Context, apiKey string, modelName string) (*GeminiAdapter, error) { + client, err := genai.NewClient(ctx, option.WithAPIKey(apiKey)) + if err != nil { + return nil, fmt.Errorf("failed to create gemini client: %w", err) + } + + return &GeminiAdapter{ + client: client, + model: modelName, + }, nil +} + +func (a *GeminiAdapter) Generate(ctx context.Context, messages []Message) (string, error) { + model := a.client.GenerativeModel(a.model) + cs := model.StartChat() + + // Convert history (excluding the last message which is the current prompt) + var history []*genai.Content + for i := 0; i < len(messages)-1; i++ { + history = append(history, &genai.Content{ + Role: mapRoleToGemini(messages[i].Role), + Parts: []genai.Part{genai.Text(messages[i].Content)}, + }) + } + cs.History = history + + // Send the last message + lastMsg := messages[len(messages)-1].Content + resp, err := cs.SendMessage(ctx, genai.Text(lastMsg)) + if err != nil { + return "", fmt.Errorf("gemini generation failed: %w", err) + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return "", fmt.Errorf("empty response from gemini") + } + + // Extract text from the first part of the first candidate + if part, ok := resp.Candidates[0].Content.Parts[0].(genai.Text); ok { + return string(part), nil + } + + return "", fmt.Errorf("unexpected response format") +} + +// GenerateStructured reflects the 'target' to create a schema, then unmarshals the result. +func (a *GeminiAdapter) GenerateStructured(ctx context.Context, messages []Message, target any) error { + t := reflect.TypeOf(target) + + model := a.client.GenerativeModel(a.model) + + // 1. Automatically generate the JSON Schema from the Go struct + // 1. Recursively map the Go struct to Gemini's Schema format + model.ResponseMIMEType = "application/json" + model.ResponseSchema = schemaFromType(t.Elem()) // 2. Convert to the internal genai.Schema format + + var prompt []genai.Part + for _, m := range messages { + prompt = append(prompt, genai.Text(m.Content)) + } + + resp, err := model.GenerateContent(ctx, prompt...) + if err != nil { + return err + } + + part := resp.Candidates[0].Content.Parts[0].(genai.Text) + err = json.Unmarshal([]byte(part), target) + return err +} + +// schemaFromType recursively builds a *genai.Schema tree from a reflect.Type +func schemaFromType(t reflect.Type) *genai.Schema { + // Follow pointers to the base type + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { + case reflect.Struct: + props := make(map[string]*genai.Schema) + var required []string + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + + // Get JSON tag or use field name + name := field.Tag.Get("json") + if name == "" || name == "-" { + name = field.Name + } + + props[name] = schemaFromType(field.Type) + required = append(required, name) + } + + return &genai.Schema{ + Type: genai.TypeObject, + Properties: props, + Required: required, + } + + case reflect.Slice, reflect.Array: + return &genai.Schema{ + Type: genai.TypeArray, + Items: schemaFromType(t.Elem()), + } + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return &genai.Schema{Type: genai.TypeInteger} + + case reflect.Float32, reflect.Float64: + return &genai.Schema{Type: genai.TypeNumber} + + case reflect.Bool: + return &genai.Schema{Type: genai.TypeBoolean} + + default: + return &genai.Schema{Type: genai.TypeString} + } +} + +func (a *GeminiAdapter) GetProviderName() string { + return "Google Gemini (" + a.model + ")" +} + +// Helper to map your roles to Gemini's expected roles +func mapRoleToGemini(role Role) string { + switch role { + case RoleUser: + return "user" + case RoleAssistant: + return "model" + default: + return "user" // Gemini doesn't have a specific 'system' role in chat history + } +} diff --git a/internal/chatter/model.go b/internal/chatter/model.go new file mode 100644 index 0000000..962682c --- /dev/null +++ b/internal/chatter/model.go @@ -0,0 +1,23 @@ +package chatter + +// Role defines who sent the message +type Role string + +const ( + RoleSystem Role = "system" + RoleUser Role = "user" + RoleAssistant Role = "assistant" +) + +// Message represents a single turn in a conversation +type Message struct { + Role Role + Content string +} + +// PredictionConfig allows for per-request overrides +type PredictionConfig struct { + Temperature float64 + MaxTokens int + Stop []string +} diff --git a/internal/chatter/ollama.go b/internal/chatter/ollama.go new file mode 100644 index 0000000..cf34e10 --- /dev/null +++ b/internal/chatter/ollama.go @@ -0,0 +1,158 @@ +package chatter + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/ollama/ollama/api" +) + +// OllamaAdapter implements the Adapter interface for local Ollama instances. +type OllamaAdapter struct { + client *api.Client + model string +} + +// NewOllamaAdapter initializes a new Ollama client. +// Default address is usually "http://localhost:11434" +func NewOllamaAdapter(endpoint string, model string) (*OllamaAdapter, error) { + client, err := api.ClientFromEnvironment() + if err != nil { + return nil, fmt.Errorf("failed to create ollama client: %w", err) + } + + return &OllamaAdapter{ + client: client, + model: model, + }, nil +} + +// Generate sends the chat history to Ollama and returns the assistant's response. +func (a *OllamaAdapter) Generate(ctx context.Context, messages []Message) (string, error) { + var ollamaMessages []api.Message + + // Map our internal Message struct to Ollama's API struct + for _, m := range messages { + ollamaMessages = append(ollamaMessages, api.Message{ + Role: string(m.Role), + Content: m.Content, + }) + } + + req := &api.ChatRequest{ + Model: a.model, + Messages: ollamaMessages, + Stream: new(bool), // Set to false for a single string response + } + + var response string + err := a.client.Chat(ctx, req, func(resp api.ChatResponse) error { + fmt.Print(resp.Message) + response = resp.Message.Content + return nil + }) + if err != nil { + return "", fmt.Errorf("ollama generation failed: %w", err) + } + + return response, nil +} + +func (a *OllamaAdapter) GetProviderName() string { + return "Ollama (" + a.model + ")" +} + +// GenerateStructured implements the same signature as your Gemini adapter. +func (a *OllamaAdapter) GenerateStructured(ctx context.Context, messages []Message, target any) error { + val := reflect.ValueOf(target) + if val.Kind() != reflect.Ptr || val.Elem().Kind() != reflect.Struct { + return fmt.Errorf("target must be a pointer to a struct") + } + + // 1. Generate the JSON Schema from the Go struct + schema := a.schemaFromType(val.Elem().Type()) + schemaBytes, err := json.Marshal(schema) + if err != nil { + return fmt.Errorf("failed to marshal schema: %w", err) + } + + // 2. Map messages + var ollamaMessages []api.Message + for _, m := range messages { + ollamaMessages = append(ollamaMessages, api.Message{ + Role: string(m.Role), + Content: m.Content, + }) + } + + // 3. Set Format to the raw JSON Schema + req := &api.ChatRequest{ + Model: a.model, + Messages: ollamaMessages, + Format: json.RawMessage(schemaBytes), + Stream: new(bool), // false + Options: map[string]interface{}{ + "temperature": 0, // Recommended for structured tasks + }, + } + + var responseText string + err = a.client.Chat(ctx, req, func(resp api.ChatResponse) error { + responseText = resp.Message.Content + return nil + }) + if err != nil { + return err + } + + // 4. Parse the result into the target struct + return json.Unmarshal([]byte(responseText), target) +} + +// schemaFromType (Reuse the same logic from the Gemini adapter) +func (a *OllamaAdapter) schemaFromType(t reflect.Type) map[string]interface{} { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { + case reflect.Struct: + props := make(map[string]interface{}) + var required []string + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if !field.IsExported() { + continue + } + name := field.Tag.Get("json") + if name == "" || name == "-" { + name = field.Name + } + props[name] = a.schemaFromType(field.Type) + required = append(required, name) + } + return map[string]interface{}{ + "type": "object", + "properties": props, + "required": required, + } + + case reflect.Slice, reflect.Array: + return map[string]interface{}{ + "type": "array", + "items": a.schemaFromType(t.Elem()), + } + + case reflect.Int, reflect.Int64: + return map[string]interface{}{"type": "integer"} + case reflect.Float64: + return map[string]interface{}{"type": "number"} + case reflect.Bool: + return map[string]interface{}{"type": "boolean"} + default: + return map[string]interface{}{"type": "string"} + } +} diff --git a/internal/gitadapters/base.go b/internal/gitadapters/base.go new file mode 100644 index 0000000..192985d --- /dev/null +++ b/internal/gitadapters/base.go @@ -0,0 +1,31 @@ +package gitadapters + +import ( + "fmt" + "io" + "net/http" + "net/url" +) + +type baseHTTP struct { + baseURL string + bearerToken string +} + +func (b *baseHTTP) createRequest(method string, body io.Reader, path ...string) (r *http.Request, err error) { + target, err := url.JoinPath(b.baseURL, path...) + if err != nil { + err = fmt.Errorf("can not parse path: %w", err) + return + } + req, err := http.NewRequest(method, target, body) + if err != nil { + return nil, err + } + + if b.bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+b.bearerToken) + } + + return req, nil +} diff --git a/internal/gitadapters/bitbucket.go b/internal/gitadapters/bitbucket.go new file mode 100644 index 0000000..33df958 --- /dev/null +++ b/internal/gitadapters/bitbucket.go @@ -0,0 +1,38 @@ +package gitadapters + +import ( + "fmt" + "io" + "net/http" +) + +type BitbucketAdapter struct { + baseHTTP +} + +func NewBitbucket(baseURL string, bearerToken string) *BitbucketAdapter { + return &BitbucketAdapter{ + baseHTTP{ + baseURL: baseURL, + bearerToken: bearerToken, + }, + } +} + +func (b *BitbucketAdapter) GetDiff(projectKey, repositorySlug string, pullRequestID int) (diff io.Reader, err error) { + r, err := b.createRequest( + http.MethodGet, + nil, + "/rest/api/1.0/projects/", projectKey, "repos", repositorySlug, "pull-requests", fmt.Sprintf("%d.diff", pullRequestID), + ) + if err != nil { + return + } + + response, err := http.DefaultClient.Do(r) + if err != nil { + return + } + diff = response.Body + return +} diff --git a/internal/gitadapters/resource.go b/internal/gitadapters/resource.go new file mode 100644 index 0000000..e40dd95 --- /dev/null +++ b/internal/gitadapters/resource.go @@ -0,0 +1 @@ +package gitadapters diff --git a/internal/pierre/judge.go b/internal/pierre/judge.go new file mode 100644 index 0000000..de91b3c --- /dev/null +++ b/internal/pierre/judge.go @@ -0,0 +1,43 @@ +package pierre + +import ( + "context" + "fmt" + "io" + + "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/chatter" +) + +type Comment struct { + File string `json:"file"` + Line int `json:"line"` + Message string `json:"message"` +} + +type ChatAdapter interface { + GenerateStructured(ctx context.Context, messages []chatter.Message, target interface{}) error +} + +func JudgePR(ctx context.Context, chat ChatAdapter, diff io.Reader) (comments []Comment, err error) { + diffBytes, err := io.ReadAll(diff) + if err != nil { + return nil, fmt.Errorf("failed to read diff: %w", err) + } + err = chat.GenerateStructured(ctx, []chatter.Message{ + { + Role: chatter.RoleSystem, + Content: ` + You are a very strict senior software architect. + You review **only** newly added or modified lines in a unified diff (lines prefixed with “+”), together with the immediate hunk context. + You do **not** report issues that appear **solely** in deleted lines (“-”) or that have already been fixed by the change. + No comments are made on pure formatting/whitespace changes or reordering that does not alter the program’s behavior. + `, + }, + { + Role: chatter.RoleUser, + Content: fmt.Sprintf("Hello please review my PR.\n Here is the git diff of it: %s", string(diffBytes)), + }, + }, &comments) + + return +}