Files
kandev/apps/backend/internal/github/workflow_attention_cache.go
2026-09-23 13:31:12 +01:00

204 lines
6.0 KiB
Go

package github
import (
"context"
"fmt"
"strings"
"time"
)
const (
workflowAttentionCacheMaxEntries = 512
workflowAttentionShortTTL = 30 * time.Second
workflowAttentionLongTTL = 5 * time.Minute
)
func newWorkflowAttentionCache() *ttlCache {
c := newTTLCache()
c.ttl = workflowAttentionShortTTL
c.maxSize = workflowAttentionCacheMaxEntries
return c
}
func workflowRunsCacheKey(cacheScope, owner, repo, headSHA string) string {
return scopedCacheKey(cacheScope, fmt.Sprintf(
"workflow-runs:%s/%s@%s", strings.ToLower(owner), strings.ToLower(repo), strings.ToLower(headSHA),
))
}
func workflowJobsCacheKey(cacheScope, owner, repo string, runID int64, attempt int) string {
return scopedCacheKey(cacheScope, fmt.Sprintf(
"workflow-jobs:%s/%s#%d/%d", strings.ToLower(owner), strings.ToLower(repo), runID, attempt,
))
}
func workflowRunsCachePrefix(cacheScope, owner, repo string) string {
return scopedCacheKey(cacheScope, fmt.Sprintf(
"workflow-runs:%s/%s@", strings.ToLower(owner), strings.ToLower(repo),
))
}
func workflowJobsCachePrefix(cacheScope, owner, repo string) string {
return scopedCacheKey(cacheScope, fmt.Sprintf(
"workflow-jobs:%s/%s#", strings.ToLower(owner), strings.ToLower(repo),
))
}
func workflowRunsCacheTTL(value any) time.Duration {
runs, ok := value.([]WorkflowRun)
if !ok || len(runs) == 0 {
return workflowAttentionShortTTL
}
for _, run := range runs {
if !strings.EqualFold(run.Status, workflowStatusCompleted) ||
run.Conclusion == "" || strings.EqualFold(run.Conclusion, workflowConclusionActionRequired) {
return workflowAttentionShortTTL
}
}
return workflowAttentionLongTTL
}
func workflowJobsCacheTTL(value any) time.Duration {
jobs, ok := value.([]WorkflowJob)
if !ok || len(jobs) == 0 {
return workflowAttentionShortTTL
}
for _, job := range jobs {
if !strings.EqualFold(job.Status, workflowStatusCompleted) || job.Conclusion == "" {
return workflowAttentionShortTTL
}
}
return workflowAttentionLongTTL
}
func (s *Service) cachedWorkflowRuns(
ctx context.Context, client Client, cacheScope, owner, repo, headSHA string,
) ([]WorkflowRun, error) {
if s == nil || s.workflowRunsCache == nil {
return client.ListWorkflowRuns(ctx, owner, repo, headSHA)
}
key := workflowRunsCacheKey(cacheScope, owner, repo, headSHA)
value, err := s.workflowRunsCache.doOrFetchWithTTL(key, func() (any, error) {
return client.ListWorkflowRuns(ctx, owner, repo, headSHA)
}, workflowRunsCacheTTL)
if err != nil {
return nil, err
}
runs, ok := value.([]WorkflowRun)
if !ok {
return nil, fmt.Errorf("invalid cached workflow runs value")
}
return runs, nil
}
func (s *Service) cachedWorkflowJobs(
ctx context.Context,
client Client,
cacheScope, owner, repo string,
runID int64, attempt int, shortLived bool,
) ([]WorkflowJob, error) {
if s == nil || s.workflowJobsCache == nil {
return client.ListWorkflowRunJobs(ctx, owner, repo, runID, attempt)
}
key := workflowJobsCacheKey(cacheScope, owner, repo, runID, attempt)
ttlFor := workflowJobsCacheTTL
if shortLived {
ttlFor = func(any) time.Duration { return workflowAttentionShortTTL }
}
value, err := s.workflowJobsCache.doOrFetchWithTTL(key, func() (any, error) {
return client.ListWorkflowRunJobs(ctx, owner, repo, runID, attempt)
}, ttlFor)
if err != nil {
return nil, err
}
jobs, ok := value.([]WorkflowJob)
if !ok {
return nil, fmt.Errorf("invalid cached workflow jobs value")
}
return jobs, nil
}
func (s *Service) collectWorkflowAttention(
ctx context.Context, client Client, cacheScope, owner, repo string, pr *PR,
) (*WorkflowAttention, error) {
if pr == nil {
return workflowAttentionUnknown(""), nil
}
if isTerminalPR(pr) {
return workflowAttentionNone(pr.HeadSHA), nil
}
if pr.HeadSHA == "" {
return workflowAttentionUnknown(""), nil
}
runs, err := s.cachedWorkflowRuns(ctx, client, cacheScope, owner, repo, pr.HeadSHA)
if err != nil {
return workflowAttentionUnknown(pr.HeadSHA), err
}
return classifyWorkflowAttentionWithJobs(ctx, owner, repo, pr, runs,
func(jobCtx context.Context, runID int64, attempt int) ([]WorkflowJob, error) {
return s.cachedWorkflowJobs(jobCtx, client, cacheScope, owner, repo, runID, attempt, true)
},
), nil
}
func (s *Service) invalidateWorkflowAttentionForPR(
cacheScope, owner, repo string, number int, headSHA string,
) {
if s == nil {
return
}
key := scopedCacheKey(cacheScope, prStatusCacheKey(owner, repo, number))
if number > 0 {
if s.prStatusCache != nil {
s.prStatusCache.invalidateKey(key)
}
if s.prFeedbackCache != nil {
s.prFeedbackCache.invalidateKey(key)
}
}
if s.workflowRunsCache != nil {
if headSHA == "" {
s.workflowRunsCache.invalidatePrefix(workflowRunsCachePrefix(cacheScope, owner, repo))
} else {
s.workflowRunsCache.invalidateKey(workflowRunsCacheKey(cacheScope, owner, repo, headSHA))
}
}
if s.workflowJobsCache != nil {
s.workflowJobsCache.invalidatePrefix(workflowJobsCachePrefix(cacheScope, owner, repo))
}
}
// invalidateWorkflowAttentionForResolvedPR clears the automation namespace
// and the personal-read namespace for the same resolved credential. The
// status synchronizer uses the former, while the feedback endpoint uses the
// latter, so an explicit refresh must invalidate both views together.
func (s *Service) invalidateWorkflowAttentionForResolvedPR(
resolved *resolvedServiceClient, owner, repo string, number int, headSHA string,
) {
if resolved == nil {
return
}
s.invalidateWorkflowAttentionForPR(resolved.CacheScope, owner, repo, number, headSHA)
personalScope := resolved.cacheScopeForPurpose(CredentialPurposePersonalRead)
if personalScope != "" && personalScope != resolved.CacheScope {
s.invalidateWorkflowAttentionForPR(personalScope, owner, repo, number, headSHA)
}
}
func (s *Service) invalidateWorkflowAttentionForResolvedWatches(
resolved *resolvedServiceClient, watches []*PRWatch,
) {
if resolved == nil {
return
}
for _, watch := range watches {
if watch == nil {
continue
}
s.invalidateWorkflowAttentionForResolvedPR(
resolved, watch.Owner, watch.Repo, watch.PRNumber, "",
)
}
}