Compare commits
7 Commits
db1ecd1e64
...
feat/conte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75ef8da1a4 | ||
|
|
2e12c39786 | ||
|
|
343f6ab165 | ||
|
|
cc321be658 | ||
|
|
2cb64194b9 | ||
| b67125024c | |||
| 9d49d94eff |
171
AGENTS.md
Normal file
171
AGENTS.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
---
|
||||||
|
|
||||||
|
# AGENTS.md – Repository Guidelines for Pierre Bot
|
||||||
|
|
||||||
|
> This document is consulted by autonomous coding agents (including the
|
||||||
|
> Open‑Source `opencode` agent) when they read, modify, test, or lint the
|
||||||
|
> code‑base. It contains the canonical commands for building, testing, and
|
||||||
|
> lint‑ing the project as well as a concise style guide that all Go code must
|
||||||
|
> follow. The file is deliberately kept around **150 lines** to be readable for
|
||||||
|
> both humans and LLMs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1️⃣ Build / Run Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| `go build -o bin/pierre ./cmd/pierre/main.go` | Build the binary into `./bin/pierre`. |
|
||||||
|
| `go run ./cmd/pierre/main.go --help` | Run the CLI with the help flag (fast sanity check). |
|
||||||
|
| `./bin/pierre --version` | Verify the built binary reports its version. |
|
||||||
|
|
||||||
|
> **Tip for agents** – always prefer the `./bin/pierre` binary over invoking `go run` in CI or tests; it guarantees the same compilation flags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2️⃣ Test Commands
|
||||||
|
|
||||||
|
The project uses the standard Go testing framework.
|
||||||
|
|
||||||
|
| Command | Use case |
|
||||||
|
| ------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| `go test ./...` | Run **all** unit and integration tests. |
|
||||||
|
| `go test ./... -run ^TestJudgePR$` | Run a **single** test (replace `TestJudgePR` with the desired name). |
|
||||||
|
| `go test -cover ./...` | Run tests and emit a coverage report. |
|
||||||
|
| `go test -run ^TestName$ -count=1 -v` | Verbose, non‑cached run for debugging a single test. |
|
||||||
|
| `go test ./... -bench .` | Execute benchmarks (if any). |
|
||||||
|
|
||||||
|
> **Agents should never** invoke `go test` with `-parallel` flags; the default parallelism is sufficient and ensures deterministic ordering for our table‑driven tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3️⃣ Lint / Static Analysis
|
||||||
|
|
||||||
|
We rely on two lightweight built‑in tools plus optional `golangci-lint` when
|
||||||
|
available.
|
||||||
|
|
||||||
|
| Tool | Command | Description |
|
||||||
|
| -------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `go vet ./...` | `go vet ./...` | Detect obvious bugs, misuse of `fmt.Printf`, etc. |
|
||||||
|
| `staticcheck ./...` | `staticcheck ./...` | Deeper static analysis (must be installed via `go install honnef.co/go/tools/cmd/staticcheck@latest`). |
|
||||||
|
| `golangci-lint` (optional) | `golangci-lint run` | Runs a configurable suite of linters. Install with `brew install golangci-lint` or `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`. |
|
||||||
|
|
||||||
|
**Agent tip** – when `golangci-lint` is present, run it after `go vet` to catch style
|
||||||
|
issues early. The CI pipeline (see `.github/workflows/*.yml` if added later) will
|
||||||
|
use the same commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4️⃣ Code‑Style Guidelines (Go)
|
||||||
|
|
||||||
|
All changes must satisfy the following conventions. They are enforced by
|
||||||
|
`go fmt`, `go vet`, and the optional `golangci-lint` config. The code does not have
|
||||||
|
to be backwards compatible.
|
||||||
|
|
||||||
|
### 4.1 Formatting & Imports
|
||||||
|
|
||||||
|
- Run `go fmt ./...` before committing. The repository uses **tab‑indented** Go code.
|
||||||
|
- Imports are grouped in three blocks, separated by a blank line:
|
||||||
|
1. **Standard library**
|
||||||
|
2. **External dependencies** (e.g., `github.com/alecthomas/kong`)
|
||||||
|
3. **Internal packages** (e.g., `github.com/yourorg/pierre/internal/...`)
|
||||||
|
- Within each block, imports are sorted alphabetically.
|
||||||
|
- Use `goimports` (or `golangci‑lint --fast`) to automatically fix import ordering.
|
||||||
|
|
||||||
|
### 4.2 Naming Conventions
|
||||||
|
|
||||||
|
| Entity | Rule |
|
||||||
|
| --------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Packages | lower‑case, no underscores or hyphens. |
|
||||||
|
| Files | snake_case (`*_test.go` for tests). |
|
||||||
|
| Types / Structs | PascalCase, descriptive (e.g., `ReviewConfig`). |
|
||||||
|
| Interfaces | End with `er` when appropriate (e.g., `ChatAdapter`). |
|
||||||
|
| Variables / Constants | camelCase for locals, PascalCase for exported. |
|
||||||
|
| Functions | PascalCase if exported, camelCase if private. |
|
||||||
|
| Constants | Use `CamelCase` for groups, `CONST_NAME` only for truly global values. |
|
||||||
|
| Test Functions | `TestXxx` and optionally `BenchmarkXxx`. |
|
||||||
|
|
||||||
|
### 4.3 Error Handling
|
||||||
|
|
||||||
|
- Errors are **never** ignored. Use the blank identifier only when the value is truly irrelevant.
|
||||||
|
- Wrap contextual information using `fmt.Errorf("context: %w", err)` or `errors.Wrap` if the project imports `github.com/pkg/errors` (currently not used).
|
||||||
|
- Return errors **as soon as** they are detected – "guard clause" style.
|
||||||
|
- In public APIs, prefer error values over panics. Panics are limited to unrecoverable
|
||||||
|
programming errors (e.g., nil‑pointer dereference in `init`).
|
||||||
|
|
||||||
|
### 4.4 Documentation & Comments
|
||||||
|
|
||||||
|
- Exported identifiers **must** have a preceding doc comment beginning with the name (e.g., `// JudgePR reviews a PR and returns comments.`).
|
||||||
|
- Inline comments should be sentence‑case and end with a period.
|
||||||
|
- Use `//go:generate` directives sparingly; they must be accompanied by a test that ensures the generated file is up‑to‑date.
|
||||||
|
|
||||||
|
### 4.5 Testing Practices
|
||||||
|
|
||||||
|
- Keep tests **table‑driven** where possible; this yields concise, readable test suites.
|
||||||
|
- Use the `testing` package only; avoid third‑party test frameworks.
|
||||||
|
- Prefer `t.Fatalf` for fatal errors and `t.Errorf` for non‑fatal assertions.
|
||||||
|
- When comparing complex structs, use `github.com/google/go-cmp/cmp` (already in `go.mod`).
|
||||||
|
- Test coverage should be **≥ 80 %** for new code.
|
||||||
|
- All tests must be **deterministic** – no reliance on external services; use mocks or fakes.
|
||||||
|
|
||||||
|
### 4.6 Dependency Management
|
||||||
|
|
||||||
|
- All dependencies are managed via Go modules (`go.mod`).
|
||||||
|
- Run `go mod tidy` after adding/removing imports.
|
||||||
|
- Do not commit `vendor/` unless a vendoring strategy is explicitly adopted.
|
||||||
|
|
||||||
|
### 4.7 Logging & Output
|
||||||
|
|
||||||
|
- Use the standard library `log` package for user‑visible output.
|
||||||
|
- For structured logs (debug mode), wrap with a small helper that respects the `--debug` flag.
|
||||||
|
- CLI output must be **machine‑parsable** when `--json-output` is set (future feature).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5️⃣ Git & Pull‑Request Workflow (for agents)
|
||||||
|
|
||||||
|
1. **Branch naming** – `feature/<short‑desc>` or `bugfix/<short‑desc>`.
|
||||||
|
2. **Commits** – One logical change per commit, with a concise subject line (<50 chars) and an optional body explaining _why_ the change was needed.
|
||||||
|
3. **CI** – Every PR must pass `go test ./...`, `go vet ./...`, and `golangci‑lint run` (if configured).
|
||||||
|
4. **Review** – Agents should add comments only via the LLM; do not edit code generated by the LLM unless a test fails.
|
||||||
|
5. **Rebasing** – Keep a linear history; use `git rebase -i` locally before merging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6️⃣ Cursor / Copilot Rules (None Present)
|
||||||
|
|
||||||
|
The repository does not contain a `.cursor` directory or a `.github/copilot‑instructions.md`
|
||||||
|
file. If such files are added in the future, agents should read them and incorporate
|
||||||
|
any additional constraints into this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7️⃣ Quick Reference Cheat‑Sheet (for agents)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o bin/pierre ./cmd/pierre/main.go
|
||||||
|
|
||||||
|
# Run single test (replace TestFoo)
|
||||||
|
go test ./... -run ^TestFoo$ -v
|
||||||
|
|
||||||
|
# Lint & Vet
|
||||||
|
go vet ./...
|
||||||
|
staticcheck ./...
|
||||||
|
# optional
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Format & Imports
|
||||||
|
go fmt ./...
|
||||||
|
goimports -w .
|
||||||
|
|
||||||
|
# Module tidy
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_End of AGENTS.md_
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
BIN
bin/pierre
Executable file
BIN
bin/pierre
Executable file
Binary file not shown.
@@ -2,38 +2,65 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"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 {
|
||||||
PRID int `arg:"" help:"Pull Request ID" name:"pr"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReviewConfig holds the review‑specific CLI options.
|
||||||
|
// The `default:"60000"` tag sets an integer default of 60 KB – Kong parses the string value into the int field, which can be confusing for readers.
|
||||||
|
type ReviewConfig struct {
|
||||||
|
MaxChunkSize int `help:"Maximum diff chunk size in bytes" default:"60000"`
|
||||||
|
Guidelines []string `help:"Project guidelines to prepend" sep:","`
|
||||||
|
DisableComments bool `help:"Disable posting comments (dry run)"`
|
||||||
|
SanityCheck bool `help:"Run sanity‑check LLM prompts per comment" default:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Bitbucket BitbucketConfig `embed:"" prefix:"bitbucket-"`
|
LogLevel string `help:"Log verbosity: debug, info, warn, error" default:"info"`
|
||||||
LLM LLMConfig `embed:"" prefix:"llm-"`
|
|
||||||
Config kong.ConfigFlag `help:"Path to a YAML config file"`
|
// Embedding ReviewConfig with a prefix changes flag names to `--review-…`.
|
||||||
|
// Existing configuration files using the old flag names will need to be updated.
|
||||||
|
// Consider keeping backwards compatibility if required.
|
||||||
|
Review ReviewConfig `embed:"" prefix:"review-"`
|
||||||
|
GitProvider string `help:"Git provider (bitbucket or gitea)" env:"GIT_PROVIDER"`
|
||||||
|
Bitbucket BitbucketConfig `embed:"" prefix:"bitbucket-"`
|
||||||
|
Gitea GiteaConfig `embed:"" prefix:"gitea-"`
|
||||||
|
Repo RepoArgs `embed:""`
|
||||||
|
LLM LLMConfig `embed:"" prefix:"llm-"`
|
||||||
|
Config kong.ConfigFlag `help:"Path to a YAML config file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -48,29 +75,75 @@ 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
|
// Configure global slog logger based on the --log-level flag
|
||||||
bitbucket := gitadapters.NewBitbucket(cfg.Bitbucket.BaseURL, cfg.Bitbucket.Token)
|
lvl := slog.LevelInfo
|
||||||
|
switch strings.ToLower(cfg.LogLevel) {
|
||||||
|
case "debug":
|
||||||
|
lvl = slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
lvl = slog.LevelInfo
|
||||||
|
case "warn":
|
||||||
|
lvl = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
lvl = slog.LevelError
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch Diff using positional args
|
// Logs are sent to stderr so that stdout can be safely piped.
|
||||||
diff, err := bitbucket.GetDiff(cfg.Bitbucket.Project, cfg.Bitbucket.Repo, cfg.Bitbucket.PRID)
|
// The review output (fmt.Printf) stays on stdout unchanged.
|
||||||
if err != nil {
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
|
||||||
log.Fatalf("Error fetching diff: %v", err)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
// Auto-detect provider
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var git pierre.GitAdapter
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 +152,9 @@ func main() {
|
|||||||
log.Fatalf("Error initializing AI: %v", err)
|
log.Fatalf("Error initializing AI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run Logic
|
pierreService := pierre.New(ai, git, cfg.Review.MaxChunkSize, cfg.Review.Guidelines, cfg.Review.DisableComments)
|
||||||
comments, err := pierre.JudgePR(context.Background(), ai, diff)
|
pierreService.SetSanityCheck(cfg.Review.SanityCheck)
|
||||||
if err != nil {
|
if err := pierreService.MakeReview(context.Background(), cfg.Repo.Owner, cfg.Repo.Repo, cfg.Repo.PRID); err != nil {
|
||||||
log.Fatalf("Error judging PR: %v", err)
|
log.Fatalf("Error during review: %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, "---")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
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:
|
||||||
repo: "api-gateway"
|
base-url: "https://gitea.com"
|
||||||
prid: 45
|
token: "your-gitea-token"
|
||||||
|
|
||||||
|
# Shared positional defaults (optional)
|
||||||
|
owner: "APP"
|
||||||
|
repo: "api-gateway"
|
||||||
|
prid: 45
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
provider: "gemini"
|
provider: "gemini"
|
||||||
api-key: "AIzaSy..."
|
api-key: "AIzaSy..."
|
||||||
model: "gemini-2.0-flash"
|
model: "gemini-2.0-flash"
|
||||||
14
go.mod
14
go.mod
@@ -1,10 +1,15 @@
|
|||||||
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/google/go-cmp v0.7.0
|
||||||
github.com/ollama/ollama v0.16.0
|
github.com/ollama/ollama v0.16.0
|
||||||
|
github.com/sashabaranov/go-openai v1.41.2
|
||||||
google.golang.org/api v0.186.0
|
google.golang.org/api v0.186.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,11 +20,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,7 +34,7 @@ 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/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
|
||||||
|
|||||||
30
go.sum
30
go.sum
@@ -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
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,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
|
|
||||||
}
|
|
||||||
80
internal/gitadapters/bitbucket/adapter_test.go
Normal file
80
internal/gitadapters/bitbucket/adapter_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package bitbucket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBitbucketGetFileContentSuccess(t *testing.T) {
|
||||||
|
const expected = "file content"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify path structure
|
||||||
|
if r.URL.Path != "/rest/api/1.0/projects/owner/repos/repo/raw/path/to/file.go" {
|
||||||
|
t.Fatalf("unexpected URL path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(expected))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Trim trailing slash handling done in NewBitbucket
|
||||||
|
adapter := NewBitbucket(server.URL, "")
|
||||||
|
content, err := adapter.GetFileContent(context.Background(), "owner", "repo", "path/to/file.go", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if content != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBitbucketGetFileContentError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("not found"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter := NewBitbucket(server.URL, "")
|
||||||
|
_, err := adapter.GetFileContent(context.Background(), "owner", "repo", "missing.go", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for non‑200 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBitbucketGetPRHeadSHASuccess(t *testing.T) {
|
||||||
|
const sha = "deadbeef"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/rest/api/1.0/projects/owner/repos/repo/pull-requests/42" {
|
||||||
|
t.Fatalf("unexpected URL: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"toRef":{"latestCommit":"` + sha + `"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter := NewBitbucket(server.URL, "")
|
||||||
|
got, err := adapter.GetPRHeadSHA(context.Background(), "owner", "repo", 42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != sha {
|
||||||
|
t.Fatalf("expected sha %s, got %s", sha, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBitbucketGetPRHeadSHAError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("error"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
adapter := NewBitbucket(server.URL, "")
|
||||||
|
_, err := adapter.GetPRHeadSHA(context.Background(), "owner", "repo", 1)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for non‑200 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/gitadapters/bitbucket/controller.go
Normal file
113
internal/gitadapters/bitbucket/controller.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package bitbucket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"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) {
|
||||||
|
slog.Debug("Bitbucket GetDiff start", "project", projectKey, "repo", repositorySlug, "pr", pullRequestID)
|
||||||
|
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 status %d, body %s", response.StatusCode, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
diff = response.Body
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BitbucketAdapter) GetPR(ctx context.Context, projectKey, repositorySlug string, pullRequestID int) (pr PullRequest, err error) {
|
||||||
|
slog.Debug("Bitbucket GetPR start", "project", projectKey, "repo", repositorySlug, "pr", pullRequestID)
|
||||||
|
r, err := b.CreateRequest(
|
||||||
|
ctx,
|
||||||
|
http.MethodGet,
|
||||||
|
nil,
|
||||||
|
"/projects/", projectKey, "repos", repositorySlug, "pull-requests", strconv.Itoa(pullRequestID),
|
||||||
|
)
|
||||||
|
|
||||||
|
response, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close() // Add this
|
||||||
|
|
||||||
|
err = json.NewDecoder(response.Body).Decode(&pr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Bitbucket GetPR decode error", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("Bitbucket GetPR success", "id", pullRequestID)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BitbucketAdapter) AddComment(ctx context.Context, owner, repo string, prID int, comment pierre.Comment) (err error) {
|
||||||
|
slog.Debug("Bitbucket AddComment start", "owner", owner, "repo", repo, "pr", prID, "file", comment.File, "line", comment.Line)
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close() // Add this
|
||||||
|
|
||||||
|
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())
|
||||||
|
slog.Error("Bitbucket AddComment failed", "status", response.StatusCode, "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Bitbucket AddComment succeeded", "pr", prID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
66
internal/gitadapters/bitbucket/resource.go
Normal file
66
internal/gitadapters/bitbucket/resource.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package bitbucket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileContent returns the raw file content at the given ref (commit SHA) or HEAD if ref is empty.
|
||||||
|
func (b *BitbucketAdapter) GetFileContent(ctx context.Context, projectKey, repositorySlug, path, ref string) (string, error) {
|
||||||
|
// Use the Rest helper to build the base URL, then add the "at" query param if needed.
|
||||||
|
r, err := b.CreateRequest(ctx, http.MethodGet, nil,
|
||||||
|
"/projects/", projectKey, "repos", repositorySlug, "raw", path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Ensure raw file retrieval
|
||||||
|
r.Header.Set("Accept", "application/octet-stream")
|
||||||
|
if ref != "" {
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Set("at", ref)
|
||||||
|
r.URL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
sb := &strings.Builder{}
|
||||||
|
io.Copy(sb, resp.Body)
|
||||||
|
return "", fmt.Errorf("error fetching file %s status %d, body %s", path, resp.StatusCode, sb.String())
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPRHeadSHA fetches the PR and returns the SHA of the source (to) branch.
|
||||||
|
func (b *BitbucketAdapter) GetPRHeadSHA(ctx context.Context, projectKey, repositorySlug string, pullRequestID int) (string, error) {
|
||||||
|
pr, err := b.GetPR(ctx, projectKey, repositorySlug, pullRequestID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return pr.ToRef.LatestCommit, nil
|
||||||
|
}
|
||||||
93
internal/gitadapters/gitea/adapter.go
Normal file
93
internal/gitadapters/gitea/adapter.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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) {
|
||||||
|
slog.Debug("Gitea GetDiff start", "owner", owner, "repo", repo, "pr", prID)
|
||||||
|
g.client.SetContext(ctx)
|
||||||
|
diff, _, err := g.client.GetPullRequestDiff(owner, repo, int64(prID), gitea.PullRequestDiffOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rc := io.NopCloser(bytes.NewReader(diff))
|
||||||
|
slog.Info("Gitea GetDiff success", "owner", owner, "repo", repo, "pr", prID, "bytes", len(diff))
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Adapter) AddComment(ctx context.Context, owner, repo string, prID int, comment pierre.Comment) error {
|
||||||
|
slog.Debug("Gitea AddComment start", "owner", owner, "repo", repo, "pr", prID, "file", comment.File, "line", comment.Line)
|
||||||
|
g.client.SetContext(ctx)
|
||||||
|
opts := gitea.CreatePullReviewOptions{
|
||||||
|
State: gitea.ReviewStateComment,
|
||||||
|
Comments: []gitea.CreatePullReviewComment{
|
||||||
|
{
|
||||||
|
Path: comment.File,
|
||||||
|
Body: comment.Message,
|
||||||
|
NewLineNum: int64(comment.Line),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, resp, err := g.client.CreatePullReview(owner, repo, int64(prID), opts)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Gitea AddComment failed", "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp != nil && resp.StatusCode != http.StatusCreated {
|
||||||
|
return fmt.Errorf("unexpected status %d creating comment", resp.StatusCode)
|
||||||
|
}
|
||||||
|
slog.Info("Gitea AddComment succeeded", "pr", prID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileContent returns the file content at a given path and ref (commit SHA).
|
||||||
|
func (g *Adapter) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
||||||
|
slog.Debug("Gitea GetFileContent start", "owner", owner, "repo", repo, "path", path, "ref", ref)
|
||||||
|
g.client.SetContext(ctx)
|
||||||
|
// The SDK's GetFile returns the raw bytes of the file.
|
||||||
|
data, _, err := g.client.GetFile(owner, repo, ref, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
slog.Info("Gitea GetFileContent success", "bytes", len(data))
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPRHeadSHA fetches the pull request and returns the head commit SHA.
|
||||||
|
func (g *Adapter) GetPRHeadSHA(ctx context.Context, owner, repo string, prID int) (string, error) {
|
||||||
|
slog.Debug("Gitea GetPRHeadSHA start", "owner", owner, "repo", repo, "pr", prID)
|
||||||
|
g.client.SetContext(ctx)
|
||||||
|
// GetPullRequest returns the detailed PR information.
|
||||||
|
pr, _, err := g.client.GetPullRequest(owner, repo, int64(prID))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if pr == nil || pr.Head == nil {
|
||||||
|
return "", fmt.Errorf("pull request %d has no head information", prID)
|
||||||
|
}
|
||||||
|
slog.Info("Gitea GetPRHeadSHA success", "sha", pr.Head.Sha)
|
||||||
|
return pr.Head.Sha, nil
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
package gitadapters
|
|
||||||
70
internal/pierre/guidelines_test.go
Normal file
70
internal/pierre/guidelines_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package pierre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseGuidelinesFromStringValid(t *testing.T) {
|
||||||
|
md := "# Rule One\n\n - Item A \n\n# Rule Two\n"
|
||||||
|
lines, err := parseGuidelinesFromString(md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
expected := []string{"# Rule One", "- Item A", "# Rule Two"}
|
||||||
|
if got, want := fmt.Sprint(lines), fmt.Sprint(expected); got != want {
|
||||||
|
t.Fatalf("expected %v, got %v", expected, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGuidelinesFromStringEmpty(t *testing.T) {
|
||||||
|
md := "\n \n"
|
||||||
|
lines, err := parseGuidelinesFromString(md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(lines) != 0 {
|
||||||
|
t.Fatalf("expected empty slice, got %d elements", len(lines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGuidelinesFromStringTooManyLines(t *testing.T) {
|
||||||
|
// generate 1001 non-empty lines
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 0; i < 1001; i++ {
|
||||||
|
sb.WriteString(fmt.Sprintf("Line %d\n", i))
|
||||||
|
}
|
||||||
|
_, err := parseGuidelinesFromString(sb.String())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for exceeding line limit, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithGuidelinesSuccess(t *testing.T) {
|
||||||
|
svc := &Service{}
|
||||||
|
md := "First line\nSecond line\n"
|
||||||
|
if err := svc.WithGuidelines(md); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
expected := []string{"First line", "Second line"}
|
||||||
|
if got, want := fmt.Sprint(svc.guidelines), fmt.Sprint(expected); got != want {
|
||||||
|
t.Fatalf("expected guidelines %v, got %v", expected, svc.guidelines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithGuidelinesError(t *testing.T) {
|
||||||
|
svc := &Service{guidelines: []string{"old"}}
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 0; i < 1001; i++ {
|
||||||
|
sb.WriteString("x\n")
|
||||||
|
}
|
||||||
|
err := svc.WithGuidelines(sb.String())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
// ensure old guidelines unchanged
|
||||||
|
if len(svc.guidelines) != 1 || svc.guidelines[0] != "old" {
|
||||||
|
t.Fatalf("guidelines should remain unchanged on error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,40 +4,156 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"bitbucket.bit.admin.ch/scm/~u80859501/pierre-bot/internal/chatter"
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultChunkSize is the fallback maximum size (in bytes) for a diff chunk when no explicit value is configured.
|
||||||
|
const DefaultChunkSize = 60000
|
||||||
|
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
File string `json:"file"`
|
File string `json:"file"`
|
||||||
Line int `json:"line"`
|
Line int `json:"line"`
|
||||||
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
|
slog.Info("judgePR started")
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
|
||||||
{
|
|
||||||
Role: chatter.RoleSystem,
|
|
||||||
Content: `
|
|
||||||
You are a very strict senior software architect.
|
|
||||||
You review **only** newly added or modified lines in a unified diff (lines prefixed with “+”), together with the immediate hunk context.
|
|
||||||
You do **not** report issues that appear **solely** in deleted lines (“-”) or that have already been fixed by the change.
|
|
||||||
No comments are made on pure formatting/whitespace changes or reordering that does not alter the program’s behavior.
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Role: chatter.RoleUser,
|
|
||||||
Content: fmt.Sprintf("Hello please review my PR.\n Here is the git diff of it: %s", string(diffBytes)),
|
|
||||||
},
|
|
||||||
}, &comments)
|
|
||||||
|
|
||||||
|
// Determine chunk size (use default if not set)
|
||||||
|
maxSize := s.maxChunkSize
|
||||||
|
if maxSize <= 0 {
|
||||||
|
maxSize = DefaultChunkSize // default 60KB ~ 15k tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := splitDiffIntoChunks(diffBytes, maxSize)
|
||||||
|
allComments := []Comment{}
|
||||||
|
|
||||||
|
// Build optional guidelines text (added as a separate section with a clear delimiter)
|
||||||
|
guidelinesText := ""
|
||||||
|
if len(s.guidelines) > 0 {
|
||||||
|
// Two newlines ensure the guidelines start on a fresh paragraph.
|
||||||
|
guidelinesText = "\n\nProject guidelines:\n"
|
||||||
|
for _, g := range s.guidelines {
|
||||||
|
guidelinesText += "- " + g + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System prompt that instructs the LLM precisely.
|
||||||
|
baseSystem := strings.TrimSpace(`
|
||||||
|
You are a strict senior software architect.
|
||||||
|
Only comment on newly added or modified lines in the diff; ignore deletions, pure formatting, or re‑ordering that does not change behavior.
|
||||||
|
For each issue output a JSON object with fields "file", "line", and "message" (message should be concise, ≤2 sentences, and actionable).
|
||||||
|
If project guidelines are provided, treat them as hard rules that must be respected.`) + guidelinesText
|
||||||
|
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
// Include the chunk identifier in the system message only if there are multiple chunks.
|
||||||
|
systemContent := baseSystem
|
||||||
|
if len(chunks) > 1 {
|
||||||
|
systemContent = fmt.Sprintf("%s\nChunk %d of %d.", baseSystem, i+1, len(chunks))
|
||||||
|
}
|
||||||
|
userContent := chunk
|
||||||
|
|
||||||
|
var chunkComments []Comment
|
||||||
|
err = s.chat.GenerateStructured(ctx, []chatter.Message{{
|
||||||
|
Role: chatter.RoleSystem,
|
||||||
|
Content: systemContent,
|
||||||
|
}, {
|
||||||
|
Role: chatter.RoleUser,
|
||||||
|
Content: userContent,
|
||||||
|
}}, &chunkComments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
allComments = append(allComments, chunkComments...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// De‑duplicate comments (keyed by file:line)
|
||||||
|
unique := make(map[string]Comment)
|
||||||
|
for _, c := range allComments {
|
||||||
|
key := fmt.Sprintf("%s:%d", c.File, c.Line)
|
||||||
|
unique[key] = c
|
||||||
|
}
|
||||||
|
for _, v := range unique {
|
||||||
|
comments = append(comments, v)
|
||||||
|
}
|
||||||
|
slog.Info("judgePR finished", "comments", len(comments))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitDiffIntoChunks splits a diff into chunks that do not exceed maxSize bytes.
|
||||||
|
// It tries to split on file boundaries ("diff --git") first, then on hunk boundaries (@@),
|
||||||
|
// and finally on a hard byte limit.
|
||||||
|
func splitDiffIntoChunks(diff []byte, maxSize int) []string {
|
||||||
|
// Preserve the file header for each chunk when a single file's diff is split across multiple chunks.
|
||||||
|
// The header is the portion before the first hunk marker "@@" (including the "diff --git" line).
|
||||||
|
// When we need to split by hunks, we prepend this header to every resulting sub‑chunk.
|
||||||
|
|
||||||
|
if len(diff) <= maxSize {
|
||||||
|
return []string{string(diff)}
|
||||||
|
}
|
||||||
|
content := string(diff)
|
||||||
|
// Split by file headers
|
||||||
|
parts := strings.Split(content, "\ndiff --git ")
|
||||||
|
chunks := []string{}
|
||||||
|
var current strings.Builder
|
||||||
|
for idx, part := range parts {
|
||||||
|
seg := part
|
||||||
|
if idx != 0 {
|
||||||
|
// Preserve the leading newline that was removed by Split
|
||||||
|
seg = "\n" + "diff --git " + part
|
||||||
|
}
|
||||||
|
if current.Len()+len(seg) > maxSize && current.Len() > 0 {
|
||||||
|
chunks = append(chunks, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
if len(seg) > maxSize {
|
||||||
|
// Determine if there is a hunk marker. If not, fall back to simple size‑based chunking.
|
||||||
|
headerEnd := strings.Index(seg, "\n@@ ")
|
||||||
|
if headerEnd == -1 {
|
||||||
|
// No hunk marker – split purely by size.
|
||||||
|
remaining := seg
|
||||||
|
for len(remaining) > maxSize {
|
||||||
|
chunks = append(chunks, remaining[:maxSize])
|
||||||
|
remaining = remaining[maxSize:]
|
||||||
|
}
|
||||||
|
current.WriteString(remaining)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Preserve the header up to the first hunk.
|
||||||
|
header := seg[:headerEnd+1] // include newline before "@@"
|
||||||
|
// Split the rest of the segment into hunks (excluding the header part).
|
||||||
|
hunks := strings.Split(strings.TrimPrefix(seg, header), "\n@@ ")
|
||||||
|
for _, h := range hunks {
|
||||||
|
// Reconstruct each hunk with its header and "@@ " prefix.
|
||||||
|
hseg := header + "@@ " + h
|
||||||
|
if current.Len()+len(hseg) > maxSize && current.Len() > 0 {
|
||||||
|
chunks = append(chunks, current.String())
|
||||||
|
current.Reset()
|
||||||
|
}
|
||||||
|
if len(hseg) > maxSize {
|
||||||
|
// If a single hunk exceeds maxSize, split it further.
|
||||||
|
for len(hseg) > maxSize {
|
||||||
|
chunks = append(chunks, hseg[:maxSize])
|
||||||
|
hseg = hseg[maxSize:]
|
||||||
|
}
|
||||||
|
current.WriteString(hseg)
|
||||||
|
} else {
|
||||||
|
current.WriteString(hseg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.WriteString(seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current.Len() > 0 {
|
||||||
|
chunks = append(chunks, current.String())
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|||||||
141
internal/pierre/judge_test.go
Normal file
141
internal/pierre/judge_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package pierre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockChat implements the ChatAdapter interface for testing.
|
||||||
|
type mockChat struct{ callCount int }
|
||||||
|
|
||||||
|
func (m *mockChat) GenerateStructured(ctx context.Context, msgs []chatter.Message, target interface{}) error {
|
||||||
|
m.callCount++
|
||||||
|
if cSlice, ok := target.(*[]Comment); ok {
|
||||||
|
*cSlice = []Comment{{File: "file.go", Line: 1, Message: "test comment"}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChat) GetProviderName() string { return "mock" }
|
||||||
|
|
||||||
|
// mockGit implements the GitAdapter interface for testing.
|
||||||
|
type mockGit struct{}
|
||||||
|
|
||||||
|
func (g *mockGit) GetDiff(ctx context.Context, owner, repo string, prID int) (io.ReadCloser, error) {
|
||||||
|
diff := "diff --git a/file1.go b/file1.go\n+line1\n" + "diff --git a/file2.go b/file2.go\n+line2\n"
|
||||||
|
return io.NopCloser(bytes.NewReader([]byte(diff))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *mockGit) AddComment(ctx context.Context, owner, repo string, prID int, comment Comment) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *mockGit) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
||||||
|
// For tests, return a simple placeholder content.
|
||||||
|
return "package main\n\nfunc placeholder() {}", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *mockGit) GetPRHeadSHA(ctx context.Context, owner, repo string, prID int) (string, error) {
|
||||||
|
return "dummysha", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitDiffIntoChunks(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
diff string
|
||||||
|
maxSize int
|
||||||
|
wantChunks int // 0 means we don't assert exact count
|
||||||
|
wantPrefixes []string
|
||||||
|
checkRecombine bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "small diff",
|
||||||
|
diff: "diff --git a/file1.txt b/file1.txt\n+added line\n",
|
||||||
|
maxSize: 1000,
|
||||||
|
wantChunks: 1,
|
||||||
|
wantPrefixes: []string{"diff --git a/file1.txt"},
|
||||||
|
checkRecombine: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple files",
|
||||||
|
diff: "diff --git a/file1.txt b/file1.txt\n+added line 1\n" +
|
||||||
|
"diff --git a/file2.txt b/file2.txt\n+added line 2\n",
|
||||||
|
maxSize: 50,
|
||||||
|
wantChunks: 2,
|
||||||
|
wantPrefixes: []string{"diff --git a/file1.txt", "diff --git a/file2.txt"},
|
||||||
|
checkRecombine: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "large single file",
|
||||||
|
diff: func() string {
|
||||||
|
line := "+very long added line that will be repeated many times to exceed the chunk size\n"
|
||||||
|
return "diff --git a/large.txt b/large.txt\n" + strings.Repeat(line, 200)
|
||||||
|
}(),
|
||||||
|
maxSize: 500,
|
||||||
|
wantChunks: 0,
|
||||||
|
wantPrefixes: nil,
|
||||||
|
checkRecombine: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
chunks := splitDiffIntoChunks([]byte(tc.diff), tc.maxSize)
|
||||||
|
if tc.wantChunks > 0 && len(chunks) != tc.wantChunks {
|
||||||
|
t.Fatalf("expected %d chunks, got %d", tc.wantChunks, len(chunks))
|
||||||
|
}
|
||||||
|
for i, prefix := range tc.wantPrefixes {
|
||||||
|
if i >= len(chunks) {
|
||||||
|
t.Fatalf("missing chunk %d for prefix check", i)
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimPrefix(chunks[i], "\n")
|
||||||
|
if !strings.HasPrefix(trimmed, prefix) {
|
||||||
|
t.Fatalf("chunk %d does not start with expected prefix %q: %s", i, prefix, chunks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, c := range chunks {
|
||||||
|
if tc.maxSize > 0 && len(c) > tc.maxSize {
|
||||||
|
t.Fatalf("chunk %d exceeds max size %d: %d", i, tc.maxSize, len(c))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.checkRecombine {
|
||||||
|
recombined := strings.Join(chunks, "")
|
||||||
|
if diff := cmp.Diff(tc.diff, recombined); diff != "" {
|
||||||
|
t.Fatalf("recombined diff differs:\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJudgePR_ChunkAggregationAndDeduplication(t *testing.T) {
|
||||||
|
chatMock := &mockChat{}
|
||||||
|
svc := &Service{
|
||||||
|
maxChunkSize: 50,
|
||||||
|
guidelines: nil,
|
||||||
|
git: &mockGit{},
|
||||||
|
chat: chatMock,
|
||||||
|
}
|
||||||
|
diffReader, err := svc.git.GetDiff(context.Background(), "", "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get diff: %v", err)
|
||||||
|
}
|
||||||
|
defer diffReader.Close()
|
||||||
|
comments, err := svc.judgePR(context.Background(), diffReader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("judgePR error: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(comments), 1; got != want {
|
||||||
|
t.Fatalf("expected %d comment after deduplication, got %d", want, got)
|
||||||
|
}
|
||||||
|
if chatMock.callCount != 2 {
|
||||||
|
t.Fatalf("expected mockChat to be called for each chunk (2), got %d", chatMock.callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/pierre/overlap_test.go
Normal file
44
internal/pierre/overlap_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package pierre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type overlapChat struct{ callCount int }
|
||||||
|
|
||||||
|
func (m *overlapChat) GenerateStructured(ctx context.Context, msgs []chatter.Message, target interface{}) error {
|
||||||
|
m.callCount++
|
||||||
|
if cSlice, ok := target.(*[]Comment); ok {
|
||||||
|
// Return two comments with same file and line to test deduplication
|
||||||
|
*cSlice = []Comment{{File: "dup.go", Line: 10, Message: "first"}, {File: "dup.go", Line: 10, Message: "second"}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *overlapChat) GetProviderName() string { return "mock" }
|
||||||
|
|
||||||
|
func TestJudgePR_DeduplicationOverlap(t *testing.T) {
|
||||||
|
chat := &overlapChat{}
|
||||||
|
svc := &Service{
|
||||||
|
maxChunkSize: 1000,
|
||||||
|
guidelines: nil,
|
||||||
|
git: &mockGit{},
|
||||||
|
chat: chat,
|
||||||
|
}
|
||||||
|
diffReader, err := svc.git.GetDiff(context.Background(), "", "", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get diff: %v", err)
|
||||||
|
}
|
||||||
|
defer diffReader.Close()
|
||||||
|
comments, err := svc.judgePR(context.Background(), diffReader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("judgePR error: %v", err)
|
||||||
|
}
|
||||||
|
if len(comments) != 1 {
|
||||||
|
t.Fatalf("expected 1 deduplicated comment, got %d", len(comments))
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/pierre/resource.go
Normal file
86
internal/pierre/resource.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package pierre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service holds the core collaborators and configuration for Pierre.
|
||||||
|
// The order of the fields is intentional: configuration fields first (used
|
||||||
|
// during initialization) followed by the adapters. This prevents accidental
|
||||||
|
// changes to the serialized layout if encoding/gob or encoding/json is used
|
||||||
|
// elsewhere in the future.
|
||||||
|
type Service struct {
|
||||||
|
maxChunkSize int
|
||||||
|
guidelines []string // stored as slice of lines; legacy, see WithGuidelines
|
||||||
|
skipSanityCheck bool // if true, skip LLM sanity‑check prompts per comment
|
||||||
|
disableComments bool
|
||||||
|
git GitAdapter
|
||||||
|
chat ChatAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(chat ChatAdapter, git GitAdapter, maxChunkSize int, guidelines []string, disableComments bool) *Service {
|
||||||
|
// Existing constructor retains slice based guidelines for backward compatibility.
|
||||||
|
return &Service{
|
||||||
|
git: git,
|
||||||
|
chat: chat,
|
||||||
|
maxChunkSize: maxChunkSize,
|
||||||
|
guidelines: guidelines,
|
||||||
|
skipSanityCheck: false,
|
||||||
|
disableComments: disableComments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGuidelines parses a raw Markdown string (or any multiline string) into
|
||||||
|
// individual guideline lines, validates the line‑count (max 1000 non‑empty lines),
|
||||||
|
// and stores the result in the Service. It returns an error if validation fails.
|
||||||
|
// This is a convenience mutator for callers that have the guidelines as a
|
||||||
|
// single string.
|
||||||
|
func (s *Service) WithGuidelines(md string) error {
|
||||||
|
lines, err := parseGuidelinesFromString(md)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.guidelines = lines
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGuidelinesFromString splits a markdown string into trimmed, non‑empty
|
||||||
|
// lines and ensures the total number of lines does not exceed 1000.
|
||||||
|
func (s *Service) SetSanityCheck(enabled bool) {
|
||||||
|
s.skipSanityCheck = !enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGuidelinesFromString splits a markdown string into trimmed, non‑empty
|
||||||
|
func parseGuidelinesFromString(md string) ([]string, error) {
|
||||||
|
var result []string
|
||||||
|
// Split on newline. Handles both \n and \r\n because TrimSpace removes \r.
|
||||||
|
rawLines := strings.Split(md, "\n")
|
||||||
|
for _, l := range rawLines {
|
||||||
|
trimmed := strings.TrimSpace(l)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
if len(result) > 1000 {
|
||||||
|
return nil, fmt.Errorf("guidelines exceed 1000 lines (found %d)", len(result))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error)
|
||||||
|
GetPRHeadSHA(ctx context.Context, owner, repo string, prID int) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatAdapter interface {
|
||||||
|
GenerateStructured(ctx context.Context, messages []chatter.Message, target interface{}) error
|
||||||
|
GetProviderName() string
|
||||||
|
}
|
||||||
84
internal/pierre/review.go
Normal file
84
internal/pierre/review.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package pierre
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sanity‑check step ----------
|
||||||
|
headSHA, err := s.git.GetPRHeadSHA(ctx, organisation, repo, prID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("could not fetch PR head SHA", "error", err)
|
||||||
|
} else if !s.skipSanityCheck {
|
||||||
|
filtered := []Comment{}
|
||||||
|
for _, c := range comments {
|
||||||
|
// Retrieve full file content at the PR head
|
||||||
|
fileContent, fErr := s.git.GetFileContent(ctx, organisation, repo, c.File, headSHA)
|
||||||
|
if fErr != nil {
|
||||||
|
slog.Warn("failed to fetch file", "path", c.File, "error", fErr)
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a simple sanity‑check prompt
|
||||||
|
systemPrompt := `You are a senior software architect. Given the full source code of a file and a review comment that refers to it, decide whether the comment is useful. Return JSON with fields "useful" (bool) and "reason" (short explanation, ≤2 sentences).`
|
||||||
|
userPrompt := fmt.Sprintf("File content:\n%s\n\nComment:\n%s", fileContent, c.Message)
|
||||||
|
|
||||||
|
type sanityResult struct {
|
||||||
|
Useful bool `json:"useful"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
var res sanityResult
|
||||||
|
if err := s.chat.GenerateStructured(ctx, []chatter.Message{{Role: chatter.RoleSystem, Content: systemPrompt}, {Role: chatter.RoleUser, Content: userPrompt}}, &res); err != nil {
|
||||||
|
slog.Error("sanity check error", "file", c.File, "line", c.Line, "error", err)
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.Useful {
|
||||||
|
// Optionally annotate the comment with the reason for debugging
|
||||||
|
c.Message = fmt.Sprintf("%s (Reason: %s)", c.Message, res.Reason)
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
} else {
|
||||||
|
slog.Info("comment discarded", "file", c.File, "line", c.Line, "reason", res.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Normal mode: print to stdout and post the comment to the VCS.
|
||||||
|
fmt.Printf("File: %s\nLine: %d\nMessage: %s\n%s\n",
|
||||||
|
c.File, c.Line, c.Message, "---")
|
||||||
|
|
||||||
|
if !s.disableComments {
|
||||||
|
if err := s.git.AddComment(ctx, organisation, repo, prID, c); err != nil {
|
||||||
|
slog.Error("failed to add comment", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user