Fix high prio recommendations, add error / health screen

This commit is contained in:
2026-07-28 15:40:11 +02:00
parent 948f3e1a79
commit 7511311297
19 changed files with 1885 additions and 148 deletions

229
github.go
View File

@@ -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) {