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