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