This repository has no description
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/patchutil"
22 "tangled.org/core/types"
23 "tangled.org/core/xrpc/xrpcclient"
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 stackBlobs := parseStackBlobForms(r.Form)
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, stackBlobs)
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, stackBlobs)
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, stackBlobs)
154 }
155 return
156 }
157}
158
159func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) {
160 l := s.logger.With("handler", "RefreshCompose")
161
162 f, err := s.repoResolver.Resolve(r)
163 if err != nil {
164 l.Error("failed to resolve repo", "err", err)
165 s.pages.Error503(w)
166 return
167 }
168
169 params, err := s.composeParams(r, f)
170 if err != nil {
171 l.Error("failed to build compose params", "err", err)
172 s.pages.Error503(w)
173 return
174 }
175 w.Header().Set("HX-Replace-Url", composeCanonicalURL(params))
176 s.pages.PullComposeHostFragment(w, params)
177}
178
179func composeCanonicalURL(params pages.RepoNewPullParams) string {
180 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName())
181 q := url.Values{}
182 if params.IsStacked {
183 q.Set("mode", "stack")
184 }
185 if params.Source != "" && params.Source != pages.SourceBranch {
186 q.Set("source", string(params.Source))
187 }
188 if params.SourceBranch != "" {
189 q.Set("sourceBranch", params.SourceBranch)
190 }
191 if params.TargetBranch != "" {
192 q.Set("targetBranch", params.TargetBranch)
193 }
194 if params.Source == pages.SourceFork && params.Fork != "" {
195 q.Set("fork", params.Fork)
196 }
197 if len(q) == 0 {
198 return base
199 }
200 return base + "?" + q.Encode()
201}
202
203func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) {
204 l := s.logger.With("handler", "composeParams")
205 user := s.oauth.GetMultiAccountUser(r)
206
207 branches, err := s.listBranches(r.Context(), repo)
208 if err != nil {
209 return pages.RepoNewPullParams{}, err
210 }
211
212 var forks []models.Repo
213 if user != nil {
214 forks, err = db.GetForksByDid(s.db, user.Did)
215 if err != nil {
216 l.Warn("failed to list user forks", "err", err, "user", user.Did)
217 }
218 }
219 forks = slices.DeleteFunc(forks, func(f models.Repo) bool {
220 return f.RepoDid == ""
221 })
222
223 repoInfo := s.repoResolver.GetRepoInfo(r, user)
224 source, ok := pages.ParseSource(r.FormValue("source"))
225 if !ok {
226 source = pages.SourceBranch
227 if !repoInfo.Roles.IsPushAllowed() {
228 source = pages.SourceFork
229 }
230 }
231
232 sourceBranch := r.FormValue("sourceBranch")
233 targetBranch := r.FormValue("targetBranch")
234 fork := r.FormValue("fork")
235 patch := r.FormValue("patch")
236
237 if source == pages.SourceFork && fork == "" && len(forks) == 1 {
238 fork = forks[0].RepoDid
239 }
240
241 var forkBranches []types.Branch
242 var forkBranchesErr error
243 if source == pages.SourceFork && fork != "" {
244 forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork)
245 if forkBranchesErr != nil {
246 l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork)
247 }
248 }
249
250 sourceBranchList := sourceBranchChoices(branches)
251 targetBranch = defaultTargetBranch(branches, targetBranch)
252 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches)
253
254 comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch)
255 var prefillErr string
256 if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil {
257 prefillErr = joined.Error()
258 }
259
260 mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison)
261
262 refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName())
263 var diffOpts types.DiffOpts
264 if r.FormValue("diff") == "split" {
265 diffOpts.Split = true
266 }
267 diffOpts.RefreshUrl = refreshUrl
268 diffOpts.Target = "#diff-area"
269
270 labelDefs, err := s.pullLabelDefs(repo)
271 if err != nil {
272 l.Warn("failed to load label definitions", "err", err)
273 }
274 labelState := labelStateFromForm(r.Form, labelDefs)
275 perCidLabelForms := parseStackLabelForms(r.Form)
276 stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms))
277 for cid, perForm := range perCidLabelForms {
278 stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs)
279 }
280
281 stackTitles := parseBracketedForm(r.Form, "stackTitle")
282 stackBodies := parseBracketedForm(r.Form, "stackBody")
283 stackSplits := parseBracketedForm(r.Form, "stackSplit")
284
285 title := r.FormValue("title")
286 body := r.FormValue("body")
287 titleDirty := r.FormValue("titleDirty") == "1"
288 bodyDirty := r.FormValue("bodyDirty") == "1"
289 if comparison != nil && len(comparison.FormatPatch) > 0 {
290 first := comparison.FormatPatch[0]
291 if !titleDirty && first.PatchHeader != nil {
292 title = first.Title
293 }
294 if !bodyDirty && first.PatchHeader != nil {
295 body = first.Body
296 }
297 }
298
299 isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch
300 var stackedDiffs []pages.StackedDiff
301 if isStacked {
302 stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits)
303 }
304
305 return pages.RepoNewPullParams{
306 BaseParams: pages.BaseParamsFromContext(r.Context()),
307 RepoInfo: repoInfo,
308 Branches: branches,
309 SourceBranches: sourceBranchList,
310 ForkBranches: forkBranches,
311 Forks: forks,
312 Source: source,
313 SourceBranch: sourceBranch,
314 TargetBranch: targetBranch,
315 Fork: fork,
316 Patch: patch,
317 Title: title,
318 Body: body,
319 TitleDirty: titleDirty,
320 BodyDirty: bodyDirty,
321 IsStacked: isStacked,
322 Comparison: comparison,
323 Diff: diff,
324 DiffOpts: diffOpts,
325 StackedDiffs: stackedDiffs,
326 MergeCheck: mergeCheck,
327 StackTitles: stackTitles,
328 StackBodies: stackBodies,
329 PrefillError: prefillErr,
330 LabelDefs: labelDefs,
331 LabelState: labelState,
332 StackLabelStates: stackLabelStates,
333 }, nil
334}
335
336func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) {
337 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url}
338 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid)
339 if err != nil {
340 return nil, err
341 }
342 var result types.RepoBranchesResponse
343 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
344 return nil, err
345 }
346 return result.Branches, nil
347}
348
349func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) {
350 if forkRepoDid == "" {
351 return nil, fmt.Errorf("fork not found")
352 }
353 forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid)
354 if errors.Is(err, sql.ErrNoRows) {
355 return nil, fmt.Errorf("fork not found")
356 }
357 if err != nil {
358 return nil, err
359 }
360 branches, err := s.listBranches(ctx, forkRepo)
361 if err != nil {
362 return nil, err
363 }
364 return sortBranchesByRecency(branches), nil
365}
366
367func sourceBranchChoices(branches []types.Branch) []types.Branch {
368 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool {
369 return b.IsDefault
370 })
371 return sortBranchesByRecency(withoutDefault)
372}
373
374func defaultTargetBranch(branches []types.Branch, current string) string {
375 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) {
376 return current
377 }
378 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 {
379 return branches[idx].Reference.Name
380 }
381 return ""
382}
383
384func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string {
385 var candidates []types.Branch
386 switch source {
387 case pages.SourceFork:
388 candidates = forkBranches
389 case pages.SourceBranch:
390 candidates = branchChoices
391 default:
392 return current
393 }
394 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) {
395 return current
396 }
397 if len(candidates) == 0 {
398 return ""
399 }
400 return candidates[0].Reference.Name
401}
402
403func sortBranchesByRecency(branches []types.Branch) []types.Branch {
404 out := slices.Clone(branches)
405 sort.SliceStable(out, func(i, j int) bool {
406 if out[i].Commit == nil || out[j].Commit == nil {
407 return out[i].Commit != nil
408 }
409 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When)
410 })
411 return out
412}
413
414func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) {
415 var (
416 comparison *types.RepoFormatPatchResponse
417 err error
418 )
419 switch source {
420 case pages.SourcePatch:
421 if strings.TrimSpace(patch) == "" {
422 return nil, nil, nil
423 }
424 if verr := validatePatch(&patch); verr != nil {
425 return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch")
426 }
427 comparison = parsePastedPatch(patch)
428 case pages.SourceBranch:
429 if targetBranch == "" || sourceBranch == "" {
430 return nil, nil, nil
431 }
432 comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch)
433 case pages.SourceFork:
434 if fork == "" || targetBranch == "" || sourceBranch == "" {
435 return nil, nil, nil
436 }
437 comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch)
438 default:
439 return nil, nil, nil
440 }
441 if err != nil {
442 s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source)
443 return nil, nil, err
444 }
445
446 return comparison, deriveDiff(comparison, targetBranch), nil
447}
448
449func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse {
450 if comparison == nil || targetBranch == "" {
451 return nil
452 }
453 patch := comparison.CombinedPatchRaw
454 if patch == "" {
455 patch = comparison.FormatPatchRaw
456 }
457 if patch == "" {
458 return nil
459 }
460
461 xrpcc := s.knotClient(repo.Knot)
462
463 resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{
464 Did: repo.Did,
465 Name: repo.Name,
466 Repo: repo.RepoDidPtr(),
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 parseStackBlobForms(form url.Values) map[string][]string {
515 out := make(map[string][]string)
516 for key, vals := range form {
517 parts, ok := bracketComponents(key, "stackBlobs")
518 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 {
519 continue
520 }
521 out[parts[0]] = vals
522 }
523 return out
524}
525
526func parseStackLabelForms(form url.Values) map[string]url.Values {
527 out := make(map[string]url.Values)
528 for key, vals := range form {
529 parts, ok := bracketComponents(key, "stackLabel")
530 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" {
531 continue
532 }
533 cid, atUri := parts[0], parts[1]
534 if _, ok := out[cid]; !ok {
535 out[cid] = make(url.Values)
536 }
537 out[cid][atUri] = append(out[cid][atUri], vals...)
538 }
539 return out
540}
541
542func parsePastedPatch(patch string) *types.RepoFormatPatchResponse {
543 if patch == "" {
544 return nil
545 }
546 response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch}
547 if patchutil.IsFormatPatch(patch) {
548 if patches, err := patchutil.ExtractPatches(patch); err == nil {
549 response.FormatPatch = patches
550 }
551 }
552 return response
553}
554
555func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) {
556 xrpcc := s.knotClient(repo.Knot)
557
558 xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch)
559 if err != nil {
560 return nil, err
561 }
562
563 var comparison types.RepoFormatPatchResponse
564 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
565 return nil, err
566 }
567 return &comparison, nil
568}
569
570func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) {
571 if forkRepoDid == "" {
572 return nil, fmt.Errorf("fork not found")
573 }
574 fork, err := db.GetForkByRepoDid(s.db, forkRepoDid)
575 if errors.Is(err, sql.ErrNoRows) {
576 return nil, fmt.Errorf("fork not found")
577 }
578 if err != nil {
579 return nil, err
580 }
581
582 client, err := s.oauth.ServiceClient(
583 r,
584 oauth.WithService(fork.Knot),
585 oauth.WithLxm(tangled.RepoHiddenRefNSID),
586 oauth.WithDev(s.config.Core.Dev),
587 )
588 if err != nil {
589 return nil, err
590 }
591
592 resp, err := tangled.RepoHiddenRef(
593 r.Context(),
594 client,
595 &tangled.RepoHiddenRef_Input{
596 ForkRef: sourceBranch,
597 RemoteRef: targetBranch,
598 Repo: fork.RepoAt().String(),
599 },
600 )
601 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
602 return nil, xrpcerr
603 }
604 if !resp.Success {
605 if resp.Error != nil {
606 return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error)
607 }
608 return nil, fmt.Errorf("hidden ref failed")
609 }
610
611 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
612 forkXrpcc := s.knotClient(fork.Knot)
613
614 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch)
615 if err != nil {
616 return nil, err
617 }
618
619 var comparison types.RepoFormatPatchResponse
620 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil {
621 return nil, err
622 }
623 return &comparison, nil
624}
625
626func stackPerCommitDiffs(
627 comparison *types.RepoFormatPatchResponse,
628 targetBranch, refreshUrl string,
629 stackSplits map[string]string,
630) []pages.StackedDiff {
631 if comparison == nil {
632 return nil
633 }
634 out := make([]pages.StackedDiff, len(comparison.FormatPatch))
635 for i, p := range comparison.FormatPatch {
636 nd := patchutil.AsNiceDiff(p.Raw, targetBranch)
637 out[i].Diff = &nd
638 cid := p.ChangeIdOrEmpty()
639 if cid == "" {
640 continue
641 }
642 out[i].Opts = types.DiffOpts{
643 Split: stackSplits[cid] == "split",
644 RefreshUrl: refreshUrl,
645 Target: fmt.Sprintf("#stack-diff-%s", cid),
646 Field: fmt.Sprintf("stackSplit[%s]", cid),
647 }
648 }
649 return out
650}
651
652func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff {
653 if comparison == nil {
654 return nil
655 }
656 raw := comparison.CombinedPatchRaw
657 if raw == "" {
658 raw = comparison.FormatPatchRaw
659 }
660 d := patchutil.AsNiceDiff(raw, targetBranch)
661 return &d
662}