Compare commits

..

1 Commits

Author SHA1 Message Date
u80864958
b5912d0725 feat: add logs 2026-02-13 17:44:56 +01:00
9 changed files with 94 additions and 251 deletions

121
AGENTS.md
View File

@@ -6,20 +6,20 @@ layout: default
> This document is consulted by autonomous coding agents (including the
> OpenSource `opencode` agent) when they read, modify, test, or lint the
> codebase. It contains the canonical commands for building, testing, and
> codebase. It contains the canonical commands for building, testing, and
> linting the project as well as a concise style guide that all Go code must
> follow. The file is deliberately kept around **150lines** to be readable for
> follow. The file is deliberately kept around **150lines** 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. |
| 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.
@@ -29,13 +29,13 @@ layout: default
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, noncached run for debugging a single test. |
| `go test ./... -bench .` | Execute benchmarks (if any). |
| 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, noncached 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 tabledriven tests.
@@ -46,10 +46,10 @@ The project uses the standard Go testing framework.
We rely on two lightweight builtin 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`). |
| 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
@@ -60,74 +60,66 @@ use the same commands.
## 4⃣ CodeStyle 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.
All changes must satisfy the following conventions. They are enforced by
`go fmt`, `go vet`, and the optional `golangci-lint` config.
### 4.1 Formatting & Imports
- Run `go fmt ./...` before committing. The repository uses **tabindented** Go code.
- Imports are grouped in three blocks, separated by a blank line:
* Run `go fmt ./...` before committing. The repository uses **tabindented** 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 `golangcilint --fast`) to automatically fix import ordering.
* Within each block, imports are sorted alphabetically.
* Use `goimports` (or `golangcilint --fast`) to automatically fix import ordering.
### 4.2 Naming Conventions
| Entity | Rule |
| --------------------- | ---------------------------------------------------------------------- |
| Packages | lowercase, 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`. |
| Entity | Rule |
|--------|------|
| Packages | lowercase, 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
* 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., nilpointer 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 sentencecase and end with a period.
- Use `//go:generate` directives sparingly; they must be accompanied by a test that ensures the generated file is uptodate.
* 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 sentencecase and end with a period.
* Use `//go:generate` directives sparingly; they must be accompanied by a test that ensures the generated file is uptodate.
### 4.5 Testing Practices
- Keep tests **tabledriven** where possible; this yields concise, readable test suites.
- Use the `testing` package only; avoid thirdparty test frameworks.
- Prefer `t.Fatalf` for fatal errors and `t.Errorf` for nonfatal 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.
* Keep tests **tabledriven** where possible; this yields concise, readable test suites.
* Use the `testing` package only; avoid thirdparty test frameworks.
* Prefer `t.Fatalf` for fatal errors and `t.Errorf` for nonfatal 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.
* 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 uservisible output.
- For structured logs (debug mode), wrap with a small helper that respects the `--debug` flag.
- CLI output must be **machineparsable** when `--json-output` is set (future feature).
* Use the standard library `log` package for uservisible output.
* For structured logs (debug mode), wrap with a small helper that respects the `--debug` flag.
* CLI output must be **machineparsable** when `--json-output` is set (future feature).
---
## 5⃣ Git & PullRequest Workflow (for agents)
1. **Branch naming** `feature/<shortdesc>` or `bugfix/<shortdesc>`.
2. **Commits** One logical change per commit, with a concise subject line (<50chars) and an optional body explaining _why_ the change was needed.
2. **Commits** One logical change per commit, with a concise subject line (<50chars) and an optional body explaining *why* the change was needed.
3. **CI** Every PR must pass `go test ./...`, `go vet ./...`, and `golangcilint 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.
@@ -137,7 +129,7 @@ to be backwards compatible.
## 6⃣ Cursor / Copilot Rules (None Present)
The repository does not contain a `.cursor` directory or a `.github/copilotinstructions.md`
file. If such files are added in the future, agents should read them and incorporate
file. If such files are added in the future, agents should read them and incorporate
any additional constraints into this document.
---
@@ -167,5 +159,4 @@ go mod tidy
---
_End of AGENTS.md_
*End of AGENTS.md*

View File

@@ -45,11 +45,15 @@ 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 sanitycheck LLM prompts per comment" default:"true"`
}
type Config struct {
LogLevel string `help:"Log verbosity: debug, info, warn, error" default:"info"`
LogLevel string `help:"Log verbosity: debug, info, warn, error"`
// Deprecated flags (no prefix). Retained for backward compatibility.
// These will be mapped to the embedded ReviewConfig if provided.
MaxChunkSize int `help:"Deprecated: use --review-max-chunk-size"`
Guidelines []string `help:"Deprecated: use --review-guidelines" sep:","`
DisableComments bool `help:"Deprecated: use --review-disable-comments"`
// Embedding ReviewConfig with a prefix changes flag names to `--review-…`.
// Existing configuration files using the old flag names will need to be updated.
@@ -64,6 +68,7 @@ type Config struct {
}
func main() {
cfg := &Config{}
home, err := os.UserHomeDir()
if err != nil {
@@ -92,12 +97,20 @@ func main() {
case "error":
lvl = slog.LevelError
}
// Logs are sent to stderr so that stdout can be safely piped.
// The review output (fmt.Printf) stays on stdout unchanged.
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl}))
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
slog.SetDefault(logger)
// Backwards compatibility: map deprecated flag values (if any) to the embedded ReviewConfig.
if cfg.MaxChunkSize != 0 {
cfg.Review.MaxChunkSize = cfg.MaxChunkSize
}
if len(cfg.Guidelines) > 0 {
cfg.Review.Guidelines = cfg.Guidelines
}
if cfg.DisableComments {
cfg.Review.DisableComments = cfg.DisableComments
}
// Auto-detect provider
provider := cfg.GitProvider
if provider == "" {
@@ -153,7 +166,6 @@ func main() {
}
pierreService := pierre.New(ai, git, cfg.Review.MaxChunkSize, cfg.Review.Guidelines, cfg.Review.DisableComments)
pierreService.SetSanityCheck(cfg.Review.SanityCheck)
if err := pierreService.MakeReview(context.Background(), cfg.Repo.Owner, cfg.Repo.Repo, cfg.Repo.PRID); err != nil {
log.Fatalf("Error during review: %v", err)
}

View File

@@ -31,8 +31,6 @@ func (b *BitbucketAdapter) GetFileContent(ctx context.Context, projectKey, repos
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)

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"code.gitea.io/sdk/gitea"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/pierre"
@@ -51,14 +50,11 @@ func (g *Adapter) AddComment(ctx context.Context, owner, repo string, prID int,
},
},
}
_, resp, err := g.client.CreatePullReview(owner, repo, int64(prID), opts)
_, _, 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
}

View File

@@ -1,70 +0,0 @@
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")
}
}

View File

@@ -1,10 +1,11 @@
package pierre
import (
"log/slog"
"context"
"fmt"
"io"
"log/slog"
"strings"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"

View File

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

View File

@@ -2,9 +2,7 @@ package pierre
import (
"context"
"fmt"
"io"
"strings"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
)
@@ -16,63 +14,22 @@ import (
// 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 sanitycheck prompts per comment
guidelines []string
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 linecount (max 1000 nonempty 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, nonempty
// 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, nonempty
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

View File

@@ -3,7 +3,7 @@ package pierre
import (
"context"
"fmt"
"log/slog"
"log"
"git.schreifuchs.ch/schreifuchs/pierre-bot/internal/chatter"
)
@@ -22,17 +22,17 @@ func (s *Service) MakeReview(ctx context.Context, organisation string, repo stri
return fmt.Errorf("error judging PR: %w", err)
}
// ---------- Sanitycheck step ----------
// ---------- Sanitycheck step (always enabled) ----------
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 {
log.Printf("warning: could not fetch PR head SHA (%v); skipping sanity check", err)
} else {
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)
log.Printf("failed to fetch file %s: %v keeping original comment", c.File, fErr)
filtered = append(filtered, c)
continue
}
@@ -47,7 +47,7 @@ func (s *Service) MakeReview(ctx context.Context, organisation string, repo stri
}
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)
log.Printf("sanity check error for %s:%d: %v keeping comment", c.File, c.Line, err)
filtered = append(filtered, c)
continue
}
@@ -56,7 +56,7 @@ func (s *Service) MakeReview(ctx context.Context, organisation string, repo stri
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)
log.Printf("comment on %s:%d discarded: %s", c.File, c.Line, res.Reason)
}
}
comments = filtered
@@ -69,13 +69,15 @@ func (s *Service) MakeReview(ctx context.Context, organisation string, repo stri
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 s.disableComments {
// Dryrun: just log what would have been posted.
log.Printf("dryrun: %s:%d => %s", c.File, c.Line, c.Message)
} else {
// 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 err := s.git.AddComment(ctx, organisation, repo, prID, c); err != nil {
slog.Error("failed to add comment", "error", err)
log.Printf("Failed to add comment: %v", err)
}
}
}