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