package github import ( "context" "encoding/json" "fmt" "io" "net/url" "sort" "strings" "time" ) const ( workflowConclusionActionRequired = "action_required" workflowEventPullRequest = "pull_request" workflowStatusCompleted = "completed" ) type workflowRunKey struct { Owner string Repo string HeadSHA string } type workflowJobKey struct { Owner string Repo string RunID int64 Attempt int } // ghWorkflowRun and its nested values are shared by the gh CLI and PAT // clients because both consume the same Actions REST response shape. type ghWorkflowRun struct { ID int64 `json:"id"` RunAttempt int `json:"run_attempt"` WorkflowID int64 `json:"workflow_id"` Name string `json:"name"` Event string `json:"event"` Status string `json:"status"` Conclusion *string `json:"conclusion"` HeadBranch string `json:"head_branch"` HeadSHA string `json:"head_sha"` HTMLURL string `json:"html_url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` HeadRepository *ghWorkflowRepository `json:"head_repository"` PullRequests []ghWorkflowPRAssociation `json:"pull_requests"` } type ghWorkflowRepository struct { ID int64 `json:"id"` FullName string `json:"full_name"` Name string `json:"name"` URL string `json:"url"` Owner ghWorkflowRepoOwner `json:"owner"` } type ghWorkflowRepoOwner struct { Login string `json:"login"` } type ghWorkflowPRAssociation struct { Number int `json:"number"` Head struct { Ref string `json:"ref"` SHA string `json:"sha"` Repo *ghWorkflowRepository `json:"repo"` } `json:"head"` } type ghWorkflowJob struct { ID int64 `json:"id"` Name string `json:"name"` Status string `json:"status"` Conclusion *string `json:"conclusion"` } func convertRawWorkflowRun(raw ghWorkflowRun) WorkflowRun { run := WorkflowRun{ ID: raw.ID, RunAttempt: raw.RunAttempt, WorkflowID: raw.WorkflowID, Name: raw.Name, Event: raw.Event, Status: raw.Status, HeadSHA: raw.HeadSHA, HeadBranch: raw.HeadBranch, HTMLURL: raw.HTMLURL, CreatedAt: raw.CreatedAt, UpdatedAt: raw.UpdatedAt, } if raw.Conclusion != nil { run.Conclusion = *raw.Conclusion } if raw.HeadRepository != nil { repository := workflowRepositoryIdentityFromRaw(raw.HeadRepository) run.HeadRepoID = repository.ID run.HeadRepoOwner = repository.Owner run.HeadRepoName = repository.Name run.HeadRepoURL = repository.URL } for _, association := range raw.PullRequests { converted := WorkflowRunPullRequest{ Number: association.Number, HeadSHA: association.Head.SHA, HeadBranch: association.Head.Ref, } if association.Head.Repo != nil { repository := workflowRepositoryIdentityFromRaw(association.Head.Repo) converted.HeadRepoID = repository.ID converted.HeadRepoOwner = repository.Owner converted.HeadRepoName = repository.Name converted.HeadRepoURL = repository.URL } run.PullRequests = append(run.PullRequests, converted) } return run } func convertRawWorkflowJobs(raw []ghWorkflowJob) []WorkflowJob { jobs := make([]WorkflowJob, 0, len(raw)) for _, job := range raw { converted := WorkflowJob{ID: job.ID, Name: job.Name, Status: job.Status} if job.Conclusion != nil { converted.Conclusion = *job.Conclusion } jobs = append(jobs, converted) } return jobs } func decodeGHWorkflowRuns(out string) ([]ghWorkflowRun, error) { return decodeJSONStream[ghWorkflowRun](out) } func decodeGHWorkflowJobs(out string) ([]ghWorkflowJob, error) { return decodeJSONStream[ghWorkflowJob](out) } func decodeJSONStream[T any](out string) ([]T, error) { if strings.TrimSpace(out) == "" { return nil, nil } decoder := json.NewDecoder(strings.NewReader(out)) var values []T for { var value T if err := decoder.Decode(&value); err != nil { if err == io.EOF { return values, nil } return nil, err } values = append(values, value) } } // workflowAttentionUnknown creates an observation that cannot make a // positive claim. Callers may replace it with a same-head stale observation // when a later provider read is unavailable. func workflowAttentionUnknown(headSHA string) *WorkflowAttention { return &WorkflowAttention{ State: WorkflowAttentionUnknown, HeadSHA: headSHA, ObservedAt: time.Now().UTC(), Stale: false, Runs: []WorkflowAttentionRun{}, } } // collectWorkflowAttention reads and classifies Actions evidence for one PR. // A failed Actions read is returned alongside an unknown observation so // callers can preserve an older same-head positive observation without // treating a permission failure as authoritative absence. func collectWorkflowAttention( ctx context.Context, client Client, 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 := client.ListWorkflowRuns(ctx, owner, repo, pr.HeadSHA) if err != nil { return workflowAttentionUnknown(pr.HeadSHA), err } return classifyWorkflowAttention(ctx, client, owner, repo, pr, runs), nil } func classifyWorkflowAttention( ctx context.Context, client Client, owner, repo string, pr *PR, runs []WorkflowRun, ) *WorkflowAttention { return classifyWorkflowAttentionWithJobs(ctx, owner, repo, pr, runs, func(ctx context.Context, runID int64, attempt int) ([]WorkflowJob, error) { return client.ListWorkflowRunJobs(ctx, owner, repo, runID, attempt) }, ) } type workflowAttentionJobReader func(context.Context, int64, int) ([]WorkflowJob, error) func classifyWorkflowAttentionWithJobs( ctx context.Context, owner, repo string, pr *PR, runs []WorkflowRun, readJobs workflowAttentionJobReader, ) *WorkflowAttention { if pr == nil { return workflowAttentionUnknown("") } if isTerminalPR(pr) { return workflowAttentionNone(pr.HeadSHA) } if pr.HeadSHA == "" { return workflowAttentionUnknown("") } attention := &WorkflowAttention{ State: WorkflowAttentionNone, HeadSHA: pr.HeadSHA, ObservedAt: time.Now().UTC(), Runs: []WorkflowAttentionRun{}, } selected := selectCurrentWorkflowRuns(runs, pr) for _, run := range selected { if !strings.EqualFold(run.Conclusion, workflowConclusionActionRequired) { continue } attempt := run.RunAttempt if attempt <= 0 { attempt = 1 } jobs, jobsErr := readJobs(ctx, run.ID, attempt) reason := workflowAttentionActionRequiredReason if jobsErr == nil && isForkWorkflowRun(run, owner, repo) && strings.EqualFold(run.Event, workflowEventPullRequest) && strings.EqualFold(run.Status, workflowStatusCompleted) && len(jobs) == 0 { reason = workflowAttentionApprovalRequiredReason } attention.Runs = append(attention.Runs, WorkflowAttentionRun{ RunID: run.ID, RunAttempt: attempt, WorkflowID: run.WorkflowID, Name: run.Name, URL: run.HTMLURL, Reason: reason, }) if reason == workflowAttentionActionRequiredReason { attention.State = WorkflowAttentionActionRequired } } if attention.State != WorkflowAttentionActionRequired && len(attention.Runs) > 0 { attention.State = WorkflowAttentionApprovalRequired } sort.SliceStable(attention.Runs, func(i, j int) bool { return workflowAttentionRunLess(attention.Runs[i], attention.Runs[j]) }) return attention } const ( workflowAttentionApprovalRequiredReason = "approval_required" workflowAttentionActionRequiredReason = "action_required" ) func workflowAttentionNone(headSHA string) *WorkflowAttention { return &WorkflowAttention{ State: WorkflowAttentionNone, HeadSHA: headSHA, ObservedAt: time.Now().UTC(), Runs: []WorkflowAttentionRun{}, } } func isTerminalPR(pr *PR) bool { if pr == nil { return false } return strings.EqualFold(pr.State, "closed") || strings.EqualFold(pr.State, "merged") || pr.MergedAt != nil } func selectCurrentWorkflowRuns(runs []WorkflowRun, pr *PR) []WorkflowRun { latest := make(map[string]WorkflowRun) for _, run := range runs { if !workflowRunMatchesPR(run, pr) { continue } key := workflowRunGroupKey(run) current, ok := latest[key] if !ok || workflowRunNewer(run, current) { latest[key] = run } } out := make([]WorkflowRun, 0, len(latest)) for _, run := range latest { out = append(out, run) } sort.Slice(out, func(i, j int) bool { return workflowRunLess(out[i], out[j]) }) return out } func workflowRunMatchesPR(run WorkflowRun, pr *PR) bool { if pr == nil || run.HeadSHA == "" || run.HeadSHA != pr.HeadSHA { return false } if len(run.PullRequests) > 0 { return workflowRunHasPRAssociation(run, pr) } // Fork pull_request runs can have an empty pull_requests association. The // source repository, branch, event, and SHA together are the conservative // fallback identity in that response shape. return workflowRunMatchesUnassociatedPR(run, pr) } func workflowRunHasPRAssociation(run WorkflowRun, pr *PR) bool { for _, association := range run.PullRequests { if workflowAssociationMatchesPR(association, run, pr) { return true } } return false } func workflowAssociationMatchesPR(association WorkflowRunPullRequest, run WorkflowRun, pr *PR) bool { if association.Number != pr.Number { return false } if association.HeadSHA != "" && association.HeadSHA != pr.HeadSHA { return false } if association.HeadBranch != "" && association.HeadBranch != pr.HeadBranch { return false } associationRepository := workflowRepositoryIdentityFromAssociation(association) if associationRepository.empty() { return run.HeadRepoID == 0 && run.HeadRepoOwner == "" && run.HeadRepoName == "" && run.HeadRepoURL == "" } runRepository := workflowRepositoryIdentityFromRun(run) merged, ok := mergeWorkflowRepositoryIdentities(associationRepository, runRepository) return ok && workflowRepositoryIdentityMatchesPR(merged, pr) } func workflowRunMatchesUnassociatedPR(run WorkflowRun, pr *PR) bool { return strings.EqualFold(run.Event, workflowEventPullRequest) && run.HeadBranch != "" && run.HeadBranch == pr.HeadBranch && workflowRepositoryIdentityMatchesPR(workflowRepositoryIdentityFromRun(run), pr) } type workflowRepositoryIdentity struct { ID int64 Owner string Name string URL string } func workflowRepositoryIdentityFromRaw(repository *ghWorkflowRepository) workflowRepositoryIdentity { if repository == nil { return workflowRepositoryIdentity{} } identity := workflowRepositoryIdentity{ ID: repository.ID, Owner: repository.Owner.Login, Name: repository.Name, URL: repository.URL, } if owner, name := workflowRepositoryPath(repository.FullName); identity.Owner == "" || identity.Name == "" { if identity.Owner == "" { identity.Owner = owner } if identity.Name == "" { identity.Name = name } } if owner, name := workflowRepositoryPath(identity.URL); identity.Owner == "" || identity.Name == "" { if identity.Owner == "" { identity.Owner = owner } if identity.Name == "" { identity.Name = name } } return identity } func workflowRepositoryIdentityFromAssociation(association WorkflowRunPullRequest) workflowRepositoryIdentity { identity := workflowRepositoryIdentity{ ID: association.HeadRepoID, Owner: association.HeadRepoOwner, Name: association.HeadRepoName, URL: association.HeadRepoURL, } if owner, name := workflowRepositoryPath(identity.URL); identity.Owner == "" || identity.Name == "" { if identity.Owner == "" { identity.Owner = owner } if identity.Name == "" { identity.Name = name } } return identity } func workflowRepositoryIdentityFromRun(run WorkflowRun) workflowRepositoryIdentity { identity := workflowRepositoryIdentity{ ID: run.HeadRepoID, Owner: run.HeadRepoOwner, Name: run.HeadRepoName, URL: run.HeadRepoURL, } if owner, name := workflowRepositoryPath(identity.URL); identity.Owner == "" || identity.Name == "" { if identity.Owner == "" { identity.Owner = owner } if identity.Name == "" { identity.Name = name } } return identity } func (identity workflowRepositoryIdentity) empty() bool { return identity.ID == 0 && identity.Owner == "" && identity.Name == "" && identity.URL == "" } func workflowRepositoryPath(value string) (string, string) { value = strings.TrimSpace(value) if value == "" { return "", "" } if parsed, err := url.Parse(value); err == nil && parsed.Path != "" { parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") for index, part := range parts { if strings.EqualFold(part, "repos") && len(parts) > index+2 { return parts[index+1], parts[index+2] } } if parsed.Host != "" && len(parts) >= 2 { return parts[len(parts)-2], parts[len(parts)-1] } } parts := strings.SplitN(strings.Trim(value, "/"), "/", 2) if len(parts) == 2 { return parts[0], parts[1] } return "", "" } func mergeWorkflowRepositoryIdentities( association workflowRepositoryIdentity, run workflowRepositoryIdentity, ) (workflowRepositoryIdentity, bool) { if workflowRepositoryIdentitiesConflict(association, run) { return workflowRepositoryIdentity{}, false } merged := association if merged.ID == 0 { merged.ID = run.ID } if merged.Owner == "" { merged.Owner = run.Owner } if merged.Name == "" { merged.Name = run.Name } if merged.URL == "" { merged.URL = run.URL } return merged, true } func workflowRepositoryIdentitiesConflict(a, b workflowRepositoryIdentity) bool { return (a.ID != 0 && b.ID != 0 && a.ID != b.ID) || (a.Owner != "" && b.Owner != "" && !strings.EqualFold(a.Owner, b.Owner)) || (a.Name != "" && b.Name != "" && !strings.EqualFold(a.Name, b.Name)) } func workflowRepositoryIdentityMatchesPR(identity workflowRepositoryIdentity, pr *PR) bool { if pr == nil { return false } return !workflowRepositoryIdentityConflictsWithPR(identity, pr) && workflowRepositoryIdentityHasPositivePRMatch(identity, pr) } func workflowRepositoryIdentityConflictsWithPR(identity workflowRepositoryIdentity, pr *PR) bool { if identity.ID != 0 && pr.HeadRepoID != 0 && identity.ID != pr.HeadRepoID { return true } if identity.Owner != "" && pr.HeadRepoOwner != "" && !strings.EqualFold(identity.Owner, pr.HeadRepoOwner) { return true } if identity.Name != "" && pr.HeadRepoName != "" && !strings.EqualFold(identity.Name, pr.HeadRepoName) { return true } return false } func workflowRepositoryIdentityHasPositivePRMatch(identity workflowRepositoryIdentity, pr *PR) bool { return (identity.ID != 0 && pr.HeadRepoID != 0) || (identity.Owner != "" && identity.Name != "" && pr.HeadRepoOwner != "" && pr.HeadRepoName != "") } func workflowRepoIdentityMatches(ownerA, repoA, ownerB, repoB string) bool { if ownerA == "" || repoA == "" || ownerB == "" || repoB == "" { return false } return strings.EqualFold(ownerA, ownerB) && strings.EqualFold(repoA, repoB) } func isForkWorkflowRun(run WorkflowRun, owner, repo string) bool { return !workflowRepoIdentityMatches(run.HeadRepoOwner, run.HeadRepoName, owner, repo) && run.HeadRepoOwner != "" && run.HeadRepoName != "" } func workflowRunGroupKey(run WorkflowRun) string { workflow := fmt.Sprintf("id:%d", run.WorkflowID) if run.WorkflowID == 0 { workflow = "name:" + strings.ToLower(run.Name) } repository := workflowRepositoryIdentityFromRun(run) source := strings.ToLower(repository.Owner) + "/" + strings.ToLower(repository.Name) if repository.Owner == "" && repository.Name == "" && repository.ID != 0 { source = fmt.Sprintf("id:%d", repository.ID) } return fmt.Sprintf("%s|event:%s|branch:%s|source:%s", workflow, strings.ToLower(run.Event), run.HeadBranch, source) } func workflowRunNewer(candidate, current WorkflowRun) bool { // UpdatedAt can move forward when an older execution is cancelled or // otherwise receives a late provider-side update. Compare distinct // executions by creation time and identity first so that update activity // cannot make an older execution hide a newer result. if candidate.ID != current.ID { if !candidate.CreatedAt.Equal(current.CreatedAt) { return candidate.CreatedAt.After(current.CreatedAt) } return candidate.ID > current.ID } if candidate.RunAttempt != current.RunAttempt { return candidate.RunAttempt > current.RunAttempt } if !candidate.UpdatedAt.Equal(current.UpdatedAt) { return candidate.UpdatedAt.After(current.UpdatedAt) } return candidate.CreatedAt.After(current.CreatedAt) } func workflowRunLess(a, b WorkflowRun) bool { if a.WorkflowID != b.WorkflowID { return a.WorkflowID < b.WorkflowID } if a.Name != b.Name { return a.Name < b.Name } if a.ID != b.ID { return a.ID < b.ID } return a.RunAttempt < b.RunAttempt } func workflowAttentionRunLess(a, b WorkflowAttentionRun) bool { if a.WorkflowID != b.WorkflowID { return a.WorkflowID < b.WorkflowID } if a.Name != b.Name { return a.Name < b.Name } if a.RunID != b.RunID { return a.RunID < b.RunID } return a.RunAttempt < b.RunAttempt } // HasActiveWorkflowAttention reports whether a stored observation blocks // merge for the TaskPR's currently observed head. A stale positive remains a // blocker until a fresh complete observation replaces it. func HasActiveWorkflowAttention(pr *TaskPR) bool { if pr == nil || pr.WorkflowAttention == nil || pr.WorkflowAttention.HeadSHA == "" || pr.WorkflowAttention.HeadSHA != pr.HeadSHA { return false } return pr.WorkflowAttention.State == WorkflowAttentionApprovalRequired || pr.WorkflowAttention.State == WorkflowAttentionActionRequired } func cloneWorkflowAttention(attention *WorkflowAttention) *WorkflowAttention { if attention == nil { return nil } clone := *attention clone.Runs = append([]WorkflowAttentionRun{}, attention.Runs...) return &clone } func workflowAttentionSemanticEqual(left, right *WorkflowAttention) bool { if left == nil || right == nil { return left == right } if left.State != right.State || left.HeadSHA != right.HeadSHA || left.Stale != right.Stale || len(left.Runs) != len(right.Runs) { return false } for i := range left.Runs { if left.Runs[i] != right.Runs[i] { return false } } return true } // resolveTaskPRWorkflowAttention applies the observation-preservation rules // at the storage boundary. A missing observation from a legacy or partial // caller preserves a same-head value, an unavailable fresh read marks the // same-head value stale, and a head change immediately discards old positive // evidence. func resolveTaskPRWorkflowAttention(previous *TaskPR, status *PRStatus, nextHeadSHA string) *WorkflowAttention { previousAttention := (*WorkflowAttention)(nil) previousSameHead := previous != nil && previous.HeadSHA != "" && previous.HeadSHA == nextHeadSHA && (previous.WorkflowAttention == nil || previous.WorkflowAttention.HeadSHA == nextHeadSHA) if previousSameHead { previousAttention = previous.WorkflowAttention } if status == nil || !status.WorkflowAttentionPopulated { return cloneWorkflowAttention(previousAttention) } incoming := cloneWorkflowAttention(status.WorkflowAttention) if incoming == nil { incoming = workflowAttentionUnknown(nextHeadSHA) } if incoming.HeadSHA == "" { incoming.HeadSHA = nextHeadSHA } if incoming.HeadSHA != nextHeadSHA { return workflowAttentionUnknown(nextHeadSHA) } if incoming.State == WorkflowAttentionUnknown { if previousAttention != nil { preserved := cloneWorkflowAttention(previousAttention) preserved.Stale = true return preserved } incoming.Stale = true return incoming } incoming.Stale = false return incoming }