180 lines
5.0 KiB
Go
180 lines
5.0 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) {
|
|
requestURL := c.restBaseURL() + "/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 ""
|
|
}
|