A vibe coded tangled fork which supports pijul.
at 972bb98882d39c2f199ee19cbec1d4145ca2e7d4 1965 lines 56 kB view raw
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}