commit 04885022fd4145a5b5f2a1e837e64936396a3efb Author: Blake Ridgway Date: Sat Apr 11 13:58:06 2026 -0500 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd31c8e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Gitea PR Reviewer — configuration +# Copy to .env and fill in values. Never commit .env to version control. + +# Port to listen on (default: 9000) +PORT=9000 + +# Gitea instance base URL (no trailing slash) +GITEA_URL=https://git.ridgwaysystems.org + +# Gitea personal access token +# Needs: read access to repository contents, write access to issues (for comments) +# Generate at: https://git.ridgwaysystems.org/user/settings/applications +GITEA_TOKEN=your-gitea-token-here + +# DeepSeek API key +# Get one at: https://platform.deepseek.com +DEEPSEEK_API_KEY=your-deepseek-api-key-here + +# Webhook secret — must match the secret set in Gitea's webhook config +# Generate with: openssl rand -hex 32 +# Leave empty to skip signature verification (not recommended in production) +WEBHOOK_SECRET=change-me-use-openssl-rand-hex-32 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..273ad6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +gitea-pr-reviewer +gitea-pr-reviewer-openbsd-amd64 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..592462c --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +BINARY = gitea-pr-reviewer +SERVICE = prbot +CMD = ./cmd/reviewer + +.PHONY: build +build: + go build -o $(BINARY) $(CMD) + +.PHONY: run +run: + go run $(CMD) + +.PHONY: run-env +run-env: + @test -f .env || (echo "No .env file found. Copy .env.example and fill it in." && exit 1) + env $$(grep '^[A-Z]' .env | xargs) go run $(CMD) + +.PHONY: cross +cross: + GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w" -o $(BINARY)-openbsd-amd64 $(CMD) + +.PHONY: tidy +tidy: + go mod tidy + +.PHONY: vet +vet: + go vet ./... + +.PHONY: clean +clean: + rm -f $(BINARY) $(BINARY)-openbsd-amd64 + +DEPLOY_HOST ?= srv01 + +.PHONY: deploy +deploy: cross + scp $(BINARY)-openbsd-amd64 $(DEPLOY_HOST):/usr/local/bin/$(BINARY) + ssh $(DEPLOY_HOST) "rcctl restart $(SERVICE)" + +# First-time server setup. Run once after provisioning. +# Requires the wrapper script to already exist at /usr/local/bin/gitea-pr-reviewer-run. +.PHONY: setup-server +setup-server: + ssh $(DEPLOY_HOST) "useradd -s /sbin/nologin -d /var/empty _prbot 2>/dev/null || true" + scp scripts/rc.d.prbot $(DEPLOY_HOST):/etc/rc.d/$(SERVICE) + ssh $(DEPLOY_HOST) "chmod 555 /etc/rc.d/$(SERVICE) && rcctl enable $(SERVICE)" + +.PHONY: help +help: + @echo "Targets:" + @echo " build Build binary for current OS" + @echo " run Run without .env" + @echo " run-env Run with .env file" + @echo " cross Cross-compile for OpenBSD amd64" + @echo " tidy go mod tidy" + @echo " vet go vet" + @echo " deploy Cross-compile and deploy to OpenBSD server" + @echo " clean Remove build artifacts" diff --git a/cmd/reviewer/main.go b/cmd/reviewer/main.go new file mode 100644 index 0000000..897ee8c --- /dev/null +++ b/cmd/reviewer/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "log" + "net/http" + "os" + + "ridgwaysystems.org/gitea-pr-reviewer/internal/deepseek" + "ridgwaysystems.org/gitea-pr-reviewer/internal/gitea" + "ridgwaysystems.org/gitea-pr-reviewer/internal/webhook" +) + +func main() { + port := getenv("PORT", "9000") + + h := &webhook.Handler{ + Gitea: gitea.New(mustenv("GITEA_URL"), mustenv("GITEA_TOKEN")), + DeepSeek: deepseek.New(mustenv("DEEPSEEK_API_KEY")), + WebhookSecret: os.Getenv("WEBHOOK_SECRET"), + } + + mux := http.NewServeMux() + mux.Handle("POST /webhook", h) + + log.Printf("gitea-pr-reviewer listening on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, mux)) +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func mustenv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("required env var %s is not set", key) + } + return v +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..811c24f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ridgwaysystems.org/gitea-pr-reviewer + +go 1.22 diff --git a/internal/deepseek/deepseek.go b/internal/deepseek/deepseek.go new file mode 100644 index 0000000..1a81806 --- /dev/null +++ b/internal/deepseek/deepseek.go @@ -0,0 +1,99 @@ +// Package deepseek provides a minimal client for the DeepSeek API, +// which is compatible with the OpenAI chat completions format. +package deepseek + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const ( + apiURL = "https://api.deepseek.com/chat/completions" + model = "deepseek-coder" + + systemPrompt = `You are an expert code reviewer. When given a pull request diff, you: +- Identify bugs, logic errors, and edge cases +- Flag security vulnerabilities (injections, auth issues, data exposure, etc.) +- Note performance concerns +- Point out style or maintainability issues +- Highlight anything that looks unfinished or unclear + +Be specific: reference file names and line numbers where relevant. +Be concise: skip praise for obvious or trivial things. +Format your response in Markdown with clear sections. +If the diff is clean, say so briefly.` +) + +// Client is a DeepSeek API client. +type Client struct { + APIKey string + http *http.Client +} + +// New creates a new DeepSeek client. +func New(apiKey string) *Client { + return &Client{ + APIKey: apiKey, + http: &http.Client{Timeout: 120 * time.Second}, + } +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Review sends a PR diff to deepseek-coder and returns the review text. +func (c *Client) Review(title, diff string) (string, error) { + userContent := fmt.Sprintf("Please review this pull request.\n\n**Title:** %s\n\n```diff\n%s\n```", title, diff) + + body, _ := json.Marshal(chatRequest{ + Model: model, + Messages: []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userContent}, + }, + }) + + req, err := http.NewRequest("POST", apiURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var r chatResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return "", fmt.Errorf("deepseek: decode response: %w", err) + } + if r.Error != nil { + return "", fmt.Errorf("deepseek: %s", r.Error.Message) + } + if len(r.Choices) == 0 { + return "", fmt.Errorf("deepseek: empty response") + } + return r.Choices[0].Message.Content, nil +} diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go new file mode 100644 index 0000000..ea88c64 --- /dev/null +++ b/internal/gitea/gitea.go @@ -0,0 +1,91 @@ +// Package gitea provides a minimal Gitea API client for fetching PR diffs +// and posting review comments. +package gitea + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const maxDiffBytes = 100 * 1024 // 100 KB — well within deepseek-coder's context + +// Client is a minimal Gitea API client. +type Client struct { + BaseURL string + Token string + http *http.Client +} + +// New creates a new Gitea client. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: baseURL, + Token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *Client) newRequest(method, url string, body []byte) (*http.Request, error) { + var req *http.Request + var err error + if body != nil { + req, err = http.NewRequest(method, url, bytes.NewReader(body)) + } else { + req, err = http.NewRequest(method, url, nil) + } + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+c.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return req, nil +} + +// FetchDiff returns the unified diff for a pull request. +// It fetches the raw .diff endpoint and caps the result at maxDiffBytes. +func (c *Client) FetchDiff(owner, repo string, number int) (string, error) { + url := fmt.Sprintf("%s/%s/%s/pulls/%d.diff", c.BaseURL, owner, repo, number) + req, err := c.newRequest("GET", url, nil) + if err != nil { + return "", err + } + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gitea: fetch diff %s/%s#%d: %s", owner, repo, number, resp.Status) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffBytes)) + if err != nil { + return "", err + } + return string(b), nil +} + +// PostComment posts a comment on a pull request. +// In Gitea, pull request comments are posted via the issues API using the PR number. +func (c *Client) PostComment(owner, repo string, number int, body string) error { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.BaseURL, owner, repo, number) + payload, _ := json.Marshal(map[string]string{"body": body}) + req, err := c.newRequest("POST", url, payload) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("gitea: post comment %s/%s#%d: %s", owner, repo, number, resp.Status) + } + return nil +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go new file mode 100644 index 0000000..8593c1e --- /dev/null +++ b/internal/webhook/webhook.go @@ -0,0 +1,116 @@ +// Package webhook handles incoming Gitea pull_request webhook events. +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log" + "net/http" + + "ridgwaysystems.org/gitea-pr-reviewer/internal/deepseek" + "ridgwaysystems.org/gitea-pr-reviewer/internal/gitea" +) + +// Handler processes Gitea pull_request webhooks. +type Handler struct { + Gitea *gitea.Client + DeepSeek *deepseek.Client + WebhookSecret string +} + +// prPayload is the subset of the Gitea pull_request webhook payload we need. +type prPayload struct { + Action string `json:"action"` + Number int `json:"number"` + Repository struct { + Name string `json:"name"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + } `json:"repository"` + PullRequest struct { + Title string `json:"title"` + } `json:"pull_request"` +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + if h.WebhookSecret != "" && !verifySignature(body, r.Header.Get("X-Gitea-Signature"), h.WebhookSecret) { + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + + if r.Header.Get("X-Gitea-Event") != "pull_request" { + w.WriteHeader(http.StatusNoContent) + return + } + + var p prPayload + if err := json.Unmarshal(body, &p); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + + // Only review when a PR is first opened, when new commits are pushed, + // or when a closed PR is reopened. + switch p.Action { + case "opened", "synchronize", "reopened": + default: + w.WriteHeader(http.StatusNoContent) + return + } + + // Respond immediately so Gitea doesn't time out waiting. + w.WriteHeader(http.StatusAccepted) + + go h.review(p) +} + +func (h *Handler) review(p prPayload) { + owner := p.Repository.Owner.Login + repo := p.Repository.Name + number := p.Number + title := p.PullRequest.Title + + log.Printf("reviewing %s/%s#%d %q (action: %s)", owner, repo, number, title, p.Action) + + diff, err := h.Gitea.FetchDiff(owner, repo, number) + if err != nil { + log.Printf("fetch diff %s/%s#%d: %v", owner, repo, number, err) + return + } + if diff == "" { + log.Printf("empty diff for %s/%s#%d, skipping", owner, repo, number) + return + } + + review, err := h.DeepSeek.Review(title, diff) + if err != nil { + log.Printf("deepseek %s/%s#%d: %v", owner, repo, number, err) + return + } + + comment := "## AI Code Review\n\n" + review + "\n\n---\n*Reviewed by deepseek-coder*" + if err := h.Gitea.PostComment(owner, repo, number, comment); err != nil { + log.Printf("post comment %s/%s#%d: %v", owner, repo, number, err) + return + } + + log.Printf("review posted for %s/%s#%d", owner, repo, number) +} + +// verifySignature checks the HMAC-SHA256 signature Gitea sends in X-Gitea-Signature. +func verifySignature(body []byte, signature, secret string) bool { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(signature), []byte(expected)) +} diff --git a/scripts/gitea-pr-reviewer-run.example b/scripts/gitea-pr-reviewer-run.example new file mode 100644 index 0000000..d3a6987 --- /dev/null +++ b/scripts/gitea-pr-reviewer-run.example @@ -0,0 +1,11 @@ +#!/bin/ksh +# /usr/local/bin/gitea-pr-reviewer-run +# Copy to server, fill in values, chmod 500, chown _prbot. + +export PORT=9000 +export GITEA_URL=https://git.ridgwaysystems.org +export GITEA_TOKEN=your-gitea-token-here +export DEEPSEEK_API_KEY=your-deepseek-api-key-here +export WEBHOOK_SECRET=your-webhook-secret-here + +exec /usr/local/bin/gitea-pr-reviewer diff --git a/scripts/rc.d.prbot b/scripts/rc.d.prbot new file mode 100644 index 0000000..1015fb0 --- /dev/null +++ b/scripts/rc.d.prbot @@ -0,0 +1,6 @@ +#!/bin/ksh +daemon="/usr/local/bin/gitea-pr-reviewer-run" +daemon_user="_prbot" +. /etc/rc.d/rc.subr +rc_bg=YES +rc_cmd $1