Monorepo for Tangled
0
fork

Configure Feed

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

appview/pulls,knotserver: rework new PR flow, link push hint to compose

Lewis: May this revision serve well! <lewis@tangled.org>

authored by

Lewis and committed by
Tangled
4a9a989a e21c8735

+702 -242
+668 -221
appview/pulls/pulls.go
··· 9 9 "errors" 10 10 "fmt" 11 11 "io" 12 + "iter" 12 13 "log/slog" 13 14 "net/http" 15 + "net/url" 14 16 "slices" 15 17 "sort" 16 18 "strconv" ··· 43 45 "tangled.org/core/xrpc" 44 46 45 47 comatproto "github.com/bluesky-social/indigo/api/atproto" 48 + "github.com/bluesky-social/indigo/atproto/atclient" 46 49 "github.com/bluesky-social/indigo/atproto/syntax" 47 50 lexutil "github.com/bluesky-social/indigo/lex/util" 48 51 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 96 99 indexer: indexer, 97 100 ogreClient: ogre.NewClient(config.Ogre.Host), 98 101 } 102 + } 103 + 104 + func (s *Pulls) knotClient(host string) *indigoxrpc.Client { 105 + scheme := "https" 106 + if s.config.Core.Dev { 107 + scheme = "http" 108 + } 109 + return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 99 110 } 100 111 101 112 // htmx fragment ··· 341 352 return types.MergeCheckResponse{} 342 353 } 343 354 344 - scheme := "https" 345 - if s.config.Core.Dev { 346 - scheme = "http" 347 - } 348 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 349 - 350 - xrpcc := indigoxrpc.Client{ 351 - Host: host, 352 - } 355 + xrpcc := s.knotClient(f.Knot) 353 356 354 357 // combine patches of substack 355 358 subStack := stack.Below(pull) ··· 360 363 361 364 resp, err := tangled.RepoMergeCheck( 362 365 r.Context(), 363 - &xrpcc, 366 + xrpcc, 364 367 &tangled.RepoMergeCheck_Input{ 365 368 Did: f.Did, 366 369 Name: f.Name, ··· 375 378 } 376 379 } 377 380 378 - // convert xrpc response to internal types 381 + return mergeCheckResponseFrom(resp) 382 + } 383 + 384 + func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 379 385 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 380 - for i, conflict := range resp.Conflicts { 381 - conflicts[i] = types.ConflictInfo{ 382 - Filename: conflict.Filename, 383 - Reason: conflict.Reason, 384 - } 386 + for i, c := range resp.Conflicts { 387 + conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 385 388 } 386 - 387 - result := types.MergeCheckResponse{ 389 + out := types.MergeCheckResponse{ 388 390 IsConflicted: resp.Is_conflicted, 389 391 Conflicts: conflicts, 390 392 } 391 - 392 393 if resp.Message != nil { 393 - result.Message = *resp.Message 394 + out.Message = *resp.Message 394 395 } 395 - 396 396 if resp.Error != nil { 397 - result.Error = *resp.Error 397 + out.Error = *resp.Error 398 398 } 399 - 400 - return result 399 + return out 401 400 } 402 401 403 402 func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { ··· 959 958 960 959 switch r.Method { 961 960 case http.MethodGet: 962 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 963 - 964 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 961 + params, err := s.composeParams(r, f) 965 962 if err != nil { 966 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 967 - l.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err) 968 - s.pages.Error503(w) 969 - return 970 - } 971 - l.Error("failed to fetch branches", "err", err) 972 - return 973 - } 974 - 975 - var result types.RepoBranchesResponse 976 - if err := json.Unmarshal(xrpcBytes, &result); err != nil { 977 - l.Error("failed to decode XRPC response", "err", err) 963 + l.Error("failed to build compose params", "err", err) 978 964 s.pages.Error503(w) 979 965 return 980 966 } 981 - 982 - // can be one of "patch", "branch" or "fork" 983 - strategy := r.URL.Query().Get("strategy") 984 - // ignored if strategy is "patch" 985 - sourceBranch := r.URL.Query().Get("sourceBranch") 986 - targetBranch := r.URL.Query().Get("targetBranch") 987 - 988 - s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 989 - LoggedInUser: user, 990 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 991 - Branches: result.Branches, 992 - Strategy: strategy, 993 - SourceBranch: sourceBranch, 994 - TargetBranch: targetBranch, 995 - Title: r.URL.Query().Get("title"), 996 - Body: r.URL.Query().Get("body"), 997 - }) 967 + s.pages.RepoNewPull(w, params) 998 968 999 969 case http.MethodPost: 1000 970 title := r.FormValue("title") ··· 1016 986 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 1017 987 isForkBased := fromFork != "" && sourceBranch != "" 1018 988 isPatchBased := patch != "" && !isBranchBased && !isForkBased 1019 - isStacked := r.FormValue("isStacked") == "on" 989 + isStacked := r.FormValue("mode") == "stack" && !isPatchBased 1020 990 1021 991 if isPatchBased && !patchutil.IsFormatPatch(patch) { 1022 992 if title == "" { ··· 1042 1012 return 1043 1013 } 1044 1014 1015 + if isBranchBased && sourceBranch == targetBranch { 1016 + s.pages.Notice(w, "pull", "Source and target branch must be different.") 1017 + return 1018 + } 1019 + 1045 1020 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1046 1021 // if err != nil { 1047 1022 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) ··· 1083 1058 return 1084 1059 } 1085 1060 1061 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 1062 + stackBodies := parseBracketedForm(r.Form, "stackBody") 1063 + 1086 1064 // Handle the PR creation based on the type 1087 1065 if isBranchBased { 1088 1066 if !caps.PullRequests.BranchSubmissions { 1089 1067 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1090 1068 return 1091 1069 } 1092 - s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked) 1070 + s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1093 1071 } else if isForkBased { 1094 1072 if !caps.PullRequests.ForkSubmissions { 1095 1073 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1096 1074 return 1097 1075 } 1098 - s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked) 1076 + s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1099 1077 } else if isPatchBased { 1100 1078 if !caps.PullRequests.PatchSubmissions { 1101 1079 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1102 1080 return 1103 1081 } 1104 - s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked) 1082 + s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 1105 1083 } 1106 1084 return 1107 1085 } ··· 1117 1095 targetBranch, 1118 1096 sourceBranch string, 1119 1097 isStacked bool, 1098 + stackTitles, stackBodies map[string]string, 1120 1099 ) { 1121 1100 l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1122 1101 1123 - scheme := "http" 1124 - if !s.config.Core.Dev { 1125 - scheme = "https" 1126 - } 1127 - host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 1128 - xrpcc := &indigoxrpc.Client{ 1129 - Host: host, 1130 - } 1102 + xrpcc := s.knotClient(repo.Knot) 1131 1103 1132 1104 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1133 1105 if err != nil { ··· 1148 1120 return 1149 1121 } 1150 1122 1123 + if len(comparison.FormatPatch) == 0 { 1124 + s.pages.Notice(w, "pull", "No commits between target and source.") 1125 + return 1126 + } 1127 + 1151 1128 sourceRev := comparison.Rev2 1152 1129 patch := comparison.FormatPatchRaw 1153 1130 combined := comparison.CombinedPatchRaw ··· 1162 1139 Branch: sourceBranch, 1163 1140 } 1164 1141 1165 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked) 1142 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1166 1143 } 1167 1144 1168 - func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool) { 1145 + 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) { 1169 1146 if err := s.validator.ValidatePatch(&patch); err != nil { 1170 1147 s.logger.Error("patch validation failed", "err", err) 1171 1148 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1172 1149 return 1173 1150 } 1174 1151 1175 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked) 1152 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 1176 1153 } 1177 1154 1178 - func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1155 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1179 1156 l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1180 1157 1181 1158 repoString := strings.SplitN(forkRepo, "/", 2) ··· 1228 1205 // hiddenRef: hidden/feature-1/main (on repo-fork) 1229 1206 // targetBranch: main (on repo-1) 1230 1207 // sourceBranch: feature-1 (on repo-fork) 1231 - forkScheme := "http" 1232 - if !s.config.Core.Dev { 1233 - forkScheme = "https" 1234 - } 1235 - forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1236 - forkXrpcc := &indigoxrpc.Client{ 1237 - Host: forkHost, 1238 - } 1208 + forkXrpcc := s.knotClient(fork.Knot) 1239 1209 1240 1210 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1241 1211 if err != nil { ··· 1256 1226 return 1257 1227 } 1258 1228 1229 + if len(comparison.FormatPatch) == 0 { 1230 + s.pages.Notice(w, "pull", "No commits between target and source.") 1231 + return 1232 + } 1233 + 1259 1234 sourceRev := comparison.Rev2 1260 1235 patch := comparison.FormatPatchRaw 1261 1236 combined := comparison.CombinedPatchRaw ··· 1279 1254 RepoDid: forkDid, 1280 1255 } 1281 1256 1282 - s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked) 1257 + s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1283 1258 } 1284 1259 1285 1260 func (s *Pulls) createPullRequest( ··· 1293 1268 sourceRev string, 1294 1269 pullSource *models.PullSource, 1295 1270 isStacked bool, 1271 + stackTitles, stackBodies map[string]string, 1296 1272 ) { 1297 1273 l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 1298 1274 ··· 1307 1283 patch, 1308 1284 sourceRev, 1309 1285 pullSource, 1286 + stackTitles, 1287 + stackBodies, 1310 1288 ) 1311 1289 return 1312 1290 } ··· 1419 1397 1420 1398 s.notifier.NewPull(r.Context(), pull) 1421 1399 1400 + s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 1401 + 1422 1402 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1423 1403 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1424 1404 } ··· 1432 1412 patch string, 1433 1413 sourceRev string, 1434 1414 pullSource *models.PullSource, 1415 + stackTitles, stackBodies map[string]string, 1435 1416 ) { 1436 1417 l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 1437 1418 1438 1419 // run some necessary checks for stacked-prs first 1439 - 1440 - // must be branch or fork based 1441 - if sourceRev == "" { 1442 - l.Error("stacked PR from patch-based pull") 1443 - s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1444 - return 1445 - } 1446 1420 1447 1421 formatPatches, err := patchutil.ExtractPatches(patch) 1448 1422 if err != nil { ··· 1479 1453 } 1480 1454 1481 1455 // build a stack out of this patch 1482 - stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs) 1456 + stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 1483 1457 if err != nil { 1484 1458 l.Error("failed to create stack", "err", err) 1485 1459 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1541 1515 for _, p := range stack { 1542 1516 s.notifier.NewPull(r.Context(), p) 1543 1517 } 1518 + 1519 + s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 1544 1520 1545 1521 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1546 1522 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1547 1523 } 1548 1524 1549 - func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1550 - l := s.logger.With("handler", "ValidatePatch") 1525 + func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 1526 + body := r.FormValue("body") 1527 + s.pages.MarkdownPreviewFragment(w, body) 1528 + } 1529 + 1530 + func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 1531 + l := s.logger.With("handler", "RefreshCompose") 1532 + 1533 + f, err := s.repoResolver.Resolve(r) 1534 + if err != nil { 1535 + l.Error("failed to resolve repo", "err", err) 1536 + s.pages.Error503(w) 1537 + return 1538 + } 1551 1539 1552 - _, err := s.repoResolver.Resolve(r) 1540 + params, err := s.composeParams(r, f) 1553 1541 if err != nil { 1554 - l.Error("failed to get repo and knot", "err", err) 1542 + l.Error("failed to build compose params", "err", err) 1543 + s.pages.Error503(w) 1555 1544 return 1556 1545 } 1546 + w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 1547 + s.pages.PullComposeHostFragment(w, params) 1548 + } 1557 1549 1550 + func composeCanonicalURL(params pages.RepoNewPullParams) string { 1551 + base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 1552 + q := url.Values{} 1553 + if params.IsStacked { 1554 + q.Set("mode", "stack") 1555 + } 1556 + if params.Source != "" && params.Source != pages.SourceBranch { 1557 + q.Set("source", string(params.Source)) 1558 + } 1559 + if params.SourceBranch != "" { 1560 + q.Set("sourceBranch", params.SourceBranch) 1561 + } 1562 + if params.TargetBranch != "" { 1563 + q.Set("targetBranch", params.TargetBranch) 1564 + } 1565 + if params.Source == pages.SourceFork && params.Fork != "" { 1566 + q.Set("fork", params.Fork) 1567 + } 1568 + if len(q) == 0 { 1569 + return base 1570 + } 1571 + return base + "?" + q.Encode() 1572 + } 1573 + 1574 + func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 1575 + l := s.logger.With("handler", "composeParams") 1576 + user := s.oauth.GetMultiAccountUser(r) 1577 + 1578 + branches, err := s.listBranches(r.Context(), repo) 1579 + if err != nil { 1580 + return pages.RepoNewPullParams{}, err 1581 + } 1582 + 1583 + var forks []models.Repo 1584 + if user != nil { 1585 + forks, err = db.GetForksByDid(s.db, user.Did) 1586 + if err != nil { 1587 + l.Warn("failed to list user forks", "err", err, "user", user.Did) 1588 + } 1589 + } 1590 + 1591 + repoInfo := s.repoResolver.GetRepoInfo(r, user) 1592 + source, ok := pages.ParseSource(r.FormValue("source")) 1593 + if !ok { 1594 + source = pages.SourceBranch 1595 + if !repoInfo.Roles.IsPushAllowed() { 1596 + source = pages.SourceFork 1597 + } 1598 + } 1599 + 1600 + sourceBranch := r.FormValue("sourceBranch") 1601 + targetBranch := r.FormValue("targetBranch") 1602 + fork := r.FormValue("fork") 1558 1603 patch := r.FormValue("patch") 1559 - if patch == "" { 1560 - s.pages.Notice(w, "patch-error", "Patch is required.") 1561 - return 1604 + 1605 + if source == pages.SourceFork && fork == "" && len(forks) == 1 { 1606 + fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name) 1562 1607 } 1563 1608 1564 - if err := s.validator.ValidatePatch(&patch); err != nil { 1565 - l.Error("failed to validate patch", "err", err) 1566 - s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1567 - return 1609 + var forkBranches []types.Branch 1610 + var forkBranchesErr error 1611 + if source == pages.SourceFork && fork != "" { 1612 + forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 1613 + if forkBranchesErr != nil { 1614 + l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 1615 + } 1616 + } 1617 + 1618 + sourceBranchList := sourceBranchChoices(branches) 1619 + targetBranch = defaultTargetBranch(branches, targetBranch) 1620 + sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 1621 + 1622 + comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 1623 + var prefillErr string 1624 + if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 1625 + prefillErr = joined.Error() 1626 + } 1627 + 1628 + mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 1629 + 1630 + refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 1631 + var diffOpts types.DiffOpts 1632 + if r.FormValue("diff") == "split" { 1633 + diffOpts.Split = true 1634 + } 1635 + diffOpts.RefreshUrl = refreshUrl 1636 + diffOpts.Target = "#diff-area" 1637 + 1638 + labelDefs, err := s.pullLabelDefs(repo) 1639 + if err != nil { 1640 + l.Warn("failed to load label definitions", "err", err) 1641 + } 1642 + labelState := labelStateFromForm(r.Form, labelDefs) 1643 + perCidLabelForms := parseStackLabelForms(r.Form) 1644 + stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 1645 + for cid, perForm := range perCidLabelForms { 1646 + stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 1647 + } 1648 + 1649 + stackTitles := parseBracketedForm(r.Form, "stackTitle") 1650 + stackBodies := parseBracketedForm(r.Form, "stackBody") 1651 + stackSplits := parseBracketedForm(r.Form, "stackSplit") 1652 + 1653 + title := r.FormValue("title") 1654 + body := r.FormValue("body") 1655 + if comparison != nil && len(comparison.FormatPatch) > 0 { 1656 + first := comparison.FormatPatch[0] 1657 + if title == "" && first.PatchHeader != nil { 1658 + title = first.Title 1659 + } 1660 + if body == "" && first.PatchHeader != nil { 1661 + body = first.Body 1662 + } 1568 1663 } 1569 1664 1570 - if patchutil.IsFormatPatch(patch) { 1571 - s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") 1572 - } else { 1573 - s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1665 + isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 1666 + var stackedDiffs []pages.StackedDiff 1667 + if isStacked { 1668 + stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 1574 1669 } 1670 + 1671 + return pages.RepoNewPullParams{ 1672 + LoggedInUser: user, 1673 + RepoInfo: repoInfo, 1674 + Branches: branches, 1675 + SourceBranches: sourceBranchList, 1676 + ForkBranches: forkBranches, 1677 + Forks: forks, 1678 + Source: source, 1679 + SourceBranch: sourceBranch, 1680 + TargetBranch: targetBranch, 1681 + Fork: fork, 1682 + Patch: patch, 1683 + Title: title, 1684 + Body: body, 1685 + IsStacked: isStacked, 1686 + Comparison: comparison, 1687 + Diff: diff, 1688 + DiffOpts: diffOpts, 1689 + StackedDiffs: stackedDiffs, 1690 + MergeCheck: mergeCheck, 1691 + StackTitles: stackTitles, 1692 + StackBodies: stackBodies, 1693 + PrefillError: prefillErr, 1694 + LabelDefs: labelDefs, 1695 + LabelState: labelState, 1696 + StackLabelStates: stackLabelStates, 1697 + }, nil 1575 1698 } 1576 1699 1577 - func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1578 - user := s.oauth.GetMultiAccountUser(r) 1700 + func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) { 1701 + defs, err := db.GetLabelDefinitions( 1702 + s.db, 1703 + orm.FilterIn("at_uri", repo.Labels), 1704 + orm.FilterContains("scope", tangled.RepoPullNSID), 1705 + ) 1706 + if err != nil { 1707 + return nil, err 1708 + } 1579 1709 1580 - s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1581 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1582 - }) 1710 + out := make(map[string]*models.LabelDefinition, len(defs)) 1711 + for i := range defs { 1712 + d := defs[i] 1713 + if !slices.Contains(d.Scope, tangled.RepoPullNSID) { 1714 + continue 1715 + } 1716 + out[d.AtUri().String()] = &d 1717 + } 1718 + return out, nil 1719 + } 1720 + 1721 + func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] { 1722 + return func(yield func(string, string) bool) { 1723 + for key := range defs { 1724 + for _, v := range form[key] { 1725 + if v == "" { 1726 + continue 1727 + } 1728 + if !yield(key, v) { 1729 + return 1730 + } 1731 + } 1732 + } 1733 + } 1734 + } 1735 + 1736 + func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState { 1737 + state := models.NewLabelState() 1738 + actx := &models.LabelApplicationCtx{Defs: defs} 1739 + for key, val := range formLabelEntries(form, defs) { 1740 + _ = actx.ApplyLabelOp(state, models.LabelOp{ 1741 + Operation: models.LabelOperationAdd, 1742 + OperandKey: key, 1743 + OperandValue: val, 1744 + }) 1745 + } 1746 + return state 1747 + } 1748 + 1749 + func buildCreationLabelOps( 1750 + userDid syntax.DID, 1751 + subject syntax.ATURI, 1752 + rkey string, 1753 + form url.Values, 1754 + defs map[string]*models.LabelDefinition, 1755 + performedAt time.Time, 1756 + ) []models.LabelOp { 1757 + var ops []models.LabelOp 1758 + for key, val := range formLabelEntries(form, defs) { 1759 + ops = append(ops, models.LabelOp{ 1760 + Did: userDid.String(), 1761 + Rkey: rkey, 1762 + Subject: subject, 1763 + Operation: models.LabelOperationAdd, 1764 + OperandKey: key, 1765 + OperandValue: val, 1766 + PerformedAt: performedAt, 1767 + }) 1768 + } 1769 + return ops 1583 1770 } 1584 1771 1585 - func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1586 - l := s.logger.With("handler", "CompareBranchesFragment") 1772 + func (s *Pulls) applyCreationLabels( 1773 + ctx context.Context, 1774 + client *atclient.APIClient, 1775 + userDid syntax.DID, 1776 + pulls []*models.Pull, 1777 + form url.Values, 1778 + repo *models.Repo, 1779 + ) { 1780 + l := s.logger.With("handler", "applyCreationLabels", "user", userDid) 1587 1781 1588 - user := s.oauth.GetMultiAccountUser(r) 1589 - f, err := s.repoResolver.Resolve(r) 1782 + defs, err := s.pullLabelDefs(repo) 1590 1783 if err != nil { 1591 - l.Error("failed to get repo and knot", "err", err) 1784 + l.Warn("failed to fetch label defs", "err", err) 1785 + return 1786 + } 1787 + if len(defs) == 0 { 1592 1788 return 1593 1789 } 1594 1790 1595 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1791 + perCidForms := parseStackLabelForms(form) 1792 + 1793 + applyAll := form.Get("applyLabelsToAll") == "on" 1794 + var firstStackForm url.Values 1795 + if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 1796 + if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 1797 + if f, ok := perCidForms[firstCid]; ok { 1798 + firstStackForm = f 1799 + } 1800 + } 1801 + } 1802 + 1803 + performedAt := time.Now() 1804 + for _, pull := range pulls { 1805 + labelForm := form 1806 + if firstStackForm != nil { 1807 + labelForm = firstStackForm 1808 + } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 1809 + if cid := pull.Submissions[0].ChangeId(); cid != "" { 1810 + if perForm, ok := perCidForms[cid]; ok { 1811 + labelForm = perForm 1812 + } 1813 + } 1814 + } 1815 + rkey := tid.TID() 1816 + raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 1596 1817 1597 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1818 + valid := make([]models.LabelOp, 0, len(raw)) 1819 + for _, op := range raw { 1820 + def := defs[op.OperandKey] 1821 + if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 1822 + l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 1823 + continue 1824 + } 1825 + valid = append(valid, op) 1826 + } 1827 + if len(valid) == 0 { 1828 + continue 1829 + } 1830 + 1831 + record := models.LabelOpsAsRecord(valid) 1832 + if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1833 + Collection: tangled.LabelOpNSID, 1834 + Repo: userDid.String(), 1835 + Rkey: rkey, 1836 + Record: &lexutil.LexiconTypeDecoder{Val: &record}, 1837 + }); err != nil { 1838 + l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 1839 + continue 1840 + } 1841 + 1842 + if err := s.indexLabelOps(ctx, valid); err != nil { 1843 + l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 1844 + if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 1845 + Collection: tangled.LabelOpNSID, 1846 + Repo: userDid.String(), 1847 + Rkey: rkey, 1848 + }); err != nil { 1849 + l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 1850 + } 1851 + continue 1852 + } 1853 + 1854 + s.notifier.NewPullLabelOp(ctx, pull) 1855 + } 1856 + } 1857 + 1858 + func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error { 1859 + tx, err := s.db.BeginTx(ctx, nil) 1598 1860 if err != nil { 1599 - l.Error("failed to fetch branches", "err", err) 1600 - s.pages.Error503(w) 1601 - return 1861 + return err 1862 + } 1863 + defer tx.Rollback() 1864 + for _, op := range ops { 1865 + if _, err := db.AddLabelOp(tx, &op); err != nil { 1866 + return err 1867 + } 1602 1868 } 1869 + return tx.Commit() 1870 + } 1603 1871 1872 + func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 1873 + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1874 + xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 1875 + if err != nil { 1876 + return nil, err 1877 + } 1604 1878 var result types.RepoBranchesResponse 1605 1879 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1606 - l.Error("failed to decode XRPC response", "err", err) 1607 - s.pages.Error503(w) 1608 - return 1880 + return nil, err 1609 1881 } 1882 + return result.Branches, nil 1883 + } 1610 1884 1611 - branches := result.Branches 1612 - sort.Slice(branches, func(i int, j int) bool { 1613 - return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1614 - }) 1615 - 1616 - withoutDefault := []types.Branch{} 1617 - for _, b := range branches { 1618 - if b.IsDefault { 1619 - continue 1620 - } 1621 - withoutDefault = append(withoutDefault, b) 1885 + func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) { 1886 + parts := strings.SplitN(forkIdent, "/", 2) 1887 + if len(parts) != 2 { 1888 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 1889 + } 1890 + forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1])) 1891 + if err != nil { 1892 + return nil, err 1893 + } 1894 + branches, err := s.listBranches(ctx, forkRepo) 1895 + if err != nil { 1896 + return nil, err 1622 1897 } 1898 + return sortBranchesByRecency(branches), nil 1899 + } 1623 1900 1624 - s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1625 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1626 - Branches: withoutDefault, 1901 + func sourceBranchChoices(branches []types.Branch) []types.Branch { 1902 + withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 1903 + return b.IsDefault 1627 1904 }) 1905 + return sortBranchesByRecency(withoutDefault) 1628 1906 } 1629 1907 1630 - func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1631 - l := s.logger.With("handler", "CompareForksFragment") 1908 + func defaultTargetBranch(branches []types.Branch, current string) string { 1909 + if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 1910 + return current 1911 + } 1912 + if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 1913 + return branches[idx].Reference.Name 1914 + } 1915 + return "" 1916 + } 1632 1917 1633 - user := s.oauth.GetMultiAccountUser(r) 1634 - if user != nil { 1635 - l = l.With("user", user.Did) 1918 + func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 1919 + var candidates []types.Branch 1920 + switch source { 1921 + case pages.SourceFork: 1922 + candidates = forkBranches 1923 + case pages.SourceBranch: 1924 + candidates = branchChoices 1925 + default: 1926 + return current 1636 1927 } 1928 + if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 1929 + return current 1930 + } 1931 + if len(candidates) == 0 { 1932 + return "" 1933 + } 1934 + return candidates[0].Reference.Name 1935 + } 1637 1936 1638 - forks, err := db.GetForksByDid(s.db, user.Did) 1937 + func sortBranchesByRecency(branches []types.Branch) []types.Branch { 1938 + out := slices.Clone(branches) 1939 + sort.SliceStable(out, func(i, j int) bool { 1940 + if out[i].Commit == nil || out[j].Commit == nil { 1941 + return out[i].Commit != nil 1942 + } 1943 + return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 1944 + }) 1945 + return out 1946 + } 1947 + 1948 + func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 1949 + var ( 1950 + comparison *types.RepoFormatPatchResponse 1951 + err error 1952 + ) 1953 + switch source { 1954 + case pages.SourcePatch: 1955 + if strings.TrimSpace(patch) == "" { 1956 + return nil, nil, nil 1957 + } 1958 + if verr := s.validator.ValidatePatch(&patch); verr != nil { 1959 + return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 1960 + } 1961 + comparison = parsePastedPatch(patch) 1962 + case pages.SourceBranch: 1963 + if targetBranch == "" || sourceBranch == "" { 1964 + return nil, nil, nil 1965 + } 1966 + comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 1967 + case pages.SourceFork: 1968 + if fork == "" || targetBranch == "" || sourceBranch == "" { 1969 + return nil, nil, nil 1970 + } 1971 + comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 1972 + default: 1973 + return nil, nil, nil 1974 + } 1639 1975 if err != nil { 1640 - l.Error("failed to get forks", "err", err) 1641 - return 1976 + s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 1977 + return nil, nil, err 1642 1978 } 1643 1979 1644 - s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1645 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1646 - Forks: forks, 1647 - Selected: r.URL.Query().Get("fork"), 1980 + return comparison, deriveDiff(comparison, targetBranch), nil 1981 + } 1982 + 1983 + func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 1984 + if comparison == nil || targetBranch == "" { 1985 + return nil 1986 + } 1987 + patch := comparison.CombinedPatchRaw 1988 + if patch == "" { 1989 + patch = comparison.FormatPatchRaw 1990 + } 1991 + if patch == "" { 1992 + return nil 1993 + } 1994 + 1995 + xrpcc := s.knotClient(repo.Knot) 1996 + 1997 + resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 1998 + Did: repo.Did, 1999 + Name: repo.Name, 2000 + Branch: targetBranch, 2001 + Patch: patch, 1648 2002 }) 2003 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2004 + s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 2005 + return &types.MergeCheckResponse{Error: xrpcerr.Error()} 2006 + } 2007 + 2008 + out := mergeCheckResponseFrom(resp) 2009 + return &out 1649 2010 } 1650 2011 1651 - func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1652 - l := s.logger.With("handler", "CompareForksBranchesFragment") 2012 + func bracketComponents(key, prefix string) ([]string, bool) { 2013 + if !strings.HasPrefix(key, prefix) { 2014 + return nil, false 2015 + } 2016 + rest := key[len(prefix):] 2017 + var parts []string 2018 + for len(rest) > 0 { 2019 + if !strings.HasPrefix(rest, "[") { 2020 + return nil, false 2021 + } 2022 + end := strings.Index(rest, "]") 2023 + if end <= 0 { 2024 + return nil, false 2025 + } 2026 + parts = append(parts, rest[1:end]) 2027 + rest = rest[end+1:] 2028 + } 2029 + if len(parts) == 0 { 2030 + return nil, false 2031 + } 2032 + return parts, true 2033 + } 1653 2034 1654 - user := s.oauth.GetMultiAccountUser(r) 1655 - if user != nil { 1656 - l = l.With("user", user.Did) 2035 + func parseBracketedForm(form url.Values, prefix string) map[string]string { 2036 + out := make(map[string]string) 2037 + for key, vals := range form { 2038 + parts, ok := bracketComponents(key, prefix) 2039 + if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 2040 + continue 2041 + } 2042 + out[parts[0]] = vals[0] 1657 2043 } 2044 + return out 2045 + } 2046 + 2047 + func parseStackLabelForms(form url.Values) map[string]url.Values { 2048 + out := make(map[string]url.Values) 2049 + for key, vals := range form { 2050 + parts, ok := bracketComponents(key, "stackLabel") 2051 + if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 2052 + continue 2053 + } 2054 + cid, atUri := parts[0], parts[1] 2055 + if _, ok := out[cid]; !ok { 2056 + out[cid] = make(url.Values) 2057 + } 2058 + out[cid][atUri] = append(out[cid][atUri], vals...) 2059 + } 2060 + return out 2061 + } 2062 + 2063 + func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 2064 + if patch == "" { 2065 + return nil 2066 + } 2067 + response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 2068 + if patchutil.IsFormatPatch(patch) { 2069 + if patches, err := patchutil.ExtractPatches(patch); err == nil { 2070 + response.FormatPatch = patches 2071 + } 2072 + } 2073 + return response 2074 + } 1658 2075 1659 - f, err := s.repoResolver.Resolve(r) 2076 + func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2077 + xrpcc := s.knotClient(repo.Knot) 2078 + 2079 + xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1660 2080 if err != nil { 1661 - l.Error("failed to get repo and knot", "err", err) 1662 - return 2081 + return nil, err 1663 2082 } 1664 2083 1665 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 2084 + var comparison types.RepoFormatPatchResponse 2085 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2086 + return nil, err 2087 + } 2088 + return &comparison, nil 2089 + } 1666 2090 1667 - forkVal := r.URL.Query().Get("fork") 1668 - repoString := strings.SplitN(forkVal, "/", 2) 1669 - forkOwnerDid := repoString[0] 1670 - forkName := repoString[1] 1671 - // fork repo 1672 - repo, err := db.GetRepo( 1673 - s.db, 1674 - orm.FilterEq("did", forkOwnerDid), 1675 - orm.FilterEq("name", forkName), 1676 - ) 2091 + func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2092 + parts := strings.SplitN(forkIdent, "/", 2) 2093 + if len(parts) != 2 { 2094 + return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 2095 + } 2096 + fork, err := db.GetForkByDid(s.db, parts[0], parts[1]) 1677 2097 if err != nil { 1678 - l.Error("failed to get repo", "fork_owner_did", forkOwnerDid, "fork_name", forkName, "err", err) 1679 - return 2098 + return nil, err 1680 2099 } 1681 2100 1682 - sourceXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, repo.RepoAt().String()) 2101 + client, err := s.oauth.ServiceClient( 2102 + r, 2103 + oauth.WithService(fork.Knot), 2104 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 2105 + oauth.WithDev(s.config.Core.Dev), 2106 + ) 1683 2107 if err != nil { 1684 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1685 - l.Error("failed to call XRPC repo.branches for source", "xrpcerr", xrpcerr, "err", err) 1686 - s.pages.Error503(w) 1687 - return 1688 - } 1689 - l.Error("failed to fetch source branches", "err", err) 1690 - return 2108 + return nil, err 1691 2109 } 1692 2110 1693 - // Decode source branches 1694 - var sourceBranches types.RepoBranchesResponse 1695 - if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1696 - l.Error("failed to decode source branches XRPC response", "err", err) 1697 - s.pages.Error503(w) 1698 - return 2111 + resp, err := tangled.RepoHiddenRef( 2112 + r.Context(), 2113 + client, 2114 + &tangled.RepoHiddenRef_Input{ 2115 + ForkRef: sourceBranch, 2116 + RemoteRef: targetBranch, 2117 + Repo: fork.RepoAt().String(), 2118 + }, 2119 + ) 2120 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2121 + return nil, xrpcerr 1699 2122 } 2123 + if !resp.Success { 2124 + if resp.Error != nil { 2125 + return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 2126 + } 2127 + return nil, fmt.Errorf("hidden ref failed") 2128 + } 2129 + 2130 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 2131 + forkXrpcc := s.knotClient(fork.Knot) 1700 2132 1701 - targetXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 2133 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1702 2134 if err != nil { 1703 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1704 - l.Error("failed to call XRPC repo.branches for target", "xrpcerr", xrpcerr, "err", err) 1705 - s.pages.Error503(w) 1706 - return 1707 - } 1708 - l.Error("failed to fetch target branches", "err", err) 1709 - return 2135 + return nil, err 1710 2136 } 1711 2137 1712 - // Decode target branches 1713 - var targetBranches types.RepoBranchesResponse 1714 - if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1715 - l.Error("failed to decode target branches XRPC response", "err", err) 1716 - s.pages.Error503(w) 1717 - return 2138 + var comparison types.RepoFormatPatchResponse 2139 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 2140 + return nil, err 1718 2141 } 2142 + return &comparison, nil 2143 + } 1719 2144 1720 - sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1721 - return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1722 - }) 2145 + func stackPerCommitDiffs( 2146 + comparison *types.RepoFormatPatchResponse, 2147 + targetBranch, refreshUrl string, 2148 + stackSplits map[string]string, 2149 + ) []pages.StackedDiff { 2150 + if comparison == nil { 2151 + return nil 2152 + } 2153 + out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 2154 + for i, p := range comparison.FormatPatch { 2155 + nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 2156 + out[i].Diff = &nd 2157 + cid := p.ChangeIdOrEmpty() 2158 + if cid == "" { 2159 + continue 2160 + } 2161 + out[i].Opts = types.DiffOpts{ 2162 + Split: stackSplits[cid] == "split", 2163 + RefreshUrl: refreshUrl, 2164 + Target: fmt.Sprintf("#stack-diff-%s", cid), 2165 + Field: fmt.Sprintf("stackSplit[%s]", cid), 2166 + } 2167 + } 2168 + return out 2169 + } 1723 2170 1724 - s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1725 - RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1726 - SourceBranches: sourceBranches.Branches, 1727 - TargetBranches: targetBranches.Branches, 1728 - }) 2171 + func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 2172 + if comparison == nil { 2173 + return nil 2174 + } 2175 + raw := comparison.CombinedPatchRaw 2176 + if raw == "" { 2177 + raw = comparison.FormatPatchRaw 2178 + } 2179 + d := patchutil.AsNiceDiff(raw, targetBranch) 2180 + return &d 1729 2181 } 1730 2182 1731 2183 func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { ··· 1833 2285 return 1834 2286 } 1835 2287 1836 - scheme := "http" 1837 - if !s.config.Core.Dev { 1838 - scheme = "https" 1839 - } 1840 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1841 - xrpcc := &indigoxrpc.Client{ 1842 - Host: host, 1843 - } 2288 + xrpcc := s.knotClient(f.Knot) 1844 2289 1845 2290 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 1846 2291 if err != nil { ··· 1937 2382 1938 2383 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1939 2384 // extract patch by performing compare 1940 - forkScheme := "http" 1941 - if !s.config.Core.Dev { 1942 - forkScheme = "https" 1943 - } 1944 - forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1945 - forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 2385 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 1946 2386 if err != nil { 1947 2387 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1948 2388 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) ··· 2115 2555 blobs[i] = blob.Blob 2116 2556 } 2117 2557 2118 - newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs) 2558 + newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 2119 2559 if err != nil { 2120 2560 l.Error("failed to create resubmitted stack", "err", err) 2121 2561 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2612 3052 pullSource *models.PullSource, 2613 3053 formatPatches []types.FormatPatch, 2614 3054 blobs []*lexutil.LexBlob, 3055 + stackTitles, stackBodies map[string]string, 2615 3056 ) (models.Stack, error) { 2616 3057 var stack models.Stack 2617 3058 var parentAtUri *syntax.ATURI 2618 3059 for i, fp := range formatPatches { 2619 3060 // all patches must have a jj change-id 2620 - _, err := fp.ChangeId() 3061 + cid, err := fp.ChangeId() 2621 3062 if err != nil { 2622 3063 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2623 3064 } 2624 3065 2625 3066 title := fp.Title 2626 3067 body := fp.Body 3068 + if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 3069 + title = override 3070 + } 3071 + if override, ok := stackBodies[cid]; ok { 3072 + body = override 3073 + } 2627 3074 rkey := tid.TID() 2628 3075 2629 3076 mentions, references := s.mentionsResolver.Resolve(ctx, body)
+3 -5
appview/pulls/router.go
··· 12 12 r.With(middleware.Paginate).Get("/", s.RepoPulls) 13 13 r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 14 r.Get("/", s.NewPull) 15 - r.Get("/patch-upload", s.PatchUploadFragment) 16 - r.Post("/validate-patch", s.ValidatePatch) 17 - r.Get("/compare-branches", s.CompareBranchesFragment) 18 - r.Get("/compare-forks", s.CompareForksFragment) 19 - r.Get("/fork-branches", s.CompareForksBranchesFragment) 15 + r.Get("/refresh", s.RefreshCompose) 16 + r.Post("/refresh", s.RefreshCompose) 17 + r.Post("/preview", s.MarkdownPreview) 20 18 r.Post("/", s.NewPull) 21 19 }) 22 20
+31 -16
knotserver/internal.go
··· 6 6 "fmt" 7 7 "log/slog" 8 8 "net/http" 9 + "net/url" 9 10 "os" 10 11 "path/filepath" 11 12 "strings" ··· 257 258 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 258 259 } 259 260 260 - err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid) 261 + err = h.emitPullRequestLink(&resp.Messages, line, ownerDid, repoName, repoDid) 261 262 if err != nil { 262 - l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 263 + l.Error("failed to reply with pull request link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 263 264 } 264 265 265 266 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions) ··· 415 416 return h.db.InsertEvent(event, h.n) 416 417 } 417 418 418 - func (h *InternalHandle) emitCompareLink( 419 + func (h *InternalHandle) emitPullRequestLink( 419 420 clientMsgs *[]string, 420 421 line git.PostReceiveLine, 421 422 ownerDid string, 422 423 repoName string, 423 424 repoDid string, 424 425 ) error { 425 - // this is a second push to a branch, don't reply with the link again 426 - if !line.OldSha.IsZero() { 426 + if line.NewSha.IsZero() { 427 427 return nil 428 428 } 429 429 430 430 // the ref was not updated to a new hash, don't reply with the link 431 431 // 432 432 // NOTE: do we need this? 433 - if line.NewSha.String() == line.OldSha.String() { 433 + if line.NewSha == line.OldSha { 434 434 return nil 435 435 } 436 436 437 437 pushedRef := plumbing.ReferenceName(line.Ref) 438 + if !pushedRef.IsBranch() { 439 + return nil 440 + } 438 441 439 - userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 440 - user := ownerDid 441 - if err == nil { 442 - user = userIdent.Handle.String() 442 + if !line.OldSha.IsZero() { 443 + return nil 443 444 } 444 445 445 446 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) ··· 457 458 return err 458 459 } 459 460 461 + pushedBranch := pushedRef.Short() 462 + 460 463 // pushing to default branch 461 - if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 464 + if pushedBranch == defaultBranch { 462 465 return nil 463 466 } 464 467 465 - // pushing a tag, don't prompt the user the open a PR 466 - if pushedRef.IsTag() { 467 - return nil 468 + userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 469 + user := ownerDid 470 + if err == nil { 471 + user = userIdent.Handle.String() 472 + } 473 + 474 + query := url.Values{} 475 + query.Set("source", "branch") 476 + query.Set("sourceBranch", pushedBranch) 477 + query.Set("targetBranch", defaultBranch) 478 + 479 + basePath, err := url.JoinPath(h.c.AppViewEndpoint, user, repoName, "pulls", "new") 480 + if err != nil { 481 + return err 468 482 } 483 + pullURL := basePath + "?" + query.Encode() 469 484 470 485 ZWS := "\u200B" 471 486 *clientMsgs = append(*clientMsgs, ZWS) 472 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 473 - *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 487 + *clientMsgs = append(*clientMsgs, "→ Open pull request:") 488 + *clientMsgs = append(*clientMsgs, " "+pullURL) 474 489 *clientMsgs = append(*clientMsgs, ZWS) 475 490 return nil 476 491 }