forked from tangled.org/core
this repo has no description

appview: pulls: enable merging stacked PRs

Changed files
+302 -55
appview
patchutil
+152 -17
appview/db/pulls.go
··· 4 4 "database/sql" 5 5 "fmt" 6 6 "log" 7 + "slices" 7 8 "sort" 8 9 "strings" 9 10 "time" ··· 157 158 } 158 159 } 159 160 return false 161 + } 162 + 163 + func (p *Pull) IsStacked() bool { 164 + return p.StackId != "" 160 165 } 161 166 162 167 func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { ··· 327 332 return pullId - 1, err 328 333 } 329 334 330 - func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { 335 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 331 336 pulls := make(map[int]*Pull) 332 337 333 - rows, err := e.Query(` 338 + var conditions []string 339 + var args []any 340 + for _, filter := range filters { 341 + conditions = append(conditions, filter.Condition()) 342 + args = append(args, filter.arg) 343 + } 344 + 345 + whereClause := "" 346 + if conditions != nil { 347 + whereClause = " where " + strings.Join(conditions, " and ") 348 + } 349 + 350 + query := fmt.Sprintf(` 334 351 select 335 352 owner_did, 353 + repo_at, 336 354 pull_id, 337 355 created, 338 356 title, ··· 341 359 body, 342 360 rkey, 343 361 source_branch, 344 - source_repo_at 362 + source_repo_at, 363 + stack_id, 364 + change_id, 365 + parent_change_id 345 366 from 346 367 pulls 347 - where 348 - repo_at = ? and state = ?`, repoAt, state) 368 + %s 369 + `, whereClause) 370 + 371 + rows, err := e.Query(query, args...) 349 372 if err != nil { 350 373 return nil, err 351 374 } ··· 354 377 for rows.Next() { 355 378 var pull Pull 356 379 var createdAt string 357 - var sourceBranch, sourceRepoAt sql.NullString 380 + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 358 381 err := rows.Scan( 359 382 &pull.OwnerDid, 383 + &pull.RepoAt, 360 384 &pull.PullId, 361 385 &createdAt, 362 386 &pull.Title, ··· 366 390 &pull.Rkey, 367 391 &sourceBranch, 368 392 &sourceRepoAt, 393 + &stackId, 394 + &changeId, 395 + &parentChangeId, 369 396 ) 370 397 if err != nil { 371 398 return nil, err ··· 390 417 } 391 418 } 392 419 420 + if stackId.Valid { 421 + pull.StackId = stackId.String 422 + } 423 + if changeId.Valid { 424 + pull.ChangeId = changeId.String 425 + } 426 + if parentChangeId.Valid { 427 + pull.ParentChangeId = parentChangeId.String 428 + } 429 + 393 430 pulls[pull.PullId] = &pull 394 431 } 395 432 ··· 397 434 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 398 435 submissionsQuery := fmt.Sprintf(` 399 436 select 400 - id, pull_id, round_number 437 + id, pull_id, round_number, patch 401 438 from 402 439 pull_submissions 403 440 where 404 - repo_at = ? and pull_id in (%s) 405 - `, inClause) 441 + repo_at in (%s) and pull_id in (%s) 442 + `, inClause, inClause) 406 443 407 - args := make([]any, len(pulls)+1) 408 - args[0] = repoAt.String() 409 - idx := 1 444 + args = make([]any, len(pulls)*2) 445 + idx := 0 446 + for _, p := range pulls { 447 + args[idx] = p.RepoAt 448 + idx += 1 449 + } 410 450 for _, p := range pulls { 411 451 args[idx] = p.PullId 412 452 idx += 1 ··· 423 463 &s.ID, 424 464 &s.PullId, 425 465 &s.RoundNumber, 466 + &s.Patch, 426 467 ) 427 468 if err != nil { 428 469 return nil, err ··· 477 518 return nil, err 478 519 } 479 520 480 - orderedByDate := []*Pull{} 521 + orderedByPullId := []*Pull{} 481 522 for _, p := range pulls { 482 - orderedByDate = append(orderedByDate, p) 523 + orderedByPullId = append(orderedByPullId, p) 483 524 } 484 - sort.Slice(orderedByDate, func(i, j int) bool { 485 - return orderedByDate[i].Created.After(orderedByDate[j].Created) 525 + sort.Slice(orderedByPullId, func(i, j int) bool { 526 + return orderedByPullId[i].PullId > orderedByPullId[j].PullId 486 527 }) 487 528 488 - return orderedByDate, nil 529 + return orderedByPullId, nil 489 530 } 490 531 491 532 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { ··· 854 895 855 896 return count, nil 856 897 } 898 + 899 + type Stack []*Pull 900 + 901 + // change-id parent-change-id 902 + // 903 + // 4 w ,-------- z (TOP) 904 + // 3 z <----',------- y 905 + // 2 y <-----',------ x 906 + // 1 x <------' nil (BOT) 907 + // 908 + // `w` is parent of none, so it is the top of the stack 909 + func GetStack(e Execer, stackId string) (Stack, error) { 910 + unorderedPulls, err := GetPulls(e, Filter("stack_id", stackId)) 911 + if err != nil { 912 + return nil, err 913 + } 914 + // map of parent-change-id to pull 915 + changeIdMap := make(map[string]*Pull, len(unorderedPulls)) 916 + parentMap := make(map[string]*Pull, len(unorderedPulls)) 917 + for _, p := range unorderedPulls { 918 + changeIdMap[p.ChangeId] = p 919 + if p.ParentChangeId != "" { 920 + parentMap[p.ParentChangeId] = p 921 + } 922 + } 923 + 924 + // the top of the stack is the pull that is not a parent of any pull 925 + var topPull *Pull 926 + for _, maybeTop := range unorderedPulls { 927 + if _, ok := parentMap[maybeTop.ChangeId]; !ok { 928 + topPull = maybeTop 929 + break 930 + } 931 + } 932 + 933 + pulls := []*Pull{} 934 + for { 935 + pulls = append(pulls, topPull) 936 + if topPull.ParentChangeId != "" { 937 + if next, ok := changeIdMap[topPull.ParentChangeId]; ok { 938 + topPull = next 939 + } else { 940 + return nil, fmt.Errorf("failed to find parent pull request, stack is malformed") 941 + } 942 + } else { 943 + break 944 + } 945 + } 946 + 947 + return pulls, nil 948 + } 949 + 950 + // position of this pull in the stack 951 + func (stack Stack) Position(pull *Pull) int { 952 + return slices.IndexFunc(stack, func(p *Pull) bool { 953 + return p.ChangeId == pull.ChangeId 954 + }) 955 + } 956 + 957 + // all pulls below this pull (including self) in this stack 958 + // 959 + // nil if this pull does not belong to this stack 960 + func (stack Stack) Below(pull *Pull) Stack { 961 + position := stack.Position(pull) 962 + 963 + if position < 0 { 964 + return nil 965 + } 966 + 967 + return stack[position:] 968 + } 969 + 970 + // all pulls below this pull (excluding self) in this stack 971 + func (stack Stack) StrictlyBelow(pull *Pull) Stack { 972 + below := stack.Below(pull) 973 + 974 + if len(below) > 0 { 975 + return below[1:] 976 + } 977 + 978 + return nil 979 + } 980 + 981 + // the combined format-patches of all the newest submissions in this stack 982 + func (stack Stack) CombinedPatch() string { 983 + // go in reverse order because the bottom of the stack is the last element in the slice 984 + var combined strings.Builder 985 + for idx := range stack { 986 + pull := stack[len(stack)-1-idx] 987 + combined.WriteString(pull.LatestPatch()) 988 + combined.WriteString("\n") 989 + } 990 + return combined.String() 991 + }
+10
appview/state/middleware.go
··· 172 172 173 173 ctx := context.WithValue(r.Context(), "pull", pr) 174 174 175 + if pr.IsStacked() { 176 + stack, err := db.GetStack(s.db, pr.StackId) 177 + if err != nil { 178 + log.Println("failed to get stack", err) 179 + return 180 + } 181 + 182 + ctx = context.WithValue(ctx, "stack", stack) 183 + } 184 + 175 185 next.ServeHTTP(w, r.WithContext(ctx)) 176 186 }) 177 187 }
+137 -26
appview/state/pull.go
··· 46 46 return 47 47 } 48 48 49 + // can be nil if this pull is not stacked 50 + stack := r.Context().Value("stack").(db.Stack) 51 + 49 52 roundNumberStr := chi.URLParam(r, "round") 50 53 roundNumber, err := strconv.Atoi(roundNumberStr) 51 54 if err != nil { ··· 57 60 return 58 61 } 59 62 60 - mergeCheckResponse := s.mergeCheck(f, pull) 63 + mergeCheckResponse := s.mergeCheck(f, pull, stack) 61 64 resubmitResult := pages.Unknown 62 65 if user.Did == pull.OwnerDid { 63 66 resubmitResult = s.resubmitCheck(f, pull) ··· 90 93 return 91 94 } 92 95 96 + // can be nil if this pull is not stacked 97 + stack := r.Context().Value("stack").(db.Stack) 98 + 93 99 totalIdents := 1 94 100 for _, submission := range pull.Submissions { 95 101 totalIdents += len(submission.Comments) ··· 117 123 } 118 124 } 119 125 120 - mergeCheckResponse := s.mergeCheck(f, pull) 126 + mergeCheckResponse := s.mergeCheck(f, pull, stack) 121 127 resubmitResult := pages.Unknown 122 128 if user != nil && user.Did == pull.OwnerDid { 123 129 resubmitResult = s.resubmitCheck(f, pull) ··· 133 139 }) 134 140 } 135 141 136 - func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse { 142 + func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 137 143 if pull.State == db.PullMerged { 138 144 return types.MergeCheckResponse{} 139 145 } ··· 154 160 } 155 161 } 156 162 157 - resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch) 163 + patch := pull.LatestPatch() 164 + if pull.IsStacked() { 165 + // combine patches of substack 166 + subStack := stack.Below(pull) 167 + 168 + // collect the portion of the stack that is mergeable 169 + var mergeable db.Stack 170 + for _, p := range subStack { 171 + // stop at the first merged PR 172 + if p.State == db.PullMerged { 173 + break 174 + } 175 + 176 + // skip over closed PRs 177 + // 178 + // we will close PRs that are "removed" from a stack 179 + if p.State != db.PullClosed { 180 + mergeable = append(mergeable, p) 181 + } 182 + } 183 + 184 + patch = mergeable.CombinedPatch() 185 + } 186 + 187 + resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 158 188 if err != nil { 159 189 log.Println("failed to check for mergeability:", err) 160 190 return types.MergeCheckResponse{ ··· 417 447 return 418 448 } 419 449 420 - pulls, err := db.GetPulls(s.db, f.RepoAt, state) 450 + pulls, err := db.GetPulls( 451 + s.db, 452 + db.Filter("repo_at", f.RepoAt), 453 + db.Filter("state", state), 454 + ) 421 455 if err != nil { 422 456 log.Println("failed to get pulls", err) 423 457 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") ··· 1009 1043 1010 1044 // TODO: can we just use a format-patch string here? 1011 1045 initialSubmission := db.PullSubmission{ 1012 - Patch: fp.Patch(), 1046 + Patch: fp.Raw, 1013 1047 SourceRev: sourceRev, 1014 1048 } 1015 1049 err = db.NewPull(tx, &db.Pull{ ··· 1038 1072 Title: title, 1039 1073 TargetRepo: string(f.RepoAt), 1040 1074 TargetBranch: targetBranch, 1041 - Patch: fp.Patch(), 1075 + Patch: fp.Raw, 1042 1076 Source: recordPullSource, 1043 1077 } 1044 1078 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ ··· 1692 1726 pull, ok := r.Context().Value("pull").(*db.Pull) 1693 1727 if !ok { 1694 1728 log.Println("failed to get pull") 1695 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1729 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1696 1730 return 1697 1731 } 1698 1732 1733 + var pullsToMerge db.Stack 1734 + pullsToMerge = append(pullsToMerge, pull) 1735 + if pull.IsStacked() { 1736 + stack, ok := r.Context().Value("stack").(db.Stack) 1737 + if !ok { 1738 + log.Println("failed to get stack") 1739 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1740 + return 1741 + } 1742 + 1743 + // combine patches of substack 1744 + subStack := stack.Below(pull) 1745 + 1746 + // collect the portion of the stack that is mergeable 1747 + for _, p := range subStack { 1748 + // stop at the first merged PR 1749 + if p.State == db.PullMerged { 1750 + break 1751 + } 1752 + 1753 + // skip over closed PRs 1754 + // 1755 + // TODO: we need a "deleted" state for such PRs, but without losing discussions 1756 + // we will close PRs that are "removed" from a stack 1757 + if p.State == db.PullClosed { 1758 + continue 1759 + } 1760 + 1761 + pullsToMerge = append(pullsToMerge, p) 1762 + } 1763 + } 1764 + 1765 + patch := pullsToMerge.CombinedPatch() 1766 + 1699 1767 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1700 1768 if err != nil { 1701 1769 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) ··· 1723 1791 } 1724 1792 1725 1793 // Merge the pull request 1726 - resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1794 + resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1727 1795 if err != nil { 1728 1796 log.Printf("failed to merge pull request: %s", err) 1729 1797 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1730 1798 return 1731 1799 } 1732 1800 1733 - if resp.StatusCode == http.StatusOK { 1734 - err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1801 + if resp.StatusCode != http.StatusOK { 1802 + log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1803 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1804 + return 1805 + } 1806 + 1807 + tx, err := s.db.Begin() 1808 + if err != nil { 1809 + log.Printf("failed to start transcation", err) 1810 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1811 + return 1812 + } 1813 + 1814 + for _, p := range pullsToMerge { 1815 + err := db.MergePull(tx, f.RepoAt, p.PullId) 1735 1816 if err != nil { 1736 1817 log.Printf("failed to update pull request status in database: %s", err) 1737 1818 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1738 1819 return 1739 1820 } 1740 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1741 - } else { 1742 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1821 + } 1822 + 1823 + err = tx.Commit() 1824 + if err != nil { 1825 + // TODO: this is unsound, we should also revert the merge from the knotserver here 1826 + log.Printf("failed to update pull request status in database: %s", err) 1743 1827 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1828 + return 1744 1829 } 1830 + 1831 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1745 1832 } 1746 1833 1747 1834 func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 1779 1866 return 1780 1867 } 1781 1868 1782 - // Close the pull in the database 1783 - err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1784 - if err != nil { 1785 - log.Println("failed to close pull", err) 1786 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 1787 - return 1869 + var pullsToClose []*db.Pull 1870 + pullsToClose = append(pullsToClose, pull) 1871 + 1872 + // if this PR is stacked, then we want to close all PRs below this one on the stack 1873 + if pull.IsStacked() { 1874 + stack := r.Context().Value("stack").(db.Stack) 1875 + subStack := stack.StrictlyBelow(pull) 1876 + pullsToClose = append(pullsToClose, subStack...) 1877 + } 1878 + 1879 + for _, p := range pullsToClose { 1880 + // Close the pull in the database 1881 + err = db.ClosePull(tx, f.RepoAt, p.PullId) 1882 + if err != nil { 1883 + log.Println("failed to close pull", err) 1884 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 1885 + return 1886 + } 1788 1887 } 1789 1888 1790 1889 // Commit the transaction ··· 1834 1933 return 1835 1934 } 1836 1935 1837 - // Reopen the pull in the database 1838 - err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1839 - if err != nil { 1840 - log.Println("failed to reopen pull", err) 1841 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1842 - return 1936 + var pullsToReopen []*db.Pull 1937 + pullsToReopen = append(pullsToReopen, pull) 1938 + 1939 + // if this PR is stacked, then we want to reopen all PRs below this one on the stack 1940 + if pull.IsStacked() { 1941 + stack := r.Context().Value("stack").(db.Stack) 1942 + subStack := stack.StrictlyBelow(pull) 1943 + pullsToReopen = append(pullsToReopen, subStack...) 1944 + } 1945 + 1946 + for _, p := range pullsToReopen { 1947 + // Close the pull in the database 1948 + err = db.ReopenPull(tx, f.RepoAt, p.PullId) 1949 + if err != nil { 1950 + log.Println("failed to close pull", err) 1951 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 1952 + return 1953 + } 1843 1954 } 1844 1955 1845 1956 // Commit the transaction
+1 -3
flake.nix
··· 49 49 inherit (gitignore.lib) gitignoreSource; 50 50 in { 51 51 overlays.default = final: prev: let 52 - goModHash = "sha256-TwlPge7vhVGmtNvYkHFFnZjJs2DWPUwPhCSBTCUYCtc="; 52 + goModHash = "sha256-CmBuvv3duQQoc8iTW4244w1rYLGeqMQS+qQ3wwReZZg="; 53 53 buildCmdPackage = name: 54 54 final.buildGoModule { 55 55 pname = name; ··· 156 156 pkgs.websocat 157 157 pkgs.tailwindcss 158 158 pkgs.nixos-shell 159 - pkgs.nodePackages.localtunnel 160 - pkgs.python312Packages.pyngrok 161 159 ]; 162 160 shellHook = '' 163 161 mkdir -p appview/pages/static/{fonts,icons}
+2 -9
patchutil/patchutil.go
··· 13 13 type FormatPatch struct { 14 14 Files []*gitdiff.File 15 15 *gitdiff.PatchHeader 16 - } 17 - 18 - // Extracts just the diff from this format-patch 19 - func (f FormatPatch) Patch() string { 20 - var b strings.Builder 21 - for _, p := range f.Files { 22 - b.WriteString(p.String()) 23 - } 24 - return b.String() 16 + Raw string 25 17 } 26 18 27 19 func (f FormatPatch) ChangeId() (string, error) { ··· 50 42 result = append(result, FormatPatch{ 51 43 Files: files, 52 44 PatchHeader: header, 45 + Raw: patch, 53 46 }) 54 47 } 55 48