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