This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

appview/{pulls,db,pages}: use spindle describe wf xrpc for wf change detection

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 18, 2026, 5:59 PM +0300) commit ff00752b parent ca962107 change-id qqltoxsm
+194 -72
+7
appview/db/db.go
··· 2441 2441 return err 2442 2442 }) 2443 2443 2444 + orm.RunMigration(conn, logger, "add-pull-submissions-merge-base", func(tx *sql.Tx) error { 2445 + _, err := tx.Exec(` 2446 + alter table pull_submissions add column merge_base text; 2447 + `) 2448 + return err 2449 + }) 2450 + 2444 2451 return &DB{ 2445 2452 db, 2446 2453 logger,
+18 -6
appview/db/pulls.go
··· 196 196 patch, 197 197 combined, 198 198 source_rev, 199 + merge_base, 199 200 patch_blob_ref, 200 201 patch_blob_mime, 201 202 patch_blob_size 202 203 ) 203 - values (?, ?, ?, ?, ?, ?, ?, ?) 204 + values (?, ?, ?, ?, ?, ?, ?, ?, ?) 204 205 `, 205 206 pull.AtUri(), 206 207 i, 207 208 s.Patch, 208 209 s.Combined, 209 210 s.SourceRev, 211 + s.MergeBase, 210 212 s.Blob.Ref.String(), 211 213 s.Blob.MimeType, 212 214 s.Blob.Size, ··· 257 259 patch, 258 260 combined, 259 261 source_rev, 262 + merge_base, 260 263 patch_blob_ref, 261 264 patch_blob_mime, 262 265 patch_blob_size 263 266 ) 264 - values (?, ?, ?, ?, ?, ?, ?, ?) 267 + values (?, ?, ?, ?, ?, ?, ?, ?, ?) 265 268 `, 266 269 pull.AtUri(), 267 270 i, 268 271 s.Patch, 269 272 s.Combined, 270 273 s.SourceRev, 274 + s.MergeBase, 271 275 s.Blob.Ref.String(), 272 276 s.Blob.MimeType, 273 277 s.Blob.Size, ··· 278 282 } 279 283 280 284 if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 281 - return fmt.Errorf("put reference_links: %w", err) 285 + return err 282 286 } 283 287 return nil 284 288 } ··· 507 511 combined, 508 512 created, 509 513 source_rev, 514 + merge_base, 510 515 patch_blob_ref, 511 516 patch_blob_mime, 512 517 patch_blob_size ··· 528 533 for rows.Next() { 529 534 var submission models.PullSubmission 530 535 var submissionCreatedStr string 531 - var submissionSourceRev, submissionCombined sql.Null[string] 536 + var submissionSourceRev, submissionCombined, submissionMergeBase sql.Null[string] 532 537 var patchBlobRef, patchBlobMime sql.Null[string] 533 538 var patchBlobSize sql.Null[int64] 534 539 err := rows.Scan( ··· 539 544 &submissionCombined, 540 545 &submissionCreatedStr, 541 546 &submissionSourceRev, 547 + &submissionMergeBase, 542 548 &patchBlobRef, 543 549 &patchBlobMime, 544 550 &patchBlobSize, ··· 553 559 554 560 if submissionSourceRev.Valid { 555 561 submission.SourceRev = submissionSourceRev.V 562 + } 563 + 564 + if submissionMergeBase.Valid { 565 + submission.MergeBase = submissionMergeBase.V 556 566 } 557 567 558 568 if submissionCombined.Valid { ··· 729 739 newPatch string, 730 740 combinedPatch string, 731 741 newSourceRev string, 742 + mergeBase string, 732 743 blob *lexutil.LexBlob, 733 744 ) error { 734 745 _, err := e.Exec(` ··· 738 749 patch, 739 750 combined, 740 751 source_rev, 752 + merge_base, 741 753 patch_blob_ref, 742 754 patch_blob_mime, 743 755 patch_blob_size 744 756 ) 745 - values (?, ?, ?, ?, ?, ?, ?, ?) 746 - `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Ref.String(), blob.MimeType, blob.Size) 757 + values (?, ?, ?, ?, ?, ?, ?, ?, ?) 758 + `, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, mergeBase, blob.Ref.String(), blob.MimeType, blob.Size) 747 759 748 760 return err 749 761 }
+1
appview/models/pull.go
··· 311 311 Combined string 312 312 Comments []Comment 313 313 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 314 + MergeBase string // merge-base of source and target at submission time 314 315 315 316 // meta 316 317 Created time.Time
+2 -2
appview/pages/pages.go
··· 1522 1522 Stack models.Stack 1523 1523 1524 1524 // Workflow warning state for fork-based pulls without a pipeline on the 1525 - // latest commit. WorkflowsChanged and ChangedWorkflowFiles are computed 1526 - // from the latest round's patch. 1525 + // latest commit, derived from the spindle's workflow-definition 1526 + // fingerprints at the pull head and its merge-base with the target branch. 1527 1527 WorkflowsChanged bool 1528 1528 ChangedWorkflowFiles []string 1529 1529 HasPipeline bool
+8 -4
appview/pulls/create.go
··· 70 70 sourceRev := comparison.Rev2 71 71 patch := comparison.FormatPatchRaw 72 72 combined := comparison.CombinedPatchRaw 73 + mergeBase := comparison.MergeBase 73 74 74 75 if err := validatePatch(&patch); err != nil { 75 76 s.logger.Error("failed to validate patch", "err", err) 76 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 77 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 77 78 return 78 79 } 79 80 ··· 81 82 Branch: sourceBranch, 82 83 } 83 84 84 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 85 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, mergeBase, pullSource, isStacked, stackTitles, stackBodies) 85 86 } 86 87 87 88 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { ··· 91 92 return 92 93 } 93 94 94 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 95 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", "", nil, isStacked, stackTitles, stackBodies) 95 96 } 96 97 97 98 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepoDid string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { ··· 177 178 sourceRev := comparison.Rev2 178 179 patch := comparison.FormatPatchRaw 179 180 combined := comparison.CombinedPatchRaw 181 + mergeBase := comparison.MergeBase 180 182 181 183 if err := validatePatch(&patch); err != nil { 182 184 s.logger.Error("failed to validate patch", "err", err) ··· 190 192 RepoDid: &forkDid, 191 193 } 192 194 193 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 195 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, mergeBase, pullSource, isStacked, stackTitles, stackBodies) 194 196 } 195 197 196 198 func (s *Pulls) createPullRequest( ··· 202 204 patch string, 203 205 combined string, 204 206 sourceRev string, 207 + mergeBase string, 205 208 pullSource *models.PullSource, 206 209 isStacked bool, 207 210 stackTitles, stackBodies map[string]string, ··· 288 291 Patch: patch, 289 292 Combined: combined, 290 293 SourceRev: sourceRev, 294 + MergeBase: mergeBase, 291 295 Blob: *blob.Blob, 292 296 Created: now, 293 297 },
+31 -26
appview/pulls/pulls.go
··· 9 9 "strings" 10 10 "time" 11 11 12 + "tangled.org/core/api/tangled" 12 13 "tangled.org/core/appview/config" 13 14 "tangled.org/core/appview/db" 14 15 pulls_indexer "tangled.org/core/appview/indexer/pulls" ··· 36 37 ) 37 38 38 39 type Pulls struct { 39 - oauth *oauth.OAuth 40 - repoResolver *reporesolver.RepoResolver 41 - pages *pages.Pages 42 - idResolver *idresolver.Resolver 43 - mentionsResolver *mentions.Resolver 44 - db *db.DB 45 - config *config.Config 46 - notifier notify.Notifier 47 - acl *knotacl.Service 48 - logger *slog.Logger 49 - indexer *pulls_indexer.Indexer 50 - ogreClient *ogre.Client 51 - diffCache *expirable.LRU[string, types.DiffRenderer] 40 + oauth *oauth.OAuth 41 + repoResolver *reporesolver.RepoResolver 42 + pages *pages.Pages 43 + idResolver *idresolver.Resolver 44 + mentionsResolver *mentions.Resolver 45 + db *db.DB 46 + config *config.Config 47 + notifier notify.Notifier 48 + acl *knotacl.Service 49 + logger *slog.Logger 50 + indexer *pulls_indexer.Indexer 51 + ogreClient *ogre.Client 52 + diffCache *expirable.LRU[string, types.DiffRenderer] 53 + workflowDescCache *expirable.LRU[string, *tangled.CiDescribeWorkflowDefinition_Output] 54 + mergeBaseCache *expirable.LRU[string, string] 52 55 } 53 56 54 57 func New( ··· 65 68 logger *slog.Logger, 66 69 ) *Pulls { 67 70 return &Pulls{ 68 - oauth: oauth, 69 - repoResolver: repoResolver, 70 - pages: pages, 71 - idResolver: resolver, 72 - mentionsResolver: mentionsResolver, 73 - db: db, 74 - config: config, 75 - notifier: notifier, 76 - acl: acl, 77 - logger: logger, 78 - indexer: indexer, 79 - ogreClient: ogre.NewClient(config.Ogre.Host), 80 - diffCache: expirable.NewLRU[string, types.DiffRenderer](diffCacheSize, nil, diffCacheTTL), 71 + oauth: oauth, 72 + repoResolver: repoResolver, 73 + pages: pages, 74 + idResolver: resolver, 75 + mentionsResolver: mentionsResolver, 76 + db: db, 77 + config: config, 78 + notifier: notifier, 79 + acl: acl, 80 + logger: logger, 81 + indexer: indexer, 82 + ogreClient: ogre.NewClient(config.Ogre.Host), 83 + diffCache: expirable.NewLRU[string, types.DiffRenderer](diffCacheSize, nil, diffCacheTTL), 84 + workflowDescCache: expirable.NewLRU[string, *tangled.CiDescribeWorkflowDefinition_Output](workflowCacheSize, nil, workflowCacheTTL), 85 + mergeBaseCache: expirable.NewLRU[string, string](workflowCacheSize, nil, workflowCacheTTL), 81 86 } 82 87 } 83 88
+6 -5
appview/pulls/resubmit.go
··· 91 91 92 92 patch := r.FormValue("patch") 93 93 94 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 94 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "", "") 95 95 } 96 96 97 97 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { ··· 154 154 patch := comparison.FormatPatchRaw 155 155 combined := comparison.CombinedPatchRaw 156 156 157 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 157 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev, comparison.MergeBase) 158 158 } 159 159 160 160 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { ··· 252 252 patch := comparison.FormatPatchRaw 253 253 combined := comparison.CombinedPatchRaw 254 254 255 - s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 255 + s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev, comparison.MergeBase) 256 256 } 257 257 258 258 func (s *Pulls) resubmitPullHelper( ··· 264 264 patch string, 265 265 combined string, 266 266 sourceRev string, 267 + mergeBase string, 267 268 ) { 268 269 l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 269 270 ··· 338 339 return 339 340 } 340 341 341 - err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 342 + err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, mergeBase, blob.Blob) 342 343 if err != nil { 343 344 l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 344 345 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") ··· 550 551 } 551 552 552 553 // create new round 553 - err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 554 + err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, np.LatestSubmission().MergeBase, blob.Blob) 554 555 if err != nil { 555 556 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 556 557 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
+2 -3
appview/pulls/single.go
··· 76 76 } 77 77 78 78 if pull.IsForkBased() && !hasPipeline { 79 - changedWorkflows, err = changedWorkflowFiles(pull.LatestSubmission().CombinedPatch()) 79 + workflowsChanged, changedWorkflows, err = s.workflowChangeFromSpindle(r, f, pull) 80 80 if err != nil { 81 - l.Error("failed to inspect latest round's patch for workflow changes", "err", err) 81 + l.Error("failed to describe workflow definitions", "err", err) 82 82 } 83 - workflowsChanged = len(changedWorkflows) > 0 84 83 } 85 84 } 86 85
+5 -26
appview/pulls/trigger_ci.go
··· 8 8 "tangled.org/core/api/tangled" 9 9 "tangled.org/core/appview/db" 10 10 "tangled.org/core/appview/models" 11 - "tangled.org/core/patchutil" 12 - "tangled.org/core/workflow" 13 11 ) 14 - 15 - func changedWorkflowFiles(patch string) ([]string, error) { 16 - files, err := patchutil.AsDiff(patch) 17 - if err != nil { 18 - return nil, err 19 - } 20 - 21 - var changed []string 22 - for _, f := range files { 23 - if f == nil { 24 - continue 25 - } 26 - for _, name := range []string{f.NewName, f.OldName} { 27 - if name != "" && strings.HasPrefix(name, workflow.WorkflowDir+"/") { 28 - changed = append(changed, name) 29 - break 30 - } 31 - } 32 - } 33 - return changed, nil 34 - } 35 12 36 13 // TriggerCi manually triggers a CI pipeline for a fork-based pull request. 37 14 // authorized against and recorded under the target repo, but checked out ··· 78 55 return 79 56 } 80 57 81 - changedFiles, err := changedWorkflowFiles(latest.CombinedPatch()) 58 + workflowsChanged, changedFiles, err := s.workflowChangeFromSpindle(r, f, pull) 82 59 if err != nil { 83 - fail("failed to inspect the latest round's patch", err) 60 + // without the fingerprint comparison there is no way to tell fork 61 + // workflows apart from reviewed ones, so refuse to run them 62 + fail("failed to verify the workflow definitions of this round", err) 84 63 return 85 64 } 86 - if len(changedFiles) > 0 && r.URL.Query().Get("confirm") != "1" { 65 + if workflowsChanged && r.URL.Query().Get("confirm") != "1" { 87 66 fail(fmt.Sprintf("workflow files changed in this round (%s); review before running", strings.Join(changedFiles, ", ")), nil) 88 67 return 89 68 }
+114
appview/pulls/workflow_change.go
··· 1 + package pulls 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "slices" 8 + "time" 9 + 10 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/hostutil" 16 + "tangled.org/core/types" 17 + ) 18 + 19 + // spindle upgrades may change how a commit is resolved and hashed (even if very rare...) 20 + const ( 21 + workflowCacheSize = 4096 22 + workflowCacheTTL = time.Hour 23 + ) 24 + 25 + func (s *Pulls) describeWorkflowAt(r *http.Request, f *models.Repo, sha, sourceRepo string) (*tangled.CiDescribeWorkflowDefinition_Output, error) { 26 + key := fmt.Sprintf("%s|%s|%s|%s", f.Spindle, f.RepoDid, sha, sourceRepo) 27 + if cached, ok := s.workflowDescCache.Get(key); ok { 28 + return cached, nil 29 + } 30 + 31 + spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) 32 + if err != nil { 33 + return nil, err 34 + } 35 + client := &indigoxrpc.Client{Host: spindleUrl} 36 + out, err := tangled.CiDescribeWorkflowDefinition(r.Context(), client, f.RepoDid, sha, sourceRepo) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + s.workflowDescCache.Add(key, out) 42 + return out, nil 43 + } 44 + 45 + // returns the merge-base of the pull's source and target branches. 46 + // 47 + // older rounds resolve it live on the fork's knot, which tracks the 48 + // target branch as refs/hidden/<source>/<target>. 49 + func (s *Pulls) mergeBase(r *http.Request, pull *models.Pull) (string, error) { 50 + if mb := pull.LatestSubmission().MergeBase; mb != "" { 51 + return mb, nil 52 + } 53 + 54 + sourceBranch := pull.PullSource.Branch 55 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, pull.TargetBranch) 56 + 57 + key := fmt.Sprintf("%s|%s|%s", pull.PullSource.RepoDid, sourceBranch, pull.TargetBranch) 58 + if cached, ok := s.mergeBaseCache.Get(key); ok { 59 + return cached, nil 60 + } 61 + 62 + forkRepo, err := db.GetRepoByDid(s.db, pull.PullSource.RepoDid.String()) 63 + if err != nil { 64 + return "", fmt.Errorf("resolving fork repo: %w", err) 65 + } 66 + compareBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, sourceBranch) 67 + if err != nil { 68 + return "", fmt.Errorf("comparing %s...%s: %w", hiddenRef, sourceBranch, err) 69 + } 70 + var comparison types.RepoFormatPatchResponse 71 + if err := json.Unmarshal(compareBytes, &comparison); err != nil { 72 + return "", fmt.Errorf("decoding comparison: %w", err) 73 + } 74 + if comparison.MergeBase == "" { 75 + return "", fmt.Errorf("no merge base between %s and %s", hiddenRef, sourceBranch) 76 + } 77 + 78 + s.mergeBaseCache.Add(key, comparison.MergeBase) 79 + return comparison.MergeBase, nil 80 + } 81 + 82 + // compares the spindle's workflow-definition fingerprints at the 83 + // pull head and its merge-base with the target branch. 84 + func (s *Pulls) workflowChangeFromSpindle(r *http.Request, f *models.Repo, pull *models.Pull) (bool, []string, error) { 85 + sourceRepo := pull.PullSource.RepoDid.String() 86 + 87 + head, err := s.describeWorkflowAt(r, f, pull.LatestSha(), sourceRepo) 88 + if err != nil { 89 + return false, nil, err 90 + } 91 + // a definition not derived from the repo cannot change between commits 92 + if !head.Derived { 93 + return false, nil, nil 94 + } 95 + 96 + baseSha, err := s.mergeBase(r, pull) 97 + if err != nil { 98 + return false, nil, err 99 + } 100 + base, err := s.describeWorkflowAt(r, f, baseSha, sourceRepo) 101 + if err != nil { 102 + return false, nil, err 103 + } 104 + if !base.Derived { 105 + return false, nil, nil 106 + } 107 + if head.Hash != nil && base.Hash != nil && *head.Hash == *base.Hash { 108 + return false, nil, nil 109 + } 110 + 111 + names := slices.Concat(head.Workflows, base.Workflows) 112 + slices.Sort(names) 113 + return true, slices.Compact(names), nil 114 + }