Compare commits

1 Commits

Author SHA1 Message Date
378d008a91 feat: gitea client 2026-02-12 20:58:55 +01:00
18 changed files with 125 additions and 529 deletions

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://git.schreifuchs.ch/schreifuchs/pierre-bot.git git clone https://bitbucket.bit.admin.ch/scm/~u80859501/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,14 +2,15 @@ package main
import ( import (
"context" "context"
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter" "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/chatter"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/bitbucket" "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/gitadapters"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/gitadapters/gitea" "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/gitadapters/gitea"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre" "bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/pierre"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
kongyaml "github.com/alecthomas/kong-yaml" kongyaml "github.com/alecthomas/kong-yaml"
) )
@@ -32,7 +33,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"`
BaseURL string `help:"Endpoint for provider (only for ollama)" env:"LLM_BASE_URL"` Endpoint string `help:"Endpoint for provider (only for ollama)" env:"LLM_ENDPOINT"`
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"`
} }
@@ -56,7 +57,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
kong.Parse(cfg, ctx := 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(),
@@ -77,20 +78,18 @@ func main() {
} }
} }
var git pierre.GitAdapter var adapter gitadapters.Adapter
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.")
} }
git = bitbucket.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token) adapter = gitadapters.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.")
} }
var err error adapter, err = gitea.New(cfg.Gitea.BaseURL, cfg.Gitea.Token)
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)
} }
@@ -98,17 +97,20 @@ 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 pierre.ChatAdapter var ai chatter.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.BaseURL, cfg.LLM.Model) ai, err = chatter.NewOllamaAdapter(cfg.LLM.Endpoint, 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)
} }
@@ -117,8 +119,18 @@ func main() {
log.Fatalf("Error initializing AI: %v", err) log.Fatalf("Error initializing AI: %v", err)
} }
pierreService := pierre.New(ai, git) // Run Logic
if err := pierreService.MakeReview(context.Background(), cfg.Repo.Owner, cfg.Repo.Repo, cfg.Repo.PRID); err != nil { comments, err := pierre.JudgePR(context.Background(), ai, diff)
log.Fatalf("Error during review: %v", err) if err != nil {
log.Fatalf("Error judging PR: %v", err)
} }
fmt.Printf("Analysis complete. Found %d issues.\n---\n", len(comments))
for _, c := range comments {
fmt.Printf("File: %s\nLine: %d\nMessage: %s\n%s\n",
c.File, c.Line, c.Message, "---")
}
_ = ctx
} }

3
go.mod
View File

@@ -1,4 +1,4 @@
module git.schreifuchs.ch/schreifuchs/pierre-bot module bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot
go 1.25.0 go 1.25.0
@@ -34,7 +34,6 @@ 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
View File

@@ -100,8 +100,6 @@ 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=

View File

@@ -1,5 +1,9 @@
package chatter package chatter
import (
"context"
)
// Role defines who sent the message // Role defines who sent the message
type Role string type Role string
@@ -15,6 +19,10 @@ 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

View File

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

@@ -0,0 +1,7 @@
package gitadapters
import "io"
type Adapter interface {
GetDiff(owner, repo string, prID int) (io.Reader, error)
}

View File

@@ -0,0 +1,31 @@
package gitadapters
import (
"fmt"
"io"
"net/http"
"net/url"
)
type baseHTTP struct {
baseURL string
bearerToken string
}
func (b *baseHTTP) createRequest(method string, body io.Reader, path ...string) (r *http.Request, err error) {
target, err := url.JoinPath(b.baseURL, path...)
if err != nil {
err = fmt.Errorf("can not parse path: %w", err)
return
}
req, err := http.NewRequest(method, target, body)
if err != nil {
return nil, err
}
if b.bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+b.bearerToken)
}
return req, nil
}

View File

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

@@ -0,0 +1,38 @@
package gitadapters
import (
"fmt"
"io"
"net/http"
)
type BitbucketAdapter struct {
baseHTTP
}
func NewBitbucket(baseURL string, bearerToken string) *BitbucketAdapter {
return &BitbucketAdapter{
baseHTTP{
baseURL: baseURL,
bearerToken: bearerToken,
},
}
}
func (b *BitbucketAdapter) GetDiff(projectKey, repositorySlug string, pullRequestID int) (diff io.Reader, err error) {
r, err := b.createRequest(
http.MethodGet,
nil,
"/rest/api/1.0/projects/", projectKey, "repos", repositorySlug, "pull-requests", fmt.Sprintf("%d.diff", pullRequestID),
)
if err != nil {
return
}
response, err := http.DefaultClient.Do(r)
if err != nil {
return
}
diff = response.Body
return
}

View File

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

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

@@ -1,20 +0,0 @@
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,11 +2,9 @@ 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 {
@@ -23,27 +21,10 @@ func New(baseURL, token string) (*Adapter, error) {
}, nil }, nil
} }
func (g *Adapter) GetDiff(ctx context.Context, owner, repo string, prID int) (io.ReadCloser, error) { func (g *Adapter) GetDiff(owner, repo string, prID int) (io.Reader, 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 io.NopCloser(bytes.NewReader(diff)), nil return 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

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

View File

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter" "bitbucket.bit.admin.ch/scm/~u80859501/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 (s *Service) judgePR(ctx context.Context, diff io.Reader) (comments []Comment, err error) { func JudgePR(ctx context.Context, chat chatter.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 = s.chat.GenerateStructured(ctx, []chatter.Message{ err = 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, together with the immediate hunk context. You review **only** newly added or modified lines in a unified diff (lines prefixed with “+”), together with the immediate hunk context.
You do **not** report issues that appear **solely** in deleted lines (“-”) or that have already been fixed by the change. 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. Write comments where improvements are necessary in new lines.\n Here is the git diff of it: %s", string(diffBytes)), Content: fmt.Sprintf("Hello please review my PR.\n Here is the git diff of it: %s", string(diffBytes)),
}, },
}, &comments) }, &comments)

View File

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

View File

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