first commit

This commit is contained in:
Blake Ridgway
2026-04-11 13:58:06 -05:00
commit 04885022fd
10 changed files with 452 additions and 0 deletions

View 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
View 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
View 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))
}