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