A vibe coded tangled fork which supports pijul.
1package state
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "sort"
12 "strconv"
13 "time"
14
15 "tangled.sh/tangled.sh/core/api/tangled"
16 "tangled.sh/tangled.sh/core/appview"
17 "tangled.sh/tangled.sh/core/appview/db"
18 "tangled.sh/tangled.sh/core/appview/knotclient"
19 "tangled.sh/tangled.sh/core/appview/oauth"
20 "tangled.sh/tangled.sh/core/appview/pages"
21 "tangled.sh/tangled.sh/core/patchutil"
22 "tangled.sh/tangled.sh/core/types"
23
24 comatproto "github.com/bluesky-social/indigo/api/atproto"
25 "github.com/bluesky-social/indigo/atproto/syntax"
26 lexutil "github.com/bluesky-social/indigo/lex/util"
27 "github.com/go-chi/chi/v5"
28 "github.com/google/uuid"
29)
30
31// htmx fragment
32func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
33 switch r.Method {
34 case http.MethodGet:
35 user := s.oauth.GetUser(r)
36 f, err := s.fullyResolvedRepo(r)
37 if err != nil {
38 log.Println("failed to get repo and knot", err)
39 return
40 }
41
42 pull, ok := r.Context().Value("pull").(*db.Pull)
43 if !ok {
44 log.Println("failed to get pull")
45 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
46 return
47 }
48
49 // can be nil if this pull is not stacked
50 stack := r.Context().Value("stack").(db.Stack)
51
52 roundNumberStr := chi.URLParam(r, "round")
53 roundNumber, err := strconv.Atoi(roundNumberStr)
54 if err != nil {
55 roundNumber = pull.LastRoundNumber()
56 }
57 if roundNumber >= len(pull.Submissions) {
58 http.Error(w, "bad round id", http.StatusBadRequest)
59 log.Println("failed to parse round id", err)
60 return
61 }
62
63 mergeCheckResponse := s.mergeCheck(f, pull, stack)
64 resubmitResult := pages.Unknown
65 if user.Did == pull.OwnerDid {
66 resubmitResult = s.resubmitCheck(f, pull)
67 }
68
69 s.pages.PullActionsFragment(w, pages.PullActionsParams{
70 LoggedInUser: user,
71 RepoInfo: f.RepoInfo(s, user),
72 Pull: pull,
73 RoundNumber: roundNumber,
74 MergeCheck: mergeCheckResponse,
75 ResubmitCheck: resubmitResult,
76 })
77 return
78 }
79}
80
81func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
82 user := s.oauth.GetUser(r)
83 f, err := s.fullyResolvedRepo(r)
84 if err != nil {
85 log.Println("failed to get repo and knot", err)
86 return
87 }
88
89 pull, ok := r.Context().Value("pull").(*db.Pull)
90 if !ok {
91 log.Println("failed to get pull")
92 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
93 return
94 }
95
96 // can be nil if this pull is not stacked
97 stack := r.Context().Value("stack").(db.Stack)
98
99 totalIdents := 1
100 for _, submission := range pull.Submissions {
101 totalIdents += len(submission.Comments)
102 }
103
104 identsToResolve := make([]string, totalIdents)
105
106 // populate idents
107 identsToResolve[0] = pull.OwnerDid
108 idx := 1
109 for _, submission := range pull.Submissions {
110 for _, comment := range submission.Comments {
111 identsToResolve[idx] = comment.OwnerDid
112 idx += 1
113 }
114 }
115
116 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
117 didHandleMap := make(map[string]string)
118 for _, identity := range resolvedIds {
119 if !identity.Handle.IsInvalidHandle() {
120 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
121 } else {
122 didHandleMap[identity.DID.String()] = identity.DID.String()
123 }
124 }
125
126 mergeCheckResponse := s.mergeCheck(f, pull, stack)
127 resubmitResult := pages.Unknown
128 if user != nil && user.Did == pull.OwnerDid {
129 resubmitResult = s.resubmitCheck(f, pull)
130 }
131
132 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
133 LoggedInUser: user,
134 RepoInfo: f.RepoInfo(s, user),
135 DidHandleMap: didHandleMap,
136 Pull: pull,
137 MergeCheck: mergeCheckResponse,
138 ResubmitCheck: resubmitResult,
139 })
140}
141
142func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
143 if pull.State == db.PullMerged {
144 return types.MergeCheckResponse{}
145 }
146
147 secret, err := db.GetRegistrationKey(s.db, f.Knot)
148 if err != nil {
149 log.Printf("failed to get registration key: %v", err)
150 return types.MergeCheckResponse{
151 Error: "failed to check merge status: this knot is unregistered",
152 }
153 }
154
155 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
156 if err != nil {
157 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
158 return types.MergeCheckResponse{
159 Error: "failed to check merge status",
160 }
161 }
162
163 patch := pull.LatestPatch()
164 if pull.IsStacked() {
165 // combine patches of substack
166 subStack := stack.Below(pull)
167
168 // collect the portion of the stack that is mergeable
169 var mergeable db.Stack
170 for _, p := range subStack {
171 // stop at the first merged PR
172 if p.State == db.PullMerged {
173 break
174 }
175
176 // skip over closed PRs
177 //
178 // we will close PRs that are "removed" from a stack
179 if p.State != db.PullClosed {
180 mergeable = append(mergeable, p)
181 }
182 }
183
184 patch = mergeable.CombinedPatch()
185 }
186
187 resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
188 if err != nil {
189 log.Println("failed to check for mergeability:", err)
190 return types.MergeCheckResponse{
191 Error: "failed to check merge status",
192 }
193 }
194 switch resp.StatusCode {
195 case 404:
196 return types.MergeCheckResponse{
197 Error: "failed to check merge status: this knot does not support PRs",
198 }
199 case 400:
200 return types.MergeCheckResponse{
201 Error: "failed to check merge status: does this knot support PRs?",
202 }
203 }
204
205 respBody, err := io.ReadAll(resp.Body)
206 if err != nil {
207 log.Println("failed to read merge check response body")
208 return types.MergeCheckResponse{
209 Error: "failed to check merge status: knot is not speaking the right language",
210 }
211 }
212 defer resp.Body.Close()
213
214 var mergeCheckResponse types.MergeCheckResponse
215 err = json.Unmarshal(respBody, &mergeCheckResponse)
216 if err != nil {
217 log.Println("failed to unmarshal merge check response", err)
218 return types.MergeCheckResponse{
219 Error: "failed to check merge status: knot is not speaking the right language",
220 }
221 }
222
223 return mergeCheckResponse
224}
225
226func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
227 if pull.State == db.PullMerged || pull.PullSource == nil {
228 return pages.Unknown
229 }
230
231 var knot, ownerDid, repoName string
232
233 if pull.PullSource.RepoAt != nil {
234 // fork-based pulls
235 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
236 if err != nil {
237 log.Println("failed to get source repo", err)
238 return pages.Unknown
239 }
240
241 knot = sourceRepo.Knot
242 ownerDid = sourceRepo.Did
243 repoName = sourceRepo.Name
244 } else {
245 // pulls within the same repo
246 knot = f.Knot
247 ownerDid = f.OwnerDid()
248 repoName = f.RepoName
249 }
250
251 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
252 if err != nil {
253 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
254 return pages.Unknown
255 }
256
257 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
258 if err != nil {
259 log.Println("failed to reach knotserver", err)
260 return pages.Unknown
261 }
262
263 body, err := io.ReadAll(resp.Body)
264 if err != nil {
265 log.Printf("error reading response body: %v", err)
266 return pages.Unknown
267 }
268 defer resp.Body.Close()
269
270 var result types.RepoBranchResponse
271 if err := json.Unmarshal(body, &result); err != nil {
272 log.Println("failed to parse response:", err)
273 return pages.Unknown
274 }
275
276 latestSubmission := pull.Submissions[pull.LastRoundNumber()]
277 if latestSubmission.SourceRev != result.Branch.Hash {
278 fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
279 return pages.ShouldResubmit
280 }
281
282 return pages.ShouldNotResubmit
283}
284
285func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
286 user := s.oauth.GetUser(r)
287 f, err := s.fullyResolvedRepo(r)
288 if err != nil {
289 log.Println("failed to get repo and knot", err)
290 return
291 }
292
293 pull, ok := r.Context().Value("pull").(*db.Pull)
294 if !ok {
295 log.Println("failed to get pull")
296 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
297 return
298 }
299
300 roundId := chi.URLParam(r, "round")
301 roundIdInt, err := strconv.Atoi(roundId)
302 if err != nil || roundIdInt >= len(pull.Submissions) {
303 http.Error(w, "bad round id", http.StatusBadRequest)
304 log.Println("failed to parse round id", err)
305 return
306 }
307
308 identsToResolve := []string{pull.OwnerDid}
309 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
310 didHandleMap := make(map[string]string)
311 for _, identity := range resolvedIds {
312 if !identity.Handle.IsInvalidHandle() {
313 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
314 } else {
315 didHandleMap[identity.DID.String()] = identity.DID.String()
316 }
317 }
318
319 diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
320
321 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
322 LoggedInUser: user,
323 DidHandleMap: didHandleMap,
324 RepoInfo: f.RepoInfo(s, user),
325 Pull: pull,
326 Round: roundIdInt,
327 Submission: pull.Submissions[roundIdInt],
328 Diff: &diff,
329 })
330
331}
332
333func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
334 user := s.oauth.GetUser(r)
335
336 f, err := s.fullyResolvedRepo(r)
337 if err != nil {
338 log.Println("failed to get repo and knot", err)
339 return
340 }
341
342 pull, ok := r.Context().Value("pull").(*db.Pull)
343 if !ok {
344 log.Println("failed to get pull")
345 s.pages.Notice(w, "pull-error", "Failed to get pull.")
346 return
347 }
348
349 roundId := chi.URLParam(r, "round")
350 roundIdInt, err := strconv.Atoi(roundId)
351 if err != nil || roundIdInt >= len(pull.Submissions) {
352 http.Error(w, "bad round id", http.StatusBadRequest)
353 log.Println("failed to parse round id", err)
354 return
355 }
356
357 if roundIdInt == 0 {
358 http.Error(w, "bad round id", http.StatusBadRequest)
359 log.Println("cannot interdiff initial submission")
360 return
361 }
362
363 identsToResolve := []string{pull.OwnerDid}
364 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
365 didHandleMap := make(map[string]string)
366 for _, identity := range resolvedIds {
367 if !identity.Handle.IsInvalidHandle() {
368 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
369 } else {
370 didHandleMap[identity.DID.String()] = identity.DID.String()
371 }
372 }
373
374 currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
375 if err != nil {
376 log.Println("failed to interdiff; current patch malformed")
377 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
378 return
379 }
380
381 previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
382 if err != nil {
383 log.Println("failed to interdiff; previous patch malformed")
384 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
385 return
386 }
387
388 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
389
390 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
391 LoggedInUser: s.oauth.GetUser(r),
392 RepoInfo: f.RepoInfo(s, user),
393 Pull: pull,
394 Round: roundIdInt,
395 DidHandleMap: didHandleMap,
396 Interdiff: interdiff,
397 })
398 return
399}
400
401func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
402 pull, ok := r.Context().Value("pull").(*db.Pull)
403 if !ok {
404 log.Println("failed to get pull")
405 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
406 return
407 }
408
409 roundId := chi.URLParam(r, "round")
410 roundIdInt, err := strconv.Atoi(roundId)
411 if err != nil || roundIdInt >= len(pull.Submissions) {
412 http.Error(w, "bad round id", http.StatusBadRequest)
413 log.Println("failed to parse round id", err)
414 return
415 }
416
417 identsToResolve := []string{pull.OwnerDid}
418 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
419 didHandleMap := make(map[string]string)
420 for _, identity := range resolvedIds {
421 if !identity.Handle.IsInvalidHandle() {
422 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
423 } else {
424 didHandleMap[identity.DID.String()] = identity.DID.String()
425 }
426 }
427
428 w.Header().Set("Content-Type", "text/plain")
429 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
430}
431
432func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
433 user := s.oauth.GetUser(r)
434 params := r.URL.Query()
435
436 state := db.PullOpen
437 switch params.Get("state") {
438 case "closed":
439 state = db.PullClosed
440 case "merged":
441 state = db.PullMerged
442 }
443
444 f, err := s.fullyResolvedRepo(r)
445 if err != nil {
446 log.Println("failed to get repo and knot", err)
447 return
448 }
449
450 pulls, err := db.GetPulls(
451 s.db,
452 db.Filter("repo_at", f.RepoAt),
453 db.Filter("state", state),
454 )
455 if err != nil {
456 log.Println("failed to get pulls", err)
457 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
458 return
459 }
460
461 for _, p := range pulls {
462 var pullSourceRepo *db.Repo
463 if p.PullSource != nil {
464 if p.PullSource.RepoAt != nil {
465 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
466 if err != nil {
467 log.Printf("failed to get repo by at uri: %v", err)
468 continue
469 } else {
470 p.PullSource.Repo = pullSourceRepo
471 }
472 }
473 }
474 }
475
476 identsToResolve := make([]string, len(pulls))
477 for i, pull := range pulls {
478 identsToResolve[i] = pull.OwnerDid
479 }
480 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
481 didHandleMap := make(map[string]string)
482 for _, identity := range resolvedIds {
483 if !identity.Handle.IsInvalidHandle() {
484 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
485 } else {
486 didHandleMap[identity.DID.String()] = identity.DID.String()
487 }
488 }
489
490 s.pages.RepoPulls(w, pages.RepoPullsParams{
491 LoggedInUser: s.oauth.GetUser(r),
492 RepoInfo: f.RepoInfo(s, user),
493 Pulls: pulls,
494 DidHandleMap: didHandleMap,
495 FilteringBy: state,
496 })
497 return
498}
499
500func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
501 user := s.oauth.GetUser(r)
502 f, err := s.fullyResolvedRepo(r)
503 if err != nil {
504 log.Println("failed to get repo and knot", err)
505 return
506 }
507
508 pull, ok := r.Context().Value("pull").(*db.Pull)
509 if !ok {
510 log.Println("failed to get pull")
511 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
512 return
513 }
514
515 roundNumberStr := chi.URLParam(r, "round")
516 roundNumber, err := strconv.Atoi(roundNumberStr)
517 if err != nil || roundNumber >= len(pull.Submissions) {
518 http.Error(w, "bad round id", http.StatusBadRequest)
519 log.Println("failed to parse round id", err)
520 return
521 }
522
523 switch r.Method {
524 case http.MethodGet:
525 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
526 LoggedInUser: user,
527 RepoInfo: f.RepoInfo(s, user),
528 Pull: pull,
529 RoundNumber: roundNumber,
530 })
531 return
532 case http.MethodPost:
533 body := r.FormValue("body")
534 if body == "" {
535 s.pages.Notice(w, "pull", "Comment body is required")
536 return
537 }
538
539 // Start a transaction
540 tx, err := s.db.BeginTx(r.Context(), nil)
541 if err != nil {
542 log.Println("failed to start transaction", err)
543 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
544 return
545 }
546 defer tx.Rollback()
547
548 createdAt := time.Now().Format(time.RFC3339)
549 ownerDid := user.Did
550
551 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
552 if err != nil {
553 log.Println("failed to get pull at", err)
554 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
555 return
556 }
557
558 atUri := f.RepoAt.String()
559 client, err := s.oauth.AuthorizedClient(r)
560 if err != nil {
561 log.Println("failed to get authorized client", err)
562 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
563 return
564 }
565 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
566 Collection: tangled.RepoPullCommentNSID,
567 Repo: user.Did,
568 Rkey: appview.TID(),
569 Record: &lexutil.LexiconTypeDecoder{
570 Val: &tangled.RepoPullComment{
571 Repo: &atUri,
572 Pull: string(pullAt),
573 Owner: &ownerDid,
574 Body: body,
575 CreatedAt: createdAt,
576 },
577 },
578 })
579 if err != nil {
580 log.Println("failed to create pull comment", err)
581 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
582 return
583 }
584
585 // Create the pull comment in the database with the commentAt field
586 commentId, err := db.NewPullComment(tx, &db.PullComment{
587 OwnerDid: user.Did,
588 RepoAt: f.RepoAt.String(),
589 PullId: pull.PullId,
590 Body: body,
591 CommentAt: atResp.Uri,
592 SubmissionId: pull.Submissions[roundNumber].ID,
593 })
594 if err != nil {
595 log.Println("failed to create pull comment", err)
596 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
597 return
598 }
599
600 // Commit the transaction
601 if err = tx.Commit(); err != nil {
602 log.Println("failed to commit transaction", err)
603 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
604 return
605 }
606
607 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
608 return
609 }
610}
611
612func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
613 user := s.oauth.GetUser(r)
614 f, err := s.fullyResolvedRepo(r)
615 if err != nil {
616 log.Println("failed to get repo and knot", err)
617 return
618 }
619
620 switch r.Method {
621 case http.MethodGet:
622 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
623 if err != nil {
624 log.Printf("failed to create unsigned client for %s", f.Knot)
625 s.pages.Error503(w)
626 return
627 }
628
629 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
630 if err != nil {
631 log.Println("failed to reach knotserver", err)
632 return
633 }
634
635 body, err := io.ReadAll(resp.Body)
636 if err != nil {
637 log.Printf("Error reading response body: %v", err)
638 return
639 }
640
641 var result types.RepoBranchesResponse
642 err = json.Unmarshal(body, &result)
643 if err != nil {
644 log.Println("failed to parse response:", err)
645 return
646 }
647
648 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
649 LoggedInUser: user,
650 RepoInfo: f.RepoInfo(s, user),
651 Branches: result.Branches,
652 })
653 case http.MethodPost:
654 title := r.FormValue("title")
655 body := r.FormValue("body")
656 targetBranch := r.FormValue("targetBranch")
657 fromFork := r.FormValue("fork")
658 sourceBranch := r.FormValue("sourceBranch")
659 patch := r.FormValue("patch")
660
661 if targetBranch == "" {
662 s.pages.Notice(w, "pull", "Target branch is required.")
663 return
664 }
665
666 // Determine PR type based on input parameters
667 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
668 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
669 isForkBased := fromFork != "" && sourceBranch != ""
670 isPatchBased := patch != "" && !isBranchBased && !isForkBased
671 isStacked := r.FormValue("isStacked") == "on"
672
673 if isPatchBased && !patchutil.IsFormatPatch(patch) {
674 if title == "" {
675 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
676 return
677 }
678 }
679
680 // Validate we have at least one valid PR creation method
681 if !isBranchBased && !isPatchBased && !isForkBased {
682 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
683 return
684 }
685
686 // Can't mix branch-based and patch-based approaches
687 if isBranchBased && patch != "" {
688 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
689 return
690 }
691
692 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
693 if err != nil {
694 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
695 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
696 return
697 }
698
699 caps, err := us.Capabilities()
700 if err != nil {
701 log.Println("error fetching knot caps", f.Knot, err)
702 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
703 return
704 }
705
706 if !caps.PullRequests.FormatPatch {
707 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
708 return
709 }
710
711 // Handle the PR creation based on the type
712 if isBranchBased {
713 if !caps.PullRequests.BranchSubmissions {
714 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
715 return
716 }
717 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
718 } else if isForkBased {
719 if !caps.PullRequests.ForkSubmissions {
720 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
721 return
722 }
723 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
724 } else if isPatchBased {
725 if !caps.PullRequests.PatchSubmissions {
726 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
727 return
728 }
729 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
730 }
731 return
732 }
733}
734
735func (s *State) handleBranchBasedPull(
736 w http.ResponseWriter,
737 r *http.Request,
738 f *FullyResolvedRepo,
739 user *oauth.User,
740 title,
741 body,
742 targetBranch,
743 sourceBranch string,
744 isStacked bool,
745) {
746 pullSource := &db.PullSource{
747 Branch: sourceBranch,
748 }
749 recordPullSource := &tangled.RepoPull_Source{
750 Branch: sourceBranch,
751 }
752
753 // Generate a patch using /compare
754 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
755 if err != nil {
756 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
757 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
758 return
759 }
760
761 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
762 if err != nil {
763 log.Println("failed to compare", err)
764 s.pages.Notice(w, "pull", err.Error())
765 return
766 }
767
768 sourceRev := comparison.Rev2
769 patch := comparison.Patch
770
771 if !patchutil.IsPatchValid(patch) {
772 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
773 return
774 }
775
776 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
777}
778
779func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
780 if !patchutil.IsPatchValid(patch) {
781 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
782 return
783 }
784
785 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
786}
787
788func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
789 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
790 if errors.Is(err, sql.ErrNoRows) {
791 s.pages.Notice(w, "pull", "No such fork.")
792 return
793 } else if err != nil {
794 log.Println("failed to fetch fork:", err)
795 s.pages.Notice(w, "pull", "Failed to fetch fork.")
796 return
797 }
798
799 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
800 if err != nil {
801 log.Println("failed to fetch registration key:", err)
802 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
803 return
804 }
805
806 sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
807 if err != nil {
808 log.Println("failed to create signed client:", err)
809 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
810 return
811 }
812
813 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
814 if err != nil {
815 log.Println("failed to create unsigned client:", err)
816 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
817 return
818 }
819
820 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
821 if err != nil {
822 log.Println("failed to create hidden ref:", err, resp.StatusCode)
823 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
824 return
825 }
826
827 switch resp.StatusCode {
828 case 404:
829 case 400:
830 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
831 return
832 }
833
834 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
835 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
836 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
837 // hiddenRef: hidden/feature-1/main (on repo-fork)
838 // targetBranch: main (on repo-1)
839 // sourceBranch: feature-1 (on repo-fork)
840 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
841 if err != nil {
842 log.Println("failed to compare across branches", err)
843 s.pages.Notice(w, "pull", err.Error())
844 return
845 }
846
847 sourceRev := comparison.Rev2
848 patch := comparison.Patch
849
850 if !patchutil.IsPatchValid(patch) {
851 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
852 return
853 }
854
855 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
856 if err != nil {
857 log.Println("failed to parse fork AT URI", err)
858 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
859 return
860 }
861
862 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
863 Branch: sourceBranch,
864 RepoAt: &forkAtUri,
865 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
866}
867
868func (s *State) createPullRequest(
869 w http.ResponseWriter,
870 r *http.Request,
871 f *FullyResolvedRepo,
872 user *oauth.User,
873 title, body, targetBranch string,
874 patch string,
875 sourceRev string,
876 pullSource *db.PullSource,
877 recordPullSource *tangled.RepoPull_Source,
878 isStacked bool,
879) {
880 if isStacked {
881 // creates a series of PRs, each linking to the previous, identified by jj's change-id
882 s.createStackedPulLRequest(
883 w,
884 r,
885 f,
886 user,
887 title, body, targetBranch,
888 patch,
889 sourceRev,
890 pullSource,
891 recordPullSource,
892 )
893 return
894 }
895
896 tx, err := s.db.BeginTx(r.Context(), nil)
897 if err != nil {
898 log.Println("failed to start tx")
899 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
900 return
901 }
902 defer tx.Rollback()
903
904 // We've already checked earlier if it's diff-based and title is empty,
905 // so if it's still empty now, it's intentionally skipped owing to format-patch.
906 if title == "" {
907 formatPatches, err := patchutil.ExtractPatches(patch)
908 if err != nil {
909 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
910 return
911 }
912 if len(formatPatches) == 0 {
913 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
914 return
915 }
916
917 title = formatPatches[0].Title
918 body = formatPatches[0].Body
919 }
920
921 rkey := appview.TID()
922 initialSubmission := db.PullSubmission{
923 Patch: patch,
924 SourceRev: sourceRev,
925 }
926 err = db.NewPull(tx, &db.Pull{
927 Title: title,
928 Body: body,
929 TargetBranch: targetBranch,
930 OwnerDid: user.Did,
931 RepoAt: f.RepoAt,
932 Rkey: rkey,
933 Submissions: []*db.PullSubmission{
934 &initialSubmission,
935 },
936 PullSource: pullSource,
937 })
938 if err != nil {
939 log.Println("failed to create pull request", err)
940 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
941 return
942 }
943 client, err := s.oauth.AuthorizedClient(r)
944 if err != nil {
945 log.Println("failed to get authorized client", err)
946 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
947 return
948 }
949 pullId, err := db.NextPullId(tx, f.RepoAt)
950 if err != nil {
951 log.Println("failed to get pull id", err)
952 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
953 return
954 }
955
956 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
957 Collection: tangled.RepoPullNSID,
958 Repo: user.Did,
959 Rkey: rkey,
960 Record: &lexutil.LexiconTypeDecoder{
961 Val: &tangled.RepoPull{
962 Title: title,
963 PullId: int64(pullId),
964 TargetRepo: string(f.RepoAt),
965 TargetBranch: targetBranch,
966 Patch: patch,
967 Source: recordPullSource,
968 },
969 },
970 })
971 if err != nil {
972 log.Println("failed to create pull request", err)
973 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
974 return
975 }
976
977 if err = tx.Commit(); err != nil {
978 log.Println("failed to create pull request", err)
979 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
980 return
981 }
982
983 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
984}
985
986func (s *State) createStackedPulLRequest(
987 w http.ResponseWriter,
988 r *http.Request,
989 f *FullyResolvedRepo,
990 user *oauth.User,
991 title, body, targetBranch string,
992 patch string,
993 sourceRev string,
994 pullSource *db.PullSource,
995 recordPullSource *tangled.RepoPull_Source,
996) {
997 // run some necessary checks for stacked-prs first
998
999 // must be branch or fork based
1000 if sourceRev == "" {
1001 log.Println("stacked PR from patch-based pull")
1002 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1003 return
1004 }
1005
1006 formatPatches, err := patchutil.ExtractPatches(patch)
1007 if err != nil {
1008 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1009 return
1010 }
1011
1012 // must have atleast 1 patch to begin with
1013 if len(formatPatches) == 0 {
1014 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1015 return
1016 }
1017
1018 tx, err := s.db.BeginTx(r.Context(), nil)
1019 if err != nil {
1020 log.Println("failed to start tx")
1021 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1022 return
1023 }
1024 defer tx.Rollback()
1025
1026 // create a series of pull requests, and write records from them at once
1027 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1028
1029 // the stack is identified by a UUID
1030 stackId := uuid.New()
1031 parentChangeId := ""
1032 for _, fp := range formatPatches {
1033 // all patches must have a jj change-id
1034 changeId, err := fp.ChangeId()
1035 if err != nil {
1036 s.pages.Notice(w, "pull", "Stacking is only supported if all patches contain a change-id commit header.")
1037 return
1038 }
1039
1040 title = fp.Title
1041 body = fp.Body
1042 rkey := appview.TID()
1043
1044 // TODO: can we just use a format-patch string here?
1045 initialSubmission := db.PullSubmission{
1046 Patch: fp.Raw,
1047 SourceRev: sourceRev,
1048 }
1049 err = db.NewPull(tx, &db.Pull{
1050 Title: title,
1051 Body: body,
1052 TargetBranch: targetBranch,
1053 OwnerDid: user.Did,
1054 RepoAt: f.RepoAt,
1055 Rkey: rkey,
1056 Submissions: []*db.PullSubmission{
1057 &initialSubmission,
1058 },
1059 PullSource: pullSource,
1060
1061 StackId: stackId.String(),
1062 ChangeId: changeId,
1063 ParentChangeId: parentChangeId,
1064 })
1065 if err != nil {
1066 log.Println("failed to create pull request", err)
1067 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1068 return
1069 }
1070
1071 record := tangled.RepoPull{
1072 Title: title,
1073 TargetRepo: string(f.RepoAt),
1074 TargetBranch: targetBranch,
1075 Patch: fp.Raw,
1076 Source: recordPullSource,
1077 }
1078 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1079 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1080 Collection: tangled.RepoPullNSID,
1081 Rkey: &rkey,
1082 Value: &lexutil.LexiconTypeDecoder{
1083 Val: &record,
1084 },
1085 },
1086 })
1087
1088 parentChangeId = changeId
1089 }
1090
1091 client, err := s.oauth.AuthorizedClient(r)
1092 if err != nil {
1093 log.Println("failed to get authorized client", err)
1094 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1095 return
1096 }
1097
1098 // apply all record creations at once
1099 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1100 Repo: user.Did,
1101 Writes: writes,
1102 })
1103 if err != nil {
1104 log.Println("failed to create stacked pull request", err)
1105 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1106 return
1107 }
1108
1109 // create all pulls at once
1110 if err = tx.Commit(); err != nil {
1111 log.Println("failed to create pull request", err)
1112 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1113 return
1114 }
1115
1116 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1117}
1118
1119func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1120 _, err := s.fullyResolvedRepo(r)
1121 if err != nil {
1122 log.Println("failed to get repo and knot", err)
1123 return
1124 }
1125
1126 patch := r.FormValue("patch")
1127 if patch == "" {
1128 s.pages.Notice(w, "patch-error", "Patch is required.")
1129 return
1130 }
1131
1132 if patch == "" || !patchutil.IsPatchValid(patch) {
1133 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1134 return
1135 }
1136
1137 if patchutil.IsFormatPatch(patch) {
1138 s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
1139 } else {
1140 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1141 }
1142}
1143
1144func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1145 user := s.oauth.GetUser(r)
1146 f, err := s.fullyResolvedRepo(r)
1147 if err != nil {
1148 log.Println("failed to get repo and knot", err)
1149 return
1150 }
1151
1152 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1153 RepoInfo: f.RepoInfo(s, user),
1154 })
1155}
1156
1157func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1158 user := s.oauth.GetUser(r)
1159 f, err := s.fullyResolvedRepo(r)
1160 if err != nil {
1161 log.Println("failed to get repo and knot", err)
1162 return
1163 }
1164
1165 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1166 if err != nil {
1167 log.Printf("failed to create unsigned client for %s", f.Knot)
1168 s.pages.Error503(w)
1169 return
1170 }
1171
1172 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
1173 if err != nil {
1174 log.Println("failed to reach knotserver", err)
1175 return
1176 }
1177
1178 body, err := io.ReadAll(resp.Body)
1179 if err != nil {
1180 log.Printf("Error reading response body: %v", err)
1181 return
1182 }
1183
1184 var result types.RepoBranchesResponse
1185 err = json.Unmarshal(body, &result)
1186 if err != nil {
1187 log.Println("failed to parse response:", err)
1188 return
1189 }
1190
1191 branches := result.Branches
1192 sort.Slice(branches, func(i int, j int) bool {
1193 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1194 })
1195
1196 withoutDefault := []types.Branch{}
1197 for _, b := range branches {
1198 if b.IsDefault {
1199 continue
1200 }
1201 withoutDefault = append(withoutDefault, b)
1202 }
1203
1204 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1205 RepoInfo: f.RepoInfo(s, user),
1206 Branches: withoutDefault,
1207 })
1208}
1209
1210func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1211 user := s.oauth.GetUser(r)
1212 f, err := s.fullyResolvedRepo(r)
1213 if err != nil {
1214 log.Println("failed to get repo and knot", err)
1215 return
1216 }
1217
1218 forks, err := db.GetForksByDid(s.db, user.Did)
1219 if err != nil {
1220 log.Println("failed to get forks", err)
1221 return
1222 }
1223
1224 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1225 RepoInfo: f.RepoInfo(s, user),
1226 Forks: forks,
1227 })
1228}
1229
1230func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1231 user := s.oauth.GetUser(r)
1232
1233 f, err := s.fullyResolvedRepo(r)
1234 if err != nil {
1235 log.Println("failed to get repo and knot", err)
1236 return
1237 }
1238
1239 forkVal := r.URL.Query().Get("fork")
1240
1241 // fork repo
1242 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1243 if err != nil {
1244 log.Println("failed to get repo", user.Did, forkVal)
1245 return
1246 }
1247
1248 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1249 if err != nil {
1250 log.Printf("failed to create unsigned client for %s", repo.Knot)
1251 s.pages.Error503(w)
1252 return
1253 }
1254
1255 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1256 if err != nil {
1257 log.Println("failed to reach knotserver for source branches", err)
1258 return
1259 }
1260
1261 sourceBody, err := io.ReadAll(sourceResp.Body)
1262 if err != nil {
1263 log.Println("failed to read source response body", err)
1264 return
1265 }
1266 defer sourceResp.Body.Close()
1267
1268 var sourceResult types.RepoBranchesResponse
1269 err = json.Unmarshal(sourceBody, &sourceResult)
1270 if err != nil {
1271 log.Println("failed to parse source branches response:", err)
1272 return
1273 }
1274
1275 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1276 if err != nil {
1277 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1278 s.pages.Error503(w)
1279 return
1280 }
1281
1282 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1283 if err != nil {
1284 log.Println("failed to reach knotserver for target branches", err)
1285 return
1286 }
1287
1288 targetBody, err := io.ReadAll(targetResp.Body)
1289 if err != nil {
1290 log.Println("failed to read target response body", err)
1291 return
1292 }
1293 defer targetResp.Body.Close()
1294
1295 var targetResult types.RepoBranchesResponse
1296 err = json.Unmarshal(targetBody, &targetResult)
1297 if err != nil {
1298 log.Println("failed to parse target branches response:", err)
1299 return
1300 }
1301
1302 sourceBranches := sourceResult.Branches
1303 sort.Slice(sourceBranches, func(i int, j int) bool {
1304 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1305 })
1306
1307 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1308 RepoInfo: f.RepoInfo(s, user),
1309 SourceBranches: sourceResult.Branches,
1310 TargetBranches: targetResult.Branches,
1311 })
1312}
1313
1314func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1315 user := s.oauth.GetUser(r)
1316 f, err := s.fullyResolvedRepo(r)
1317 if err != nil {
1318 log.Println("failed to get repo and knot", err)
1319 return
1320 }
1321
1322 pull, ok := r.Context().Value("pull").(*db.Pull)
1323 if !ok {
1324 log.Println("failed to get pull")
1325 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1326 return
1327 }
1328
1329 switch r.Method {
1330 case http.MethodGet:
1331 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1332 RepoInfo: f.RepoInfo(s, user),
1333 Pull: pull,
1334 })
1335 return
1336 case http.MethodPost:
1337 if pull.IsPatchBased() {
1338 s.resubmitPatch(w, r)
1339 return
1340 } else if pull.IsBranchBased() {
1341 s.resubmitBranch(w, r)
1342 return
1343 } else if pull.IsForkBased() {
1344 s.resubmitFork(w, r)
1345 return
1346 }
1347 }
1348}
1349
1350func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1351 user := s.oauth.GetUser(r)
1352
1353 pull, ok := r.Context().Value("pull").(*db.Pull)
1354 if !ok {
1355 log.Println("failed to get pull")
1356 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1357 return
1358 }
1359
1360 f, err := s.fullyResolvedRepo(r)
1361 if err != nil {
1362 log.Println("failed to get repo and knot", err)
1363 return
1364 }
1365
1366 if user.Did != pull.OwnerDid {
1367 log.Println("unauthorized user")
1368 w.WriteHeader(http.StatusUnauthorized)
1369 return
1370 }
1371
1372 patch := r.FormValue("patch")
1373
1374 if err = validateResubmittedPatch(pull, patch); err != nil {
1375 s.pages.Notice(w, "resubmit-error", err.Error())
1376 return
1377 }
1378
1379 tx, err := s.db.BeginTx(r.Context(), nil)
1380 if err != nil {
1381 log.Println("failed to start tx")
1382 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1383 return
1384 }
1385 defer tx.Rollback()
1386
1387 err = db.ResubmitPull(tx, pull, patch, "")
1388 if err != nil {
1389 log.Println("failed to resubmit pull request", err)
1390 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1391 return
1392 }
1393 client, err := s.oauth.AuthorizedClient(r)
1394 if err != nil {
1395 log.Println("failed to get authorized client", err)
1396 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1397 return
1398 }
1399
1400 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1401 if err != nil {
1402 // failed to get record
1403 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1404 return
1405 }
1406
1407 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1408 Collection: tangled.RepoPullNSID,
1409 Repo: user.Did,
1410 Rkey: pull.Rkey,
1411 SwapRecord: ex.Cid,
1412 Record: &lexutil.LexiconTypeDecoder{
1413 Val: &tangled.RepoPull{
1414 Title: pull.Title,
1415 PullId: int64(pull.PullId),
1416 TargetRepo: string(f.RepoAt),
1417 TargetBranch: pull.TargetBranch,
1418 Patch: patch, // new patch
1419 },
1420 },
1421 })
1422 if err != nil {
1423 log.Println("failed to update record", err)
1424 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1425 return
1426 }
1427
1428 if err = tx.Commit(); err != nil {
1429 log.Println("failed to commit transaction", err)
1430 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1431 return
1432 }
1433
1434 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1435 return
1436}
1437
1438func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1439 user := s.oauth.GetUser(r)
1440
1441 pull, ok := r.Context().Value("pull").(*db.Pull)
1442 if !ok {
1443 log.Println("failed to get pull")
1444 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1445 return
1446 }
1447
1448 f, err := s.fullyResolvedRepo(r)
1449 if err != nil {
1450 log.Println("failed to get repo and knot", err)
1451 return
1452 }
1453
1454 if user.Did != pull.OwnerDid {
1455 log.Println("unauthorized user")
1456 w.WriteHeader(http.StatusUnauthorized)
1457 return
1458 }
1459
1460 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1461 log.Println("unauthorized user")
1462 w.WriteHeader(http.StatusUnauthorized)
1463 return
1464 }
1465
1466 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1467 if err != nil {
1468 log.Printf("failed to create client for %s: %s", f.Knot, err)
1469 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1470 return
1471 }
1472
1473 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1474 if err != nil {
1475 log.Printf("compare request failed: %s", err)
1476 s.pages.Notice(w, "resubmit-error", err.Error())
1477 return
1478 }
1479
1480 sourceRev := comparison.Rev2
1481 patch := comparison.Patch
1482
1483 if err = validateResubmittedPatch(pull, patch); err != nil {
1484 s.pages.Notice(w, "resubmit-error", err.Error())
1485 return
1486 }
1487
1488 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1489 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1490 return
1491 }
1492
1493 tx, err := s.db.BeginTx(r.Context(), nil)
1494 if err != nil {
1495 log.Println("failed to start tx")
1496 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1497 return
1498 }
1499 defer tx.Rollback()
1500
1501 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1502 if err != nil {
1503 log.Println("failed to create pull request", err)
1504 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1505 return
1506 }
1507 client, err := s.oauth.AuthorizedClient(r)
1508 if err != nil {
1509 log.Println("failed to authorize client")
1510 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1511 return
1512 }
1513
1514 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1515 if err != nil {
1516 // failed to get record
1517 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1518 return
1519 }
1520
1521 recordPullSource := &tangled.RepoPull_Source{
1522 Branch: pull.PullSource.Branch,
1523 }
1524 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1525 Collection: tangled.RepoPullNSID,
1526 Repo: user.Did,
1527 Rkey: pull.Rkey,
1528 SwapRecord: ex.Cid,
1529 Record: &lexutil.LexiconTypeDecoder{
1530 Val: &tangled.RepoPull{
1531 Title: pull.Title,
1532 PullId: int64(pull.PullId),
1533 TargetRepo: string(f.RepoAt),
1534 TargetBranch: pull.TargetBranch,
1535 Patch: patch, // new patch
1536 Source: recordPullSource,
1537 },
1538 },
1539 })
1540 if err != nil {
1541 log.Println("failed to update record", err)
1542 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1543 return
1544 }
1545
1546 if err = tx.Commit(); err != nil {
1547 log.Println("failed to commit transaction", err)
1548 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1549 return
1550 }
1551
1552 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1553 return
1554}
1555
1556func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1557 user := s.oauth.GetUser(r)
1558
1559 pull, ok := r.Context().Value("pull").(*db.Pull)
1560 if !ok {
1561 log.Println("failed to get pull")
1562 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1563 return
1564 }
1565
1566 f, err := s.fullyResolvedRepo(r)
1567 if err != nil {
1568 log.Println("failed to get repo and knot", err)
1569 return
1570 }
1571
1572 if user.Did != pull.OwnerDid {
1573 log.Println("unauthorized user")
1574 w.WriteHeader(http.StatusUnauthorized)
1575 return
1576 }
1577
1578 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1579 if err != nil {
1580 log.Println("failed to get source repo", err)
1581 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1582 return
1583 }
1584
1585 // extract patch by performing compare
1586 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1587 if err != nil {
1588 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1589 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1590 return
1591 }
1592
1593 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1594 if err != nil {
1595 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1596 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1597 return
1598 }
1599
1600 // update the hidden tracking branch to latest
1601 signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1602 if err != nil {
1603 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1604 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1605 return
1606 }
1607
1608 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1609 if err != nil || resp.StatusCode != http.StatusNoContent {
1610 log.Printf("failed to update tracking branch: %s", err)
1611 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1612 return
1613 }
1614
1615 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1616 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1617 if err != nil {
1618 log.Printf("failed to compare branches: %s", err)
1619 s.pages.Notice(w, "resubmit-error", err.Error())
1620 return
1621 }
1622
1623 sourceRev := comparison.Rev2
1624 patch := comparison.Patch
1625
1626 if err = validateResubmittedPatch(pull, patch); err != nil {
1627 s.pages.Notice(w, "resubmit-error", err.Error())
1628 return
1629 }
1630
1631 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1632 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1633 return
1634 }
1635
1636 tx, err := s.db.BeginTx(r.Context(), nil)
1637 if err != nil {
1638 log.Println("failed to start tx")
1639 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1640 return
1641 }
1642 defer tx.Rollback()
1643
1644 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1645 if err != nil {
1646 log.Println("failed to create pull request", err)
1647 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1648 return
1649 }
1650 client, err := s.oauth.AuthorizedClient(r)
1651 if err != nil {
1652 log.Println("failed to get client")
1653 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1654 return
1655 }
1656
1657 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1658 if err != nil {
1659 // failed to get record
1660 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1661 return
1662 }
1663
1664 repoAt := pull.PullSource.RepoAt.String()
1665 recordPullSource := &tangled.RepoPull_Source{
1666 Branch: pull.PullSource.Branch,
1667 Repo: &repoAt,
1668 }
1669 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1670 Collection: tangled.RepoPullNSID,
1671 Repo: user.Did,
1672 Rkey: pull.Rkey,
1673 SwapRecord: ex.Cid,
1674 Record: &lexutil.LexiconTypeDecoder{
1675 Val: &tangled.RepoPull{
1676 Title: pull.Title,
1677 PullId: int64(pull.PullId),
1678 TargetRepo: string(f.RepoAt),
1679 TargetBranch: pull.TargetBranch,
1680 Patch: patch, // new patch
1681 Source: recordPullSource,
1682 },
1683 },
1684 })
1685 if err != nil {
1686 log.Println("failed to update record", err)
1687 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1688 return
1689 }
1690
1691 if err = tx.Commit(); err != nil {
1692 log.Println("failed to commit transaction", err)
1693 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1694 return
1695 }
1696
1697 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1698 return
1699}
1700
1701// validate a resubmission against a pull request
1702func validateResubmittedPatch(pull *db.Pull, patch string) error {
1703 if patch == "" {
1704 return fmt.Errorf("Patch is empty.")
1705 }
1706
1707 if patch == pull.LatestPatch() {
1708 return fmt.Errorf("Patch is identical to previous submission.")
1709 }
1710
1711 if !patchutil.IsPatchValid(patch) {
1712 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1713 }
1714
1715 return nil
1716}
1717
1718func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1719 f, err := s.fullyResolvedRepo(r)
1720 if err != nil {
1721 log.Println("failed to resolve repo:", err)
1722 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1723 return
1724 }
1725
1726 pull, ok := r.Context().Value("pull").(*db.Pull)
1727 if !ok {
1728 log.Println("failed to get pull")
1729 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1730 return
1731 }
1732
1733 var pullsToMerge db.Stack
1734 pullsToMerge = append(pullsToMerge, pull)
1735 if pull.IsStacked() {
1736 stack, ok := r.Context().Value("stack").(db.Stack)
1737 if !ok {
1738 log.Println("failed to get stack")
1739 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1740 return
1741 }
1742
1743 // combine patches of substack
1744 subStack := stack.Below(pull)
1745
1746 // collect the portion of the stack that is mergeable
1747 for _, p := range subStack {
1748 // stop at the first merged PR
1749 if p.State == db.PullMerged {
1750 break
1751 }
1752
1753 // skip over closed PRs
1754 //
1755 // TODO: we need a "deleted" state for such PRs, but without losing discussions
1756 // we will close PRs that are "removed" from a stack
1757 if p.State == db.PullClosed {
1758 continue
1759 }
1760
1761 pullsToMerge = append(pullsToMerge, p)
1762 }
1763 }
1764
1765 patch := pullsToMerge.CombinedPatch()
1766
1767 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1768 if err != nil {
1769 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1770 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1771 return
1772 }
1773
1774 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1775 if err != nil {
1776 log.Printf("resolving identity: %s", err)
1777 w.WriteHeader(http.StatusNotFound)
1778 return
1779 }
1780
1781 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1782 if err != nil {
1783 log.Printf("failed to get primary email: %s", err)
1784 }
1785
1786 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1787 if err != nil {
1788 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1789 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1790 return
1791 }
1792
1793 // Merge the pull request
1794 resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1795 if err != nil {
1796 log.Printf("failed to merge pull request: %s", err)
1797 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1798 return
1799 }
1800
1801 if resp.StatusCode != http.StatusOK {
1802 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1803 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1804 return
1805 }
1806
1807 tx, err := s.db.Begin()
1808 if err != nil {
1809 log.Printf("failed to start transcation", err)
1810 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1811 return
1812 }
1813
1814 for _, p := range pullsToMerge {
1815 err := db.MergePull(tx, f.RepoAt, p.PullId)
1816 if err != nil {
1817 log.Printf("failed to update pull request status in database: %s", err)
1818 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1819 return
1820 }
1821 }
1822
1823 err = tx.Commit()
1824 if err != nil {
1825 // TODO: this is unsound, we should also revert the merge from the knotserver here
1826 log.Printf("failed to update pull request status in database: %s", err)
1827 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1828 return
1829 }
1830
1831 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1832}
1833
1834func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1835 user := s.oauth.GetUser(r)
1836
1837 f, err := s.fullyResolvedRepo(r)
1838 if err != nil {
1839 log.Println("malformed middleware")
1840 return
1841 }
1842
1843 pull, ok := r.Context().Value("pull").(*db.Pull)
1844 if !ok {
1845 log.Println("failed to get pull")
1846 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1847 return
1848 }
1849
1850 // auth filter: only owner or collaborators can close
1851 roles := RolesInRepo(s, user, f)
1852 isCollaborator := roles.IsCollaborator()
1853 isPullAuthor := user.Did == pull.OwnerDid
1854 isCloseAllowed := isCollaborator || isPullAuthor
1855 if !isCloseAllowed {
1856 log.Println("failed to close pull")
1857 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1858 return
1859 }
1860
1861 // Start a transaction
1862 tx, err := s.db.BeginTx(r.Context(), nil)
1863 if err != nil {
1864 log.Println("failed to start transaction", err)
1865 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1866 return
1867 }
1868
1869 var pullsToClose []*db.Pull
1870 pullsToClose = append(pullsToClose, pull)
1871
1872 // if this PR is stacked, then we want to close all PRs below this one on the stack
1873 if pull.IsStacked() {
1874 stack := r.Context().Value("stack").(db.Stack)
1875 subStack := stack.StrictlyBelow(pull)
1876 pullsToClose = append(pullsToClose, subStack...)
1877 }
1878
1879 for _, p := range pullsToClose {
1880 // Close the pull in the database
1881 err = db.ClosePull(tx, f.RepoAt, p.PullId)
1882 if err != nil {
1883 log.Println("failed to close pull", err)
1884 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1885 return
1886 }
1887 }
1888
1889 // Commit the transaction
1890 if err = tx.Commit(); err != nil {
1891 log.Println("failed to commit transaction", err)
1892 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1893 return
1894 }
1895
1896 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1897 return
1898}
1899
1900func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1901 user := s.oauth.GetUser(r)
1902
1903 f, err := s.fullyResolvedRepo(r)
1904 if err != nil {
1905 log.Println("failed to resolve repo", err)
1906 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1907 return
1908 }
1909
1910 pull, ok := r.Context().Value("pull").(*db.Pull)
1911 if !ok {
1912 log.Println("failed to get pull")
1913 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1914 return
1915 }
1916
1917 // auth filter: only owner or collaborators can close
1918 roles := RolesInRepo(s, user, f)
1919 isCollaborator := roles.IsCollaborator()
1920 isPullAuthor := user.Did == pull.OwnerDid
1921 isCloseAllowed := isCollaborator || isPullAuthor
1922 if !isCloseAllowed {
1923 log.Println("failed to close pull")
1924 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1925 return
1926 }
1927
1928 // Start a transaction
1929 tx, err := s.db.BeginTx(r.Context(), nil)
1930 if err != nil {
1931 log.Println("failed to start transaction", err)
1932 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1933 return
1934 }
1935
1936 var pullsToReopen []*db.Pull
1937 pullsToReopen = append(pullsToReopen, pull)
1938
1939 // if this PR is stacked, then we want to reopen all PRs below this one on the stack
1940 if pull.IsStacked() {
1941 stack := r.Context().Value("stack").(db.Stack)
1942 subStack := stack.StrictlyBelow(pull)
1943 pullsToReopen = append(pullsToReopen, subStack...)
1944 }
1945
1946 for _, p := range pullsToReopen {
1947 // Close the pull in the database
1948 err = db.ReopenPull(tx, f.RepoAt, p.PullId)
1949 if err != nil {
1950 log.Println("failed to close pull", err)
1951 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1952 return
1953 }
1954 }
1955
1956 // Commit the transaction
1957 if err = tx.Commit(); err != nil {
1958 log.Println("failed to commit transaction", err)
1959 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1960 return
1961 }
1962
1963 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1964 return
1965}