335 lines
9.8 KiB
Go
335 lines
9.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"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 (c *GitHubClient) RepositoryTree(
|
|
ctx context.Context, owner, repo, commitOID string,
|
|
) (AIRepositoryTree, error) {
|
|
baseURL := c.restBaseURL() + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo)
|
|
var commit struct {
|
|
Tree struct {
|
|
SHA string `json:"sha"`
|
|
} `json:"tree"`
|
|
}
|
|
if err := c.getAIRepositoryJSON(
|
|
ctx, baseURL+"/git/commits/"+url.PathEscape(commitOID), 1<<20, &commit,
|
|
); err != nil {
|
|
return AIRepositoryTree{}, fmt.Errorf("load repository commit: %w", err)
|
|
}
|
|
if commit.Tree.SHA == "" {
|
|
return AIRepositoryTree{}, fmt.Errorf("load repository commit: GitHub returned no tree OID")
|
|
}
|
|
var tree struct {
|
|
Truncated bool `json:"truncated"`
|
|
Tree []struct {
|
|
Path string `json:"path"`
|
|
Mode string `json:"mode"`
|
|
Type string `json:"type"`
|
|
SHA string `json:"sha"`
|
|
Size int64 `json:"size"`
|
|
} `json:"tree"`
|
|
}
|
|
if err := c.getAIRepositoryJSON(
|
|
ctx, baseURL+"/git/trees/"+url.PathEscape(commit.Tree.SHA)+"?recursive=1",
|
|
8<<20, &tree,
|
|
); err != nil {
|
|
return AIRepositoryTree{}, fmt.Errorf("load repository tree: %w", err)
|
|
}
|
|
result := AIRepositoryTree{
|
|
CommitOID: commitOID,
|
|
Entries: make([]AIRepositoryEntry, 0, len(tree.Tree)),
|
|
Truncated: tree.Truncated,
|
|
}
|
|
for _, entry := range tree.Tree {
|
|
clean := filepath.ToSlash(filepath.Clean(entry.Path))
|
|
if clean == "." || clean == "" || filepath.IsAbs(clean) ||
|
|
strings.HasPrefix(clean, "../") {
|
|
continue
|
|
}
|
|
result.Entries = append(result.Entries, AIRepositoryEntry{
|
|
Path: clean, OID: entry.SHA, Type: entry.Type, Mode: entry.Mode, Size: entry.Size,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (c *CachedGitHubService) RepositoryTree(
|
|
ctx context.Context, owner, repo, commitOID string,
|
|
) (AIRepositoryTree, error) {
|
|
service, ok := c.remote.(AIRepositoryService)
|
|
if !ok {
|
|
return AIRepositoryTree{}, fmt.Errorf("configured GitHub service cannot load repository trees")
|
|
}
|
|
return service.RepositoryTree(ctx, owner, repo, commitOID)
|
|
}
|
|
|
|
func (c *GitHubClient) RepositoryBlob(
|
|
ctx context.Context, owner, repo, blobOID string, maxBytes int,
|
|
) ([]byte, error) {
|
|
baseURL := c.restBaseURL() + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo)
|
|
var blob struct {
|
|
Content string `json:"content"`
|
|
Encoding string `json:"encoding"`
|
|
Size int `json:"size"`
|
|
SHA string `json:"sha"`
|
|
}
|
|
responseLimit := int64(max(16_384, maxBytes*2+8_192))
|
|
if err := c.getAIRepositoryJSON(
|
|
ctx, baseURL+"/git/blobs/"+url.PathEscape(blobOID), responseLimit, &blob,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("load repository blob: %w", err)
|
|
}
|
|
if blob.SHA != "" && blob.SHA != blobOID {
|
|
return nil, fmt.Errorf("load repository blob: GitHub returned an unexpected blob OID")
|
|
}
|
|
if blob.Size > maxBytes {
|
|
return nil, fmt.Errorf("repository file exceeds the %d-byte AI limit", maxBytes)
|
|
}
|
|
if blob.Encoding != "base64" {
|
|
return nil, fmt.Errorf("load repository blob: unsupported encoding %q", blob.Encoding)
|
|
}
|
|
content, err := base64.StdEncoding.DecodeString(strings.Map(func(r rune) rune {
|
|
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, blob.Content))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode repository blob: %w", err)
|
|
}
|
|
if len(content) > maxBytes {
|
|
return nil, fmt.Errorf("repository file exceeds the %d-byte AI limit", maxBytes)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func (c *CachedGitHubService) RepositoryBlob(
|
|
ctx context.Context, owner, repo, blobOID string, maxBytes int,
|
|
) ([]byte, error) {
|
|
service, ok := c.remote.(AIRepositoryService)
|
|
if !ok {
|
|
return nil, fmt.Errorf("configured GitHub service cannot load repository blobs")
|
|
}
|
|
return service.RepositoryBlob(ctx, owner, repo, blobOID, maxBytes)
|
|
}
|
|
|
|
func (c *GitHubClient) getAIRepositoryJSON(
|
|
ctx context.Context, requestURL string, limit int64, output any,
|
|
) error {
|
|
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+json")
|
|
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)))
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return fmt.Errorf("GitHub response exceeds the %d-byte safety limit", limit)
|
|
}
|
|
if err := json.Unmarshal(data, output); err != nil {
|
|
return fmt.Errorf("decode GitHub response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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"
|
|
}
|
|
if aiPathMatches(file.Path, config.SensitivePaths) {
|
|
return "sensitive"
|
|
}
|
|
if aiPathMatches(file.Path, config.Exclude) {
|
|
return "excluded"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func aiPathMatches(filePath string, patterns []string) bool {
|
|
lower := strings.ToLower(filepath.ToSlash(filePath))
|
|
for _, pattern := range patterns {
|
|
pattern = filepath.ToSlash(pattern)
|
|
if strings.HasSuffix(pattern, "/") {
|
|
directory := strings.ToLower(pattern)
|
|
if strings.HasPrefix(lower, directory) || strings.Contains(lower, "/"+directory) {
|
|
return true
|
|
}
|
|
}
|
|
if matched, _ := path.Match(strings.ToLower(pattern), path.Base(lower)); matched {
|
|
return true
|
|
}
|
|
if matched, _ := path.Match(strings.ToLower(pattern), lower); matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|