forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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) ResubmitPull(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 pull, ok := r.Context().Value("pull").(*db.Pull) 655 if !ok { 656 log.Println("failed to get pull") 657 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 658 return 659 } 660 661 switch r.Method { 662 case http.MethodGet: 663 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 664 RepoInfo: f.RepoInfo(s, user), 665 Pull: pull, 666 }) 667 return 668 case http.MethodPost: 669 patch := r.FormValue("patch") 670 var sourceRev string 671 var recordPullSource *tangled.RepoPull_Source 672 673 // this pull is a branch based pull 674 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 675 if pull.IsSameRepoBranch() && isPushAllowed { 676 sourceBranch := pull.PullSource.Branch 677 targetBranch := pull.TargetBranch 678 recordPullSource = &tangled.RepoPull_Source{ 679 Branch: sourceBranch, 680 } 681 // extract patch by performing compare 682 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 683 if err != nil { 684 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 685 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 686 return 687 } 688 689 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 690 switch resp.StatusCode { 691 case 404: 692 case 400: 693 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 694 } 695 696 respBody, err := io.ReadAll(resp.Body) 697 if err != nil { 698 log.Println("failed to compare across branches") 699 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 700 } 701 defer resp.Body.Close() 702 703 var diffTreeResponse types.RepoDiffTreeResponse 704 err = json.Unmarshal(respBody, &diffTreeResponse) 705 if err != nil { 706 log.Println("failed to unmarshal diff tree response", err) 707 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 708 } 709 710 sourceRev = diffTreeResponse.DiffTree.Rev2 711 patch = diffTreeResponse.DiffTree.Patch 712 } 713 714 if patch == "" { 715 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 716 return 717 } 718 719 if patch == pull.LatestPatch() { 720 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 721 return 722 } 723 724 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 725 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 726 return 727 } 728 729 // Validate patch format 730 if !isPatchValid(patch) { 731 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 732 return 733 } 734 735 tx, err := s.db.BeginTx(r.Context(), nil) 736 if err != nil { 737 log.Println("failed to start tx") 738 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 739 return 740 } 741 defer tx.Rollback() 742 743 err = db.ResubmitPull(tx, pull, patch, sourceRev) 744 if err != nil { 745 log.Println("failed to create pull request", err) 746 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 747 return 748 } 749 client, _ := s.auth.AuthorizedClient(r) 750 751 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 752 if err != nil { 753 // failed to get record 754 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 755 return 756 } 757 758 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 759 Collection: tangled.RepoPullNSID, 760 Repo: user.Did, 761 Rkey: pull.Rkey, 762 SwapRecord: ex.Cid, 763 Record: &lexutil.LexiconTypeDecoder{ 764 Val: &tangled.RepoPull{ 765 Title: pull.Title, 766 PullId: int64(pull.PullId), 767 TargetRepo: string(f.RepoAt), 768 TargetBranch: pull.TargetBranch, 769 Patch: patch, // new patch 770 Source: recordPullSource, 771 }, 772 }, 773 }) 774 if err != nil { 775 log.Println("failed to update record", err) 776 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 777 return 778 } 779 780 if err = tx.Commit(); err != nil { 781 log.Println("failed to commit transaction", err) 782 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 783 return 784 } 785 786 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 787 return 788 } 789} 790 791func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 792 f, err := fullyResolvedRepo(r) 793 if err != nil { 794 log.Println("failed to resolve repo:", err) 795 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 796 return 797 } 798 799 pull, ok := r.Context().Value("pull").(*db.Pull) 800 if !ok { 801 log.Println("failed to get pull") 802 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 803 return 804 } 805 806 secret, err := db.GetRegistrationKey(s.db, f.Knot) 807 if err != nil { 808 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 809 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 810 return 811 } 812 813 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 814 if err != nil { 815 log.Printf("resolving identity: %s", err) 816 w.WriteHeader(http.StatusNotFound) 817 return 818 } 819 820 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 821 if err != nil { 822 log.Printf("failed to get primary email: %s", err) 823 } 824 825 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 826 if err != nil { 827 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 828 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 829 return 830 } 831 832 // Merge the pull request 833 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 834 if err != nil { 835 log.Printf("failed to merge pull request: %s", err) 836 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 837 return 838 } 839 840 if resp.StatusCode == http.StatusOK { 841 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 842 if err != nil { 843 log.Printf("failed to update pull request status in database: %s", err) 844 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 845 return 846 } 847 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 848 } else { 849 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 850 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 851 } 852} 853 854func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 855 user := s.auth.GetUser(r) 856 857 f, err := fullyResolvedRepo(r) 858 if err != nil { 859 log.Println("malformed middleware") 860 return 861 } 862 863 pull, ok := r.Context().Value("pull").(*db.Pull) 864 if !ok { 865 log.Println("failed to get pull") 866 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 867 return 868 } 869 870 // auth filter: only owner or collaborators can close 871 roles := RolesInRepo(s, user, f) 872 isCollaborator := roles.IsCollaborator() 873 isPullAuthor := user.Did == pull.OwnerDid 874 isCloseAllowed := isCollaborator || isPullAuthor 875 if !isCloseAllowed { 876 log.Println("failed to close pull") 877 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 878 return 879 } 880 881 // Start a transaction 882 tx, err := s.db.BeginTx(r.Context(), nil) 883 if err != nil { 884 log.Println("failed to start transaction", err) 885 s.pages.Notice(w, "pull-close", "Failed to close pull.") 886 return 887 } 888 889 // Close the pull in the database 890 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 891 if err != nil { 892 log.Println("failed to close pull", err) 893 s.pages.Notice(w, "pull-close", "Failed to close pull.") 894 return 895 } 896 897 // Commit the transaction 898 if err = tx.Commit(); err != nil { 899 log.Println("failed to commit transaction", err) 900 s.pages.Notice(w, "pull-close", "Failed to close pull.") 901 return 902 } 903 904 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 905 return 906} 907 908func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 909 user := s.auth.GetUser(r) 910 911 f, err := fullyResolvedRepo(r) 912 if err != nil { 913 log.Println("failed to resolve repo", err) 914 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 915 return 916 } 917 918 pull, ok := r.Context().Value("pull").(*db.Pull) 919 if !ok { 920 log.Println("failed to get pull") 921 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 922 return 923 } 924 925 // auth filter: only owner or collaborators can close 926 roles := RolesInRepo(s, user, f) 927 isCollaborator := roles.IsCollaborator() 928 isPullAuthor := user.Did == pull.OwnerDid 929 isCloseAllowed := isCollaborator || isPullAuthor 930 if !isCloseAllowed { 931 log.Println("failed to close pull") 932 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 933 return 934 } 935 936 // Start a transaction 937 tx, err := s.db.BeginTx(r.Context(), nil) 938 if err != nil { 939 log.Println("failed to start transaction", err) 940 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 941 return 942 } 943 944 // Reopen the pull in the database 945 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 946 if err != nil { 947 log.Println("failed to reopen pull", err) 948 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 949 return 950 } 951 952 // Commit the transaction 953 if err = tx.Commit(); err != nil { 954 log.Println("failed to commit transaction", err) 955 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 956 return 957 } 958 959 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 960 return 961} 962 963// Very basic validation to check if it looks like a diff/patch 964// A valid patch usually starts with diff or --- lines 965func isPatchValid(patch string) bool { 966 // Basic validation to check if it looks like a diff/patch 967 // A valid patch usually starts with diff or --- lines 968 if len(patch) == 0 { 969 return false 970 } 971 972 lines := strings.Split(patch, "\n") 973 if len(lines) < 2 { 974 return false 975 } 976 977 // Check for common patch format markers 978 firstLine := strings.TrimSpace(lines[0]) 979 return strings.HasPrefix(firstLine, "diff ") || 980 strings.HasPrefix(firstLine, "--- ") || 981 strings.HasPrefix(firstLine, "Index: ") || 982 strings.HasPrefix(firstLine, "+++ ") || 983 strings.HasPrefix(firstLine, "@@ ") 984}