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 isStacked := r.FormValue("isStacked") == "on" 637 638 if isPatchBased && !patchutil.IsFormatPatch(patch) { 639 if title == "" { 640 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 641 return 642 } 643 } 644 645 // Validate we have at least one valid PR creation method 646 if !isBranchBased && !isPatchBased && !isForkBased { 647 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 648 return 649 } 650 651 // Can't mix branch-based and patch-based approaches 652 if isBranchBased && patch != "" { 653 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 654 return 655 } 656 657 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 658 if err != nil { 659 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 660 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 661 return 662 } 663 664 caps, err := us.Capabilities() 665 if err != nil { 666 log.Println("error fetching knot caps", f.Knot, err) 667 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 668 return 669 } 670 671 if !caps.PullRequests.FormatPatch { 672 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 673 return 674 } 675 676 // Handle the PR creation based on the type 677 if isBranchBased { 678 if !caps.PullRequests.BranchSubmissions { 679 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 680 return 681 } 682 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 683 } else if isForkBased { 684 if !caps.PullRequests.ForkSubmissions { 685 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 686 return 687 } 688 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 689 } else if isPatchBased { 690 if !caps.PullRequests.PatchSubmissions { 691 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 692 return 693 } 694 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 695 } 696 return 697 } 698} 699 700func (s *State) handleBranchBasedPull( 701 w http.ResponseWriter, 702 r *http.Request, 703 f *FullyResolvedRepo, 704 user *oauth.User, 705 title, 706 body, 707 targetBranch, 708 sourceBranch string, 709 isStacked bool, 710) { 711 pullSource := &db.PullSource{ 712 Branch: sourceBranch, 713 } 714 recordPullSource := &tangled.RepoPull_Source{ 715 Branch: sourceBranch, 716 } 717 718 // Generate a patch using /compare 719 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 720 if err != nil { 721 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 722 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 723 return 724 } 725 726 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 727 if err != nil { 728 log.Println("failed to compare", err) 729 s.pages.Notice(w, "pull", err.Error()) 730 return 731 } 732 733 sourceRev := comparison.Rev2 734 patch := comparison.Patch 735 736 if !patchutil.IsPatchValid(patch) { 737 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 738 return 739 } 740 741 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 742} 743 744func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 745 if !patchutil.IsPatchValid(patch) { 746 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 747 return 748 } 749 750 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 751} 752 753func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 754 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 755 if errors.Is(err, sql.ErrNoRows) { 756 s.pages.Notice(w, "pull", "No such fork.") 757 return 758 } else if err != nil { 759 log.Println("failed to fetch fork:", err) 760 s.pages.Notice(w, "pull", "Failed to fetch fork.") 761 return 762 } 763 764 secret, err := db.GetRegistrationKey(s.db, fork.Knot) 765 if err != nil { 766 log.Println("failed to fetch registration key:", err) 767 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 768 return 769 } 770 771 sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 772 if err != nil { 773 log.Println("failed to create signed client:", err) 774 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 775 return 776 } 777 778 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 779 if err != nil { 780 log.Println("failed to create unsigned client:", err) 781 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 782 return 783 } 784 785 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 786 if err != nil { 787 log.Println("failed to create hidden ref:", err, resp.StatusCode) 788 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 789 return 790 } 791 792 switch resp.StatusCode { 793 case 404: 794 case 400: 795 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 796 return 797 } 798 799 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 800 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 801 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 802 // hiddenRef: hidden/feature-1/main (on repo-fork) 803 // targetBranch: main (on repo-1) 804 // sourceBranch: feature-1 (on repo-fork) 805 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 806 if err != nil { 807 log.Println("failed to compare across branches", err) 808 s.pages.Notice(w, "pull", err.Error()) 809 return 810 } 811 812 sourceRev := comparison.Rev2 813 patch := comparison.Patch 814 815 if !patchutil.IsPatchValid(patch) { 816 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 817 return 818 } 819 820 forkAtUri, err := syntax.ParseATURI(fork.AtUri) 821 if err != nil { 822 log.Println("failed to parse fork AT URI", err) 823 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 824 return 825 } 826 827 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 828 Branch: sourceBranch, 829 RepoAt: &forkAtUri, 830 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked) 831} 832 833func (s *State) createPullRequest( 834 w http.ResponseWriter, 835 r *http.Request, 836 f *FullyResolvedRepo, 837 user *oauth.User, 838 title, body, targetBranch string, 839 patch string, 840 sourceRev string, 841 pullSource *db.PullSource, 842 recordPullSource *tangled.RepoPull_Source, 843 isStacked bool, 844) { 845 if isStacked { 846 } 847 848 tx, err := s.db.BeginTx(r.Context(), nil) 849 if err != nil { 850 log.Println("failed to start tx") 851 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 852 return 853 } 854 defer tx.Rollback() 855 856 // We've already checked earlier if it's diff-based and title is empty, 857 // so if it's still empty now, it's intentionally skipped owing to format-patch. 858 if title == "" { 859 formatPatches, err := patchutil.ExtractPatches(patch) 860 if err != nil { 861 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 862 return 863 } 864 if len(formatPatches) == 0 { 865 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 866 return 867 } 868 869 title = formatPatches[0].Title 870 body = formatPatches[0].Body 871 } 872 873 rkey := appview.TID() 874 initialSubmission := db.PullSubmission{ 875 Patch: patch, 876 SourceRev: sourceRev, 877 } 878 err = db.NewPull(tx, &db.Pull{ 879 Title: title, 880 Body: body, 881 TargetBranch: targetBranch, 882 OwnerDid: user.Did, 883 RepoAt: f.RepoAt, 884 Rkey: rkey, 885 Submissions: []*db.PullSubmission{ 886 &initialSubmission, 887 }, 888 PullSource: pullSource, 889 }) 890 if err != nil { 891 log.Println("failed to create pull request", err) 892 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 893 return 894 } 895 client, err := s.oauth.AuthorizedClient(r) 896 if err != nil { 897 log.Println("failed to get authorized client", err) 898 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 899 return 900 } 901 pullId, err := db.NextPullId(tx, f.RepoAt) 902 if err != nil { 903 log.Println("failed to get pull id", err) 904 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 905 return 906 } 907 908 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 909 Collection: tangled.RepoPullNSID, 910 Repo: user.Did, 911 Rkey: rkey, 912 Record: &lexutil.LexiconTypeDecoder{ 913 Val: &tangled.RepoPull{ 914 Title: title, 915 PullId: int64(pullId), 916 TargetRepo: string(f.RepoAt), 917 TargetBranch: targetBranch, 918 Patch: patch, 919 Source: recordPullSource, 920 }, 921 }, 922 }) 923 if err != nil { 924 log.Println("failed to create pull request", err) 925 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 926 return 927 } 928 929 if err = tx.Commit(); err != nil { 930 log.Println("failed to create pull request", err) 931 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 932 return 933 } 934 935 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 936} 937 938func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { 939 _, err := s.fullyResolvedRepo(r) 940 if err != nil { 941 log.Println("failed to get repo and knot", err) 942 return 943 } 944 945 patch := r.FormValue("patch") 946 if patch == "" { 947 s.pages.Notice(w, "patch-error", "Patch is required.") 948 return 949 } 950 951 if patch == "" || !patchutil.IsPatchValid(patch) { 952 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 953 return 954 } 955 956 if patchutil.IsFormatPatch(patch) { 957 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.") 958 } else { 959 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 960 } 961} 962 963func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 964 user := s.oauth.GetUser(r) 965 f, err := s.fullyResolvedRepo(r) 966 if err != nil { 967 log.Println("failed to get repo and knot", err) 968 return 969 } 970 971 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 972 RepoInfo: f.RepoInfo(s, user), 973 }) 974} 975 976func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 977 user := s.oauth.GetUser(r) 978 f, err := s.fullyResolvedRepo(r) 979 if err != nil { 980 log.Println("failed to get repo and knot", err) 981 return 982 } 983 984 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 985 if err != nil { 986 log.Printf("failed to create unsigned client for %s", f.Knot) 987 s.pages.Error503(w) 988 return 989 } 990 991 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 992 if err != nil { 993 log.Println("failed to reach knotserver", err) 994 return 995 } 996 997 body, err := io.ReadAll(resp.Body) 998 if err != nil { 999 log.Printf("Error reading response body: %v", err) 1000 return 1001 } 1002 1003 var result types.RepoBranchesResponse 1004 err = json.Unmarshal(body, &result) 1005 if err != nil { 1006 log.Println("failed to parse response:", err) 1007 return 1008 } 1009 1010 branches := result.Branches 1011 sort.Slice(branches, func(i int, j int) bool { 1012 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1013 }) 1014 1015 withoutDefault := []types.Branch{} 1016 for _, b := range branches { 1017 if b.IsDefault { 1018 continue 1019 } 1020 withoutDefault = append(withoutDefault, b) 1021 } 1022 1023 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1024 RepoInfo: f.RepoInfo(s, user), 1025 Branches: withoutDefault, 1026 }) 1027} 1028 1029func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1030 user := s.oauth.GetUser(r) 1031 f, err := s.fullyResolvedRepo(r) 1032 if err != nil { 1033 log.Println("failed to get repo and knot", err) 1034 return 1035 } 1036 1037 forks, err := db.GetForksByDid(s.db, user.Did) 1038 if err != nil { 1039 log.Println("failed to get forks", err) 1040 return 1041 } 1042 1043 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1044 RepoInfo: f.RepoInfo(s, user), 1045 Forks: forks, 1046 }) 1047} 1048 1049func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1050 user := s.oauth.GetUser(r) 1051 1052 f, err := s.fullyResolvedRepo(r) 1053 if err != nil { 1054 log.Println("failed to get repo and knot", err) 1055 return 1056 } 1057 1058 forkVal := r.URL.Query().Get("fork") 1059 1060 // fork repo 1061 repo, err := db.GetRepo(s.db, user.Did, forkVal) 1062 if err != nil { 1063 log.Println("failed to get repo", user.Did, forkVal) 1064 return 1065 } 1066 1067 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1068 if err != nil { 1069 log.Printf("failed to create unsigned client for %s", repo.Knot) 1070 s.pages.Error503(w) 1071 return 1072 } 1073 1074 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1075 if err != nil { 1076 log.Println("failed to reach knotserver for source branches", err) 1077 return 1078 } 1079 1080 sourceBody, err := io.ReadAll(sourceResp.Body) 1081 if err != nil { 1082 log.Println("failed to read source response body", err) 1083 return 1084 } 1085 defer sourceResp.Body.Close() 1086 1087 var sourceResult types.RepoBranchesResponse 1088 err = json.Unmarshal(sourceBody, &sourceResult) 1089 if err != nil { 1090 log.Println("failed to parse source branches response:", err) 1091 return 1092 } 1093 1094 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1095 if err != nil { 1096 log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1097 s.pages.Error503(w) 1098 return 1099 } 1100 1101 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1102 if err != nil { 1103 log.Println("failed to reach knotserver for target branches", err) 1104 return 1105 } 1106 1107 targetBody, err := io.ReadAll(targetResp.Body) 1108 if err != nil { 1109 log.Println("failed to read target response body", err) 1110 return 1111 } 1112 defer targetResp.Body.Close() 1113 1114 var targetResult types.RepoBranchesResponse 1115 err = json.Unmarshal(targetBody, &targetResult) 1116 if err != nil { 1117 log.Println("failed to parse target branches response:", err) 1118 return 1119 } 1120 1121 sourceBranches := sourceResult.Branches 1122 sort.Slice(sourceBranches, func(i int, j int) bool { 1123 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1124 }) 1125 1126 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1127 RepoInfo: f.RepoInfo(s, user), 1128 SourceBranches: sourceResult.Branches, 1129 TargetBranches: targetResult.Branches, 1130 }) 1131} 1132 1133func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1134 user := s.oauth.GetUser(r) 1135 f, err := s.fullyResolvedRepo(r) 1136 if err != nil { 1137 log.Println("failed to get repo and knot", err) 1138 return 1139 } 1140 1141 pull, ok := r.Context().Value("pull").(*db.Pull) 1142 if !ok { 1143 log.Println("failed to get pull") 1144 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1145 return 1146 } 1147 1148 switch r.Method { 1149 case http.MethodGet: 1150 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1151 RepoInfo: f.RepoInfo(s, user), 1152 Pull: pull, 1153 }) 1154 return 1155 case http.MethodPost: 1156 if pull.IsPatchBased() { 1157 s.resubmitPatch(w, r) 1158 return 1159 } else if pull.IsBranchBased() { 1160 s.resubmitBranch(w, r) 1161 return 1162 } else if pull.IsForkBased() { 1163 s.resubmitFork(w, r) 1164 return 1165 } 1166 } 1167} 1168 1169func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1170 user := s.oauth.GetUser(r) 1171 1172 pull, ok := r.Context().Value("pull").(*db.Pull) 1173 if !ok { 1174 log.Println("failed to get pull") 1175 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1176 return 1177 } 1178 1179 f, err := s.fullyResolvedRepo(r) 1180 if err != nil { 1181 log.Println("failed to get repo and knot", err) 1182 return 1183 } 1184 1185 if user.Did != pull.OwnerDid { 1186 log.Println("unauthorized user") 1187 w.WriteHeader(http.StatusUnauthorized) 1188 return 1189 } 1190 1191 patch := r.FormValue("patch") 1192 1193 if err = validateResubmittedPatch(pull, patch); err != nil { 1194 s.pages.Notice(w, "resubmit-error", err.Error()) 1195 return 1196 } 1197 1198 tx, err := s.db.BeginTx(r.Context(), nil) 1199 if err != nil { 1200 log.Println("failed to start tx") 1201 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1202 return 1203 } 1204 defer tx.Rollback() 1205 1206 err = db.ResubmitPull(tx, pull, patch, "") 1207 if err != nil { 1208 log.Println("failed to resubmit pull request", err) 1209 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.") 1210 return 1211 } 1212 client, err := s.oauth.AuthorizedClient(r) 1213 if err != nil { 1214 log.Println("failed to get authorized client", err) 1215 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1216 return 1217 } 1218 1219 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1220 if err != nil { 1221 // failed to get record 1222 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1223 return 1224 } 1225 1226 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1227 Collection: tangled.RepoPullNSID, 1228 Repo: user.Did, 1229 Rkey: pull.Rkey, 1230 SwapRecord: ex.Cid, 1231 Record: &lexutil.LexiconTypeDecoder{ 1232 Val: &tangled.RepoPull{ 1233 Title: pull.Title, 1234 PullId: int64(pull.PullId), 1235 TargetRepo: string(f.RepoAt), 1236 TargetBranch: pull.TargetBranch, 1237 Patch: patch, // new patch 1238 }, 1239 }, 1240 }) 1241 if err != nil { 1242 log.Println("failed to update record", err) 1243 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1244 return 1245 } 1246 1247 if err = tx.Commit(); err != nil { 1248 log.Println("failed to commit transaction", err) 1249 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1250 return 1251 } 1252 1253 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1254 return 1255} 1256 1257func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1258 user := s.oauth.GetUser(r) 1259 1260 pull, ok := r.Context().Value("pull").(*db.Pull) 1261 if !ok { 1262 log.Println("failed to get pull") 1263 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1264 return 1265 } 1266 1267 f, err := s.fullyResolvedRepo(r) 1268 if err != nil { 1269 log.Println("failed to get repo and knot", err) 1270 return 1271 } 1272 1273 if user.Did != pull.OwnerDid { 1274 log.Println("unauthorized user") 1275 w.WriteHeader(http.StatusUnauthorized) 1276 return 1277 } 1278 1279 if !f.RepoInfo(s, user).Roles.IsPushAllowed() { 1280 log.Println("unauthorized user") 1281 w.WriteHeader(http.StatusUnauthorized) 1282 return 1283 } 1284 1285 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1286 if err != nil { 1287 log.Printf("failed to create client for %s: %s", f.Knot, err) 1288 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1289 return 1290 } 1291 1292 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1293 if err != nil { 1294 log.Printf("compare request failed: %s", err) 1295 s.pages.Notice(w, "resubmit-error", err.Error()) 1296 return 1297 } 1298 1299 sourceRev := comparison.Rev2 1300 patch := comparison.Patch 1301 1302 if err = validateResubmittedPatch(pull, patch); err != nil { 1303 s.pages.Notice(w, "resubmit-error", err.Error()) 1304 return 1305 } 1306 1307 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1308 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1309 return 1310 } 1311 1312 tx, err := s.db.BeginTx(r.Context(), nil) 1313 if err != nil { 1314 log.Println("failed to start tx") 1315 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1316 return 1317 } 1318 defer tx.Rollback() 1319 1320 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1321 if err != nil { 1322 log.Println("failed to create pull request", err) 1323 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1324 return 1325 } 1326 client, err := s.oauth.AuthorizedClient(r) 1327 if err != nil { 1328 log.Println("failed to authorize client") 1329 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1330 return 1331 } 1332 1333 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1334 if err != nil { 1335 // failed to get record 1336 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1337 return 1338 } 1339 1340 recordPullSource := &tangled.RepoPull_Source{ 1341 Branch: pull.PullSource.Branch, 1342 } 1343 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1344 Collection: tangled.RepoPullNSID, 1345 Repo: user.Did, 1346 Rkey: pull.Rkey, 1347 SwapRecord: ex.Cid, 1348 Record: &lexutil.LexiconTypeDecoder{ 1349 Val: &tangled.RepoPull{ 1350 Title: pull.Title, 1351 PullId: int64(pull.PullId), 1352 TargetRepo: string(f.RepoAt), 1353 TargetBranch: pull.TargetBranch, 1354 Patch: patch, // new patch 1355 Source: recordPullSource, 1356 }, 1357 }, 1358 }) 1359 if err != nil { 1360 log.Println("failed to update record", err) 1361 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1362 return 1363 } 1364 1365 if err = tx.Commit(); err != nil { 1366 log.Println("failed to commit transaction", err) 1367 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1368 return 1369 } 1370 1371 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1372 return 1373} 1374 1375func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { 1376 user := s.oauth.GetUser(r) 1377 1378 pull, ok := r.Context().Value("pull").(*db.Pull) 1379 if !ok { 1380 log.Println("failed to get pull") 1381 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1382 return 1383 } 1384 1385 f, err := s.fullyResolvedRepo(r) 1386 if err != nil { 1387 log.Println("failed to get repo and knot", err) 1388 return 1389 } 1390 1391 if user.Did != pull.OwnerDid { 1392 log.Println("unauthorized user") 1393 w.WriteHeader(http.StatusUnauthorized) 1394 return 1395 } 1396 1397 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1398 if err != nil { 1399 log.Println("failed to get source repo", err) 1400 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1401 return 1402 } 1403 1404 // extract patch by performing compare 1405 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1406 if err != nil { 1407 log.Printf("failed to create 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 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1413 if err != nil { 1414 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1415 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1416 return 1417 } 1418 1419 // update the hidden tracking branch to latest 1420 signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1421 if err != nil { 1422 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1423 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1424 return 1425 } 1426 1427 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1428 if err != nil || resp.StatusCode != http.StatusNoContent { 1429 log.Printf("failed to update tracking branch: %s", err) 1430 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1431 return 1432 } 1433 1434 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1435 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1436 if err != nil { 1437 log.Printf("failed to compare branches: %s", err) 1438 s.pages.Notice(w, "resubmit-error", err.Error()) 1439 return 1440 } 1441 1442 sourceRev := comparison.Rev2 1443 patch := comparison.Patch 1444 1445 if err = validateResubmittedPatch(pull, patch); err != nil { 1446 s.pages.Notice(w, "resubmit-error", err.Error()) 1447 return 1448 } 1449 1450 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1451 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1452 return 1453 } 1454 1455 tx, err := s.db.BeginTx(r.Context(), nil) 1456 if err != nil { 1457 log.Println("failed to start tx") 1458 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1459 return 1460 } 1461 defer tx.Rollback() 1462 1463 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1464 if err != nil { 1465 log.Println("failed to create pull request", err) 1466 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1467 return 1468 } 1469 client, err := s.oauth.AuthorizedClient(r) 1470 if err != nil { 1471 log.Println("failed to get client") 1472 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1473 return 1474 } 1475 1476 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1477 if err != nil { 1478 // failed to get record 1479 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1480 return 1481 } 1482 1483 repoAt := pull.PullSource.RepoAt.String() 1484 recordPullSource := &tangled.RepoPull_Source{ 1485 Branch: pull.PullSource.Branch, 1486 Repo: &repoAt, 1487 } 1488 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1489 Collection: tangled.RepoPullNSID, 1490 Repo: user.Did, 1491 Rkey: pull.Rkey, 1492 SwapRecord: ex.Cid, 1493 Record: &lexutil.LexiconTypeDecoder{ 1494 Val: &tangled.RepoPull{ 1495 Title: pull.Title, 1496 PullId: int64(pull.PullId), 1497 TargetRepo: string(f.RepoAt), 1498 TargetBranch: pull.TargetBranch, 1499 Patch: patch, // new patch 1500 Source: recordPullSource, 1501 }, 1502 }, 1503 }) 1504 if err != nil { 1505 log.Println("failed to update record", err) 1506 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1507 return 1508 } 1509 1510 if err = tx.Commit(); err != nil { 1511 log.Println("failed to commit transaction", err) 1512 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1513 return 1514 } 1515 1516 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1517 return 1518} 1519 1520// validate a resubmission against a pull request 1521func validateResubmittedPatch(pull *db.Pull, patch string) error { 1522 if patch == "" { 1523 return fmt.Errorf("Patch is empty.") 1524 } 1525 1526 if patch == pull.LatestPatch() { 1527 return fmt.Errorf("Patch is identical to previous submission.") 1528 } 1529 1530 if !patchutil.IsPatchValid(patch) { 1531 return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1532 } 1533 1534 return nil 1535} 1536 1537func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1538 f, err := s.fullyResolvedRepo(r) 1539 if err != nil { 1540 log.Println("failed to resolve repo:", err) 1541 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 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 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1553 if err != nil { 1554 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1555 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1556 return 1557 } 1558 1559 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1560 if err != nil { 1561 log.Printf("resolving identity: %s", err) 1562 w.WriteHeader(http.StatusNotFound) 1563 return 1564 } 1565 1566 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1567 if err != nil { 1568 log.Printf("failed to get primary email: %s", err) 1569 } 1570 1571 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1572 if err != nil { 1573 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1574 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1575 return 1576 } 1577 1578 // Merge the pull request 1579 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1580 if err != nil { 1581 log.Printf("failed to merge pull request: %s", err) 1582 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1583 return 1584 } 1585 1586 if resp.StatusCode == http.StatusOK { 1587 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1588 if err != nil { 1589 log.Printf("failed to update pull request status in database: %s", err) 1590 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1591 return 1592 } 1593 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1594 } else { 1595 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1596 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1597 } 1598} 1599 1600func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1601 user := s.oauth.GetUser(r) 1602 1603 f, err := s.fullyResolvedRepo(r) 1604 if err != nil { 1605 log.Println("malformed middleware") 1606 return 1607 } 1608 1609 pull, ok := r.Context().Value("pull").(*db.Pull) 1610 if !ok { 1611 log.Println("failed to get pull") 1612 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1613 return 1614 } 1615 1616 // auth filter: only owner or collaborators can close 1617 roles := RolesInRepo(s, user, f) 1618 isCollaborator := roles.IsCollaborator() 1619 isPullAuthor := user.Did == pull.OwnerDid 1620 isCloseAllowed := isCollaborator || isPullAuthor 1621 if !isCloseAllowed { 1622 log.Println("failed to close pull") 1623 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1624 return 1625 } 1626 1627 // Start a transaction 1628 tx, err := s.db.BeginTx(r.Context(), nil) 1629 if err != nil { 1630 log.Println("failed to start transaction", err) 1631 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1632 return 1633 } 1634 1635 // Close the pull in the database 1636 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1637 if err != nil { 1638 log.Println("failed to close pull", err) 1639 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1640 return 1641 } 1642 1643 // Commit the transaction 1644 if err = tx.Commit(); err != nil { 1645 log.Println("failed to commit transaction", err) 1646 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1647 return 1648 } 1649 1650 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1651 return 1652} 1653 1654func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1655 user := s.oauth.GetUser(r) 1656 1657 f, err := s.fullyResolvedRepo(r) 1658 if err != nil { 1659 log.Println("failed to resolve repo", err) 1660 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1661 return 1662 } 1663 1664 pull, ok := r.Context().Value("pull").(*db.Pull) 1665 if !ok { 1666 log.Println("failed to get pull") 1667 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1668 return 1669 } 1670 1671 // auth filter: only owner or collaborators can close 1672 roles := RolesInRepo(s, user, f) 1673 isCollaborator := roles.IsCollaborator() 1674 isPullAuthor := user.Did == pull.OwnerDid 1675 isCloseAllowed := isCollaborator || isPullAuthor 1676 if !isCloseAllowed { 1677 log.Println("failed to close pull") 1678 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1679 return 1680 } 1681 1682 // Start a transaction 1683 tx, err := s.db.BeginTx(r.Context(), nil) 1684 if err != nil { 1685 log.Println("failed to start transaction", err) 1686 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1687 return 1688 } 1689 1690 // Reopen the pull in the database 1691 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1692 if err != nil { 1693 log.Println("failed to reopen pull", err) 1694 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1695 return 1696 } 1697 1698 // Commit the transaction 1699 if err = tx.Commit(); err != nil { 1700 log.Println("failed to commit transaction", err) 1701 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1702 return 1703 } 1704 1705 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1706 return 1707}