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