Compare commits
3 Commits
378d008a91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb64194b9 | ||
| b67125024c | |||
| 9d49d94eff |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ 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/gitadapters/gitea"
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/gitea"
|
||||||
"bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/pierre"
|
"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"
|
||||||
)
|
)
|
||||||
@@ -33,7 +32,7 @@ type RepoArgs struct {
|
|||||||
|
|
||||||
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"`
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,7 @@ func main() {
|
|||||||
defaultConfig := filepath.Join(home, ".config", "pierre", "config.yaml")
|
defaultConfig := filepath.Join(home, ".config", "pierre", "config.yaml")
|
||||||
|
|
||||||
// Parse flags, env vars, and config files
|
// Parse flags, env vars, and config files
|
||||||
ctx := kong.Parse(cfg,
|
kong.Parse(cfg,
|
||||||
kong.Name("pierre"),
|
kong.Name("pierre"),
|
||||||
kong.Description("AI-powered Pull Request reviewer"),
|
kong.Description("AI-powered Pull Request reviewer"),
|
||||||
kong.UsageOnError(),
|
kong.UsageOnError(),
|
||||||
@@ -78,18 +77,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var adapter gitadapters.Adapter
|
var git pierre.GitAdapter
|
||||||
|
|
||||||
switch provider {
|
switch provider {
|
||||||
case "bitbucket":
|
case "bitbucket":
|
||||||
if cfg.Bitbucket.BaseURL == "" {
|
if cfg.Bitbucket.BaseURL == "" {
|
||||||
log.Fatal("Bitbucket Base URL is required when using bitbucket provider.")
|
log.Fatal("Bitbucket Base URL is required when using bitbucket provider.")
|
||||||
}
|
}
|
||||||
adapter = gitadapters.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token)
|
git = bitbucket.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token)
|
||||||
case "gitea":
|
case "gitea":
|
||||||
if cfg.Gitea.BaseURL == "" {
|
if cfg.Gitea.BaseURL == "" {
|
||||||
log.Fatal("Gitea Base URL is required when using gitea provider.")
|
log.Fatal("Gitea Base URL is required when using gitea provider.")
|
||||||
}
|
}
|
||||||
adapter, err = gitea.New(cfg.Gitea.BaseURL, cfg.Gitea.Token)
|
var err error
|
||||||
|
git, err = gitea.New(cfg.Gitea.BaseURL, cfg.Gitea.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error initializing Gitea adapter: %v", err)
|
log.Fatalf("Error initializing Gitea adapter: %v", err)
|
||||||
}
|
}
|
||||||
@@ -97,20 +98,17 @@ func main() {
|
|||||||
log.Fatalf("Unknown git provider: %s", provider)
|
log.Fatalf("Unknown git provider: %s", provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Diff using positional args from shared RepoArgs
|
|
||||||
diff, err := adapter.GetDiff(cfg.Repo.Owner, cfg.Repo.Repo, cfg.Repo.PRID)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error fetching diff: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize AI Adapter
|
// Initialize AI Adapter
|
||||||
var ai chatter.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)
|
||||||
}
|
}
|
||||||
@@ -119,18 +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, "---")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = ctx
|
|
||||||
}
|
}
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot
|
module git.schreifuchs.ch/schreifuchs/pierre-bot
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ require (
|
|||||||
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
|
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
|
||||||
github.com/hashicorp/go-version v1.7.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
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -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 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=
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package chatter
|
package chatter
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Role defines who sent the message
|
// Role defines who sent the message
|
||||||
type Role string
|
type Role string
|
||||||
|
|
||||||
@@ -19,10 +15,6 @@ type Message struct {
|
|||||||
Content string
|
Content string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatAdapter interface {
|
|
||||||
GenerateStructured(ctx context.Context, messages []Message, target interface{}) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PredictionConfig allows for per-request overrides
|
// PredictionConfig allows for per-request overrides
|
||||||
type PredictionConfig struct {
|
type PredictionConfig struct {
|
||||||
Temperature float64
|
Temperature float64
|
||||||
|
|||||||
184
internal/chatter/openai.go
Normal file
184
internal/chatter/openai.go
Normal 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 + ")"
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package gitadapters
|
|
||||||
|
|
||||||
import "io"
|
|
||||||
|
|
||||||
type Adapter interface {
|
|
||||||
GetDiff(owner, repo string, prID int) (io.Reader, error)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
62
internal/gitadapters/baseadapter/rest.go
Normal file
62
internal/gitadapters/baseadapter/rest.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
101
internal/gitadapters/bitbucket/controller.go
Normal file
101
internal/gitadapters/bitbucket/controller.go
Normal 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
|
||||||
|
}
|
||||||
44
internal/gitadapters/bitbucket/model.go
Normal file
44
internal/gitadapters/bitbucket/model.go
Normal 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"`
|
||||||
|
}
|
||||||
20
internal/gitadapters/bitbucket/resource.go
Normal file
20
internal/gitadapters/bitbucket/resource.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ package gitea
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Adapter struct {
|
type Adapter struct {
|
||||||
@@ -21,10 +23,27 @@ func New(baseURL, token string) (*Adapter, error) {
|
|||||||
}, nil
|
}, 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{})
|
diff, _, err := g.client.GetPullRequestDiff(owner, repo, int64(prID), gitea.PullRequestDiffOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return bytes.NewReader(diff), nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
package gitadapters
|
|
||||||
@@ -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,24 +14,24 @@ type Comment struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func JudgePR(ctx context.Context, chat chatter.ChatAdapter, diff io.Reader) (comments []Comment, err error) {
|
func (s *Service) judgePR(ctx context.Context, 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 program’s behavior.
|
No comments are made on pure formatting/whitespace changes or reordering that does not alter the program’s 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)
|
||||||
|
|
||||||
|
|||||||
30
internal/pierre/resource.go
Normal file
30
internal/pierre/resource.go
Normal 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
38
internal/pierre/review.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user