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