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