Monorepo for Tangled
tangled.org
1package pulls
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "net/url"
11 "slices"
12 "sort"
13 "strings"
14
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/appview/db"
17 "tangled.org/core/appview/models"
18 "tangled.org/core/appview/oauth"
19 "tangled.org/core/appview/pages"
20 "tangled.org/core/appview/pages/markup"
21 "tangled.org/core/appview/pages/repoinfo"
22 "tangled.org/core/appview/xrpcclient"
23 "tangled.org/core/patchutil"
24 "tangled.org/core/types"
25
26 "github.com/bluesky-social/indigo/atproto/syntax"
27 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
28)
29
30func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) {
31 l := s.logger.With("handler", "NewPull")
32
33 user := s.oauth.GetMultiAccountUser(r)
34 if user != nil {
35 l = l.With("user", user.Did)
36 }
37
38 f, err := s.repoResolver.Resolve(r)
39 if err != nil {
40 l.Error("failed to get repo and knot", "err", err)
41 return
42 }
43 l = l.With("repo_at", f.RepoAt().String())
44
45 switch r.Method {
46 case http.MethodGet:
47 params, err := s.composeParams(r, f)
48 if err != nil {
49 l.Error("failed to build compose params", "err", err)
50 s.pages.Error503(w)
51 return
52 }
53 s.pages.RepoNewPull(w, params)
54
55 case http.MethodPost:
56 title := r.FormValue("title")
57 body := r.FormValue("body")
58 targetBranch := r.FormValue("targetBranch")
59 fromFork := r.FormValue("fork")
60 sourceBranch := r.FormValue("sourceBranch")
61 patch := r.FormValue("patch")
62 userDid := syntax.DID(user.Did)
63
64 if targetBranch == "" {
65 s.pages.Notice(w, "pull", "Target branch is required.")
66 return
67 }
68
69 // Determine PR type based on input parameters
70 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())}
71 isPushAllowed := roles.IsPushAllowed()
72 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
73 isForkBased := fromFork != "" && sourceBranch != ""
74 isPatchBased := patch != "" && !isBranchBased && !isForkBased
75 isStacked := r.FormValue("mode") == "stack" && !isPatchBased
76
77 if isPatchBased && !patchutil.IsFormatPatch(patch) {
78 if title == "" {
79 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
80 return
81 }
82 sanitizer := markup.NewSanitizer()
83 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" {
84 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
85 return
86 }
87 }
88
89 // Validate we have at least one valid PR creation method
90 if !isBranchBased && !isPatchBased && !isForkBased {
91 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
92 return
93 }
94
95 // Can't mix branch-based and patch-based approaches
96 if isBranchBased && patch != "" {
97 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
98 return
99 }
100
101 if isBranchBased && sourceBranch == targetBranch {
102 s.pages.Notice(w, "pull", "Source and target branch must be different.")
103 return
104 }
105
106 // TODO: make capabilities an xrpc call
107 caps := struct {
108 PullRequests struct {
109 FormatPatch bool
110 BranchSubmissions bool
111 ForkSubmissions bool
112 PatchSubmissions bool
113 }
114 }{
115 PullRequests: struct {
116 FormatPatch bool
117 BranchSubmissions bool
118 ForkSubmissions bool
119 PatchSubmissions bool
120 }{
121 FormatPatch: true,
122 BranchSubmissions: true,
123 ForkSubmissions: true,
124 PatchSubmissions: true,
125 },
126 }
127
128 if !caps.PullRequests.FormatPatch {
129 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
130 return
131 }
132
133 stackTitles := parseBracketedForm(r.Form, "stackTitle")
134 stackBodies := parseBracketedForm(r.Form, "stackBody")
135
136 // Handle the PR creation based on the type
137 if isBranchBased {
138 if !caps.PullRequests.BranchSubmissions {
139 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
140 return
141 }
142 s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies)
143 } else if isForkBased {
144 if !caps.PullRequests.ForkSubmissions {
145 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
146 return
147 }
148 s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies)
149 } else if isPatchBased {
150 if !caps.PullRequests.PatchSubmissions {
151 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
152 return
153 }
154 s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies)
155 }
156 return
157 }
158}
159
160func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) {
161 body := r.FormValue("body")
162 s.pages.MarkdownPreviewFragment(w, body)
163}
164
165func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) {
166 l := s.logger.With("handler", "RefreshCompose")
167
168 f, err := s.repoResolver.Resolve(r)
169 if err != nil {
170 l.Error("failed to resolve repo", "err", err)
171 s.pages.Error503(w)
172 return
173 }
174
175 params, err := s.composeParams(r, f)
176 if err != nil {
177 l.Error("failed to build compose params", "err", err)
178 s.pages.Error503(w)
179 return
180 }
181 w.Header().Set("HX-Replace-Url", composeCanonicalURL(params))
182 s.pages.PullComposeHostFragment(w, params)
183}
184
185func composeCanonicalURL(params pages.RepoNewPullParams) string {
186 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName())
187 q := url.Values{}
188 if params.IsStacked {
189 q.Set("mode", "stack")
190 }
191 if params.Source != "" && params.Source != pages.SourceBranch {
192 q.Set("source", string(params.Source))
193 }
194 if params.SourceBranch != "" {
195 q.Set("sourceBranch", params.SourceBranch)
196 }
197 if params.TargetBranch != "" {
198 q.Set("targetBranch", params.TargetBranch)
199 }
200 if params.Source == pages.SourceFork && params.Fork != "" {
201 q.Set("fork", params.Fork)
202 }
203 if len(q) == 0 {
204 return base
205 }
206 return base + "?" + q.Encode()
207}
208
209func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) {
210 l := s.logger.With("handler", "composeParams")
211 user := s.oauth.GetMultiAccountUser(r)
212
213 branches, err := s.listBranches(r.Context(), repo)
214 if err != nil {
215 return pages.RepoNewPullParams{}, err
216 }
217
218 var forks []models.Repo
219 if user != nil {
220 forks, err = db.GetForksByDid(s.db, user.Did)
221 if err != nil {
222 l.Warn("failed to list user forks", "err", err, "user", user.Did)
223 }
224 }
225 forks = slices.DeleteFunc(forks, func(f models.Repo) bool {
226 return f.RepoDid == ""
227 })
228
229 repoInfo := s.repoResolver.GetRepoInfo(r, user)
230 source, ok := pages.ParseSource(r.FormValue("source"))
231 if !ok {
232 source = pages.SourceBranch
233 if !repoInfo.Roles.IsPushAllowed() {
234 source = pages.SourceFork
235 }
236 }
237
238 sourceBranch := r.FormValue("sourceBranch")
239 targetBranch := r.FormValue("targetBranch")
240 fork := r.FormValue("fork")
241 patch := r.FormValue("patch")
242
243 if source == pages.SourceFork && fork == "" && len(forks) == 1 {
244 fork = forks[0].RepoDid
245 }
246
247 var forkBranches []types.Branch
248 var forkBranchesErr error
249 if source == pages.SourceFork && fork != "" {
250 forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork)
251 if forkBranchesErr != nil {
252 l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork)
253 }
254 }
255
256 sourceBranchList := sourceBranchChoices(branches)
257 targetBranch = defaultTargetBranch(branches, targetBranch)
258 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches)
259
260 comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch)
261 var prefillErr string
262 if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil {
263 prefillErr = joined.Error()
264 }
265
266 mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison)
267
268 refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName())
269 var diffOpts types.DiffOpts
270 if r.FormValue("diff") == "split" {
271 diffOpts.Split = true
272 }
273 diffOpts.RefreshUrl = refreshUrl
274 diffOpts.Target = "#diff-area"
275
276 labelDefs, err := s.pullLabelDefs(repo)
277 if err != nil {
278 l.Warn("failed to load label definitions", "err", err)
279 }
280 labelState := labelStateFromForm(r.Form, labelDefs)
281 perCidLabelForms := parseStackLabelForms(r.Form)
282 stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms))
283 for cid, perForm := range perCidLabelForms {
284 stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs)
285 }
286
287 stackTitles := parseBracketedForm(r.Form, "stackTitle")
288 stackBodies := parseBracketedForm(r.Form, "stackBody")
289 stackSplits := parseBracketedForm(r.Form, "stackSplit")
290
291 title := r.FormValue("title")
292 body := r.FormValue("body")
293 if comparison != nil && len(comparison.FormatPatch) > 0 {
294 first := comparison.FormatPatch[0]
295 if title == "" && first.PatchHeader != nil {
296 title = first.Title
297 }
298 if body == "" && first.PatchHeader != nil {
299 body = first.Body
300 }
301 }
302
303 isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch
304 var stackedDiffs []pages.StackedDiff
305 if isStacked {
306 stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits)
307 }
308
309 return pages.RepoNewPullParams{
310 LoggedInUser: user,
311 RepoInfo: repoInfo,
312 Branches: branches,
313 SourceBranches: sourceBranchList,
314 ForkBranches: forkBranches,
315 Forks: forks,
316 Source: source,
317 SourceBranch: sourceBranch,
318 TargetBranch: targetBranch,
319 Fork: fork,
320 Patch: patch,
321 Title: title,
322 Body: body,
323 IsStacked: isStacked,
324 Comparison: comparison,
325 Diff: diff,
326 DiffOpts: diffOpts,
327 StackedDiffs: stackedDiffs,
328 MergeCheck: mergeCheck,
329 StackTitles: stackTitles,
330 StackBodies: stackBodies,
331 PrefillError: prefillErr,
332 LabelDefs: labelDefs,
333 LabelState: labelState,
334 StackLabelStates: stackLabelStates,
335 }, nil
336}
337
338func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) {
339 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
340 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid)
341 if err != nil {
342 return nil, err
343 }
344 var result types.RepoBranchesResponse
345 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
346 return nil, err
347 }
348 return result.Branches, nil
349}
350
351func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) {
352 if forkRepoDid == "" {
353 return nil, fmt.Errorf("fork not found")
354 }
355 forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid)
356 if errors.Is(err, sql.ErrNoRows) {
357 return nil, fmt.Errorf("fork not found")
358 }
359 if err != nil {
360 return nil, err
361 }
362 branches, err := s.listBranches(ctx, forkRepo)
363 if err != nil {
364 return nil, err
365 }
366 return sortBranchesByRecency(branches), nil
367}
368
369func sourceBranchChoices(branches []types.Branch) []types.Branch {
370 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool {
371 return b.IsDefault
372 })
373 return sortBranchesByRecency(withoutDefault)
374}
375
376func defaultTargetBranch(branches []types.Branch, current string) string {
377 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) {
378 return current
379 }
380 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 {
381 return branches[idx].Reference.Name
382 }
383 return ""
384}
385
386func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string {
387 var candidates []types.Branch
388 switch source {
389 case pages.SourceFork:
390 candidates = forkBranches
391 case pages.SourceBranch:
392 candidates = branchChoices
393 default:
394 return current
395 }
396 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) {
397 return current
398 }
399 if len(candidates) == 0 {
400 return ""
401 }
402 return candidates[0].Reference.Name
403}
404
405func sortBranchesByRecency(branches []types.Branch) []types.Branch {
406 out := slices.Clone(branches)
407 sort.SliceStable(out, func(i, j int) bool {
408 if out[i].Commit == nil || out[j].Commit == nil {
409 return out[i].Commit != nil
410 }
411 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When)
412 })
413 return out
414}
415
416func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) {
417 var (
418 comparison *types.RepoFormatPatchResponse
419 err error
420 )
421 switch source {
422 case pages.SourcePatch:
423 if strings.TrimSpace(patch) == "" {
424 return nil, nil, nil
425 }
426 if verr := s.validator.ValidatePatch(&patch); verr != nil {
427 return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch")
428 }
429 comparison = parsePastedPatch(patch)
430 case pages.SourceBranch:
431 if targetBranch == "" || sourceBranch == "" {
432 return nil, nil, nil
433 }
434 comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch)
435 case pages.SourceFork:
436 if fork == "" || targetBranch == "" || sourceBranch == "" {
437 return nil, nil, nil
438 }
439 comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch)
440 default:
441 return nil, nil, nil
442 }
443 if err != nil {
444 s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source)
445 return nil, nil, err
446 }
447
448 return comparison, deriveDiff(comparison, targetBranch), nil
449}
450
451func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse {
452 if comparison == nil || targetBranch == "" {
453 return nil
454 }
455 patch := comparison.CombinedPatchRaw
456 if patch == "" {
457 patch = comparison.FormatPatchRaw
458 }
459 if patch == "" {
460 return nil
461 }
462
463 xrpcc := s.knotClient(repo.Knot)
464
465 resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{
466 Did: repo.Did,
467 Name: repo.Name,
468 Branch: targetBranch,
469 Patch: patch,
470 })
471 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
472 s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch)
473 return &types.MergeCheckResponse{Error: xrpcerr.Error()}
474 }
475
476 out := mergeCheckResponseFrom(resp)
477 return &out
478}
479
480func bracketComponents(key, prefix string) ([]string, bool) {
481 if !strings.HasPrefix(key, prefix) {
482 return nil, false
483 }
484 rest := key[len(prefix):]
485 var parts []string
486 for len(rest) > 0 {
487 if !strings.HasPrefix(rest, "[") {
488 return nil, false
489 }
490 end := strings.Index(rest, "]")
491 if end <= 0 {
492 return nil, false
493 }
494 parts = append(parts, rest[1:end])
495 rest = rest[end+1:]
496 }
497 if len(parts) == 0 {
498 return nil, false
499 }
500 return parts, true
501}
502
503func parseBracketedForm(form url.Values, prefix string) map[string]string {
504 out := make(map[string]string)
505 for key, vals := range form {
506 parts, ok := bracketComponents(key, prefix)
507 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 {
508 continue
509 }
510 out[parts[0]] = vals[0]
511 }
512 return out
513}
514
515func parseStackLabelForms(form url.Values) map[string]url.Values {
516 out := make(map[string]url.Values)
517 for key, vals := range form {
518 parts, ok := bracketComponents(key, "stackLabel")
519 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" {
520 continue
521 }
522 cid, atUri := parts[0], parts[1]
523 if _, ok := out[cid]; !ok {
524 out[cid] = make(url.Values)
525 }
526 out[cid][atUri] = append(out[cid][atUri], vals...)
527 }
528 return out
529}
530
531func parsePastedPatch(patch string) *types.RepoFormatPatchResponse {
532 if patch == "" {
533 return nil
534 }
535 response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch}
536 if patchutil.IsFormatPatch(patch) {
537 if patches, err := patchutil.ExtractPatches(patch); err == nil {
538 response.FormatPatch = patches
539 }
540 }
541 return response
542}
543
544func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) {
545 xrpcc := s.knotClient(repo.Knot)
546
547 xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch)
548 if err != nil {
549 return nil, err
550 }
551
552 var comparison types.RepoFormatPatchResponse
553 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
554 return nil, err
555 }
556 return &comparison, nil
557}
558
559func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) {
560 if forkRepoDid == "" {
561 return nil, fmt.Errorf("fork not found")
562 }
563 fork, err := db.GetForkByRepoDid(s.db, forkRepoDid)
564 if errors.Is(err, sql.ErrNoRows) {
565 return nil, fmt.Errorf("fork not found")
566 }
567 if err != nil {
568 return nil, err
569 }
570
571 client, err := s.oauth.ServiceClient(
572 r,
573 oauth.WithService(fork.Knot),
574 oauth.WithLxm(tangled.RepoHiddenRefNSID),
575 oauth.WithDev(s.config.Core.Dev),
576 )
577 if err != nil {
578 return nil, err
579 }
580
581 resp, err := tangled.RepoHiddenRef(
582 r.Context(),
583 client,
584 &tangled.RepoHiddenRef_Input{
585 ForkRef: sourceBranch,
586 RemoteRef: targetBranch,
587 Repo: fork.RepoAt().String(),
588 },
589 )
590 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
591 return nil, xrpcerr
592 }
593 if !resp.Success {
594 if resp.Error != nil {
595 return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error)
596 }
597 return nil, fmt.Errorf("hidden ref failed")
598 }
599
600 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
601 forkXrpcc := s.knotClient(fork.Knot)
602
603 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch)
604 if err != nil {
605 return nil, err
606 }
607
608 var comparison types.RepoFormatPatchResponse
609 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
610 return nil, err
611 }
612 return &comparison, nil
613}
614
615func stackPerCommitDiffs(
616 comparison *types.RepoFormatPatchResponse,
617 targetBranch, refreshUrl string,
618 stackSplits map[string]string,
619) []pages.StackedDiff {
620 if comparison == nil {
621 return nil
622 }
623 out := make([]pages.StackedDiff, len(comparison.FormatPatch))
624 for i, p := range comparison.FormatPatch {
625 nd := patchutil.AsNiceDiff(p.Raw, targetBranch)
626 out[i].Diff = &nd
627 cid := p.ChangeIdOrEmpty()
628 if cid == "" {
629 continue
630 }
631 out[i].Opts = types.DiffOpts{
632 Split: stackSplits[cid] == "split",
633 RefreshUrl: refreshUrl,
634 Target: fmt.Sprintf("#stack-diff-%s", cid),
635 Field: fmt.Sprintf("stackSplit[%s]", cid),
636 }
637 }
638 return out
639}
640
641func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff {
642 if comparison == nil {
643 return nil
644 }
645 raw := comparison.CombinedPatchRaw
646 if raw == "" {
647 raw = comparison.FormatPatchRaw
648 }
649 d := patchutil.AsNiceDiff(raw, targetBranch)
650 return &d
651}