package pulls import ( "encoding/json" "fmt" "net/http" "slices" "time" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/hostutil" "tangled.org/core/types" ) // spindle upgrades may change how a commit is resolved and hashed (even if very rare...) const ( workflowCacheSize = 4096 workflowCacheTTL = time.Hour ) func (s *Pulls) describeWorkflowAt(r *http.Request, f *models.Repo, sha, sourceRepo string) (*tangled.CiDescribeWorkflowDefinition_Output, error) { key := fmt.Sprintf("%s|%s|%s|%s", f.Spindle, f.RepoDid, sha, sourceRepo) if cached, ok := s.workflowDescCache.Get(key); ok { return cached, nil } spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) if err != nil { return nil, err } client := &indigoxrpc.Client{Host: spindleUrl} out, err := tangled.CiDescribeWorkflowDefinition(r.Context(), client, f.RepoDid, sha, sourceRepo) if err != nil { return nil, err } s.workflowDescCache.Add(key, out) return out, nil } // returns the merge-base of the pull's source and target branches. // // older rounds resolve it live on the fork's knot, which tracks the // target branch as refs/hidden//. func (s *Pulls) mergeBase(r *http.Request, pull *models.Pull) (string, error) { if mb := pull.LatestSubmission().MergeBase; mb != "" { return mb, nil } sourceBranch := pull.PullSource.Branch hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, pull.TargetBranch) key := fmt.Sprintf("%s|%s|%s", pull.PullSource.RepoDid, sourceBranch, pull.TargetBranch) if cached, ok := s.mergeBaseCache.Get(key); ok { return cached, nil } forkRepo, err := db.GetRepoByDid(s.db, pull.PullSource.RepoDid.String()) if err != nil { return "", fmt.Errorf("resolving fork repo: %w", err) } compareBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, sourceBranch) if err != nil { return "", fmt.Errorf("comparing %s...%s: %w", hiddenRef, sourceBranch, err) } var comparison types.RepoFormatPatchResponse if err := json.Unmarshal(compareBytes, &comparison); err != nil { return "", fmt.Errorf("decoding comparison: %w", err) } if comparison.MergeBase == "" { return "", fmt.Errorf("no merge base between %s and %s", hiddenRef, sourceBranch) } s.mergeBaseCache.Add(key, comparison.MergeBase) return comparison.MergeBase, nil } // compares the spindle's workflow-definition fingerprints at the // pull head and its merge-base with the target branch. func (s *Pulls) workflowChangeFromSpindle(r *http.Request, f *models.Repo, pull *models.Pull) (bool, []string, error) { sourceRepo := pull.PullSource.RepoDid.String() head, err := s.describeWorkflowAt(r, f, pull.LatestSha(), sourceRepo) if err != nil { return false, nil, err } // a definition not derived from the repo cannot change between commits if !head.Derived { return false, nil, nil } baseSha, err := s.mergeBase(r, pull) if err != nil { return false, nil, err } base, err := s.describeWorkflowAt(r, f, baseSha, sourceRepo) if err != nil { return false, nil, err } if !base.Derived { return false, nil, nil } if head.Hash != nil && base.Hash != nil && *head.Hash == *base.Hash { return false, nil, nil } names := slices.Concat(head.Workflows, base.Workflows) slices.Sort(names) return true, slices.Compact(names), nil }