feat: correctly implement bitbucket & add OpenAIAdapter

This commit is contained in:
u80864958
2026-02-13 13:54:57 +01:00
parent b67125024c
commit 2cb64194b9
13 changed files with 408 additions and 91 deletions

View File

@@ -7,7 +7,7 @@ import (
"path/filepath"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/bitbucket"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/gitea"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
"github.com/alecthomas/kong"
@@ -32,7 +32,7 @@ type RepoArgs struct {
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"`
BaseURL string `help:"Endpoint for provider (only for ollama)" env:"LLM_BASE_URL"`
APIKey string `help:"APIKey for provider" env:"LLM_API_KEY"`
Model string `help:"Model to use" env:"LLM_MODEL"`
}
@@ -84,7 +84,7 @@ func main() {
if cfg.Bitbucket.BaseURL == "" {
log.Fatal("Bitbucket Base URL is required when using bitbucket provider.")
}
git = gitadapters.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token)
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.")
@@ -105,7 +105,10 @@ func main() {
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)
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:
log.Fatalf("%s is not a valid llm provider", cfg.LLM.Provider)
}

1
go.mod
View File

@@ -34,6 +34,7 @@ require (
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
github.com/hashicorp/go-version v1.7.0 // 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
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect

2
go.sum
View File

@@ -100,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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

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

@@ -1,6 +1,9 @@
package baseadapter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -12,17 +15,45 @@ type Rest struct {
bearerToken string
}
func (b *Rest) createRequest(method string, body io.Reader, path ...string) (r *http.Request, err error) {
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
}
req, err := http.NewRequest(method, target, body)
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)
}

View File

@@ -1,46 +0,0 @@
package gitadapters
import (
"fmt"
"io"
"net/http"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
)
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
}
func (b *BitbucketAdapter) AddComment(projectKey, repositorySlug string, pullRequestID int, comment pierre.Comment) error {
fmt.Printf("[MOCK BITBUCKET] Adding comment to PR %s/%s #%d: %s at %s:%d\n",
projectKey, repositorySlug, pullRequestID, comment.Message, comment.File, comment.Line)
return nil
}

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

@@ -2,10 +2,11 @@ package gitea
import (
"bytes"
"context"
"io"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
)
type Adapter struct {
@@ -22,15 +23,17 @@ func New(baseURL, token string) (*Adapter, error) {
}, nil
}
func (g *Adapter) GetDiff(owner, repo string, prID int) (io.Reader, error) {
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 bytes.NewReader(diff), nil
return io.NopCloser(bytes.NewReader(diff)), nil
}
func (g *Adapter) AddComment(owner, repo string, prID int, comment pierre.Comment) error {
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{

View File

@@ -20,10 +20,11 @@ func New(chat ChatAdapter, git GitAdapter) *Service {
}
type GitAdapter interface {
GetDiff(owner, repo string, prID int) (io.Reader, error)
AddComment(owner, repo string, prID int, comment Comment) error
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
}

View File

@@ -8,7 +8,8 @@ import (
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(organisation, repo, prID)
diff, err := s.git.GetDiff(ctx, organisation, repo, prID)
defer diff.Close()
if err != nil {
return fmt.Errorf("error fetching diff: %w", err)
}
@@ -21,11 +22,14 @@ func (s *Service) MakeReview(ctx context.Context, organisation string, repo stri
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(organisation, repo, prID, c); err != nil {
if err := s.git.AddComment(ctx, organisation, repo, prID, c); err != nil {
log.Printf("Failed to add comment: %v", err)
}
}