first commit
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -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
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
gitea-pr-reviewer
|
||||||
|
gitea-pr-reviewer-openbsd-amd64
|
||||||
59
Makefile
Normal file
59
Makefile
Normal file
@@ -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"
|
||||||
42
cmd/reviewer/main.go
Normal file
42
cmd/reviewer/main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
99
internal/deepseek/deepseek.go
Normal file
99
internal/deepseek/deepseek.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
91
internal/gitea/gitea.go
Normal file
91
internal/gitea/gitea.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
116
internal/webhook/webhook.go
Normal file
116
internal/webhook/webhook.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
11
scripts/gitea-pr-reviewer-run.example
Normal file
11
scripts/gitea-pr-reviewer-run.example
Normal file
@@ -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
|
||||||
6
scripts/rc.d.prbot
Normal file
6
scripts/rc.d.prbot
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user