Compare commits

3 Commits

Author SHA1 Message Date
u80864958
2cb64194b9 feat: correctly implement bitbucket & add OpenAIAdapter 2026-02-13 14:31:20 +01:00
b67125024c Merge pull request 'feat: gitea client' (#1) from feat/gitea into main
Reviewed-on: #1
2026-02-13 09:30:02 +01:00
9d49d94eff feat: gitea client 2026-02-12 22:05:38 +01:00
18 changed files with 722 additions and 125 deletions

77
GEMINI.md Normal file
View File

@@ -0,0 +1,77 @@
# Pierre Bot
Pierre Bot is an intelligent, AI-powered code review assistant designed for Bitbucket Server/Data Center. It automates the initial pass of code review by analyzing Pull Request diffs and identifying potential bugs, logic errors, and style issues using modern LLMs (Google Gemini 2.0 Flash or Ollama).
## Project Overview
* **Type:** Go CLI Application
* **Core Function:** Fetches PR diffs from Bitbucket -> Sends to LLM -> Prints structured review comments.
* **Key Technologies:**
* **Language:** Go (1.25+)
* **AI SDKs:** `google/generative-ai-go`, `ollama/ollama`
* **CLI Framework:** `alecthomas/kong`
## Architecture
The project follows a standard Go project layout:
* **`cmd/pierre/`**: Contains the `main.go` entry point. It handles configuration parsing (flags, env vars, file), initializes adapters, and orchestrates the application flow.
* **`internal/pierre/`**: Contains the core business logic.
* `judge.go`: Defines the `JudgePR` function which prepares the system prompt and context for the LLM.
* **`internal/chatter/`**: Abstraction layer for LLM providers.
* `gemini.go`: Implements the `ChatAdapter` interface for Google Gemini. notably includes **dynamic JSON schema generation** via reflection (`schemaFromType`) to enforce structured output from the model.
* `ollama.go`: Implements the `ChatAdapter` for Ollama (local models).
* **`internal/gitadapters/`**: Abstraction for Version Control Systems.
* `bitbucket.go`: Client for fetching PR diffs from Bitbucket Server.
## Building and Running
### Prerequisites
* Go 1.25 or later
* Access to a Bitbucket Server instance
* API Key for Google Gemini (or a running Ollama instance)
### Build
```bash
go build -o pierre ./cmd/pierre/main.go
```
### Configuration
Configuration is handled via `kong` and supports a hierarchy: **Flags > Env Vars > Config File**.
**1. Environment Variables:**
* `BITBUCKET_URL`: Base URL of the Bitbucket instance.
* `BITBUCKET_TOKEN`: Personal Access Token (HTTP) for Bitbucket.
* `LLM_PROVIDER`: `gemini` or `ollama`.
* `LLM_API_KEY`: API Key for Gemini.
* `LLM_MODEL`: Model name (e.g., `gemini-2.0-flash`).
**2. Configuration File (`config.yaml`):**
See `config.example.yaml` for a template. Place it in the current directory or `~/.pierre.yaml`.
### Usage
Run the bot against a specific Pull Request:
```bash
# Syntax: ./pierre [flags] <PROJECT_KEY> <REPO_SLUG> <PR_ID>
./pierre --llm-provider=gemini --llm-model=gemini-2.0-flash MYPROJ my-repo 123
```
## Development Conventions
* **Structured Output:** The bot relies on the LLM returning valid JSON matching the `Comment` struct. This is enforced in `internal/chatter/gemini.go` by converting the Go struct definition into a `genai.Schema`.
* **Dependency Injection:** Adapters (`gitadapters`, `chatter`) are initialized in `main` and passed to the core logic, making testing easier.
* **Error Handling:** strict error checks are preferred; the bot will exit if it cannot fetch the diff or initialize the AI.
## Key Files
* **`cmd/pierre/main.go`**: Application entry point and config wiring.
* **`internal/pierre/judge.go`**: The "brain" that constructs the prompt for the AI.
* **`internal/chatter/gemini.go`**: Gemini integration logic, including the reflection-based schema generator.
* **`config.example.yaml`**: Reference configuration file.

View File

@@ -8,7 +8,7 @@ It fetches pull request diffs, analyzes them using Google's Gemini 2.0 Flash mod
Ensure you have [Go](https://go.dev/) installed, then clone the repository and build the binary: Ensure you have [Go](https://go.dev/) installed, then clone the repository and build the binary:
```bash ```bash
git clone https://bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot.git git clone https://git.schreifuchs.ch/schreifuchs/pierre-bot.git
cd pierre-bot cd pierre-bot
go build -o pierre ./cmd/pierre/main.go go build -o pierre ./cmd/pierre/main.go

View File

@@ -2,36 +2,46 @@ package main
import ( import (
"context" "context"
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/chatter" "git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
"bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/gitadapters" "git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/bitbucket"
"bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/pierre" "git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/gitea"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
kongyaml "github.com/alecthomas/kong-yaml" kongyaml "github.com/alecthomas/kong-yaml"
) )
type BitbucketConfig struct { type BitbucketConfig struct {
BaseURL string `help:"Bitbucket Base URL (e.g. https://bitbucket.example.com)" required:"" env:"BITBUCKET_URL"` BaseURL string `help:"Bitbucket Base URL (e.g. https://bitbucket.example.com)" env:"BITBUCKET_URL"`
Token string `help:"Bearer Token" env:"BITBUCKET_TOKEN"` 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"` type GiteaConfig struct {
BaseURL string `help:"Gitea Base URL (e.g. https://gitea.com)" env:"GITEA_URL"`
Token string `help:"API Token" env:"GITEA_TOKEN"`
}
type RepoArgs struct {
Owner string `arg:"" help:"Project Key or Owner" env:"PIERRE_OWNER"`
Repo string `arg:"" help:"Repository Slug" env:"PIERRE_REPO"`
PRID int `arg:"" help:"Pull Request ID" name:"pr"` PRID int `arg:"" help:"Pull Request ID" name:"pr"`
} }
type LLMConfig struct { type LLMConfig struct {
Provider string `help:"Provider for llm (ollama or gemini)" required:"" env:"LLM_PROVIDER"` 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"` BaseURL string `help:"Endpoint for provider (only for ollama)" env:"LLM_BASE_URL"`
APIKey string `help:"APIKey for provider" env:"LLM_API_KEY"` APIKey string `help:"APIKey for provider" env:"LLM_API_KEY"`
Model string `help:"Model to use" env:"LLM_MODEL"` Model string `help:"Model to use" env:"LLM_MODEL"`
} }
type Config struct { type Config struct {
GitProvider string `help:"Git provider (bitbucket or gitea)" env:"GIT_PROVIDER"`
Bitbucket BitbucketConfig `embed:"" prefix:"bitbucket-"` Bitbucket BitbucketConfig `embed:"" prefix:"bitbucket-"`
Gitea GiteaConfig `embed:"" prefix:"gitea-"`
Repo RepoArgs `embed:""`
LLM LLMConfig `embed:"" prefix:"llm-"` LLM LLMConfig `embed:"" prefix:"llm-"`
Config kong.ConfigFlag `help:"Path to a YAML config file"` Config kong.ConfigFlag `help:"Path to a YAML config file"`
} }
@@ -48,29 +58,57 @@ func main() {
// Parse flags, env vars, and config files // Parse flags, env vars, and config files
kong.Parse(cfg, kong.Parse(cfg,
kong.Name("pierre"), kong.Name("pierre"),
kong.Description("AI-powered Pull Request reviewer for Bitbucket"), kong.Description("AI-powered Pull Request reviewer"),
kong.UsageOnError(), kong.UsageOnError(),
kong.Configuration(kongyaml.Loader, "config.yaml", defaultConfig), kong.Configuration(kongyaml.Loader, "config.yaml", defaultConfig),
) )
// Initialize Bitbucket Adapter // Auto-detect provider
bitbucket := gitadapters.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token) provider := cfg.GitProvider
if provider == "" {
if cfg.Bitbucket.BaseURL != "" && cfg.Gitea.BaseURL == "" {
provider = "bitbucket"
} else if cfg.Gitea.BaseURL != "" && cfg.Bitbucket.BaseURL == "" {
provider = "gitea"
} else if cfg.Bitbucket.BaseURL != "" && cfg.Gitea.BaseURL != "" {
log.Fatal("Multiple git providers configured. Please specify one using --git-provider.")
} else {
log.Fatal("No git provider configured. Please provide Bitbucket or Gitea configuration.")
}
}
// Fetch Diff using positional args var git pierre.GitAdapter
diff, err := bitbucket.GetDiff(cfg.Bitbucket.Project, cfg.Bitbucket.Repo, cfg.Bitbucket.PRID)
switch provider {
case "bitbucket":
if cfg.Bitbucket.BaseURL == "" {
log.Fatal("Bitbucket Base URL is required when using bitbucket provider.")
}
git = bitbucket.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token)
case "gitea":
if cfg.Gitea.BaseURL == "" {
log.Fatal("Gitea Base URL is required when using gitea provider.")
}
var err error
git, err = gitea.New(cfg.Gitea.BaseURL, cfg.Gitea.Token)
if err != nil { if err != nil {
log.Fatalf("Error fetching diff: %v", err) log.Fatalf("Error initializing Gitea adapter: %v", err)
}
default:
log.Fatalf("Unknown git provider: %s", provider)
} }
// Initialize AI Adapter // Initialize AI Adapter
var ai pierre.ChatAdapter var ai pierre.ChatAdapter
switch cfg.LLM.Provider { switch cfg.LLM.Provider {
case "gemini": case "gemini":
ai, err = chatter.NewGeminiAdapter(context.Background(), cfg.LLM.APIKey, cfg.LLM.Model) ai, err = chatter.NewGeminiAdapter(context.Background(), cfg.LLM.APIKey, cfg.LLM.Model)
case "ollama": case "ollama":
ai, err = chatter.NewOllamaAdapter(cfg.LLM.Endpoint, cfg.LLM.Model) ai, err = chatter.NewOllamaAdapter(cfg.LLM.BaseURL, cfg.LLM.Model)
case "openai":
ai = chatter.NewOpenAIAdapter(cfg.LLM.APIKey, cfg.LLM.Model, cfg.LLM.BaseURL)
default: default:
log.Fatalf("%s is not a valid llm provider", cfg.LLM.Provider) log.Fatalf("%s is not a valid llm provider", cfg.LLM.Provider)
} }
@@ -79,16 +117,8 @@ func main() {
log.Fatalf("Error initializing AI: %v", err) log.Fatalf("Error initializing AI: %v", err)
} }
// Run Logic pierreService := pierre.New(ai, git)
comments, err := pierre.JudgePR(context.Background(), ai, diff) if err := pierreService.MakeReview(context.Background(), cfg.Repo.Owner, cfg.Repo.Repo, cfg.Repo.PRID); err != nil {
if err != nil { log.Fatalf("Error during review: %v", err)
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, "---")
} }
} }

View File

@@ -1,8 +1,15 @@
git-provider: "bitbucket" # Optional if only one is configured (bitbucket or gitea)
bitbucket: bitbucket:
base-url: "https://bitbucket.your-org.ch" base-url: "https://bitbucket.your-org.ch"
token: "BMTY4OTU0NjU3OTo..." token: "BMTY4OTU0NjU3OTo..."
# Positional defaults (optional)
project: "APP" gitea:
base-url: "https://gitea.com"
token: "your-gitea-token"
# Shared positional defaults (optional)
owner: "APP"
repo: "api-gateway" repo: "api-gateway"
prid: 45 prid: 45

13
go.mod
View File

@@ -1,8 +1,11 @@
module bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot module git.schreifuchs.ch/schreifuchs/pierre-bot
go 1.25.0 go 1.25.0
require ( require (
code.gitea.io/sdk/gitea v0.23.2
github.com/alecthomas/kong v1.14.0
github.com/alecthomas/kong-yaml v0.2.0
github.com/google/generative-ai-go v0.20.1 github.com/google/generative-ai-go v0.20.1
github.com/ollama/ollama v0.16.0 github.com/ollama/ollama v0.16.0
google.golang.org/api v0.186.0 google.golang.org/api v0.186.0
@@ -15,11 +18,12 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.2 // 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/compute/metadata v0.3.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/alecthomas/kong v1.14.0 // indirect github.com/42wim/httpsig v1.2.3 // indirect
github.com/alecthomas/kong-yaml v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
@@ -28,8 +32,9 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect github.com/googleapis/gax-go/v2 v2.12.5 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/sashabaranov/go-openai v1.41.2 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.opencensus.io v0.24.0 // 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/google.golang.org/grpc/otelgrpc v0.51.0 // indirect

30
go.sum
View File

@@ -11,11 +11,19 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2Qx
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 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 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= 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 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 h1:iiVVqVttmOsHKawlaW/TljPsjaEv1O4ODx6dloSA58Y=
github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfRP03Q1StN69/g= github.com/alecthomas/kong-yaml v0.2.0/go.mod h1:vMvOIy+wpB49MCZ0TA3KMts38Mu9YfRP03Q1StN69/g=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 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/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 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
@@ -26,12 +34,16 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 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.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/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/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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 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 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -72,9 +84,15 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfF
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= 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 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= 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/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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 h1:wDrjgZvx+ej1iYrD//q7crGRA4b4482WZodRYc7oQTI=
@@ -82,6 +100,8 @@ github.com/ollama/ollama v0.16.0/go.mod h1:FEk95NbAJJZk+t7cLh+bPGTul72j1O3PLLlYN
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -106,6 +126,7 @@ go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2L
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= 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-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.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -118,6 +139,7 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -132,8 +154,10 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -177,9 +201,9 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 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 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 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 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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

184
internal/chatter/openai.go Normal file
View File

@@ -0,0 +1,184 @@
package chatter
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"reflect"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
type OpenAIAdapter struct {
client *openai.Client
model string
}
func NewOpenAIAdapter(apiKey string, model string, baseURL string) *OpenAIAdapter {
config := openai.DefaultConfig(apiKey)
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // Bypasses the "not standards compliant" error
},
}
config.HTTPClient = &http.Client{Transport: tr}
if baseURL != "" {
config.BaseURL = baseURL
}
if baseURL != "" {
config.BaseURL = baseURL
}
return &OpenAIAdapter{
client: openai.NewClientWithConfig(config),
model: model,
}
}
func (a *OpenAIAdapter) Generate(ctx context.Context, messages []Message) (string, error) {
var chatMsgs []openai.ChatCompletionMessage
for _, m := range messages {
chatMsgs = append(chatMsgs, openai.ChatCompletionMessage{
Role: string(m.Role),
Content: m.Content,
})
}
resp, err := a.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: a.model,
Messages: chatMsgs,
})
if err != nil {
return "", err
}
return resp.Choices[0].Message.Content, nil
}
func (a *OpenAIAdapter) GenerateStructured(ctx context.Context, messages []Message, target any) error {
val := reflect.ValueOf(target)
if val.Kind() != reflect.Ptr {
return fmt.Errorf("target must be a pointer")
}
elem := val.Elem()
var schemaType reflect.Type
isSlice := elem.Kind() == reflect.Slice
// 1. Wrap slices in an object because OpenAI requires a root object
if isSlice {
schemaType = reflect.StructOf([]reflect.StructField{
{
Name: "Items",
Type: elem.Type(),
Tag: `json:"items"`,
},
})
} else {
schemaType = elem.Type()
}
// 2. Build the Schema Map
schemaObj := a.reflectTypeToSchema(schemaType)
// 3. Convert to json.RawMessage to satisfy the json.Marshaler interface
schemaBytes, err := json.Marshal(schemaObj)
if err != nil {
return fmt.Errorf("failed to marshal schema: %w", err)
}
var chatMsgs []openai.ChatCompletionMessage
for _, m := range messages {
chatMsgs = append(chatMsgs, openai.ChatCompletionMessage{
Role: string(m.Role),
Content: m.Content,
})
}
// 4. Send Request
req := openai.ChatCompletionRequest{
Model: a.model,
Messages: chatMsgs,
ResponseFormat: &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONSchema,
JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{
Name: "output_schema",
Strict: true,
Schema: json.RawMessage(schemaBytes),
},
},
}
resp, err := a.client.CreateChatCompletion(ctx, req)
if err != nil {
return err
}
content := resp.Choices[0].Message.Content
// 5. Unmarshal and Unwrap if necessary
if isSlice {
temp := struct {
Items json.RawMessage `json:"items"`
}{}
if err := json.Unmarshal([]byte(content), &temp); err != nil {
return err
}
return json.Unmarshal(temp.Items, target)
}
return json.Unmarshal([]byte(content), target)
}
func (a *OpenAIAdapter) reflectTypeToSchema(t reflect.Type) jsonschema.Definition {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t.Kind() {
case reflect.Struct:
def := jsonschema.Definition{
Type: jsonschema.Object,
Properties: make(map[string]jsonschema.Definition),
AdditionalProperties: false,
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
}
def.Properties[name] = a.reflectTypeToSchema(field.Type)
def.Required = append(def.Required, name)
}
return def
case reflect.Slice, reflect.Array:
items := a.reflectTypeToSchema(t.Elem())
return jsonschema.Definition{
Type: jsonschema.Array,
Items: &items,
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return jsonschema.Definition{Type: jsonschema.Integer}
case reflect.Float32, reflect.Float64:
return jsonschema.Definition{Type: jsonschema.Number}
case reflect.Bool:
return jsonschema.Definition{Type: jsonschema.Boolean}
default:
return jsonschema.Definition{Type: jsonschema.String}
}
}
func (a *OpenAIAdapter) GetProviderName() string {
return "OpenAI (" + a.model + ")"
}

View File

@@ -1,31 +0,0 @@
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
}

View File

@@ -0,0 +1,62 @@
package baseadapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
type Rest struct {
baseURL string
bearerToken string
}
func NewRest(baseURL string, bearerToken string) Rest {
return Rest{
baseURL: baseURL,
bearerToken: bearerToken,
}
}
const defaultBodyBufferSize = 100
func (b *Rest) CreateRequest(ctx context.Context, method string, body any, 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
}
var bodyReader io.Reader
if body != nil {
bodyBuff := bytes.NewBuffer(make([]byte, 0, defaultBodyBufferSize))
err = json.NewEncoder(bodyBuff).Encode(body)
if err != nil {
return
}
bodyReader = bodyBuff
}
req, err := http.NewRequest(method, target, bodyReader)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if b.bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+b.bearerToken)
}
return req, nil
}

View File

@@ -1,38 +0,0 @@
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
}

View File

@@ -0,0 +1,101 @@
package bitbucket
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
)
func (b *BitbucketAdapter) GetDiff(ctx context.Context, projectKey, repositorySlug string, pullRequestID int) (diff io.ReadCloser, err error) {
r, err := b.CreateRequest(
ctx,
http.MethodGet,
nil,
"/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
}
if response.StatusCode != http.StatusOK {
sb := &strings.Builder{}
io.Copy(sb, response.Body)
err = fmt.Errorf("error while fetching bitbucket diff staus %d, body %s", response.Status, sb.String())
}
diff = response.Body
return
}
func (b *BitbucketAdapter) GetPR(ctx context.Context, projectKey, repositorySlug string, pullRequestID int) (pr PullRequest, err error) {
r, err := b.CreateRequest(
ctx,
http.MethodGet,
nil,
"/projects/", projectKey, "repos", repositorySlug, "pull-requests", strconv.Itoa(pullRequestID),
)
response, err := http.DefaultClient.Do(r)
defer response.Body.Close() // Add this
if err != nil {
return
}
err = json.NewDecoder(response.Body).Decode(&pr)
return
}
func (b *BitbucketAdapter) AddComment(ctx context.Context, owner, repo string, prID int, comment pierre.Comment) (err error) {
// pr, err := b.GetPR(ctx, owner, repo, prID)
// if err != nil {
// return
// }
commentDTO := Comment{
Content: comment.Message,
Anchor: Anchor{
Path: comment.File,
Line: comment.Line,
LineType: "ADDED",
FileType: "TO",
DiffType: "EFFECTIVE",
// FromHash: pr.ToRef.LatestCommit,
// ToHash: pr.FromRef.LatestCommit,
},
}
r, err := b.CreateRequest(ctx,
http.MethodPost,
commentDTO,
"/projects/", owner, "/repos/", repo, "/pull-requests/", strconv.Itoa(prID), "/comments",
)
if err != nil {
return
}
response, err := http.DefaultClient.Do(r)
defer response.Body.Close() // Add this
if err != nil {
return err
}
if response.StatusCode >= 300 || response.StatusCode < 200 {
sb := &strings.Builder{}
io.Copy(sb, response.Body)
err = fmt.Errorf("error while creating comment staus %d, body %s", response.StatusCode, sb.String())
}
return err
}

View File

@@ -0,0 +1,44 @@
package bitbucket
type Anchor struct {
Path string `json:"path"`
Line int `json:"line"`
LineType string `json:"lineType,omitempty"`
FileType string `json:"fileType"`
FromHash string `json:"fromHash,omitempty"`
ToHash string `json:"toHash,omitempty"`
DiffType string `json:"diffType,omitempty"`
}
type Comment struct {
Content string `json:"text"`
Anchor Anchor `json:"anchor"`
}
type PullRequest struct {
ID int64 `json:"id"`
Version int `json:"version"`
Title string `json:"title"`
State string `json:"state"`
Open bool `json:"open"`
Closed bool `json:"closed"`
FromRef Ref `json:"fromRef"`
ToRef Ref `json:"toRef"`
Description string `json:"description"`
}
type Ref struct {
ID string `json:"id"`
DisplayID string `json:"displayId"`
LatestCommit string `json:"latestCommit"`
Repository Repository `json:"repository"`
}
type Repository struct {
Slug string `json:"slug"`
Project Project `json:"project"`
}
type Project struct {
Key string `json:"key"`
}

View File

@@ -0,0 +1,20 @@
package bitbucket
import (
"strings"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/baseadapter"
)
type BitbucketAdapter struct {
baseadapter.Rest
}
func NewBitbucket(baseURL string, bearerToken string) *BitbucketAdapter {
baseURL, _ = strings.CutSuffix(baseURL, "/")
baseURL += "/rest/api/1.0"
return &BitbucketAdapter{
Rest: baseadapter.NewRest(baseURL, bearerToken),
}
}

View File

@@ -0,0 +1,49 @@
package gitea
import (
"bytes"
"context"
"io"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
)
type Adapter struct {
client *gitea.Client
}
func New(baseURL, token string) (*Adapter, error) {
client, err := gitea.NewClient(baseURL, gitea.SetToken(token))
if err != nil {
return nil, err
}
return &Adapter{
client: client,
}, nil
}
func (g *Adapter) GetDiff(ctx context.Context, owner, repo string, prID int) (io.ReadCloser, error) {
g.client.SetContext(ctx)
diff, _, err := g.client.GetPullRequestDiff(owner, repo, int64(prID), gitea.PullRequestDiffOptions{})
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(diff)), nil
}
func (g *Adapter) AddComment(ctx context.Context, owner, repo string, prID int, comment pierre.Comment) error {
g.client.SetContext(ctx)
opts := gitea.CreatePullReviewOptions{
State: gitea.ReviewStateComment,
Comments: []gitea.CreatePullReviewComment{
{
Path: comment.File,
Body: comment.Message,
NewLineNum: int64(comment.Line),
},
},
}
_, _, err := g.client.CreatePullReview(owner, repo, int64(prID), opts)
return err
}

View File

@@ -1 +0,0 @@
package gitadapters

View File

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/chatter" "git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
) )
type Comment struct { type Comment struct {
@@ -14,28 +14,24 @@ type Comment struct {
Message string `json:"message"` Message string `json:"message"`
} }
type ChatAdapter interface { func (s *Service) judgePR(ctx context.Context, diff io.Reader) (comments []Comment, err error) {
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) diffBytes, err := io.ReadAll(diff)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read diff: %w", err) return nil, fmt.Errorf("failed to read diff: %w", err)
} }
err = chat.GenerateStructured(ctx, []chatter.Message{ err = s.chat.GenerateStructured(ctx, []chatter.Message{
{ {
Role: chatter.RoleSystem, Role: chatter.RoleSystem,
Content: ` Content: `
You are a very strict senior software architect. 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 review **only** newly added or modified lines in a unified diff, 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. 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 programs behavior. No comments are made on pure formatting/whitespace changes or reordering that does not alter the programs behavior.
`, `,
}, },
{ {
Role: chatter.RoleUser, Role: chatter.RoleUser,
Content: fmt.Sprintf("Hello please review my PR.\n Here is the git diff of it: %s", string(diffBytes)), Content: fmt.Sprintf("Hello please review my PR. Write comments where improvements are necessary in new lines.\n Here is the git diff of it: %s", string(diffBytes)),
}, },
}, &comments) }, &comments)

View File

@@ -0,0 +1,30 @@
package pierre
import (
"context"
"io"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
)
type Service struct {
git GitAdapter
chat ChatAdapter
}
func New(chat ChatAdapter, git GitAdapter) *Service {
return &Service{
git: git,
chat: chat,
}
}
type GitAdapter interface {
GetDiff(ctx context.Context, owner, repo string, prID int) (io.ReadCloser, error)
AddComment(ctx context.Context, owner, repo string, prID int, comment Comment) error
}
type ChatAdapter interface {
GenerateStructured(ctx context.Context, messages []chatter.Message, target interface{}) error
GetProviderName() string
}

38
internal/pierre/review.go Normal file
View File

@@ -0,0 +1,38 @@
package pierre
import (
"context"
"fmt"
"log"
)
func (s *Service) MakeReview(ctx context.Context, organisation string, repo string, prID int) error {
// Fetch Diff using positional args from shared RepoArgs
diff, err := s.git.GetDiff(ctx, organisation, repo, prID)
defer diff.Close()
if err != nil {
return fmt.Errorf("error fetching diff: %w", err)
}
// Run Logic
comments, err := s.judgePR(ctx, diff)
if err != nil {
return fmt.Errorf("error judging PR: %w", err)
}
fmt.Printf("Analysis complete. Found %d issues.\n---\n", len(comments))
model := s.chat.GetProviderName()
for _, c := range comments {
c.Message = fmt.Sprintf("%s (Generated by: %s)", c.Message, model)
fmt.Printf("File: %s\nLine: %d\nMessage: %s\n%s\n",
c.File, c.Line, c.Message, "---")
if err := s.git.AddComment(ctx, organisation, repo, prID, c); err != nil {
log.Printf("Failed to add comment: %v", err)
}
}
return nil
}