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