package github import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/kandev/kandev/internal/common/securityutil" ) func validateGitHubCommitSHA(sha string) error { if !securityutil.LooksLikeCommitSHA(sha) { return fmt.Errorf("invalid commit SHA") } return nil } // buildReviewSearchQuery assembles the full GitHub search query. // When customQuery is non-empty, it is used verbatim as the entire query. // Otherwise a query is built from scope + optional filter qualifier. func buildReviewSearchQuery(scope, filter, customQuery string) string { if customQuery != "" { return customQuery } var base string if scope == ReviewScopeUser { base = "type:pr state:open user-review-requested:@me -is:draft" } else { base = "type:pr state:open review-requested:@me -is:draft" } if filter != "" { base += " " + filter } return base } // getPRFeedback fetches aggregated feedback for a PR using any Client implementation. // This shared function eliminates duplication between GHClient and PATClient. func getPRFeedback(ctx context.Context, c Client, owner, repo string, number int) (*PRFeedback, error) { pr, err := c.GetPR(ctx, owner, repo, number) if err != nil { return nil, err } fetchCtx, cancel := context.WithCancel(ctx) defer cancel() type feedbackResult struct { reviews []PRReview comments []PRComment checks []CheckRun err error } results := make(chan feedbackResult, 3) go func() { value, callErr := c.ListPRReviews(fetchCtx, owner, repo, number) results <- feedbackResult{reviews: value, err: callErr} }() go func() { value, callErr := c.ListPRComments(fetchCtx, owner, repo, number, nil) results <- feedbackResult{comments: value, err: callErr} }() go func() { value, callErr := c.ListCheckRuns(fetchCtx, owner, repo, pr.HeadSHA) results <- feedbackResult{checks: value, err: callErr} }() var reviews []PRReview var comments []PRComment var checks []CheckRun for range 3 { result := <-results if result.err != nil { return nil, result.err } if result.reviews != nil { reviews = result.reviews } if result.comments != nil { comments = result.comments } if result.checks != nil { checks = result.checks } } // Ensure non-nil slices so JSON serialization produces [] instead of null. if reviews == nil { reviews = []PRReview{} } if comments == nil { comments = []PRComment{} } if checks == nil { checks = []CheckRun{} } hasIssues := hasFailingChecks(checks) || hasChangesRequested(reviews) return &PRFeedback{ PR: pr, Reviews: reviews, Comments: comments, Checks: checks, HasIssues: hasIssues, }, nil } // getPRStatus fetches lightweight PR status using any Client implementation. // Unlike getPRFeedback, it skips comments to reduce API calls. func getPRStatus(ctx context.Context, c Client, owner, repo string, number int) (*PRStatus, error) { pr, err := c.GetPR(ctx, owner, repo, number) if err != nil { return nil, err } reviews, err := c.ListPRReviews(ctx, owner, repo, number) if err != nil { return nil, err } checks, err := c.ListCheckRuns(ctx, owner, repo, pr.HeadSHA) if err != nil { return nil, err } if reviews == nil { reviews = []PRReview{} } if checks == nil { checks = []CheckRun{} } return newPRStatus(pr, reviews, checks), nil } // newPRStatus derives the PRStatus rollup from the three upstream reads every // REST-shaped path already makes: the PR itself, its reviews, and its check // runs. getPRStatus and the PR-feedback path both land here so the two cannot // drift — feedback fetches a strict superset (it adds comments), so the same // derivation is valid for both. // // Both Populated flags are set because this path genuinely counted: a zero // here is a real "no checks" / "no reviews" answer, not "I didn't look". // UnresolvedReviewThreadsPopulated stays false — neither REST caller fetches // review threads, so SyncTaskPR must preserve whatever the GraphQL path stored. func newPRStatus(pr *PR, reviews []PRReview, checks []CheckRun) *PRStatus { reviewState, pendingReviewCount := deriveReviewSyncState(pr, reviews) total, passing := countCheckResults(checks) return &PRStatus{ PR: pr, ReviewState: reviewState, ChecksState: computeOverallCheckStatus(checks), MergeableState: pr.MergeableState, // ReviewCount is the number of distinct reviewers whose latest review // state is APPROVED — it's the value the popover renders as // "Approved (N)" / "Approved N / M required". Counting raw review // entries (`len(reviews)`) double-counts authors who left multiple // reviews and also includes COMMENTED / CHANGES_REQUESTED rows. ReviewCount: countApprovedReviewers(reviews), PendingReviewCount: pendingReviewCount, ChecksTotal: total, ChecksPassing: passing, // REST path actually counted check runs + reviews — distinguish // from the batched-GraphQL path that only carries rollup state. ChecksPopulated: true, ReviewCountsPopulated: true, } } // reviewSample is a normalized review row consumed by latestReviewStateByAuthor. // Adapter functions below convert PRReview (REST) and reviewNode (GraphQL) // to this shape so the per-reviewer dedup rule is only spelled out once. type reviewSample struct { author string state string at time.Time } // latestReviewStateByAuthor reduces a list of reviews to "one entry per // reviewer, latest binding state wins". COMMENTED and PENDING never // override a prior APPROVED / CHANGES_REQUESTED for the same author so a // reviewer who approved-then-commented still counts as approved. // Anonymous reviewers (deleted users) get a unique synthetic key so each // review still counts independently. func latestReviewStateByAuthor(samples []reviewSample) map[string]reviewSample { byAuthor := make(map[string]reviewSample, len(samples)) for _, s := range samples { key := s.author if key == "" { key = ":" + s.at.UTC().Format(time.RFC3339Nano) } state := strings.ToUpper(s.state) if state != reviewStateApproved && state != reviewStateChangesRequested { if _, ok := byAuthor[key]; ok { continue } } prev, ok := byAuthor[key] if !ok || !s.at.Before(prev.at) { byAuthor[key] = reviewSample{author: s.author, state: state, at: s.at} } } return byAuthor } // countApprovedAuthors counts distinct reviewers whose latest binding state is // APPROVED. Shared between the REST and GraphQL count helpers. func countApprovedAuthors(samples []reviewSample) int { count := 0 for _, s := range latestReviewStateByAuthor(samples) { if s.state == reviewStateApproved { count++ } } return count } // reduceReviewSummary collapses the per-author map into a single overall // review state. CHANGES_REQUESTED wins across authors; APPROVED otherwise; // "" when nothing is binding. Shared between REST and GraphQL paths. func reduceReviewSummary(samples []reviewSample) string { hasApproval := false for _, l := range latestReviewStateByAuthor(samples) { switch l.state { case reviewStateChangesRequested: return computedReviewStateChangesRequested case reviewStateApproved: hasApproval = true } } if hasApproval { return computedReviewStateApproved } return "" } // countApprovedReviewers returns the number of distinct authors whose latest // review state is APPROVED. REST-path adapter over the shared helper. func countApprovedReviewers(reviews []PRReview) int { samples := make([]reviewSample, len(reviews)) for i, r := range reviews { samples[i] = reviewSample{author: r.Author, state: r.State, at: r.CreatedAt} } return countApprovedAuthors(samples) } // countCheckResults returns (total, passing) counts using the same logic as // computeOverallCheckStatus: skipped/neutral and still-running checks are // excluded from the total so the X/Y badge communicates pass rate rather // than conflating in-flight checks with failures. The pending state is // carried separately via ChecksState. func countCheckResults(checks []CheckRun) (int, int) { total, passing := 0, 0 for _, c := range checks { if c.Status != checkStatusCompleted { continue } switch c.Conclusion { case checkConclusionSkipped, checkConclusionNeutral: continue case checkConclusionFail, checkConclusionTimedOut, checkConclusionCancelled, checkConclusionActionRequired: total++ default: total++ passing++ } } return total, passing } // convertRawCheckRuns converts raw ghCheckRun structs into the domain CheckRun type. func convertRawCheckRuns(raw []ghCheckRun) []CheckRun { checks := make([]CheckRun, len(raw)) for i, cr := range raw { conclusion := "" if cr.Conclusion != nil { conclusion = *cr.Conclusion } output := "" if cr.Output.Summary != nil { output = *cr.Output.Summary } checks[i] = CheckRun{ Name: cr.Name, Source: checkSourceCheckRun, Status: cr.Status, Conclusion: conclusion, HTMLURL: cr.HTMLURL, Output: output, StartedAt: parseTimePtr(cr.StartedAt), CompletedAt: parseTimePtr(cr.CompletedAt), } } return checks } // convertRawComments converts raw review comments into the domain PRComment type. func convertRawComments(raw []ghComment) []PRComment { comments := make([]PRComment, len(raw)) for i, c := range raw { comments[i] = PRComment{ ID: c.ID, Author: c.User.Login, AuthorAvatar: c.User.AvatarURL, AuthorIsBot: isGitHubBot(c.User.Type), Body: c.Body, Path: c.Path, Line: c.Line, Side: c.Side, CommentType: commentTypeReview, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, InReplyTo: c.InReplyTo, } } return comments } // ghIssueComment is the JSON shape for issue comments from the GitHub API. type ghIssueComment struct { ID int64 `json:"id"` Body string `json:"body"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` User struct { Login string `json:"login"` AvatarURL string `json:"avatar_url"` Type string `json:"type"` } `json:"user"` } // convertRawIssueComments converts PR conversation comments into PRComment rows. func convertRawIssueComments(raw []ghIssueComment) []PRComment { comments := make([]PRComment, len(raw)) for i, c := range raw { comments[i] = PRComment{ ID: c.ID, Author: c.User.Login, AuthorAvatar: c.User.AvatarURL, AuthorIsBot: isGitHubBot(c.User.Type), Body: c.Body, CommentType: commentTypeIssue, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, } } return comments } // mergeAndSortPRComments combines review and issue comments and sorts by creation time. func mergeAndSortPRComments(reviewComments, issueComments []PRComment) []PRComment { comments := make([]PRComment, 0, len(reviewComments)+len(issueComments)) comments = append(comments, reviewComments...) comments = append(comments, issueComments...) sort.SliceStable(comments, func(i, j int) bool { if comments[i].CreatedAt.Equal(comments[j].CreatedAt) { return comments[i].ID < comments[j].ID } return comments[i].CreatedAt.Before(comments[j].CreatedAt) }) return comments } func isGitHubBot(userType string) bool { return strings.EqualFold(userType, githubUserTypeBot) } // ghStatusContext is the JSON shape from /commits/:ref/status. type ghStatusContext struct { Context string `json:"context"` State string `json:"state"` // error, failure, pending, success TargetURL string `json:"target_url"` Description string `json:"description"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // convertRawStatusContexts converts commit status contexts into CheckRun rows. func convertRawStatusContexts(raw []ghStatusContext) []CheckRun { checks := make([]CheckRun, len(raw)) for i, st := range raw { status := checkStatusCompleted conclusion := st.State switch st.State { case commitStatusSuccess: conclusion = checkStatusSuccess case commitStatusFailure, commitStatusError: conclusion = checkConclusionFail case commitStatusPending: status = "in_progress" conclusion = "" } checks[i] = CheckRun{ Name: st.Context, Source: checkSourceStatusContext, Status: status, Conclusion: conclusion, HTMLURL: st.TargetURL, Output: st.Description, StartedAt: parseTimePtr(st.CreatedAt), } if status == checkStatusCompleted { checks[i].CompletedAt = parseTimePtr(st.UpdatedAt) } } return checks } // mergeChecks deduplicates check runs and commit statuses by normalized name. // Check-run source wins over status-context; among same-source duplicates the // most recent entry (by StartedAt) is kept. func mergeChecks(checkRuns, statusChecks []CheckRun) []CheckRun { merged := make([]CheckRun, 0, len(checkRuns)+len(statusChecks)) byKey := make(map[string]int) for _, check := range statusChecks { key := checkMergeKey(check) if idx, ok := byKey[key]; ok { if isNewerCheck(check, merged[idx]) { merged[idx] = check } continue } byKey[key] = len(merged) merged = append(merged, check) } for _, check := range checkRuns { key := checkMergeKey(check) if idx, ok := byKey[key]; ok { if merged[idx].Source == checkSourceStatusContext || isNewerCheck(check, merged[idx]) { merged[idx] = check } continue } byKey[key] = len(merged) merged = append(merged, check) } return merged } func checkMergeKey(check CheckRun) string { return strings.ToLower(strings.TrimSpace(check.Name)) } // isNewerCheck reports whether a started more recently than b. func isNewerCheck(a, b CheckRun) bool { if a.StartedAt == nil { return false } if b.StartedAt == nil { return true } return a.StartedAt.After(*b.StartedAt) } // convertSearchItemToPR converts common search result fields into a PR struct. // GitHub's /search/issues API returns merged PRs with state "closed" and a // non-empty pull_request.merged_at; promote those to the "merged" state so the // UI renders the purple merged icon instead of the red closed one. func convertSearchItemToPR( id int64, nodeID string, number int, title, htmlURL, state, authorLogin, repositoryURL, mergedAt string, draft bool, createdAt, updatedAt time.Time, ) *PR { owner, repo := parseRepoURL(repositoryURL) if mergedAt != "" { state = prStateMerged } return &PR{ ID: id, NodeID: nodeID, Number: number, Title: title, HTMLURL: htmlURL, State: state, AuthorLogin: authorLogin, Draft: draft, RepoOwner: owner, RepoName: repo, CreatedAt: createdAt, UpdatedAt: updatedAt, MergedAt: parseTimePtr(mergedAt), } } // clampSearchPage normalizes page and perPage to GitHub's Search API limits: // page >= 1, 1 <= perPage <= 100. Defaults: page=1, perPage=50. func clampSearchPage(page, perPage int) (int, int) { if page < 1 { page = 1 } if perPage <= 0 { perPage = 50 } if perPage > 100 { perPage = 100 } return page, perPage } // hasTypeQualifier reports whether the query already pins the result type via // `type:pr`, `type:issue`, `is:pr`, or `is:issue` (case-insensitive). func hasTypeQualifier(query string) bool { lower := strings.ToLower(query) for _, tok := range strings.Fields(lower) { switch tok { case "type:pr", "type:issue", "is:pr", "is:issue": return true } } return false } // buildIssueSearchQuery assembles a GitHub search query for issues. // The `type:issue` qualifier is always injected (unless the caller already // pinned it) because `/search/issues` returns both PRs and issues otherwise. func buildIssueSearchQuery(filter, customQuery string) string { return buildSearchQuery("type:issue", filter, customQuery) } // buildPRSearchQuery assembles a GitHub search query for pull requests. // The `type:pr` qualifier is always injected (unless the caller already pinned // it) because `/search/issues` returns both PRs and issues otherwise. func buildPRSearchQuery(filter, customQuery string) string { return buildSearchQuery("type:pr", filter, customQuery) } func buildSearchQuery(typeQualifier, filter, customQuery string) string { if customQuery != "" { if hasTypeQualifier(customQuery) { return customQuery } return typeQualifier + " " + customQuery } if filter != "" { return typeQualifier + " " + filter } return typeQualifier } // issueSearchItem is an issue item from the GitHub search API. type issueSearchItem struct { ID int64 `json:"id"` NodeID string `json:"node_id"` Number int `json:"number"` Title string `json:"title"` Body string `json:"body"` HTMLURL string `json:"html_url"` State string `json:"state"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ClosedAt *string `json:"closed_at"` RepositoryURL string `json:"repository_url"` User struct { Login string `json:"login"` } `json:"user"` Labels []struct { Name string `json:"name"` } `json:"labels"` Assignees []struct { Login string `json:"login"` } `json:"assignees"` PullRequest *struct { URL string `json:"url"` } `json:"pull_request"` } // convertSearchItemToIssue converts a search API item into an Issue struct. func convertSearchItemToIssue(item issueSearchItem) *Issue { owner, repo := parseRepoURL(item.RepositoryURL) labels := make([]string, len(item.Labels)) for i, l := range item.Labels { labels[i] = l.Name } assignees := make([]string, len(item.Assignees)) for i, a := range item.Assignees { assignees[i] = a.Login } issue := &Issue{ ID: item.ID, NodeID: item.NodeID, Number: item.Number, Title: item.Title, Body: item.Body, HTMLURL: item.HTMLURL, State: item.State, AuthorLogin: item.User.Login, RepoOwner: owner, RepoName: repo, Labels: labels, Assignees: assignees, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } if item.ClosedAt != nil { issue.ClosedAt = parseTimePtr(*item.ClosedAt) } return issue } // parseIssueSearchResults parses GitHub search API response items into Issue structs, // filtering out any pull requests (which also appear in search/issues results). func parseIssueSearchResults(items []issueSearchItem) []*Issue { var issues []*Issue for _, item := range items { // GitHub search/issues returns both issues and PRs; skip PRs if item.PullRequest != nil { continue } issues = append(issues, convertSearchItemToIssue(item)) } return issues } // latestReviewByAuthor returns a map of the most recent review per author. func latestReviewByAuthor(reviews []PRReview) map[string]PRReview { latest := make(map[string]PRReview) for _, r := range reviews { existing, ok := latest[r.Author] if !ok || r.CreatedAt.After(existing.CreatedAt) { latest[r.Author] = r } } return latest } // hasFailingChecks returns true if any completed check run has failed. func hasFailingChecks(checks []CheckRun) bool { for _, c := range checks { if c.Status == checkStatusCompleted && c.Conclusion == checkConclusionFail { return true } } return false } // hasChangesRequested returns true if any review has requested changes. func hasChangesRequested(reviews []PRReview) bool { for _, r := range reviews { if r.State == reviewStateChangesRequested { return true } } return false } // --- PR files and commits parsing --- // ghPRFile is the JSON shape from the GitHub REST API for PR files. type ghPRFile struct { Filename string `json:"filename"` Status string `json:"status"` // added, removed, modified, renamed, copied, changed, unchanged Additions int `json:"additions"` Deletions int `json:"deletions"` Patch string `json:"patch"` PreviousFilename string `json:"previous_filename"` } // ghPRCommit is the JSON shape from the GitHub REST API for PR commits. type ghPRCommit struct { SHA string `json:"sha"` Commit struct { Message string `json:"message"` Author struct { Date string `json:"date"` } `json:"author"` } `json:"commit"` Author *struct { Login string `json:"login"` } `json:"author"` } // ghPRCommitDetail is the JSON shape returned by GitHub's individual commit // endpoint. With gh api --paginate --slurp, the same shape is wrapped in an // array, one element per page. type ghPRCommitDetail struct { SHA string `json:"sha"` Commit struct { Message string `json:"message"` Author struct { Name string `json:"name"` Date string `json:"date"` } `json:"author"` } `json:"commit"` Author *struct { Login string `json:"login"` } `json:"author"` Stats struct { Additions int `json:"additions"` Deletions int `json:"deletions"` } `json:"stats"` Files []ghPRFile `json:"files"` } // parsePRFilesJSON parses the JSON response from the PR files API. func parsePRFilesJSON(data string) ([]PRFile, error) { var raw []ghPRFile if err := json.Unmarshal([]byte(data), &raw); err != nil { return nil, fmt.Errorf("parse PR files: %w", err) } return convertRawPRFiles(raw), nil } // convertRawPRFiles converts raw GitHub API file structs to domain PRFile. func convertRawPRFiles(raw []ghPRFile) []PRFile { files := make([]PRFile, len(raw)) for i, f := range raw { files[i] = PRFile{ Filename: f.Filename, Status: f.Status, Additions: f.Additions, Deletions: f.Deletions, Patch: buildFullDiff(f.Filename, f.Status, f.Patch, f.PreviousFilename), OldPath: f.PreviousFilename, } } return files } // parsePRCommitDetailJSON parses one or more pages from GitHub's individual // commit endpoint. Metadata and aggregate stats come from the first page; // file records are appended in provider order and duplicate filenames are // ignored when a provider repeats a record across pages. func parsePRCommitDetailJSON(data string) (PRCommitDetail, error) { var pages []ghPRCommitDetail trimmed := strings.TrimSpace(data) if strings.HasPrefix(trimmed, "[") { if err := json.Unmarshal([]byte(trimmed), &pages); err != nil { return PRCommitDetail{}, fmt.Errorf("parse PR commit detail: %w", err) } } else { var page ghPRCommitDetail if err := json.Unmarshal([]byte(trimmed), &page); err != nil { return PRCommitDetail{}, fmt.Errorf("parse PR commit detail: %w", err) } pages = []ghPRCommitDetail{page} } if len(pages) == 0 { return PRCommitDetail{}, errors.New("parse PR commit detail: empty response") } first := pages[0] if first.SHA == "" { return PRCommitDetail{}, errors.New("parse PR commit detail: missing commit SHA") } detail := PRCommitDetail{ SHA: first.SHA, Message: first.Commit.Message, AuthorName: first.Commit.Author.Name, AuthorDate: first.Commit.Author.Date, Additions: first.Stats.Additions, Deletions: first.Stats.Deletions, Files: make([]PRFile, 0), } if first.Author != nil { detail.AuthorLogin = first.Author.Login } seen := make(map[string]struct{}) for _, page := range pages { for _, file := range page.Files { key := file.Filename + "\x00" + file.PreviousFilename if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} detail.Files = append(detail.Files, convertRawPRFiles([]ghPRFile{file})[0]) } } detail.FilesChanged = len(detail.Files) return detail, nil } // buildFullDiff wraps a GitHub patch fragment into a complete unified diff. func buildFullDiff(filename, status, patch, previousFilename string) string { if patch == "" { return "" } var b strings.Builder switch status { case "added": fmt.Fprintf(&b, "diff --git a/%s b/%s\n", filename, filename) b.WriteString("new file mode 100644\n") fmt.Fprintf(&b, "--- /dev/null\n") fmt.Fprintf(&b, "+++ b/%s\n", filename) case "removed": fmt.Fprintf(&b, "diff --git a/%s b/%s\n", filename, filename) b.WriteString("deleted file mode 100644\n") fmt.Fprintf(&b, "--- a/%s\n", filename) fmt.Fprintf(&b, "+++ /dev/null\n") case "renamed": oldName := previousFilename if oldName == "" { oldName = filename } fmt.Fprintf(&b, "diff --git a/%s b/%s\n", oldName, filename) fmt.Fprintf(&b, "rename from %s\n", oldName) fmt.Fprintf(&b, "rename to %s\n", filename) fmt.Fprintf(&b, "--- a/%s\n", oldName) fmt.Fprintf(&b, "+++ b/%s\n", filename) default: fmt.Fprintf(&b, "diff --git a/%s b/%s\n", filename, filename) fmt.Fprintf(&b, "--- a/%s\n", filename) fmt.Fprintf(&b, "+++ b/%s\n", filename) } b.WriteString(patch) if !strings.HasSuffix(patch, "\n") { b.WriteByte('\n') } return b.String() } // parsePRCommitsJSON parses the JSON response from the PR commits API. func parsePRCommitsJSON(data string) ([]PRCommitInfo, error) { var raw []ghPRCommit if err := json.Unmarshal([]byte(data), &raw); err != nil { return nil, fmt.Errorf("parse PR commits: %w", err) } return convertRawPRCommits(raw), nil } // convertRawPRCommits converts raw GitHub API commit structs to domain PRCommitInfo. func convertRawPRCommits(raw []ghPRCommit) []PRCommitInfo { commits := make([]PRCommitInfo, len(raw)) for i, c := range raw { author := "" if c.Author != nil { author = c.Author.Login } // First line of commit message msg := c.Commit.Message if idx := strings.Index(msg, "\n"); idx >= 0 { msg = msg[:idx] } commits[i] = PRCommitInfo{ SHA: c.SHA, Message: msg, AuthorLogin: author, AuthorDate: c.Commit.Author.Date, StatsAvailable: false, } } return commits }