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