forked from tangled.org/core
this repo has no description
at fork-repo 29 kB view raw
1package state 2 3import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "log" 8 "net/http" 9 "strconv" 10 "strings" 11 "time" 12 13 "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/db" 16 "tangled.sh/tangled.sh/core/appview/pages" 17 "tangled.sh/tangled.sh/core/types" 18 19 comatproto "github.com/bluesky-social/indigo/api/atproto" 20 lexutil "github.com/bluesky-social/indigo/lex/util" 21) 22 23// htmx fragment 24func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 25 switch r.Method { 26 case http.MethodGet: 27 user := s.auth.GetUser(r) 28 f, err := fullyResolvedRepo(r) 29 if err != nil { 30 log.Println("failed to get repo and knot", err) 31 return 32 } 33 34 pull, ok := r.Context().Value("pull").(*db.Pull) 35 if !ok { 36 log.Println("failed to get pull") 37 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 38 return 39 } 40 41 roundNumberStr := chi.URLParam(r, "round") 42 roundNumber, err := strconv.Atoi(roundNumberStr) 43 if err != nil { 44 roundNumber = pull.LastRoundNumber() 45 } 46 if roundNumber >= len(pull.Submissions) { 47 http.Error(w, "bad round id", http.StatusBadRequest) 48 log.Println("failed to parse round id", err) 49 return 50 } 51 52 mergeCheckResponse := s.mergeCheck(f, pull) 53 var resubmitResult pages.ResubmitResult 54 if user.Did == pull.OwnerDid { 55 resubmitResult = s.resubmitCheck(f, pull) 56 } 57 58 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 59 LoggedInUser: user, 60 RepoInfo: f.RepoInfo(s, user), 61 Pull: pull, 62 RoundNumber: roundNumber, 63 MergeCheck: mergeCheckResponse, 64 ResubmitCheck: resubmitResult, 65 }) 66 return 67 } 68} 69 70func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 71 user := s.auth.GetUser(r) 72 f, err := fullyResolvedRepo(r) 73 if err != nil { 74 log.Println("failed to get repo and knot", err) 75 return 76 } 77 78 pull, ok := r.Context().Value("pull").(*db.Pull) 79 if !ok { 80 log.Println("failed to get pull") 81 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 82 return 83 } 84 85 totalIdents := 1 86 for _, submission := range pull.Submissions { 87 totalIdents += len(submission.Comments) 88 } 89 90 identsToResolve := make([]string, totalIdents) 91 92 // populate idents 93 identsToResolve[0] = pull.OwnerDid 94 idx := 1 95 for _, submission := range pull.Submissions { 96 for _, comment := range submission.Comments { 97 identsToResolve[idx] = comment.OwnerDid 98 idx += 1 99 } 100 } 101 102 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 103 didHandleMap := make(map[string]string) 104 for _, identity := range resolvedIds { 105 if !identity.Handle.IsInvalidHandle() { 106 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 107 } else { 108 didHandleMap[identity.DID.String()] = identity.DID.String() 109 } 110 } 111 112 mergeCheckResponse := s.mergeCheck(f, pull) 113 var resubmitResult pages.ResubmitResult 114 if user.Did == pull.OwnerDid { 115 resubmitResult = s.resubmitCheck(f, pull) 116 } 117 118 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 119 LoggedInUser: user, 120 RepoInfo: f.RepoInfo(s, user), 121 DidHandleMap: didHandleMap, 122 Pull: pull, 123 MergeCheck: mergeCheckResponse, 124 ResubmitCheck: resubmitResult, 125 }) 126} 127 128func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse { 129 if pull.State == db.PullMerged { 130 return types.MergeCheckResponse{} 131 } 132 133 secret, err := db.GetRegistrationKey(s.db, f.Knot) 134 if err != nil { 135 log.Printf("failed to get registration key: %v", err) 136 return types.MergeCheckResponse{ 137 Error: "failed to check merge status: this knot is unregistered", 138 } 139 } 140 141 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 142 if err != nil { 143 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 144 return types.MergeCheckResponse{ 145 Error: "failed to check merge status", 146 } 147 } 148 149 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch) 150 if err != nil { 151 log.Println("failed to check for mergeability:", err) 152 return types.MergeCheckResponse{ 153 Error: "failed to check merge status", 154 } 155 } 156 switch resp.StatusCode { 157 case 404: 158 return types.MergeCheckResponse{ 159 Error: "failed to check merge status: this knot does not support PRs", 160 } 161 case 400: 162 return types.MergeCheckResponse{ 163 Error: "failed to check merge status: does this knot support PRs?", 164 } 165 } 166 167 respBody, err := io.ReadAll(resp.Body) 168 if err != nil { 169 log.Println("failed to read merge check response body") 170 return types.MergeCheckResponse{ 171 Error: "failed to check merge status: knot is not speaking the right language", 172 } 173 } 174 defer resp.Body.Close() 175 176 var mergeCheckResponse types.MergeCheckResponse 177 err = json.Unmarshal(respBody, &mergeCheckResponse) 178 if err != nil { 179 log.Println("failed to unmarshal merge check response", err) 180 return types.MergeCheckResponse{ 181 Error: "failed to check merge status: knot is not speaking the right language", 182 } 183 } 184 185 return mergeCheckResponse 186} 187 188func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { 189 if pull.State == db.PullMerged { 190 return pages.Unknown 191 } 192 193 if pull.PullSource == nil { 194 return pages.Unknown 195 } 196 197 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 198 if err != nil { 199 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 200 return pages.Unknown 201 } 202 203 resp, err := us.Branch(f.OwnerDid(), f.RepoName, pull.PullSource.Branch) 204 if err != nil { 205 log.Println("failed to reach knotserver", err) 206 return pages.Unknown 207 } 208 209 body, err := io.ReadAll(resp.Body) 210 if err != nil { 211 log.Printf("Error reading response body: %v", err) 212 return pages.Unknown 213 } 214 215 var result types.RepoBranchResponse 216 err = json.Unmarshal(body, &result) 217 if err != nil { 218 log.Println("failed to parse response:", err) 219 return pages.Unknown 220 } 221 222 if pull.Submissions[pull.LastRoundNumber()].SourceRev != result.Branch.Hash { 223 log.Println(pull.Submissions[pull.LastRoundNumber()].SourceRev, result.Branch.Hash) 224 return pages.ShouldResubmit 225 } else { 226 return pages.ShouldNotResubmit 227 } 228} 229 230func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 231 user := s.auth.GetUser(r) 232 f, err := fullyResolvedRepo(r) 233 if err != nil { 234 log.Println("failed to get repo and knot", err) 235 return 236 } 237 238 pull, ok := r.Context().Value("pull").(*db.Pull) 239 if !ok { 240 log.Println("failed to get pull") 241 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 242 return 243 } 244 245 roundId := chi.URLParam(r, "round") 246 roundIdInt, err := strconv.Atoi(roundId) 247 if err != nil || roundIdInt >= len(pull.Submissions) { 248 http.Error(w, "bad round id", http.StatusBadRequest) 249 log.Println("failed to parse round id", err) 250 return 251 } 252 253 identsToResolve := []string{pull.OwnerDid} 254 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 255 didHandleMap := make(map[string]string) 256 for _, identity := range resolvedIds { 257 if !identity.Handle.IsInvalidHandle() { 258 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 259 } else { 260 didHandleMap[identity.DID.String()] = identity.DID.String() 261 } 262 } 263 264 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 265 LoggedInUser: user, 266 DidHandleMap: didHandleMap, 267 RepoInfo: f.RepoInfo(s, user), 268 Pull: pull, 269 Round: roundIdInt, 270 Submission: pull.Submissions[roundIdInt], 271 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 272 }) 273 274} 275 276func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 277 pull, ok := r.Context().Value("pull").(*db.Pull) 278 if !ok { 279 log.Println("failed to get pull") 280 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 281 return 282 } 283 284 roundId := chi.URLParam(r, "round") 285 roundIdInt, err := strconv.Atoi(roundId) 286 if err != nil || roundIdInt >= len(pull.Submissions) { 287 http.Error(w, "bad round id", http.StatusBadRequest) 288 log.Println("failed to parse round id", err) 289 return 290 } 291 292 identsToResolve := []string{pull.OwnerDid} 293 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 294 didHandleMap := make(map[string]string) 295 for _, identity := range resolvedIds { 296 if !identity.Handle.IsInvalidHandle() { 297 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 298 } else { 299 didHandleMap[identity.DID.String()] = identity.DID.String() 300 } 301 } 302 303 w.Header().Set("Content-Type", "text/plain") 304 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 305} 306 307func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 308 user := s.auth.GetUser(r) 309 params := r.URL.Query() 310 311 state := db.PullOpen 312 switch params.Get("state") { 313 case "closed": 314 state = db.PullClosed 315 case "merged": 316 state = db.PullMerged 317 } 318 319 f, err := fullyResolvedRepo(r) 320 if err != nil { 321 log.Println("failed to get repo and knot", err) 322 return 323 } 324 325 pulls, err := db.GetPulls(s.db, f.RepoAt, state) 326 if err != nil { 327 log.Println("failed to get pulls", err) 328 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 329 return 330 } 331 332 identsToResolve := make([]string, len(pulls)) 333 for i, pull := range pulls { 334 identsToResolve[i] = pull.OwnerDid 335 } 336 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 337 didHandleMap := make(map[string]string) 338 for _, identity := range resolvedIds { 339 if !identity.Handle.IsInvalidHandle() { 340 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 341 } else { 342 didHandleMap[identity.DID.String()] = identity.DID.String() 343 } 344 } 345 346 s.pages.RepoPulls(w, pages.RepoPullsParams{ 347 LoggedInUser: s.auth.GetUser(r), 348 RepoInfo: f.RepoInfo(s, user), 349 Pulls: pulls, 350 DidHandleMap: didHandleMap, 351 FilteringBy: state, 352 }) 353 return 354} 355 356func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 357 user := s.auth.GetUser(r) 358 f, err := fullyResolvedRepo(r) 359 if err != nil { 360 log.Println("failed to get repo and knot", err) 361 return 362 } 363 364 pull, ok := r.Context().Value("pull").(*db.Pull) 365 if !ok { 366 log.Println("failed to get pull") 367 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 368 return 369 } 370 371 roundNumberStr := chi.URLParam(r, "round") 372 roundNumber, err := strconv.Atoi(roundNumberStr) 373 if err != nil || roundNumber >= len(pull.Submissions) { 374 http.Error(w, "bad round id", http.StatusBadRequest) 375 log.Println("failed to parse round id", err) 376 return 377 } 378 379 switch r.Method { 380 case http.MethodGet: 381 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 382 LoggedInUser: user, 383 RepoInfo: f.RepoInfo(s, user), 384 Pull: pull, 385 RoundNumber: roundNumber, 386 }) 387 return 388 case http.MethodPost: 389 body := r.FormValue("body") 390 if body == "" { 391 s.pages.Notice(w, "pull", "Comment body is required") 392 return 393 } 394 395 // Start a transaction 396 tx, err := s.db.BeginTx(r.Context(), nil) 397 if err != nil { 398 log.Println("failed to start transaction", err) 399 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 400 return 401 } 402 defer tx.Rollback() 403 404 createdAt := time.Now().Format(time.RFC3339) 405 ownerDid := user.Did 406 407 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 408 if err != nil { 409 log.Println("failed to get pull at", err) 410 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 411 return 412 } 413 414 atUri := f.RepoAt.String() 415 client, _ := s.auth.AuthorizedClient(r) 416 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 417 Collection: tangled.RepoPullCommentNSID, 418 Repo: user.Did, 419 Rkey: s.TID(), 420 Record: &lexutil.LexiconTypeDecoder{ 421 Val: &tangled.RepoPullComment{ 422 Repo: &atUri, 423 Pull: pullAt, 424 Owner: &ownerDid, 425 Body: &body, 426 CreatedAt: &createdAt, 427 }, 428 }, 429 }) 430 if err != nil { 431 log.Println("failed to create pull comment", err) 432 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 433 return 434 } 435 436 // Create the pull comment in the database with the commentAt field 437 commentId, err := db.NewPullComment(tx, &db.PullComment{ 438 OwnerDid: user.Did, 439 RepoAt: f.RepoAt.String(), 440 PullId: pull.PullId, 441 Body: body, 442 CommentAt: atResp.Uri, 443 SubmissionId: pull.Submissions[roundNumber].ID, 444 }) 445 if err != nil { 446 log.Println("failed to create pull comment", err) 447 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 448 return 449 } 450 451 // Commit the transaction 452 if err = tx.Commit(); err != nil { 453 log.Println("failed to commit transaction", err) 454 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 455 return 456 } 457 458 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 459 return 460 } 461} 462 463func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 464 user := s.auth.GetUser(r) 465 f, err := fullyResolvedRepo(r) 466 if err != nil { 467 log.Println("failed to get repo and knot", err) 468 return 469 } 470 471 switch r.Method { 472 case http.MethodGet: 473 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 474 if err != nil { 475 log.Printf("failed to create unsigned client for %s", f.Knot) 476 s.pages.Error503(w) 477 return 478 } 479 480 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 481 if err != nil { 482 log.Println("failed to reach knotserver", err) 483 return 484 } 485 486 body, err := io.ReadAll(resp.Body) 487 if err != nil { 488 log.Printf("Error reading response body: %v", err) 489 return 490 } 491 492 var result types.RepoBranchesResponse 493 err = json.Unmarshal(body, &result) 494 if err != nil { 495 log.Println("failed to parse response:", err) 496 return 497 } 498 499 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 500 LoggedInUser: user, 501 RepoInfo: f.RepoInfo(s, user), 502 Branches: result.Branches, 503 }) 504 case http.MethodPost: 505 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 506 title := r.FormValue("title") 507 body := r.FormValue("body") 508 targetBranch := r.FormValue("targetBranch") 509 sourceBranch := r.FormValue("sourceBranch") 510 patch := r.FormValue("patch") 511 512 isBranchBased := isPushAllowed && (sourceBranch != "") 513 isPatchBased := patch != "" 514 515 if !isBranchBased && !isPatchBased { 516 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 517 return 518 } 519 520 if isBranchBased && isPatchBased { 521 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 522 return 523 } 524 525 if title == "" || body == "" || targetBranch == "" { 526 s.pages.Notice(w, "pull", "Title, body and target branch are required.") 527 return 528 } 529 530 // TODO: check if knot has this capability 531 var sourceRev string 532 var pullSource *db.PullSource 533 var recordPullSource *tangled.RepoPull_Source 534 if isBranchBased { 535 pullSource = &db.PullSource{ 536 Branch: sourceBranch, 537 } 538 recordPullSource = &tangled.RepoPull_Source{ 539 Branch: sourceBranch, 540 } 541 // generate a patch using /compare 542 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 543 if err != nil { 544 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 545 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 546 return 547 } 548 549 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 550 switch resp.StatusCode { 551 case 404: 552 case 400: 553 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 554 } 555 556 respBody, err := io.ReadAll(resp.Body) 557 if err != nil { 558 log.Println("failed to compare across branches") 559 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 560 } 561 defer resp.Body.Close() 562 563 var diffTreeResponse types.RepoDiffTreeResponse 564 err = json.Unmarshal(respBody, &diffTreeResponse) 565 if err != nil { 566 log.Println("failed to unmarshal diff tree response", err) 567 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 568 } 569 570 sourceRev = diffTreeResponse.DiffTree.Rev2 571 patch = diffTreeResponse.DiffTree.Patch 572 } 573 574 // Validate patch format 575 if !isPatchValid(patch) { 576 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 577 return 578 } 579 580 tx, err := s.db.BeginTx(r.Context(), nil) 581 if err != nil { 582 log.Println("failed to start tx") 583 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 584 return 585 } 586 defer tx.Rollback() 587 588 rkey := s.TID() 589 initialSubmission := db.PullSubmission{ 590 Patch: patch, 591 SourceRev: sourceRev, 592 } 593 err = db.NewPull(tx, &db.Pull{ 594 Title: title, 595 Body: body, 596 TargetBranch: targetBranch, 597 OwnerDid: user.Did, 598 RepoAt: f.RepoAt, 599 Rkey: rkey, 600 Submissions: []*db.PullSubmission{ 601 &initialSubmission, 602 }, 603 PullSource: pullSource, 604 }) 605 if err != nil { 606 log.Println("failed to create pull request", err) 607 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 608 return 609 } 610 client, _ := s.auth.AuthorizedClient(r) 611 pullId, err := db.NextPullId(s.db, f.RepoAt) 612 if err != nil { 613 log.Println("failed to get pull id", err) 614 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 615 return 616 } 617 618 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 Collection: tangled.RepoPullNSID, 620 Repo: user.Did, 621 Rkey: rkey, 622 Record: &lexutil.LexiconTypeDecoder{ 623 Val: &tangled.RepoPull{ 624 Title: title, 625 PullId: int64(pullId), 626 TargetRepo: string(f.RepoAt), 627 TargetBranch: targetBranch, 628 Patch: patch, 629 Source: recordPullSource, 630 }, 631 }, 632 }) 633 634 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 635 if err != nil { 636 log.Println("failed to get pull id", err) 637 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 638 return 639 } 640 641 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 642 return 643 } 644} 645 646func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 647 user := s.auth.GetUser(r) 648 f, err := fullyResolvedRepo(r) 649 if err != nil { 650 log.Println("failed to get repo and knot", err) 651 return 652 } 653 654 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 655 RepoInfo: f.RepoInfo(s, user), 656 }) 657} 658 659func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 660 user := s.auth.GetUser(r) 661 f, err := fullyResolvedRepo(r) 662 if err != nil { 663 log.Println("failed to get repo and knot", err) 664 return 665 } 666 667 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 668 if err != nil { 669 log.Printf("failed to create unsigned client for %s", f.Knot) 670 s.pages.Error503(w) 671 return 672 } 673 674 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 675 if err != nil { 676 log.Println("failed to reach knotserver", err) 677 return 678 } 679 680 body, err := io.ReadAll(resp.Body) 681 if err != nil { 682 log.Printf("Error reading response body: %v", err) 683 return 684 } 685 686 var result types.RepoBranchesResponse 687 err = json.Unmarshal(body, &result) 688 if err != nil { 689 log.Println("failed to parse response:", err) 690 return 691 } 692 693 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 694 RepoInfo: f.RepoInfo(s, user), 695 Branches: result.Branches, 696 }) 697} 698 699func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 700 user := s.auth.GetUser(r) 701 f, err := fullyResolvedRepo(r) 702 if err != nil { 703 log.Println("failed to get repo and knot", err) 704 return 705 } 706 707 pull, ok := r.Context().Value("pull").(*db.Pull) 708 if !ok { 709 log.Println("failed to get pull") 710 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 711 return 712 } 713 714 switch r.Method { 715 case http.MethodGet: 716 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 717 RepoInfo: f.RepoInfo(s, user), 718 Pull: pull, 719 }) 720 return 721 case http.MethodPost: 722 patch := r.FormValue("patch") 723 var sourceRev string 724 var recordPullSource *tangled.RepoPull_Source 725 726 // this pull is a branch based pull 727 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 728 if pull.IsSameRepoBranch() && isPushAllowed { 729 sourceBranch := pull.PullSource.Branch 730 targetBranch := pull.TargetBranch 731 recordPullSource = &tangled.RepoPull_Source{ 732 Branch: sourceBranch, 733 } 734 // extract patch by performing compare 735 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 736 if err != nil { 737 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 738 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 739 return 740 } 741 742 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 743 switch resp.StatusCode { 744 case 404: 745 case 400: 746 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 747 } 748 749 respBody, err := io.ReadAll(resp.Body) 750 if err != nil { 751 log.Println("failed to compare across branches") 752 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 753 } 754 defer resp.Body.Close() 755 756 var diffTreeResponse types.RepoDiffTreeResponse 757 err = json.Unmarshal(respBody, &diffTreeResponse) 758 if err != nil { 759 log.Println("failed to unmarshal diff tree response", err) 760 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 761 } 762 763 sourceRev = diffTreeResponse.DiffTree.Rev2 764 patch = diffTreeResponse.DiffTree.Patch 765 } 766 767 if patch == "" { 768 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 769 return 770 } 771 772 if patch == pull.LatestPatch() { 773 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 774 return 775 } 776 777 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 778 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 779 return 780 } 781 782 // Validate patch format 783 if !isPatchValid(patch) { 784 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 785 return 786 } 787 788 tx, err := s.db.BeginTx(r.Context(), nil) 789 if err != nil { 790 log.Println("failed to start tx") 791 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 792 return 793 } 794 defer tx.Rollback() 795 796 err = db.ResubmitPull(tx, pull, patch, sourceRev) 797 if err != nil { 798 log.Println("failed to create pull request", err) 799 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 800 return 801 } 802 client, _ := s.auth.AuthorizedClient(r) 803 804 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 805 if err != nil { 806 // failed to get record 807 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 808 return 809 } 810 811 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 812 Collection: tangled.RepoPullNSID, 813 Repo: user.Did, 814 Rkey: pull.Rkey, 815 SwapRecord: ex.Cid, 816 Record: &lexutil.LexiconTypeDecoder{ 817 Val: &tangled.RepoPull{ 818 Title: pull.Title, 819 PullId: int64(pull.PullId), 820 TargetRepo: string(f.RepoAt), 821 TargetBranch: pull.TargetBranch, 822 Patch: patch, // new patch 823 Source: recordPullSource, 824 }, 825 }, 826 }) 827 if err != nil { 828 log.Println("failed to update record", err) 829 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 830 return 831 } 832 833 if err = tx.Commit(); err != nil { 834 log.Println("failed to commit transaction", err) 835 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 836 return 837 } 838 839 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 840 return 841 } 842} 843 844func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 845 f, err := fullyResolvedRepo(r) 846 if err != nil { 847 log.Println("failed to resolve repo:", err) 848 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 849 return 850 } 851 852 pull, ok := r.Context().Value("pull").(*db.Pull) 853 if !ok { 854 log.Println("failed to get pull") 855 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 856 return 857 } 858 859 secret, err := db.GetRegistrationKey(s.db, f.Knot) 860 if err != nil { 861 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 862 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 863 return 864 } 865 866 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 867 if err != nil { 868 log.Printf("resolving identity: %s", err) 869 w.WriteHeader(http.StatusNotFound) 870 return 871 } 872 873 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 874 if err != nil { 875 log.Printf("failed to get primary email: %s", err) 876 } 877 878 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 879 if err != nil { 880 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 881 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 882 return 883 } 884 885 // Merge the pull request 886 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 887 if err != nil { 888 log.Printf("failed to merge pull request: %s", err) 889 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 890 return 891 } 892 893 if resp.StatusCode == http.StatusOK { 894 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 895 if err != nil { 896 log.Printf("failed to update pull request status in database: %s", err) 897 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 898 return 899 } 900 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 901 } else { 902 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 903 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 904 } 905} 906 907func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 908 user := s.auth.GetUser(r) 909 910 f, err := fullyResolvedRepo(r) 911 if err != nil { 912 log.Println("malformed middleware") 913 return 914 } 915 916 pull, ok := r.Context().Value("pull").(*db.Pull) 917 if !ok { 918 log.Println("failed to get pull") 919 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 920 return 921 } 922 923 // auth filter: only owner or collaborators can close 924 roles := RolesInRepo(s, user, f) 925 isCollaborator := roles.IsCollaborator() 926 isPullAuthor := user.Did == pull.OwnerDid 927 isCloseAllowed := isCollaborator || isPullAuthor 928 if !isCloseAllowed { 929 log.Println("failed to close pull") 930 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 931 return 932 } 933 934 // Start a transaction 935 tx, err := s.db.BeginTx(r.Context(), nil) 936 if err != nil { 937 log.Println("failed to start transaction", err) 938 s.pages.Notice(w, "pull-close", "Failed to close pull.") 939 return 940 } 941 942 // Close the pull in the database 943 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 944 if err != nil { 945 log.Println("failed to close pull", err) 946 s.pages.Notice(w, "pull-close", "Failed to close pull.") 947 return 948 } 949 950 // Commit the transaction 951 if err = tx.Commit(); err != nil { 952 log.Println("failed to commit transaction", err) 953 s.pages.Notice(w, "pull-close", "Failed to close pull.") 954 return 955 } 956 957 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 958 return 959} 960 961func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 962 user := s.auth.GetUser(r) 963 964 f, err := fullyResolvedRepo(r) 965 if err != nil { 966 log.Println("failed to resolve repo", err) 967 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 968 return 969 } 970 971 pull, ok := r.Context().Value("pull").(*db.Pull) 972 if !ok { 973 log.Println("failed to get pull") 974 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 975 return 976 } 977 978 // auth filter: only owner or collaborators can close 979 roles := RolesInRepo(s, user, f) 980 isCollaborator := roles.IsCollaborator() 981 isPullAuthor := user.Did == pull.OwnerDid 982 isCloseAllowed := isCollaborator || isPullAuthor 983 if !isCloseAllowed { 984 log.Println("failed to close pull") 985 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 986 return 987 } 988 989 // Start a transaction 990 tx, err := s.db.BeginTx(r.Context(), nil) 991 if err != nil { 992 log.Println("failed to start transaction", err) 993 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 994 return 995 } 996 997 // Reopen the pull in the database 998 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 999 if err != nil { 1000 log.Println("failed to reopen pull", err) 1001 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1002 return 1003 } 1004 1005 // Commit the transaction 1006 if err = tx.Commit(); err != nil { 1007 log.Println("failed to commit transaction", err) 1008 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1009 return 1010 } 1011 1012 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1013 return 1014} 1015 1016// Very basic validation to check if it looks like a diff/patch 1017// A valid patch usually starts with diff or --- lines 1018func isPatchValid(patch string) bool { 1019 // Basic validation to check if it looks like a diff/patch 1020 // A valid patch usually starts with diff or --- lines 1021 if len(patch) == 0 { 1022 return false 1023 } 1024 1025 lines := strings.Split(patch, "\n") 1026 if len(lines) < 2 { 1027 return false 1028 } 1029 1030 // Check for common patch format markers 1031 firstLine := strings.TrimSpace(lines[0]) 1032 return strings.HasPrefix(firstLine, "diff ") || 1033 strings.HasPrefix(firstLine, "--- ") || 1034 strings.HasPrefix(firstLine, "Index: ") || 1035 strings.HasPrefix(firstLine, "+++ ") || 1036 strings.HasPrefix(firstLine, "@@ ") 1037}