Files
diple/ai_diff.go

189 lines
5.3 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
)
func (c *GitHubClient) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
base := strings.TrimSuffix(c.endpoint, "/")
switch {
case base == "https://api.github.com/graphql":
base = "https://api.github.com"
case strings.HasSuffix(base, "/api/graphql"):
base = strings.TrimSuffix(base, "/api/graphql") + "/api/v3"
default:
base = strings.TrimSuffix(base, "/graphql")
}
requestURL := base + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo) +
"/pulls/" + strconv.Itoa(number)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github.v3.diff")
req.Header.Set("User-Agent", "diple")
response, err := c.http.Do(req)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return "", fmt.Errorf("GitHub returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(io.LimitReader(response.Body, (32<<20)+1))
if err != nil {
return "", err
}
if len(body) > 32<<20 {
return "", fmt.Errorf("PR diff exceeds the 32 MiB safety limit")
}
return string(body), nil
}
func (c *CachedGitHubService) PullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
service, ok := c.remote.(AIDiffService)
if !ok {
return "", fmt.Errorf("configured GitHub service cannot load pull request diffs")
}
return service.PullRequestDiff(ctx, owner, repo, number)
}
func prepareAIDiff(raw string, config AIConfig) ([]aiDiffFile, []string, int) {
sections := strings.Split(raw, "\ndiff --git ")
var files []aiDiffFile
var excluded []string
redactions := 0
for sectionIndex, section := range sections {
if sectionIndex > 0 {
section = "diff --git " + section
}
file, ok := parseAIDiffFile(section)
if !ok {
continue
}
reason := aiExcludedReason(file, config)
if reason != "" {
excluded = append(excluded, file.Path+" ("+reason+")")
continue
}
redacted, count := redactAISecrets(file.Text)
file.Text = sanitizeAIControls(redacted)
redactions += count
files = append(files, file)
}
return files, excluded, redactions
}
func parseAIDiffFile(section string) (aiDiffFile, bool) {
scanner := bufio.NewScanner(strings.NewReader(section))
scanner.Buffer(make([]byte, 4096), 2<<20)
file := aiDiffFile{
ChangedLines: make(map[int]bool), DeletedLines: make(map[int]bool), Text: section,
}
oldPath := ""
oldLine := 0
newLine := 0
inHunk := false
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "--- ") && !inHunk {
candidate := strings.TrimPrefix(line, "--- ")
if strings.HasPrefix(candidate, `"`) {
if unquoted, err := strconv.Unquote(candidate); err == nil {
candidate = unquoted
}
}
if strings.HasPrefix(candidate, "a/") {
oldPath = strings.TrimPrefix(candidate, "a/")
}
}
if strings.HasPrefix(line, "+++ ") && !inHunk {
candidate := strings.TrimPrefix(line, "+++ ")
if strings.HasPrefix(candidate, `"`) {
if unquoted, err := strconv.Unquote(candidate); err == nil {
candidate = unquoted
}
}
if strings.HasPrefix(candidate, "b/") {
file.Path = strings.TrimPrefix(candidate, "b/")
}
}
if strings.HasPrefix(line, "@@ ") {
parts := strings.Fields(line)
if len(parts) >= 3 {
oldRange := strings.TrimPrefix(parts[1], "-")
oldStart := strings.SplitN(oldRange, ",", 2)[0]
oldLine, _ = strconv.Atoi(oldStart)
rangePart := strings.TrimPrefix(parts[2], "+")
start := strings.SplitN(rangePart, ",", 2)[0]
newLine, _ = strconv.Atoi(start)
inHunk = true
}
continue
}
if !inHunk || line == "" {
continue
}
switch line[0] {
case '+':
file.ChangedLines[newLine] = true
newLine++
case '-':
file.DeletedLines[oldLine] = true
oldLine++
default:
oldLine++
newLine++
}
}
if file.Path == "" {
file.Path = oldPath
}
if file.Path == "" || file.Path == "/dev/null" {
return aiDiffFile{}, false
}
file.Path = filepath.ToSlash(filepath.Clean(file.Path))
if strings.HasPrefix(file.Path, "../") || filepath.IsAbs(file.Path) {
return aiDiffFile{}, false
}
return file, len(file.ChangedLines) > 0 || len(file.DeletedLines) > 0
}
func aiExcludedReason(file aiDiffFile, config AIConfig) string {
if len(file.Text) > config.MaxFileBytes {
return "oversized"
}
if strings.Contains(file.Text, "GIT binary patch") ||
strings.Contains(file.Text, "Binary files ") || strings.IndexByte(file.Text, 0) >= 0 {
return "binary"
}
lower := strings.ToLower(file.Path)
for _, pattern := range config.Exclude {
pattern = filepath.ToSlash(pattern)
if strings.HasSuffix(pattern, "/") {
directory := strings.ToLower(pattern)
if strings.HasPrefix(lower, directory) || strings.Contains(lower, "/"+directory) {
return "excluded"
}
}
if matched, _ := path.Match(strings.ToLower(pattern), path.Base(lower)); matched {
return "excluded"
}
if matched, _ := path.Match(strings.ToLower(pattern), lower); matched {
return "excluded"
}
}
return ""
}