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 "" }