Files
kandev/apps/backend/internal/github/client_helpers.go
T
2026-08-05 20:45:59 +01:00

826 lines
24 KiB
Go

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{}
}
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,
}, nil
}
// 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 = "<anon>:" + 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
}