Fix high prio recommendations, add error / health screen
This commit is contained in:
229
github.go
229
github.go
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -33,22 +34,30 @@ type GitHubBranchService interface {
|
||||
ListBranches(context.Context, string, string) ([]RepositoryBranch, error)
|
||||
}
|
||||
|
||||
type GitHubEnrichmentService interface {
|
||||
EnrichPullRequest(context.Context, PRDetails) PRDetailsEnrichment
|
||||
}
|
||||
|
||||
type GitHubClient struct {
|
||||
endpoint string
|
||||
token string
|
||||
http *http.Client
|
||||
conflicts conflictFileLoader
|
||||
conflictMu sync.Mutex
|
||||
conflictCache map[string]conflictFileResult
|
||||
endpoint string
|
||||
token string
|
||||
http *http.Client
|
||||
conflicts conflictFileLoader
|
||||
conflictMu sync.Mutex
|
||||
conflictCache map[string]conflictFileResult
|
||||
health healthTracker
|
||||
annotationMu sync.Mutex
|
||||
annotationCache map[string][]CheckAnnotation
|
||||
}
|
||||
|
||||
func NewGitHubClient(endpoint, token string) *GitHubClient {
|
||||
return &GitHubClient{
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
conflicts: analyzeConflictFiles,
|
||||
conflictCache: make(map[string]conflictFileResult),
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
conflicts: analyzeConflictFiles,
|
||||
conflictCache: make(map[string]conflictFileResult),
|
||||
annotationCache: make(map[string][]CheckAnnotation),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +75,21 @@ type graphQLResponse[T any] struct {
|
||||
Errors []graphQLError `json:"errors"`
|
||||
}
|
||||
|
||||
func (c *GitHubClient) query(ctx context.Context, query string, variables map[string]any, target any) error {
|
||||
func (c *GitHubClient) query(
|
||||
ctx context.Context, query string, variables map[string]any, target any,
|
||||
) (resultErr error) {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
component := HealthComponent{
|
||||
Name: "GitHub API", Level: healthOK, Summary: "last request succeeded",
|
||||
Detail: time.Since(started).Round(time.Millisecond).String(), UpdatedAt: time.Now(),
|
||||
}
|
||||
if resultErr != nil {
|
||||
component.Level = healthError
|
||||
component.Summary = resultErr.Error()
|
||||
}
|
||||
c.health.set(component)
|
||||
}()
|
||||
payload, err := json.Marshal(graphQLRequest{Query: query, Variables: variables})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode GraphQL request: %w", err)
|
||||
@@ -84,6 +107,7 @@ func (c *GitHubClient) query(ctx context.Context, query string, variables map[st
|
||||
return fmt.Errorf("GitHub request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
c.recordRateLimit(resp)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read GitHub response: %w", err)
|
||||
@@ -109,6 +133,40 @@ func (c *GitHubClient) query(ctx context.Context, query string, variables map[st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GitHubClient) recordRateLimit(response *http.Response) {
|
||||
parseInt := func(name string) int {
|
||||
value, _ := strconv.Atoi(response.Header.Get(name))
|
||||
return value
|
||||
}
|
||||
rate := RateLimitSnapshot{
|
||||
Limit: parseInt("X-RateLimit-Limit"), Remaining: parseInt("X-RateLimit-Remaining"),
|
||||
Used: parseInt("X-RateLimit-Used"), UpdatedAt: time.Now(),
|
||||
}
|
||||
if reset, err := strconv.ParseInt(response.Header.Get("X-RateLimit-Reset"), 10, 64); err == nil {
|
||||
rate.ResetAt = time.Unix(reset, 0)
|
||||
}
|
||||
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
|
||||
rate.RetryAfter = time.Now().Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
if (response.StatusCode == http.StatusForbidden ||
|
||||
response.StatusCode == http.StatusTooManyRequests) && rate.RetryAfter.IsZero() {
|
||||
if rate.Remaining == 0 && !rate.ResetAt.IsZero() {
|
||||
rate.RetryAfter = rate.ResetAt
|
||||
} else {
|
||||
rate.RetryAfter = time.Now().Add(time.Minute)
|
||||
}
|
||||
}
|
||||
c.health.setRate(rate)
|
||||
}
|
||||
|
||||
func (c *GitHubClient) HealthReport() []HealthComponent {
|
||||
return c.health.report()
|
||||
}
|
||||
|
||||
func (c *GitHubClient) RateLimit() RateLimitSnapshot {
|
||||
return c.health.rateLimit()
|
||||
}
|
||||
|
||||
const listPRsQuery = `
|
||||
query PullRequests($query: String!, $first: Int!, $after: String) {
|
||||
viewer { login }
|
||||
@@ -937,16 +995,6 @@ func (c *GitHubClient) allCheckContexts(
|
||||
connection = data.Node.Contexts
|
||||
nodes = append(nodes, connection.Nodes...)
|
||||
}
|
||||
for index := range nodes {
|
||||
if nodes[index].ID == "" || !checkMayHaveUsefulAnnotations(nodes[index]) {
|
||||
continue
|
||||
}
|
||||
annotations, err := c.checkAnnotations(ctx, nodes[index].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes[index].Annotations.Nodes = annotations
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
@@ -1034,8 +1082,6 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
reviewErr error
|
||||
timelineErr error
|
||||
checkErr error
|
||||
conflictFiles []string
|
||||
conflictFileErr error
|
||||
wait sync.WaitGroup
|
||||
)
|
||||
wait.Add(4)
|
||||
@@ -1063,24 +1109,25 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
checkNodes, checkErr = c.allCheckContexts(ctx, rollup.Contexts, rollup.ID)
|
||||
}()
|
||||
}
|
||||
if node.Mergeable == "CONFLICTING" && c.conflicts != nil {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
baseOID := ""
|
||||
if node.BaseRef != nil && node.BaseRef.Target != nil {
|
||||
baseOID = node.BaseRef.Target.OID
|
||||
}
|
||||
conflictFiles, conflictFileErr = c.loadConflictFiles(
|
||||
ctx, data.Repository.URL, number, node.BaseRefName, baseOID, node.HeadRefOID,
|
||||
)
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
for _, err := range []error{threadErr, conversationErr, reviewErr, timelineErr, checkErr} {
|
||||
if err != nil {
|
||||
return PRDetails{}, err
|
||||
}
|
||||
if threadErr != nil {
|
||||
threadNodes = append([]githubReviewThread(nil), node.ReviewThreads.Nodes...)
|
||||
}
|
||||
if conversationErr != nil {
|
||||
conversationNodes = append([]githubPRComment(nil), node.Comments.Nodes...)
|
||||
}
|
||||
if reviewErr != nil {
|
||||
reviewNodes = append([]githubReviewSummary(nil), node.Reviews.Nodes...)
|
||||
}
|
||||
if timelineErr != nil {
|
||||
timelineNodes = append([]githubTimelineNode(nil), node.TimelineItems.Nodes...)
|
||||
}
|
||||
if checkErr != nil && len(node.Commits.Nodes) > 0 &&
|
||||
node.Commits.Nodes[0].Commit.StatusCheckRollup != nil {
|
||||
checkNodes = append(
|
||||
[]githubCheckContext(nil),
|
||||
node.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes...,
|
||||
)
|
||||
}
|
||||
details := PRDetails{
|
||||
PullRequest: PullRequest{
|
||||
@@ -1091,7 +1138,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
},
|
||||
Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName,
|
||||
HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus,
|
||||
ConflictFiles: conflictFiles,
|
||||
RepositoryURL: data.Repository.URL,
|
||||
Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles,
|
||||
CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount,
|
||||
CheckState: "NONE", ReviewDecision: node.ReviewDecision,
|
||||
@@ -1101,8 +1148,18 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge,
|
||||
},
|
||||
}
|
||||
if conflictFileErr != nil {
|
||||
details.ConflictFileError = conflictFileErr.Error()
|
||||
if node.BaseRef != nil && node.BaseRef.Target != nil {
|
||||
details.BaseOID = node.BaseRef.Target.OID
|
||||
}
|
||||
for component, err := range map[string]error{
|
||||
"review threads": threadErr, "conversation": conversationErr,
|
||||
"submitted reviews": reviewErr, "timeline": timelineErr, "checks": checkErr,
|
||||
} {
|
||||
if err != nil {
|
||||
details.DataIssues = append(details.DataIssues, DataIssue{
|
||||
Component: component, Message: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if node.BaseRef != nil && node.BaseRef.BranchProtectionRule != nil {
|
||||
rule := node.BaseRef.BranchProtectionRule
|
||||
@@ -1228,6 +1285,90 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n
|
||||
return details, nil
|
||||
}
|
||||
|
||||
func (c *GitHubClient) EnrichPullRequest(
|
||||
ctx context.Context, details PRDetails,
|
||||
) PRDetailsEnrichment {
|
||||
result := PRDetailsEnrichment{
|
||||
Owner: details.Owner, Repository: details.Repository, Number: details.Number,
|
||||
HeadOID: details.HeadOID, CheckAnnotations: make(map[string][]CheckAnnotation),
|
||||
}
|
||||
for _, check := range details.Checks {
|
||||
if check.ID == "" || !checkStateMayHaveUsefulAnnotations(check) {
|
||||
continue
|
||||
}
|
||||
if annotations, ok := c.cachedAnnotations(check.ID); ok {
|
||||
result.CheckAnnotations[check.ID] = annotations
|
||||
continue
|
||||
}
|
||||
nodes, err := c.checkAnnotations(ctx, check.ID)
|
||||
if err != nil {
|
||||
result.Issues = append(result.Issues, DataIssue{
|
||||
Component: "check annotations", Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
annotations := make([]CheckAnnotation, 0, len(nodes))
|
||||
for _, annotation := range nodes {
|
||||
annotations = append(annotations, CheckAnnotation{
|
||||
Path: annotation.Path, StartLine: annotation.Location.Start.Line,
|
||||
EndLine: annotation.Location.End.Line, Level: annotation.AnnotationLevel,
|
||||
Title: annotation.Title, Message: annotation.Message,
|
||||
})
|
||||
}
|
||||
c.storeAnnotations(check.ID, annotations)
|
||||
result.CheckAnnotations[check.ID] = annotations
|
||||
}
|
||||
if details.Mergeable == "CONFLICTING" && c.conflicts != nil {
|
||||
files, err := c.loadConflictFiles(
|
||||
ctx, details.RepositoryURL, details.Number, details.BaseRef,
|
||||
details.BaseOID, details.HeadOID,
|
||||
)
|
||||
if err != nil {
|
||||
result.Issues = append(result.Issues, DataIssue{
|
||||
Component: "conflict file scan", Message: err.Error(),
|
||||
})
|
||||
} else {
|
||||
result.ConflictFiles = files
|
||||
}
|
||||
}
|
||||
level, summary := healthOK, "secondary PR data loaded"
|
||||
if len(result.Issues) > 0 {
|
||||
level, summary = healthWarning, fmt.Sprintf(
|
||||
"%d secondary data source(s) failed", len(result.Issues),
|
||||
)
|
||||
}
|
||||
c.health.set(HealthComponent{
|
||||
Name: "PR enrichment", Level: level, Summary: summary, UpdatedAt: time.Now(),
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func checkStateMayHaveUsefulAnnotations(check Check) bool {
|
||||
state := strings.ToUpper(firstNonEmpty(check.Conclusion, check.State))
|
||||
switch state {
|
||||
case "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GitHubClient) cachedAnnotations(checkID string) ([]CheckAnnotation, bool) {
|
||||
c.annotationMu.Lock()
|
||||
defer c.annotationMu.Unlock()
|
||||
annotations, ok := c.annotationCache[checkID]
|
||||
return append([]CheckAnnotation(nil), annotations...), ok
|
||||
}
|
||||
|
||||
func (c *GitHubClient) storeAnnotations(checkID string, annotations []CheckAnnotation) {
|
||||
c.annotationMu.Lock()
|
||||
defer c.annotationMu.Unlock()
|
||||
if len(c.annotationCache) >= 256 {
|
||||
c.annotationCache = make(map[string][]CheckAnnotation)
|
||||
}
|
||||
c.annotationCache[checkID] = append([]CheckAnnotation(nil), annotations...)
|
||||
}
|
||||
|
||||
func (c *GitHubClient) SetThreadResolved(
|
||||
ctx context.Context, threadID string, resolved bool,
|
||||
) (ReviewThread, error) {
|
||||
|
||||
Reference in New Issue
Block a user