658 lines
20 KiB
Go
658 lines
20 KiB
Go
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
|
|
}
|