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