This repository has no description
1package pulls
2
3import (
4 "cmp"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "log/slog"
11 "net/http"
12 "net/url"
13 "slices"
14 "sort"
15 "strings"
16
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/db"
19 "tangled.org/core/appview/knotcompat"
20 "tangled.org/core/appview/models"
21 "tangled.org/core/appview/pages"
22 "tangled.org/core/appview/pages/markup/sanitizer"
23 "tangled.org/core/consts"
24 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen"
25 "tangled.org/core/types"
26
27 "github.com/bluesky-social/indigo/atproto/syntax"
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 if err := s.pages.RepoNewPull(w, params); err != nil {
54 l.Error("failed to render", "err", err)
55 }
56
57 case http.MethodPost:
58 userDid := syntax.DID(user.Did)
59 var (
60 title = r.FormValue("title")
61 body = r.FormValue("body")
62 targetBranch = r.FormValue("targetBranch")
63 sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid)
64 )
65 sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw)
66 if err != nil {
67 s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw))
68 return
69 }
70 sourceBranch := r.FormValue("sourceBranch")
71 patch := r.FormValue("patch")
72
73 if title == "" {
74 s.pages.Notice(w, "pull", "Title is required")
75 return
76 }
77 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" {
78 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization")
79 return
80 }
81
82 if targetBranch == "" {
83 s.pages.Notice(w, "pull", "Target branch is required.")
84 return
85 }
86
87 // Validate we have at least one valid PR creation method
88 if sourceBranch == "" && patch == "" {
89 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
90 return
91 }
92 // Can't mix branch-based and patch-based approaches
93 if sourceBranch != "" && patch != "" {
94 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
95 return
96 }
97
98 var sourceRepo *models.Repo
99 if sourceRepoDid == syntax.DID(f.RepoDid) {
100 sourceRepo = f
101 } else {
102 var err error
103 sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String())
104 if err != nil {
105 s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid))
106 return
107 }
108 }
109
110 if sourceBranch != "" {
111 roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String())
112 if !roles.IsPushAllowed() {
113 s.pages.Notice(w, "pull", "Cannot select forbidden branch.")
114 return
115 }
116 }
117
118 if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch {
119 s.pages.Notice(w, "pull", "Source and target branch must be different.")
120 return
121 }
122
123 if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok {
124 s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?")
125 return
126 }
127
128 if sourceBranch != "" {
129 s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body)
130 return
131 } else if patch != "" {
132 s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.")
133 return
134 }
135 }
136}
137
138func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) {
139 l := s.logger.With("handler", "PullComposeDiffFragment")
140 ctx := r.Context()
141
142 var (
143 baseRepoRaw = r.URL.Query().Get("baseRepo")
144 baseBranch = r.URL.Query().Get("base") // base branch name
145 headRepoRaw = r.URL.Query().Get("headRepo")
146 headBranch = r.URL.Query().Get("head") // head branch name
147 unified = r.URL.Query().Get("view") == "unified"
148 )
149 baseRepo, err := syntax.ParseDID(baseRepoRaw)
150 if err != nil {
151 http.Error(w, "invalid base repo DID", http.StatusBadRequest)
152 return
153 }
154 headRepo, err := syntax.ParseDID(headRepoRaw)
155 if err != nil {
156 http.Error(w, "invalid head repo DID", http.StatusBadRequest)
157 return
158 }
159 l.Debug(
160 "compose diff fragment",
161 "base.repo", baseRepo,
162 "base.commit", baseBranch,
163 "head.repo", headRepo,
164 "head.commit", headBranch,
165 )
166
167 // resolve branch to commit IDs
168 base, err := s.resolveRev(ctx, baseRepo, baseBranch)
169 if err != nil {
170 l.Error("failed to resolve base branch", "branch", baseBranch, "repo", baseRepo, "err", err)
171 s.renderComposeDiffErr(w, l, "Failed to resolve base branch.")
172 return
173 }
174 head, err := s.resolveRev(ctx, headRepo, headBranch)
175 if err != nil {
176 l.Error("failed to resolve head branch", "branch", headBranch, "repo", headRepo, "err", err)
177 s.renderComposeDiffErr(w, l, "Failed to resolve head branch.")
178 return
179 }
180
181 var params pages.PullDiffFragmentParams
182 params.BaseRepo = baseRepo
183 params.HeadRepo = headRepo
184 params.DiffBase = baseBranch
185 params.DiffHead = headBranch
186 params.DiffUrl = r.URL.Path
187 params.Unified = unified
188 params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified)
189 if err := s.pages.PullComposeDiffFragment(w, params); err != nil {
190 l.Error("failed to render", "err", err)
191 }
192}
193
194func (s *Pulls) resolveRev(ctx context.Context, repo syntax.DID, rev string) (string, error) {
195 out, err := s.gitmirror.ResolveRev(ctx, &gitmirrorv1.ResolveRevRequest{
196 Repo: repo.String(),
197 Rev: []byte(rev),
198 })
199 if err != nil {
200 return "", err
201 }
202 return out.GetCommit(), nil
203}
204
205func (s *Pulls) renderComposeDiffErr(w http.ResponseWriter, l *slog.Logger, msg string) {
206 if err := s.pages.PullComposeDiffFragment(w, pages.PullDiffFragmentParams{ErrorMsg: msg}); err != nil {
207 l.Error("failed to render", "err", err)
208 }
209}
210
211func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) {
212 l := s.logger.With("handler", "RefreshCompose")
213
214 f, err := s.repoResolver.Resolve(r)
215 if err != nil {
216 l.Error("failed to resolve repo", "err", err)
217 s.pages.Error503(w)
218 return
219 }
220
221 params, err := s.composeParams(r, f)
222 if err != nil {
223 l.Error("failed to build compose params", "err", err)
224 s.pages.Error503(w)
225 return
226 }
227 w.Header().Set("HX-Replace-Url", composeCanonicalURL(params))
228 s.pages.PullComposeHostFragment(w, params)
229}
230
231func composeCanonicalURL(params pages.RepoNewPullParams) string {
232 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName())
233 q := url.Values{}
234 if params.Source != "" && params.Source != pages.SourceBranch {
235 q.Set("source", string(params.Source))
236 }
237 if params.SourceBranch != "" {
238 q.Set("sourceBranch", params.SourceBranch)
239 }
240 if params.TargetBranch != "" {
241 q.Set("targetBranch", params.TargetBranch)
242 }
243 if params.Source == pages.SourceFork && params.Fork != "" {
244 q.Set("fork", params.Fork)
245 }
246 if len(q) == 0 {
247 return base
248 }
249 return base + "?" + q.Encode()
250}
251
252func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) {
253 l := s.logger.With("handler", "composeParams")
254 user := s.oauth.GetMultiAccountUser(r)
255
256 branches, err := s.listBranches(r.Context(), repo)
257 if err != nil {
258 return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err)
259 }
260
261 var forks []models.Repo
262 if user != nil {
263 forks, err = db.GetForksByDid(s.db, user.Did)
264 if err != nil {
265 l.Warn("failed to list user forks", "err", err, "user", user.Did)
266 }
267 }
268 forks = slices.DeleteFunc(forks, func(f models.Repo) bool {
269 return f.RepoDid == ""
270 })
271
272 f, err := s.repoResolver.Resolve(r)
273 if err != nil {
274 return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err)
275 }
276
277 repoInfo := s.repoResolver.GetRepoInfo(r, user)
278 source, ok := pages.ParseSource(r.FormValue("source"))
279 if !ok {
280 source = pages.SourceBranch
281 if !repoInfo.Roles.IsPushAllowed() {
282 source = pages.SourceFork
283 }
284 }
285
286 sourceBranch := r.FormValue("sourceBranch")
287 targetBranch := r.FormValue("targetBranch")
288 fork := r.FormValue("fork")
289 patch := r.FormValue("patch")
290
291 if source == pages.SourceFork && fork == "" && len(forks) == 1 {
292 fork = forks[0].RepoDid
293 }
294
295 var prefillErr error
296
297 var forkBranches []types.Branch
298 if source == pages.SourceFork && fork != "" {
299 forkBranches, err = s.listForkBranches(r.Context(), fork)
300 if err != nil {
301 l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork)
302 prefillErr = errors.Join(prefillErr, err)
303 }
304 }
305
306 sourceBranchList := sourceBranchChoices(branches)
307 targetBranch = defaultTargetBranch(branches, targetBranch)
308 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches)
309
310 var sourceRepo syntax.DID
311 if fork != "" {
312 sourceRepo = syntax.DID(fork)
313 } else {
314 sourceRepo = syntax.DID(repoInfo.RepoDid)
315 }
316
317 if sourceRepo == "" || sourceBranch == "" || targetBranch == "" {
318 l.Error("params missing", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch)
319 return pages.RepoNewPullParams{
320 BaseParams: pages.BaseParamsFromContext(r.Context()),
321 RepoInfo: repoInfo,
322 Branches: branches,
323 SourceBranches: sourceBranchList,
324 ForkBranches: forkBranches,
325 Forks: forks,
326 Source: source,
327 SourceBranch: sourceBranch,
328 TargetBranch: targetBranch,
329 Fork: fork,
330 Patch: patch,
331 }, nil
332 }
333
334 var stepReviewParams pages.RepoNewPull_StepReviewParams
335
336 commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch)
337 if err != nil {
338 prefillErr = errors.Join(prefillErr, err)
339 }
340 stepReviewParams.Commits = commits
341
342 var prefillErrorMsg string
343 if prefillErr != nil {
344 prefillErrorMsg = prefillErr.Error()
345 }
346
347 labelDefs, err := s.pullLabelDefs(repo)
348 if err != nil {
349 l.Error("failed to load label definitions", "err", err)
350 }
351 labelState := labelStateFromForm(r.Form, labelDefs)
352
353 title := r.FormValue("title")
354 body := r.FormValue("body")
355 titleDirty := r.FormValue("titleDirty") == "1"
356 bodyDirty := r.FormValue("bodyDirty") == "1"
357 if len(commits) == 1 {
358 message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2)
359 if !titleDirty {
360 title = message[0]
361 }
362 if !bodyDirty && len(message) > 1 && message[1] != "" {
363 // TODO: strip trailers?
364 body = message[1]
365 }
366 }
367
368 l.Debug("label defs", "defs", labelDefs)
369
370 var mergeCheckParams pages.MergeCheckParams
371 if len(commits) > 0 {
372 mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String())
373 }
374
375 return pages.RepoNewPullParams{
376 BaseParams: pages.BaseParamsFromContext(r.Context()),
377 RepoInfo: repoInfo,
378 Branches: branches,
379 SourceBranches: sourceBranchList,
380 ForkBranches: forkBranches,
381 Forks: forks,
382 Source: source,
383 SourceBranch: sourceBranch,
384 TargetBranch: targetBranch,
385 Fork: fork,
386 Patch: patch,
387 Title: title,
388 Body: body,
389 TitleDirty: titleDirty,
390 BodyDirty: bodyDirty,
391 StepReviewParams: &stepReviewParams,
392 MergeCheck: mergeCheckParams,
393 PrefillError: prefillErrorMsg,
394 LabelDefs: labelDefs,
395 LabelState: labelState,
396 }, nil
397}
398
399func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) {
400 xrpcc := s.knotMirrorXRPC
401 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid)
402 if err != nil {
403 return nil, err
404 }
405 var result types.RepoBranchesResponse
406 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
407 return nil, err
408 }
409 return result.Branches, nil
410}
411
412func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) {
413 if forkRepoDid == "" {
414 return nil, fmt.Errorf("fork not found")
415 }
416 forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid)
417 if errors.Is(err, sql.ErrNoRows) {
418 return nil, fmt.Errorf("fork not found")
419 }
420 if err != nil {
421 return nil, err
422 }
423 branches, err := s.listBranches(ctx, forkRepo)
424 if err != nil {
425 return nil, err
426 }
427 return sortBranchesByRecency(branches), nil
428}
429
430func sourceBranchChoices(branches []types.Branch) []types.Branch {
431 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool {
432 return b.IsDefault
433 })
434 return sortBranchesByRecency(withoutDefault)
435}
436
437func defaultTargetBranch(branches []types.Branch, current string) string {
438 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) {
439 return current
440 }
441 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 {
442 return branches[idx].Reference.Name
443 }
444 return ""
445}
446
447func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string {
448 var candidates []types.Branch
449 switch source {
450 case pages.SourceFork:
451 candidates = forkBranches
452 case pages.SourceBranch:
453 candidates = branchChoices
454 default:
455 return current
456 }
457 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) {
458 return current
459 }
460 if len(candidates) == 0 {
461 return ""
462 }
463 return candidates[0].Reference.Name
464}
465
466func sortBranchesByRecency(branches []types.Branch) []types.Branch {
467 out := slices.Clone(branches)
468 sort.SliceStable(out, func(i, j int) bool {
469 if out[i].Commit == nil || out[j].Commit == nil {
470 return out[i].Commit != nil
471 }
472 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When)
473 })
474 return out
475}
476
477func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams {
478 l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo.RepoDid, "branch", targetBranch, "source", sourceCommit)
479
480 targetSha, err := s.resolveRev(ctx, syntax.DID(targetRepo.RepoDid), targetBranch)
481 if err != nil {
482 l.Warn("failed to resolve target branch", "err", err)
483 return pages.MergeCheckParams{Error: "merge check failed"}
484 }
485
486 out, err := s.gitmirror.MergeCheck(ctx, &gitmirrorv1.MergeCheckRequest{
487 Target: &gitmirrorv1.RepoCommit{Repo: targetRepo.RepoDid, Commit: []byte(targetSha)},
488 Source: &gitmirrorv1.RepoCommit{Repo: sourceRepoDid.String(), Commit: []byte(sourceCommit)},
489 })
490 if err != nil {
491 l.Warn("failed to do merge-check", "err", err)
492 return pages.MergeCheckParams{Error: "merge check failed"}
493 }
494 return pages.MergeCheckParams{
495 IsConflicted: out.IsConflicted,
496 Conflicts: out.Conflicts,
497 }
498}
499
500func bracketComponents(key, prefix string) ([]string, bool) {
501 if !strings.HasPrefix(key, prefix) {
502 return nil, false
503 }
504 rest := key[len(prefix):]
505 var parts []string
506 for len(rest) > 0 {
507 if !strings.HasPrefix(rest, "[") {
508 return nil, false
509 }
510 end := strings.Index(rest, "]")
511 if end <= 0 {
512 return nil, false
513 }
514 parts = append(parts, rest[1:end])
515 rest = rest[end+1:]
516 }
517 if len(parts) == 0 {
518 return nil, false
519 }
520 return parts, true
521}
522
523func parseBracketedForm(form url.Values, prefix string) map[string]string {
524 out := make(map[string]string)
525 for key, vals := range form {
526 parts, ok := bracketComponents(key, prefix)
527 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 {
528 continue
529 }
530 out[parts[0]] = vals[0]
531 }
532 return out
533}
534
535func parseStackLabelForms(form url.Values) map[string]url.Values {
536 out := make(map[string]url.Values)
537 for key, vals := range form {
538 parts, ok := bracketComponents(key, "stackLabel")
539 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" {
540 continue
541 }
542 cid, atUri := parts[0], parts[1]
543 if _, ok := out[cid]; !ok {
544 out[cid] = make(url.Values)
545 }
546 out[cid][atUri] = append(out[cid][atUri], vals...)
547 }
548 return out
549}