this repo has no description
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 title := r.FormValue("title") 454 body := r.FormValue("body") 455 targetBranch := r.FormValue("targetBranch") 456 sourceBranch := r.FormValue("sourceBranch") 457 patch := r.FormValue("patch") 458 459 if sourceBranch == "" && patch == "" { 460 s.pages.Notice(w, "pull", "neither sourceBranch nor patch supplied") 461 return 462 } 463 464 if title == "" || body == "" || targetBranch == "" { 465 s.pages.Notice(w, "pull", "Title, body and patch diff are required.") 466 return 467 } 468 469 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 470 471 // TODO: check if knot has this capability 472 var pullSource *db.PullSource 473 if sourceBranch != "" && isPushAllowed { 474 pullSource = &db.PullSource{ 475 Branch: sourceBranch, 476 } 477 // generate a patch using /compare 478 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 479 if err != nil { 480 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 481 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 482 return 483 } 484 485 log.Println(targetBranch, sourceBranch) 486 487 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 488 switch resp.StatusCode { 489 case 404: 490 case 400: 491 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 492 } 493 494 respBody, err := io.ReadAll(resp.Body) 495 if err != nil { 496 log.Println("failed to compare across branches") 497 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 498 } 499 defer resp.Body.Close() 500 501 var diffTreeResponse types.RepoDiffTreeResponse 502 err = json.Unmarshal(respBody, &diffTreeResponse) 503 if err != nil { 504 log.Println("failed to unmarshal diff tree response", err) 505 log.Println(string(respBody)) 506 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 507 } 508 509 patch = diffTreeResponse.DiffTree.Patch 510 } 511 512 log.Println(patch) 513 514 // Validate patch format 515 if !isPatchValid(patch) { 516 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 517 return 518 } 519 520 tx, err := s.db.BeginTx(r.Context(), nil) 521 if err != nil { 522 log.Println("failed to start tx") 523 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 524 return 525 } 526 defer tx.Rollback() 527 528 rkey := s.TID() 529 initialSubmission := db.PullSubmission{ 530 Patch: patch, 531 } 532 err = db.NewPull(tx, &db.Pull{ 533 Title: title, 534 Body: body, 535 TargetBranch: targetBranch, 536 OwnerDid: user.Did, 537 RepoAt: f.RepoAt, 538 Rkey: rkey, 539 Submissions: []*db.PullSubmission{ 540 &initialSubmission, 541 }, 542 PullSource: pullSource, 543 }) 544 if err != nil { 545 log.Println("failed to create pull request", err) 546 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 547 return 548 } 549 client, _ := s.auth.AuthorizedClient(r) 550 pullId, err := db.NextPullId(s.db, f.RepoAt) 551 if err != nil { 552 log.Println("failed to get pull id", err) 553 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 554 return 555 } 556 557 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 558 Collection: tangled.RepoPullNSID, 559 Repo: user.Did, 560 Rkey: rkey, 561 Record: &lexutil.LexiconTypeDecoder{ 562 Val: &tangled.RepoPull{ 563 Title: title, 564 PullId: int64(pullId), 565 TargetRepo: string(f.RepoAt), 566 TargetBranch: targetBranch, 567 Patch: patch, 568 }, 569 }, 570 }) 571 572 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 573 if err != nil { 574 log.Println("failed to get pull id", err) 575 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 576 return 577 } 578 579 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 580 return 581 } 582} 583 584func (s *State) RenderDiffFragment(w http.ResponseWriter, r *http.Request) { 585} 586 587func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 588 user := s.auth.GetUser(r) 589 f, err := fullyResolvedRepo(r) 590 if err != nil { 591 log.Println("failed to get repo and knot", err) 592 return 593 } 594 595 pull, ok := r.Context().Value("pull").(*db.Pull) 596 if !ok { 597 log.Println("failed to get pull") 598 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 599 return 600 } 601 602 switch r.Method { 603 case http.MethodGet: 604 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 605 RepoInfo: f.RepoInfo(s, user), 606 Pull: pull, 607 }) 608 return 609 case http.MethodPost: 610 patch := r.FormValue("patch") 611 612 // this pull is a branch based pull 613 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 614 if pull.IsSameRepoBranch() && isPushAllowed { 615 sourceBranch := pull.PullSource.Branch 616 targetBranch := pull.TargetBranch 617 // extract patch by performing compare 618 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 619 if err != nil { 620 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 621 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 622 return 623 } 624 625 log.Println(targetBranch, sourceBranch) 626 627 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 628 switch resp.StatusCode { 629 case 404: 630 case 400: 631 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 632 } 633 634 respBody, err := io.ReadAll(resp.Body) 635 if err != nil { 636 log.Println("failed to compare across branches") 637 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 638 } 639 defer resp.Body.Close() 640 641 var diffTreeResponse types.RepoDiffTreeResponse 642 err = json.Unmarshal(respBody, &diffTreeResponse) 643 if err != nil { 644 log.Println("failed to unmarshal diff tree response", err) 645 log.Println(string(respBody)) 646 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 647 } 648 649 patch = diffTreeResponse.DiffTree.Patch 650 } 651 652 if patch == "" { 653 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 654 return 655 } 656 657 if patch == pull.LatestPatch() { 658 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 659 return 660 } 661 662 // Validate patch format 663 if !isPatchValid(patch) { 664 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 665 return 666 } 667 668 tx, err := s.db.BeginTx(r.Context(), nil) 669 if err != nil { 670 log.Println("failed to start tx") 671 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 672 return 673 } 674 defer tx.Rollback() 675 676 err = db.ResubmitPull(tx, pull, patch) 677 if err != nil { 678 log.Println("failed to create pull request", err) 679 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 680 return 681 } 682 client, _ := s.auth.AuthorizedClient(r) 683 684 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 685 if err != nil { 686 // failed to get record 687 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 688 return 689 } 690 691 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 692 Collection: tangled.RepoPullNSID, 693 Repo: user.Did, 694 Rkey: pull.Rkey, 695 SwapRecord: ex.Cid, 696 Record: &lexutil.LexiconTypeDecoder{ 697 Val: &tangled.RepoPull{ 698 Title: pull.Title, 699 PullId: int64(pull.PullId), 700 TargetRepo: string(f.RepoAt), 701 TargetBranch: pull.TargetBranch, 702 Patch: patch, // new patch 703 }, 704 }, 705 }) 706 if err != nil { 707 log.Println("failed to update record", err) 708 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 709 return 710 } 711 712 if err = tx.Commit(); err != nil { 713 log.Println("failed to commit transaction", err) 714 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 715 return 716 } 717 718 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 719 return 720 } 721} 722 723func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 724 f, err := fullyResolvedRepo(r) 725 if err != nil { 726 log.Println("failed to resolve repo:", err) 727 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 728 return 729 } 730 731 pull, ok := r.Context().Value("pull").(*db.Pull) 732 if !ok { 733 log.Println("failed to get pull") 734 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 735 return 736 } 737 738 secret, err := db.GetRegistrationKey(s.db, f.Knot) 739 if err != nil { 740 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 741 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 742 return 743 } 744 745 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 746 if err != nil { 747 log.Printf("resolving identity: %s", err) 748 w.WriteHeader(http.StatusNotFound) 749 return 750 } 751 752 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 753 if err != nil { 754 log.Printf("failed to get primary email: %s", err) 755 } 756 757 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 758 if err != nil { 759 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 760 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 761 return 762 } 763 764 // Merge the pull request 765 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 766 if err != nil { 767 log.Printf("failed to merge pull request: %s", err) 768 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 769 return 770 } 771 772 if resp.StatusCode == http.StatusOK { 773 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 774 if err != nil { 775 log.Printf("failed to update pull request status in database: %s", err) 776 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 777 return 778 } 779 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 780 } else { 781 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 782 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 783 } 784} 785 786func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 787 user := s.auth.GetUser(r) 788 789 f, err := fullyResolvedRepo(r) 790 if err != nil { 791 log.Println("malformed middleware") 792 return 793 } 794 795 pull, ok := r.Context().Value("pull").(*db.Pull) 796 if !ok { 797 log.Println("failed to get pull") 798 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 799 return 800 } 801 802 // auth filter: only owner or collaborators can close 803 roles := RolesInRepo(s, user, f) 804 isCollaborator := roles.IsCollaborator() 805 isPullAuthor := user.Did == pull.OwnerDid 806 isCloseAllowed := isCollaborator || isPullAuthor 807 if !isCloseAllowed { 808 log.Println("failed to close pull") 809 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 810 return 811 } 812 813 // Start a transaction 814 tx, err := s.db.BeginTx(r.Context(), nil) 815 if err != nil { 816 log.Println("failed to start transaction", err) 817 s.pages.Notice(w, "pull-close", "Failed to close pull.") 818 return 819 } 820 821 // Close the pull in the database 822 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 823 if err != nil { 824 log.Println("failed to close pull", err) 825 s.pages.Notice(w, "pull-close", "Failed to close pull.") 826 return 827 } 828 829 // Commit the transaction 830 if err = tx.Commit(); err != nil { 831 log.Println("failed to commit transaction", err) 832 s.pages.Notice(w, "pull-close", "Failed to close pull.") 833 return 834 } 835 836 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 837 return 838} 839 840func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 841 user := s.auth.GetUser(r) 842 843 f, err := fullyResolvedRepo(r) 844 if err != nil { 845 log.Println("failed to resolve repo", err) 846 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 847 return 848 } 849 850 pull, ok := r.Context().Value("pull").(*db.Pull) 851 if !ok { 852 log.Println("failed to get pull") 853 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 854 return 855 } 856 857 // auth filter: only owner or collaborators can close 858 roles := RolesInRepo(s, user, f) 859 isCollaborator := roles.IsCollaborator() 860 isPullAuthor := user.Did == pull.OwnerDid 861 isCloseAllowed := isCollaborator || isPullAuthor 862 if !isCloseAllowed { 863 log.Println("failed to close pull") 864 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 865 return 866 } 867 868 // Start a transaction 869 tx, err := s.db.BeginTx(r.Context(), nil) 870 if err != nil { 871 log.Println("failed to start transaction", err) 872 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 873 return 874 } 875 876 // Reopen the pull in the database 877 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 878 if err != nil { 879 log.Println("failed to reopen pull", err) 880 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 881 return 882 } 883 884 // Commit the transaction 885 if err = tx.Commit(); err != nil { 886 log.Println("failed to commit transaction", err) 887 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 888 return 889 } 890 891 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 892 return 893} 894 895// Very basic validation to check if it looks like a diff/patch 896// A valid patch usually starts with diff or --- lines 897func isPatchValid(patch string) bool { 898 // Basic validation to check if it looks like a diff/patch 899 // A valid patch usually starts with diff or --- lines 900 if len(patch) == 0 { 901 return false 902 } 903 904 lines := strings.Split(patch, "\n") 905 if len(lines) < 2 { 906 return false 907 } 908 909 // Check for common patch format markers 910 firstLine := strings.TrimSpace(lines[0]) 911 return strings.HasPrefix(firstLine, "diff ") || 912 strings.HasPrefix(firstLine, "--- ") || 913 strings.HasPrefix(firstLine, "Index: ") || 914 strings.HasPrefix(firstLine, "+++ ") || 915 strings.HasPrefix(firstLine, "@@ ") 916}