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