this repo has no description
1package state 2 3import ( 4 "database/sql" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 "net/url" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/go-chi/chi/v5" 17 "tangled.sh/tangled.sh/core/api/tangled" 18 "tangled.sh/tangled.sh/core/appview/auth" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/types" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26) 27 28// htmx fragment 29func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 30 switch r.Method { 31 case http.MethodGet: 32 user := s.auth.GetUser(r) 33 f, err := fullyResolvedRepo(r) 34 if err != nil { 35 log.Println("failed to get repo and knot", err) 36 return 37 } 38 39 pull, ok := r.Context().Value("pull").(*db.Pull) 40 if !ok { 41 log.Println("failed to get pull") 42 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 43 return 44 } 45 46 roundNumberStr := chi.URLParam(r, "round") 47 roundNumber, err := strconv.Atoi(roundNumberStr) 48 if err != nil { 49 roundNumber = pull.LastRoundNumber() 50 } 51 if roundNumber >= len(pull.Submissions) { 52 http.Error(w, "bad round id", http.StatusBadRequest) 53 log.Println("failed to parse round id", err) 54 return 55 } 56 57 mergeCheckResponse := s.mergeCheck(f, pull) 58 var resubmitResult pages.ResubmitResult 59 if user.Did == pull.OwnerDid { 60 resubmitResult = s.resubmitCheck(f, pull) 61 } 62 63 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 64 LoggedInUser: user, 65 RepoInfo: f.RepoInfo(s, user), 66 Pull: pull, 67 RoundNumber: roundNumber, 68 MergeCheck: mergeCheckResponse, 69 ResubmitCheck: resubmitResult, 70 }) 71 return 72 } 73} 74 75func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 76 user := s.auth.GetUser(r) 77 f, err := fullyResolvedRepo(r) 78 if err != nil { 79 log.Println("failed to get repo and knot", err) 80 return 81 } 82 83 pull, ok := r.Context().Value("pull").(*db.Pull) 84 if !ok { 85 log.Println("failed to get pull") 86 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 87 return 88 } 89 90 totalIdents := 1 91 for _, submission := range pull.Submissions { 92 totalIdents += len(submission.Comments) 93 } 94 95 identsToResolve := make([]string, totalIdents) 96 97 // populate idents 98 identsToResolve[0] = pull.OwnerDid 99 idx := 1 100 for _, submission := range pull.Submissions { 101 for _, comment := range submission.Comments { 102 identsToResolve[idx] = comment.OwnerDid 103 idx += 1 104 } 105 } 106 107 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 108 didHandleMap := make(map[string]string) 109 for _, identity := range resolvedIds { 110 if !identity.Handle.IsInvalidHandle() { 111 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 } else { 113 didHandleMap[identity.DID.String()] = identity.DID.String() 114 } 115 } 116 117 mergeCheckResponse := s.mergeCheck(f, pull) 118 var resubmitResult pages.ResubmitResult 119 if user.Did == pull.OwnerDid { 120 resubmitResult = s.resubmitCheck(f, pull) 121 } 122 123 var pullSourceRepo *db.Repo 124 if pull.PullSource != nil { 125 if pull.PullSource.Repo != nil { 126 pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String()) 127 if err != nil { 128 log.Printf("failed to get repo by at uri: %v", err) 129 return 130 } 131 } 132 } 133 134 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 135 LoggedInUser: user, 136 RepoInfo: f.RepoInfo(s, user), 137 DidHandleMap: didHandleMap, 138 Pull: pull, 139 PullSourceRepo: pullSourceRepo, 140 MergeCheck: mergeCheckResponse, 141 ResubmitCheck: resubmitResult, 142 }) 143} 144 145func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse { 146 if pull.State == db.PullMerged { 147 return types.MergeCheckResponse{} 148 } 149 150 secret, err := db.GetRegistrationKey(s.db, f.Knot) 151 if err != nil { 152 log.Printf("failed to get registration key: %v", err) 153 return types.MergeCheckResponse{ 154 Error: "failed to check merge status: this knot is unregistered", 155 } 156 } 157 158 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 159 if err != nil { 160 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 161 return types.MergeCheckResponse{ 162 Error: "failed to check merge status", 163 } 164 } 165 166 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch) 167 if err != nil { 168 log.Println("failed to check for mergeability:", err) 169 return types.MergeCheckResponse{ 170 Error: "failed to check merge status", 171 } 172 } 173 switch resp.StatusCode { 174 case 404: 175 return types.MergeCheckResponse{ 176 Error: "failed to check merge status: this knot does not support PRs", 177 } 178 case 400: 179 return types.MergeCheckResponse{ 180 Error: "failed to check merge status: does this knot support PRs?", 181 } 182 } 183 184 respBody, err := io.ReadAll(resp.Body) 185 if err != nil { 186 log.Println("failed to read merge check response body") 187 return types.MergeCheckResponse{ 188 Error: "failed to check merge status: knot is not speaking the right language", 189 } 190 } 191 defer resp.Body.Close() 192 193 var mergeCheckResponse types.MergeCheckResponse 194 err = json.Unmarshal(respBody, &mergeCheckResponse) 195 if err != nil { 196 log.Println("failed to unmarshal merge check response", err) 197 return types.MergeCheckResponse{ 198 Error: "failed to check merge status: knot is not speaking the right language", 199 } 200 } 201 202 return mergeCheckResponse 203} 204 205func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { 206 if pull.State == db.PullMerged || pull.PullSource == nil { 207 return pages.Unknown 208 } 209 210 var knot, ownerDid, repoName string 211 212 if pull.PullSource.Repo != nil { 213 // fork-based pulls 214 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String()) 215 if err != nil { 216 log.Println("failed to get source repo", err) 217 return pages.Unknown 218 } 219 220 knot = sourceRepo.Knot 221 ownerDid = sourceRepo.Did 222 repoName = sourceRepo.Name 223 } else { 224 // pulls within the same repo 225 knot = f.Knot 226 ownerDid = f.OwnerDid() 227 repoName = f.RepoName 228 } 229 230 us, err := NewUnsignedClient(knot, s.config.Dev) 231 if err != nil { 232 log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 233 return pages.Unknown 234 } 235 236 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 237 if err != nil { 238 log.Println("failed to reach knotserver", err) 239 return pages.Unknown 240 } 241 242 body, err := io.ReadAll(resp.Body) 243 if err != nil { 244 log.Printf("error reading response body: %v", err) 245 return pages.Unknown 246 } 247 defer resp.Body.Close() 248 249 var result types.RepoBranchResponse 250 if err := json.Unmarshal(body, &result); err != nil { 251 log.Println("failed to parse response:", err) 252 return pages.Unknown 253 } 254 255 latestSubmission := pull.Submissions[pull.LastRoundNumber()] 256 if latestSubmission.SourceRev != result.Branch.Hash { 257 return pages.ShouldResubmit 258 } 259 260 return pages.ShouldNotResubmit 261} 262 263func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 264 user := s.auth.GetUser(r) 265 f, err := fullyResolvedRepo(r) 266 if err != nil { 267 log.Println("failed to get repo and knot", err) 268 return 269 } 270 271 pull, ok := r.Context().Value("pull").(*db.Pull) 272 if !ok { 273 log.Println("failed to get pull") 274 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 275 return 276 } 277 278 roundId := chi.URLParam(r, "round") 279 roundIdInt, err := strconv.Atoi(roundId) 280 if err != nil || roundIdInt >= len(pull.Submissions) { 281 http.Error(w, "bad round id", http.StatusBadRequest) 282 log.Println("failed to parse round id", err) 283 return 284 } 285 286 identsToResolve := []string{pull.OwnerDid} 287 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 288 didHandleMap := make(map[string]string) 289 for _, identity := range resolvedIds { 290 if !identity.Handle.IsInvalidHandle() { 291 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 292 } else { 293 didHandleMap[identity.DID.String()] = identity.DID.String() 294 } 295 } 296 297 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 298 LoggedInUser: user, 299 DidHandleMap: didHandleMap, 300 RepoInfo: f.RepoInfo(s, user), 301 Pull: pull, 302 Round: roundIdInt, 303 Submission: pull.Submissions[roundIdInt], 304 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 305 }) 306 307} 308 309func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 310 pull, ok := r.Context().Value("pull").(*db.Pull) 311 if !ok { 312 log.Println("failed to get pull") 313 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 314 return 315 } 316 317 roundId := chi.URLParam(r, "round") 318 roundIdInt, err := strconv.Atoi(roundId) 319 if err != nil || roundIdInt >= len(pull.Submissions) { 320 http.Error(w, "bad round id", http.StatusBadRequest) 321 log.Println("failed to parse round id", err) 322 return 323 } 324 325 identsToResolve := []string{pull.OwnerDid} 326 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 327 didHandleMap := make(map[string]string) 328 for _, identity := range resolvedIds { 329 if !identity.Handle.IsInvalidHandle() { 330 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 331 } else { 332 didHandleMap[identity.DID.String()] = identity.DID.String() 333 } 334 } 335 336 w.Header().Set("Content-Type", "text/plain") 337 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 338} 339 340func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 341 user := s.auth.GetUser(r) 342 params := r.URL.Query() 343 344 state := db.PullOpen 345 switch params.Get("state") { 346 case "closed": 347 state = db.PullClosed 348 case "merged": 349 state = db.PullMerged 350 } 351 352 f, err := fullyResolvedRepo(r) 353 if err != nil { 354 log.Println("failed to get repo and knot", err) 355 return 356 } 357 358 pulls, err := db.GetPulls(s.db, f.RepoAt, state) 359 if err != nil { 360 log.Println("failed to get pulls", err) 361 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 362 return 363 } 364 365 identsToResolve := make([]string, len(pulls)) 366 for i, pull := range pulls { 367 identsToResolve[i] = pull.OwnerDid 368 } 369 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 370 didHandleMap := make(map[string]string) 371 for _, identity := range resolvedIds { 372 if !identity.Handle.IsInvalidHandle() { 373 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 374 } else { 375 didHandleMap[identity.DID.String()] = identity.DID.String() 376 } 377 } 378 379 s.pages.RepoPulls(w, pages.RepoPullsParams{ 380 LoggedInUser: s.auth.GetUser(r), 381 RepoInfo: f.RepoInfo(s, user), 382 Pulls: pulls, 383 DidHandleMap: didHandleMap, 384 FilteringBy: state, 385 }) 386 return 387} 388 389func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 390 user := s.auth.GetUser(r) 391 f, err := fullyResolvedRepo(r) 392 if err != nil { 393 log.Println("failed to get repo and knot", err) 394 return 395 } 396 397 pull, ok := r.Context().Value("pull").(*db.Pull) 398 if !ok { 399 log.Println("failed to get pull") 400 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 401 return 402 } 403 404 roundNumberStr := chi.URLParam(r, "round") 405 roundNumber, err := strconv.Atoi(roundNumberStr) 406 if err != nil || roundNumber >= len(pull.Submissions) { 407 http.Error(w, "bad round id", http.StatusBadRequest) 408 log.Println("failed to parse round id", err) 409 return 410 } 411 412 switch r.Method { 413 case http.MethodGet: 414 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 415 LoggedInUser: user, 416 RepoInfo: f.RepoInfo(s, user), 417 Pull: pull, 418 RoundNumber: roundNumber, 419 }) 420 return 421 case http.MethodPost: 422 body := r.FormValue("body") 423 if body == "" { 424 s.pages.Notice(w, "pull", "Comment body is required") 425 return 426 } 427 428 // Start a transaction 429 tx, err := s.db.BeginTx(r.Context(), nil) 430 if err != nil { 431 log.Println("failed to start transaction", err) 432 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 433 return 434 } 435 defer tx.Rollback() 436 437 createdAt := time.Now().Format(time.RFC3339) 438 ownerDid := user.Did 439 440 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 441 if err != nil { 442 log.Println("failed to get pull at", err) 443 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 444 return 445 } 446 447 atUri := f.RepoAt.String() 448 client, _ := s.auth.AuthorizedClient(r) 449 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 450 Collection: tangled.RepoPullCommentNSID, 451 Repo: user.Did, 452 Rkey: s.TID(), 453 Record: &lexutil.LexiconTypeDecoder{ 454 Val: &tangled.RepoPullComment{ 455 Repo: &atUri, 456 Pull: pullAt, 457 Owner: &ownerDid, 458 Body: &body, 459 CreatedAt: &createdAt, 460 }, 461 }, 462 }) 463 if err != nil { 464 log.Println("failed to create pull comment", err) 465 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 466 return 467 } 468 469 // Create the pull comment in the database with the commentAt field 470 commentId, err := db.NewPullComment(tx, &db.PullComment{ 471 OwnerDid: user.Did, 472 RepoAt: f.RepoAt.String(), 473 PullId: pull.PullId, 474 Body: body, 475 CommentAt: atResp.Uri, 476 SubmissionId: pull.Submissions[roundNumber].ID, 477 }) 478 if err != nil { 479 log.Println("failed to create pull comment", err) 480 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 481 return 482 } 483 484 // Commit the transaction 485 if err = tx.Commit(); err != nil { 486 log.Println("failed to commit transaction", err) 487 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 488 return 489 } 490 491 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 492 return 493 } 494} 495 496func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 497 user := s.auth.GetUser(r) 498 f, err := fullyResolvedRepo(r) 499 if err != nil { 500 log.Println("failed to get repo and knot", err) 501 return 502 } 503 504 switch r.Method { 505 case http.MethodGet: 506 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 507 if err != nil { 508 log.Printf("failed to create unsigned client for %s", f.Knot) 509 s.pages.Error503(w) 510 return 511 } 512 513 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 514 if err != nil { 515 log.Println("failed to reach knotserver", err) 516 return 517 } 518 519 body, err := io.ReadAll(resp.Body) 520 if err != nil { 521 log.Printf("Error reading response body: %v", err) 522 return 523 } 524 525 var result types.RepoBranchesResponse 526 err = json.Unmarshal(body, &result) 527 if err != nil { 528 log.Println("failed to parse response:", err) 529 return 530 } 531 532 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 533 LoggedInUser: user, 534 RepoInfo: f.RepoInfo(s, user), 535 Branches: result.Branches, 536 }) 537 case http.MethodPost: 538 title := r.FormValue("title") 539 body := r.FormValue("body") 540 targetBranch := r.FormValue("targetBranch") 541 fromFork := r.FormValue("fork") 542 sourceBranch := r.FormValue("sourceBranch") 543 patch := r.FormValue("patch") 544 545 // Validate required fields for all PR types 546 if title == "" || body == "" || targetBranch == "" { 547 s.pages.Notice(w, "pull", "Title, body and target branch are required.") 548 return 549 } 550 551 // Determine PR type based on input parameters 552 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 553 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 554 isForkBased := fromFork != "" && sourceBranch != "" 555 isPatchBased := patch != "" && !isBranchBased && !isForkBased 556 557 // Validate we have at least one valid PR creation method 558 if !isBranchBased && !isPatchBased && !isForkBased { 559 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 560 return 561 } 562 563 // Can't mix branch-based and patch-based approaches 564 if isBranchBased && patch != "" { 565 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 566 return 567 } 568 569 // Handle the PR creation based on the type 570 if isBranchBased { 571 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) 572 } else if isForkBased { 573 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) 574 } else if isPatchBased { 575 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) 576 } 577 return 578 } 579} 580 581func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { 582 pullSource := &db.PullSource{ 583 Branch: sourceBranch, 584 } 585 recordPullSource := &tangled.RepoPull_Source{ 586 Branch: sourceBranch, 587 } 588 589 // Generate a patch using /compare 590 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 591 if err != nil { 592 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 593 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 594 return 595 } 596 597 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 598 switch resp.StatusCode { 599 case 404: 600 case 400: 601 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 602 return 603 } 604 605 respBody, err := io.ReadAll(resp.Body) 606 if err != nil { 607 log.Println("failed to compare across branches") 608 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 609 return 610 } 611 defer resp.Body.Close() 612 613 var diffTreeResponse types.RepoDiffTreeResponse 614 err = json.Unmarshal(respBody, &diffTreeResponse) 615 if err != nil { 616 log.Println("failed to unmarshal diff tree response", err) 617 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 618 return 619 } 620 621 sourceRev := diffTreeResponse.DiffTree.Rev2 622 patch := diffTreeResponse.DiffTree.Patch 623 624 if !isPatchValid(patch) { 625 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 626 return 627 } 628 629 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 630} 631 632func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 633 if !isPatchValid(patch) { 634 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 635 return 636 } 637 638 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 639} 640 641func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 642 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 643 if errors.Is(err, sql.ErrNoRows) { 644 s.pages.Notice(w, "pull", "No such fork.") 645 return 646 } else if err != nil { 647 log.Println("failed to fetch fork:", err) 648 s.pages.Notice(w, "pull", "Failed to fetch fork.") 649 return 650 } 651 652 secret, err := db.GetRegistrationKey(s.db, fork.Knot) 653 if err != nil { 654 log.Println("failed to fetch registration key:", err) 655 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 656 return 657 } 658 659 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) 660 if err != nil { 661 log.Println("failed to create signed client:", err) 662 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 663 return 664 } 665 666 us, err := NewUnsignedClient(fork.Knot, s.config.Dev) 667 if err != nil { 668 log.Println("failed to create unsigned client:", err) 669 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 670 return 671 } 672 673 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 674 if err != nil { 675 log.Println("failed to create hidden ref:", err, resp.StatusCode) 676 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 677 return 678 } 679 680 switch resp.StatusCode { 681 case 404: 682 case 400: 683 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 684 return 685 } 686 687 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 688 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 689 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 690 // hiddenRef: hidden/feature-1/main (on repo-fork) 691 // targetBranch: main (on repo-1) 692 // sourceBranch: feature-1 (on repo-fork) 693 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 694 if err != nil { 695 log.Println("failed to compare across branches", err) 696 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 697 return 698 } 699 700 respBody, err := io.ReadAll(diffResp.Body) 701 if err != nil { 702 log.Println("failed to read response body", err) 703 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 704 return 705 } 706 707 defer resp.Body.Close() 708 709 var diffTreeResponse types.RepoDiffTreeResponse 710 err = json.Unmarshal(respBody, &diffTreeResponse) 711 if err != nil { 712 log.Println("failed to unmarshal diff tree response", err) 713 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 714 return 715 } 716 717 sourceRev := diffTreeResponse.DiffTree.Rev2 718 patch := diffTreeResponse.DiffTree.Patch 719 720 if !isPatchValid(patch) { 721 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 722 return 723 } 724 725 forkAtUri, err := syntax.ParseATURI(fork.AtUri) 726 if err != nil { 727 log.Println("failed to parse fork AT URI", err) 728 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 729 return 730 } 731 732 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 733 Branch: sourceBranch, 734 Repo: &forkAtUri, 735 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 736} 737 738func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) { 739 tx, err := s.db.BeginTx(r.Context(), nil) 740 if err != nil { 741 log.Println("failed to start tx") 742 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 743 return 744 } 745 defer tx.Rollback() 746 747 rkey := s.TID() 748 initialSubmission := db.PullSubmission{ 749 Patch: patch, 750 SourceRev: sourceRev, 751 } 752 err = db.NewPull(tx, &db.Pull{ 753 Title: title, 754 Body: body, 755 TargetBranch: targetBranch, 756 OwnerDid: user.Did, 757 RepoAt: f.RepoAt, 758 Rkey: rkey, 759 Submissions: []*db.PullSubmission{ 760 &initialSubmission, 761 }, 762 PullSource: pullSource, 763 }) 764 if err != nil { 765 log.Println("failed to create pull request", err) 766 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 767 return 768 } 769 client, _ := s.auth.AuthorizedClient(r) 770 pullId, err := db.NextPullId(s.db, f.RepoAt) 771 if err != nil { 772 log.Println("failed to get pull id", err) 773 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 774 return 775 } 776 777 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 778 Collection: tangled.RepoPullNSID, 779 Repo: user.Did, 780 Rkey: rkey, 781 Record: &lexutil.LexiconTypeDecoder{ 782 Val: &tangled.RepoPull{ 783 Title: title, 784 PullId: int64(pullId), 785 TargetRepo: string(f.RepoAt), 786 TargetBranch: targetBranch, 787 Patch: patch, 788 Source: recordPullSource, 789 }, 790 }, 791 }) 792 793 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 794 if err != nil { 795 log.Println("failed to get pull id", err) 796 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 797 return 798 } 799 800 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 801} 802 803func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 804 user := s.auth.GetUser(r) 805 f, err := fullyResolvedRepo(r) 806 if err != nil { 807 log.Println("failed to get repo and knot", err) 808 return 809 } 810 811 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 812 RepoInfo: f.RepoInfo(s, user), 813 }) 814} 815 816func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 817 user := s.auth.GetUser(r) 818 f, err := fullyResolvedRepo(r) 819 if err != nil { 820 log.Println("failed to get repo and knot", err) 821 return 822 } 823 824 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 825 if err != nil { 826 log.Printf("failed to create unsigned client for %s", f.Knot) 827 s.pages.Error503(w) 828 return 829 } 830 831 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 832 if err != nil { 833 log.Println("failed to reach knotserver", err) 834 return 835 } 836 837 body, err := io.ReadAll(resp.Body) 838 if err != nil { 839 log.Printf("Error reading response body: %v", err) 840 return 841 } 842 843 var result types.RepoBranchesResponse 844 err = json.Unmarshal(body, &result) 845 if err != nil { 846 log.Println("failed to parse response:", err) 847 return 848 } 849 850 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 851 RepoInfo: f.RepoInfo(s, user), 852 Branches: result.Branches, 853 }) 854} 855 856func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 857 user := s.auth.GetUser(r) 858 f, err := fullyResolvedRepo(r) 859 if err != nil { 860 log.Println("failed to get repo and knot", err) 861 return 862 } 863 864 forks, err := db.GetForksByDid(s.db, user.Did) 865 if err != nil { 866 log.Println("failed to get forks", err) 867 return 868 } 869 870 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 871 RepoInfo: f.RepoInfo(s, user), 872 Forks: forks, 873 }) 874} 875 876func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 877 user := s.auth.GetUser(r) 878 879 f, err := fullyResolvedRepo(r) 880 if err != nil { 881 log.Println("failed to get repo and knot", err) 882 return 883 } 884 885 forkVal := r.URL.Query().Get("fork") 886 887 // fork repo 888 repo, err := db.GetRepo(s.db, user.Did, forkVal) 889 if err != nil { 890 log.Println("failed to get repo", user.Did, forkVal) 891 return 892 } 893 894 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) 895 if err != nil { 896 log.Printf("failed to create unsigned client for %s", repo.Knot) 897 s.pages.Error503(w) 898 return 899 } 900 901 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 902 if err != nil { 903 log.Println("failed to reach knotserver for source branches", err) 904 return 905 } 906 907 sourceBody, err := io.ReadAll(sourceResp.Body) 908 if err != nil { 909 log.Println("failed to read source response body", err) 910 return 911 } 912 defer sourceResp.Body.Close() 913 914 var sourceResult types.RepoBranchesResponse 915 err = json.Unmarshal(sourceBody, &sourceResult) 916 if err != nil { 917 log.Println("failed to parse source branches response:", err) 918 return 919 } 920 921 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 922 if err != nil { 923 log.Printf("failed to create unsigned client for target knot %s", f.Knot) 924 s.pages.Error503(w) 925 return 926 } 927 928 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 929 if err != nil { 930 log.Println("failed to reach knotserver for target branches", err) 931 return 932 } 933 934 targetBody, err := io.ReadAll(targetResp.Body) 935 if err != nil { 936 log.Println("failed to read target response body", err) 937 return 938 } 939 defer targetResp.Body.Close() 940 941 var targetResult types.RepoBranchesResponse 942 err = json.Unmarshal(targetBody, &targetResult) 943 if err != nil { 944 log.Println("failed to parse target branches response:", err) 945 return 946 } 947 948 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 949 RepoInfo: f.RepoInfo(s, user), 950 SourceBranches: sourceResult.Branches, 951 TargetBranches: targetResult.Branches, 952 }) 953} 954 955func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 956 user := s.auth.GetUser(r) 957 f, err := fullyResolvedRepo(r) 958 if err != nil { 959 log.Println("failed to get repo and knot", err) 960 return 961 } 962 963 pull, ok := r.Context().Value("pull").(*db.Pull) 964 if !ok { 965 log.Println("failed to get pull") 966 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 967 return 968 } 969 970 switch r.Method { 971 case http.MethodGet: 972 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 973 RepoInfo: f.RepoInfo(s, user), 974 Pull: pull, 975 }) 976 return 977 case http.MethodPost: 978 patch := r.FormValue("patch") 979 var sourceRev string 980 var recordPullSource *tangled.RepoPull_Source 981 982 var ownerDid, repoName, knotName string 983 var isSameRepo bool = pull.IsSameRepoBranch() 984 sourceBranch := pull.PullSource.Branch 985 targetBranch := pull.TargetBranch 986 recordPullSource = &tangled.RepoPull_Source{ 987 Branch: sourceBranch, 988 } 989 990 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 991 if isSameRepo && isPushAllowed { 992 ownerDid = f.OwnerDid() 993 repoName = f.RepoName 994 knotName = f.Knot 995 } else if !isSameRepo { 996 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String()) 997 if err != nil { 998 log.Println("failed to get source repo", err) 999 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1000 return 1001 } 1002 ownerDid = sourceRepo.Did 1003 repoName = sourceRepo.Name 1004 knotName = sourceRepo.Knot 1005 } 1006 1007 if sourceBranch != "" && knotName != "" { 1008 // extract patch by performing compare 1009 ksClient, err := NewUnsignedClient(knotName, s.config.Dev) 1010 if err != nil { 1011 log.Printf("failed to create client for %s: %s", knotName, err) 1012 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1013 return 1014 } 1015 1016 if !isSameRepo { 1017 secret, err := db.GetRegistrationKey(s.db, knotName) 1018 if err != nil { 1019 log.Printf("failed to get registration key for %s: %s", knotName, err) 1020 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1021 return 1022 } 1023 // update the hidden tracking branch to latest 1024 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev) 1025 if err != nil { 1026 log.Printf("failed to create signed client for %s: %s", knotName, err) 1027 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1028 return 1029 } 1030 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch) 1031 if err != nil || resp.StatusCode != http.StatusNoContent { 1032 log.Printf("failed to update tracking branch: %s", err) 1033 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1034 return 1035 } 1036 } 1037 1038 var compareResp *http.Response 1039 if !isSameRepo { 1040 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 1041 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch) 1042 } else { 1043 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch) 1044 } 1045 if err != nil { 1046 log.Printf("failed to compare branches: %s", err) 1047 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1048 return 1049 } 1050 defer compareResp.Body.Close() 1051 1052 switch compareResp.StatusCode { 1053 case 404: 1054 case 400: 1055 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 1056 return 1057 } 1058 1059 respBody, err := io.ReadAll(compareResp.Body) 1060 if err != nil { 1061 log.Println("failed to compare across branches") 1062 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1063 return 1064 } 1065 defer compareResp.Body.Close() 1066 1067 var diffTreeResponse types.RepoDiffTreeResponse 1068 err = json.Unmarshal(respBody, &diffTreeResponse) 1069 if err != nil { 1070 log.Println("failed to unmarshal diff tree response", err) 1071 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1072 return 1073 } 1074 1075 sourceRev = diffTreeResponse.DiffTree.Rev2 1076 patch = diffTreeResponse.DiffTree.Patch 1077 } 1078 1079 if patch == "" { 1080 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 1081 return 1082 } 1083 1084 if patch == pull.LatestPatch() { 1085 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1086 return 1087 } 1088 1089 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1090 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1091 return 1092 } 1093 1094 if !isPatchValid(patch) { 1095 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 1096 return 1097 } 1098 1099 tx, err := s.db.BeginTx(r.Context(), nil) 1100 if err != nil { 1101 log.Println("failed to start tx") 1102 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1103 return 1104 } 1105 defer tx.Rollback() 1106 1107 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1108 if err != nil { 1109 log.Println("failed to create pull request", err) 1110 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1111 return 1112 } 1113 client, _ := s.auth.AuthorizedClient(r) 1114 1115 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1116 if err != nil { 1117 // failed to get record 1118 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1119 return 1120 } 1121 1122 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1123 Collection: tangled.RepoPullNSID, 1124 Repo: user.Did, 1125 Rkey: pull.Rkey, 1126 SwapRecord: ex.Cid, 1127 Record: &lexutil.LexiconTypeDecoder{ 1128 Val: &tangled.RepoPull{ 1129 Title: pull.Title, 1130 PullId: int64(pull.PullId), 1131 TargetRepo: string(f.RepoAt), 1132 TargetBranch: pull.TargetBranch, 1133 Patch: patch, // new patch 1134 Source: recordPullSource, 1135 }, 1136 }, 1137 }) 1138 if err != nil { 1139 log.Println("failed to update record", err) 1140 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1141 return 1142 } 1143 1144 if err = tx.Commit(); err != nil { 1145 log.Println("failed to commit transaction", err) 1146 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1147 return 1148 } 1149 1150 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1151 return 1152 } 1153} 1154 1155func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1156 f, err := fullyResolvedRepo(r) 1157 if err != nil { 1158 log.Println("failed to resolve repo:", err) 1159 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1160 return 1161 } 1162 1163 pull, ok := r.Context().Value("pull").(*db.Pull) 1164 if !ok { 1165 log.Println("failed to get pull") 1166 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1167 return 1168 } 1169 1170 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1171 if err != nil { 1172 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1173 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1174 return 1175 } 1176 1177 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1178 if err != nil { 1179 log.Printf("resolving identity: %s", err) 1180 w.WriteHeader(http.StatusNotFound) 1181 return 1182 } 1183 1184 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1185 if err != nil { 1186 log.Printf("failed to get primary email: %s", err) 1187 } 1188 1189 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1190 if err != nil { 1191 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1192 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1193 return 1194 } 1195 1196 // Merge the pull request 1197 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1198 if err != nil { 1199 log.Printf("failed to merge pull request: %s", err) 1200 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1201 return 1202 } 1203 1204 if resp.StatusCode == http.StatusOK { 1205 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1206 if err != nil { 1207 log.Printf("failed to update pull request status in database: %s", err) 1208 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1209 return 1210 } 1211 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1212 } else { 1213 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1214 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1215 } 1216} 1217 1218func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1219 user := s.auth.GetUser(r) 1220 1221 f, err := fullyResolvedRepo(r) 1222 if err != nil { 1223 log.Println("malformed middleware") 1224 return 1225 } 1226 1227 pull, ok := r.Context().Value("pull").(*db.Pull) 1228 if !ok { 1229 log.Println("failed to get pull") 1230 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1231 return 1232 } 1233 1234 // auth filter: only owner or collaborators can close 1235 roles := RolesInRepo(s, user, f) 1236 isCollaborator := roles.IsCollaborator() 1237 isPullAuthor := user.Did == pull.OwnerDid 1238 isCloseAllowed := isCollaborator || isPullAuthor 1239 if !isCloseAllowed { 1240 log.Println("failed to close pull") 1241 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1242 return 1243 } 1244 1245 // Start a transaction 1246 tx, err := s.db.BeginTx(r.Context(), nil) 1247 if err != nil { 1248 log.Println("failed to start transaction", err) 1249 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1250 return 1251 } 1252 1253 // Close the pull in the database 1254 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1255 if err != nil { 1256 log.Println("failed to close pull", err) 1257 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1258 return 1259 } 1260 1261 // Commit the transaction 1262 if err = tx.Commit(); err != nil { 1263 log.Println("failed to commit transaction", err) 1264 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1265 return 1266 } 1267 1268 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1269 return 1270} 1271 1272func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1273 user := s.auth.GetUser(r) 1274 1275 f, err := fullyResolvedRepo(r) 1276 if err != nil { 1277 log.Println("failed to resolve repo", err) 1278 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1279 return 1280 } 1281 1282 pull, ok := r.Context().Value("pull").(*db.Pull) 1283 if !ok { 1284 log.Println("failed to get pull") 1285 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1286 return 1287 } 1288 1289 // auth filter: only owner or collaborators can close 1290 roles := RolesInRepo(s, user, f) 1291 isCollaborator := roles.IsCollaborator() 1292 isPullAuthor := user.Did == pull.OwnerDid 1293 isCloseAllowed := isCollaborator || isPullAuthor 1294 if !isCloseAllowed { 1295 log.Println("failed to close pull") 1296 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1297 return 1298 } 1299 1300 // Start a transaction 1301 tx, err := s.db.BeginTx(r.Context(), nil) 1302 if err != nil { 1303 log.Println("failed to start transaction", err) 1304 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1305 return 1306 } 1307 1308 // Reopen the pull in the database 1309 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1310 if err != nil { 1311 log.Println("failed to reopen pull", err) 1312 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1313 return 1314 } 1315 1316 // Commit the transaction 1317 if err = tx.Commit(); err != nil { 1318 log.Println("failed to commit transaction", err) 1319 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1320 return 1321 } 1322 1323 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1324 return 1325} 1326 1327// Very basic validation to check if it looks like a diff/patch 1328// A valid patch usually starts with diff or --- lines 1329func isPatchValid(patch string) bool { 1330 // Basic validation to check if it looks like a diff/patch 1331 // A valid patch usually starts with diff or --- lines 1332 if len(patch) == 0 { 1333 return false 1334 } 1335 1336 lines := strings.Split(patch, "\n") 1337 if len(lines) < 2 { 1338 return false 1339 } 1340 1341 // Check for common patch format markers 1342 firstLine := strings.TrimSpace(lines[0]) 1343 return strings.HasPrefix(firstLine, "diff ") || 1344 strings.HasPrefix(firstLine, "--- ") || 1345 strings.HasPrefix(firstLine, "Index: ") || 1346 strings.HasPrefix(firstLine, "+++ ") || 1347 strings.HasPrefix(firstLine, "@@ ") 1348}