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