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