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