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 forks, err := db.GetForksByDid(s.db, user.Did) 505 if err != nil { 506 log.Println("failed to get forks", err) 507 return 508 } 509 510 switch r.Method { 511 case http.MethodGet: 512 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 513 if err != nil { 514 log.Printf("failed to create unsigned client for %s", f.Knot) 515 s.pages.Error503(w) 516 return 517 } 518 519 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 520 if err != nil { 521 log.Println("failed to reach knotserver", err) 522 return 523 } 524 525 body, err := io.ReadAll(resp.Body) 526 if err != nil { 527 log.Printf("Error reading response body: %v", err) 528 return 529 } 530 531 var result types.RepoBranchesResponse 532 err = json.Unmarshal(body, &result) 533 if err != nil { 534 log.Println("failed to parse response:", err) 535 return 536 } 537 538 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 539 LoggedInUser: user, 540 RepoInfo: f.RepoInfo(s, user), 541 Forks: forks, 542 Branches: result.Branches, 543 }) 544 case http.MethodPost: 545 title := r.FormValue("title") 546 body := r.FormValue("body") 547 targetBranch := r.FormValue("targetBranch") 548 fromFork := r.FormValue("fork") 549 sourceBranch := r.FormValue("sourceBranch") 550 patch := r.FormValue("patch") 551 552 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 553 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 554 isPatchBased := patch != "" 555 isForkBased := fromFork != "" && sourceBranch != "" 556 557 if !isBranchBased && !isPatchBased && !isForkBased { 558 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 559 return 560 } 561 562 if isBranchBased && isPatchBased { 563 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 564 return 565 } 566 567 if title == "" || body == "" || targetBranch == "" { 568 s.pages.Notice(w, "pull", "Title, body and target branch are required.") 569 return 570 } 571 572 if isBranchBased { 573 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) 574 } else if isPatchBased { 575 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) 576 } else if isForkBased { 577 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) 578 } 579 return 580 } 581} 582 583func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { 584 pullSource := &db.PullSource{ 585 Branch: sourceBranch, 586 } 587 recordPullSource := &tangled.RepoPull_Source{ 588 Branch: sourceBranch, 589 } 590 591 // Generate a patch using /compare 592 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 593 if err != nil { 594 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 595 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 596 return 597 } 598 599 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 600 switch resp.StatusCode { 601 case 404: 602 case 400: 603 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 604 return 605 } 606 607 respBody, err := io.ReadAll(resp.Body) 608 if err != nil { 609 log.Println("failed to compare across branches") 610 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 611 return 612 } 613 defer resp.Body.Close() 614 615 var diffTreeResponse types.RepoDiffTreeResponse 616 err = json.Unmarshal(respBody, &diffTreeResponse) 617 if err != nil { 618 log.Println("failed to unmarshal diff tree response", err) 619 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 620 return 621 } 622 623 sourceRev := diffTreeResponse.DiffTree.Rev2 624 patch := diffTreeResponse.DiffTree.Patch 625 626 if !isPatchValid(patch) { 627 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 628 return 629 } 630 631 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 632} 633 634func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 635 if !isPatchValid(patch) { 636 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 637 return 638 } 639 640 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 641} 642 643func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 644 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 645 if errors.Is(err, sql.ErrNoRows) { 646 s.pages.Notice(w, "pull", "No such fork.") 647 return 648 } else if err != nil { 649 log.Println("failed to fetch fork:", err) 650 s.pages.Notice(w, "pull", "Failed to fetch fork.") 651 return 652 } 653 654 secret, err := db.GetRegistrationKey(s.db, fork.Knot) 655 if err != nil { 656 log.Println("failed to fetch registration key:", err) 657 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 658 return 659 } 660 661 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) 662 if err != nil { 663 log.Println("failed to create signed client:", err) 664 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 665 return 666 } 667 668 us, err := NewUnsignedClient(fork.Knot, s.config.Dev) 669 if err != nil { 670 log.Println("failed to create unsigned client:", err) 671 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 672 return 673 } 674 675 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 676 if err != nil { 677 log.Println("failed to create hidden ref:", err, resp.StatusCode) 678 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 679 return 680 } 681 682 switch resp.StatusCode { 683 case 404: 684 case 400: 685 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 686 return 687 } 688 689 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 690 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 691 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 692 // hiddenRef: hidden/feature-1/main (on repo-fork) 693 // targetBranch: main (on repo-1) 694 // sourceBranch: feature-1 (on repo-fork) 695 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 696 if err != nil { 697 log.Println("failed to compare across branches", err) 698 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 699 return 700 } 701 702 respBody, err := io.ReadAll(diffResp.Body) 703 if err != nil { 704 log.Println("failed to read response body", err) 705 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 706 return 707 } 708 709 defer resp.Body.Close() 710 711 var diffTreeResponse types.RepoDiffTreeResponse 712 err = json.Unmarshal(respBody, &diffTreeResponse) 713 if err != nil { 714 log.Println("failed to unmarshal diff tree response", err) 715 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 716 return 717 } 718 719 sourceRev := diffTreeResponse.DiffTree.Rev2 720 patch := diffTreeResponse.DiffTree.Patch 721 722 if !isPatchValid(patch) { 723 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 724 return 725 } 726 727 forkAtUri, err := syntax.ParseATURI(fork.AtUri) 728 if err != nil { 729 log.Println("failed to parse fork AT URI", err) 730 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 731 return 732 } 733 734 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 735 Branch: sourceBranch, 736 Repo: &forkAtUri, 737 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 738} 739 740func (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) { 741 tx, err := s.db.BeginTx(r.Context(), nil) 742 if err != nil { 743 log.Println("failed to start tx") 744 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 745 return 746 } 747 defer tx.Rollback() 748 749 rkey := s.TID() 750 initialSubmission := db.PullSubmission{ 751 Patch: patch, 752 SourceRev: sourceRev, 753 } 754 err = db.NewPull(tx, &db.Pull{ 755 Title: title, 756 Body: body, 757 TargetBranch: targetBranch, 758 OwnerDid: user.Did, 759 RepoAt: f.RepoAt, 760 Rkey: rkey, 761 Submissions: []*db.PullSubmission{ 762 &initialSubmission, 763 }, 764 PullSource: pullSource, 765 }) 766 if err != nil { 767 log.Println("failed to create pull request", err) 768 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 769 return 770 } 771 client, _ := s.auth.AuthorizedClient(r) 772 pullId, err := db.NextPullId(s.db, f.RepoAt) 773 if err != nil { 774 log.Println("failed to get pull id", err) 775 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 776 return 777 } 778 779 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 780 Collection: tangled.RepoPullNSID, 781 Repo: user.Did, 782 Rkey: rkey, 783 Record: &lexutil.LexiconTypeDecoder{ 784 Val: &tangled.RepoPull{ 785 Title: title, 786 PullId: int64(pullId), 787 TargetRepo: string(f.RepoAt), 788 TargetBranch: targetBranch, 789 Patch: patch, 790 Source: recordPullSource, 791 }, 792 }, 793 }) 794 795 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 796 if err != nil { 797 log.Println("failed to get pull id", err) 798 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 799 return 800 } 801 802 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 803} 804 805func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 806 user := s.auth.GetUser(r) 807 f, err := fullyResolvedRepo(r) 808 if err != nil { 809 log.Println("failed to get repo and knot", err) 810 return 811 } 812 813 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 814 RepoInfo: f.RepoInfo(s, user), 815 }) 816} 817 818func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 819 user := s.auth.GetUser(r) 820 f, err := fullyResolvedRepo(r) 821 if err != nil { 822 log.Println("failed to get repo and knot", err) 823 return 824 } 825 826 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 827 if err != nil { 828 log.Printf("failed to create unsigned client for %s", f.Knot) 829 s.pages.Error503(w) 830 return 831 } 832 833 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 834 if err != nil { 835 log.Println("failed to reach knotserver", err) 836 return 837 } 838 839 body, err := io.ReadAll(resp.Body) 840 if err != nil { 841 log.Printf("Error reading response body: %v", err) 842 return 843 } 844 845 var result types.RepoBranchesResponse 846 err = json.Unmarshal(body, &result) 847 if err != nil { 848 log.Println("failed to parse response:", err) 849 return 850 } 851 852 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 853 RepoInfo: f.RepoInfo(s, user), 854 Branches: result.Branches, 855 }) 856} 857 858func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 859 user := s.auth.GetUser(r) 860 f, err := fullyResolvedRepo(r) 861 if err != nil { 862 log.Println("failed to get repo and knot", err) 863 return 864 } 865 866 forks, err := db.GetForksByDid(s.db, user.Did) 867 if err != nil { 868 log.Println("failed to get forks", err) 869 return 870 } 871 872 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 873 RepoInfo: f.RepoInfo(s, user), 874 Forks: forks, 875 }) 876} 877 878func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 879 user := s.auth.GetUser(r) 880 881 f, err := fullyResolvedRepo(r) 882 if err != nil { 883 log.Println("failed to get repo and knot", err) 884 return 885 } 886 887 forkVal := r.URL.Query().Get("fork") 888 889 // fork repo 890 repo, err := db.GetRepo(s.db, user.Did, forkVal) 891 if err != nil { 892 log.Println("failed to get repo", user.Did, forkVal) 893 return 894 } 895 896 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) 897 if err != nil { 898 log.Printf("failed to create unsigned client for %s", repo.Knot) 899 s.pages.Error503(w) 900 return 901 } 902 903 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 904 if err != nil { 905 log.Println("failed to reach knotserver for source branches", err) 906 return 907 } 908 909 sourceBody, err := io.ReadAll(sourceResp.Body) 910 if err != nil { 911 log.Println("failed to read source response body", err) 912 return 913 } 914 defer sourceResp.Body.Close() 915 916 var sourceResult types.RepoBranchesResponse 917 err = json.Unmarshal(sourceBody, &sourceResult) 918 if err != nil { 919 log.Println("failed to parse source branches response:", err) 920 return 921 } 922 923 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 924 if err != nil { 925 log.Printf("failed to create unsigned client for target knot %s", f.Knot) 926 s.pages.Error503(w) 927 return 928 } 929 930 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 931 if err != nil { 932 log.Println("failed to reach knotserver for target branches", err) 933 return 934 } 935 936 targetBody, err := io.ReadAll(targetResp.Body) 937 if err != nil { 938 log.Println("failed to read target response body", err) 939 return 940 } 941 defer targetResp.Body.Close() 942 943 var targetResult types.RepoBranchesResponse 944 err = json.Unmarshal(targetBody, &targetResult) 945 if err != nil { 946 log.Println("failed to parse target branches response:", err) 947 return 948 } 949 950 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 951 RepoInfo: f.RepoInfo(s, user), 952 SourceBranches: sourceResult.Branches, 953 TargetBranches: targetResult.Branches, 954 }) 955} 956 957func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 958 user := s.auth.GetUser(r) 959 f, err := fullyResolvedRepo(r) 960 if err != nil { 961 log.Println("failed to get repo and knot", err) 962 return 963 } 964 965 pull, ok := r.Context().Value("pull").(*db.Pull) 966 if !ok { 967 log.Println("failed to get pull") 968 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 969 return 970 } 971 972 switch r.Method { 973 case http.MethodGet: 974 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 975 RepoInfo: f.RepoInfo(s, user), 976 Pull: pull, 977 }) 978 return 979 case http.MethodPost: 980 patch := r.FormValue("patch") 981 var sourceRev string 982 var recordPullSource *tangled.RepoPull_Source 983 984 var ownerDid, repoName, knotName string 985 var isSameRepo bool = pull.IsSameRepoBranch() 986 sourceBranch := pull.PullSource.Branch 987 targetBranch := pull.TargetBranch 988 recordPullSource = &tangled.RepoPull_Source{ 989 Branch: sourceBranch, 990 } 991 992 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 993 if isSameRepo && isPushAllowed { 994 ownerDid = f.OwnerDid() 995 repoName = f.RepoName 996 knotName = f.Knot 997 } else if !isSameRepo { 998 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String()) 999 if err != nil { 1000 log.Println("failed to get source repo", err) 1001 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1002 return 1003 } 1004 ownerDid = sourceRepo.Did 1005 repoName = sourceRepo.Name 1006 knotName = sourceRepo.Knot 1007 } 1008 1009 if sourceBranch != "" && knotName != "" { 1010 // extract patch by performing compare 1011 ksClient, err := NewUnsignedClient(knotName, s.config.Dev) 1012 if err != nil { 1013 log.Printf("failed to create client for %s: %s", knotName, err) 1014 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1015 return 1016 } 1017 1018 if !isSameRepo { 1019 secret, err := db.GetRegistrationKey(s.db, knotName) 1020 if err != nil { 1021 log.Printf("failed to get registration key for %s: %s", knotName, err) 1022 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1023 return 1024 } 1025 // update the hidden tracking branch to latest 1026 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev) 1027 if err != nil { 1028 log.Printf("failed to create signed client for %s: %s", knotName, err) 1029 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1030 return 1031 } 1032 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch) 1033 if err != nil || resp.StatusCode != http.StatusNoContent { 1034 log.Printf("failed to update tracking branch: %s", err) 1035 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1036 return 1037 } 1038 } 1039 1040 var compareResp *http.Response 1041 if !isSameRepo { 1042 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 1043 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch) 1044 } else { 1045 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch) 1046 } 1047 if err != nil { 1048 log.Printf("failed to compare branches: %s", err) 1049 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1050 return 1051 } 1052 defer compareResp.Body.Close() 1053 1054 switch compareResp.StatusCode { 1055 case 404: 1056 case 400: 1057 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 1058 return 1059 } 1060 1061 respBody, err := io.ReadAll(compareResp.Body) 1062 if err != nil { 1063 log.Println("failed to compare across branches") 1064 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1065 return 1066 } 1067 defer compareResp.Body.Close() 1068 1069 var diffTreeResponse types.RepoDiffTreeResponse 1070 err = json.Unmarshal(respBody, &diffTreeResponse) 1071 if err != nil { 1072 log.Println("failed to unmarshal diff tree response", err) 1073 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1074 return 1075 } 1076 1077 sourceRev = diffTreeResponse.DiffTree.Rev2 1078 patch = diffTreeResponse.DiffTree.Patch 1079 } 1080 1081 if patch == "" { 1082 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 1083 return 1084 } 1085 1086 if patch == pull.LatestPatch() { 1087 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1088 return 1089 } 1090 1091 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1092 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1093 return 1094 } 1095 1096 if !isPatchValid(patch) { 1097 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 1098 return 1099 } 1100 1101 tx, err := s.db.BeginTx(r.Context(), nil) 1102 if err != nil { 1103 log.Println("failed to start tx") 1104 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1105 return 1106 } 1107 defer tx.Rollback() 1108 1109 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1110 if err != nil { 1111 log.Println("failed to create pull request", err) 1112 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1113 return 1114 } 1115 client, _ := s.auth.AuthorizedClient(r) 1116 1117 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1118 if err != nil { 1119 // failed to get record 1120 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1121 return 1122 } 1123 1124 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1125 Collection: tangled.RepoPullNSID, 1126 Repo: user.Did, 1127 Rkey: pull.Rkey, 1128 SwapRecord: ex.Cid, 1129 Record: &lexutil.LexiconTypeDecoder{ 1130 Val: &tangled.RepoPull{ 1131 Title: pull.Title, 1132 PullId: int64(pull.PullId), 1133 TargetRepo: string(f.RepoAt), 1134 TargetBranch: pull.TargetBranch, 1135 Patch: patch, // new patch 1136 Source: recordPullSource, 1137 }, 1138 }, 1139 }) 1140 if err != nil { 1141 log.Println("failed to update record", err) 1142 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1143 return 1144 } 1145 1146 if err = tx.Commit(); err != nil { 1147 log.Println("failed to commit transaction", err) 1148 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1149 return 1150 } 1151 1152 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1153 return 1154 } 1155} 1156 1157func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1158 f, err := fullyResolvedRepo(r) 1159 if err != nil { 1160 log.Println("failed to resolve repo:", err) 1161 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1162 return 1163 } 1164 1165 pull, ok := r.Context().Value("pull").(*db.Pull) 1166 if !ok { 1167 log.Println("failed to get pull") 1168 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1169 return 1170 } 1171 1172 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1173 if err != nil { 1174 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1175 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1176 return 1177 } 1178 1179 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1180 if err != nil { 1181 log.Printf("resolving identity: %s", err) 1182 w.WriteHeader(http.StatusNotFound) 1183 return 1184 } 1185 1186 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1187 if err != nil { 1188 log.Printf("failed to get primary email: %s", err) 1189 } 1190 1191 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1192 if err != nil { 1193 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1194 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1195 return 1196 } 1197 1198 // Merge the pull request 1199 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1200 if err != nil { 1201 log.Printf("failed to merge pull request: %s", err) 1202 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1203 return 1204 } 1205 1206 if resp.StatusCode == http.StatusOK { 1207 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1208 if err != nil { 1209 log.Printf("failed to update pull request status in database: %s", err) 1210 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1211 return 1212 } 1213 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1214 } else { 1215 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1216 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1217 } 1218} 1219 1220func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1221 user := s.auth.GetUser(r) 1222 1223 f, err := fullyResolvedRepo(r) 1224 if err != nil { 1225 log.Println("malformed middleware") 1226 return 1227 } 1228 1229 pull, ok := r.Context().Value("pull").(*db.Pull) 1230 if !ok { 1231 log.Println("failed to get pull") 1232 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1233 return 1234 } 1235 1236 // auth filter: only owner or collaborators can close 1237 roles := RolesInRepo(s, user, f) 1238 isCollaborator := roles.IsCollaborator() 1239 isPullAuthor := user.Did == pull.OwnerDid 1240 isCloseAllowed := isCollaborator || isPullAuthor 1241 if !isCloseAllowed { 1242 log.Println("failed to close pull") 1243 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1244 return 1245 } 1246 1247 // Start a transaction 1248 tx, err := s.db.BeginTx(r.Context(), nil) 1249 if err != nil { 1250 log.Println("failed to start transaction", err) 1251 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1252 return 1253 } 1254 1255 // Close the pull in the database 1256 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1257 if err != nil { 1258 log.Println("failed to close pull", err) 1259 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1260 return 1261 } 1262 1263 // Commit the transaction 1264 if err = tx.Commit(); err != nil { 1265 log.Println("failed to commit transaction", err) 1266 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1267 return 1268 } 1269 1270 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1271 return 1272} 1273 1274func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1275 user := s.auth.GetUser(r) 1276 1277 f, err := fullyResolvedRepo(r) 1278 if err != nil { 1279 log.Println("failed to resolve repo", err) 1280 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1281 return 1282 } 1283 1284 pull, ok := r.Context().Value("pull").(*db.Pull) 1285 if !ok { 1286 log.Println("failed to get pull") 1287 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1288 return 1289 } 1290 1291 // auth filter: only owner or collaborators can close 1292 roles := RolesInRepo(s, user, f) 1293 isCollaborator := roles.IsCollaborator() 1294 isPullAuthor := user.Did == pull.OwnerDid 1295 isCloseAllowed := isCollaborator || isPullAuthor 1296 if !isCloseAllowed { 1297 log.Println("failed to close pull") 1298 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1299 return 1300 } 1301 1302 // Start a transaction 1303 tx, err := s.db.BeginTx(r.Context(), nil) 1304 if err != nil { 1305 log.Println("failed to start transaction", err) 1306 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1307 return 1308 } 1309 1310 // Reopen the pull in the database 1311 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1312 if err != nil { 1313 log.Println("failed to reopen pull", err) 1314 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1315 return 1316 } 1317 1318 // Commit the transaction 1319 if err = tx.Commit(); err != nil { 1320 log.Println("failed to commit transaction", err) 1321 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1322 return 1323 } 1324 1325 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1326 return 1327} 1328 1329// Very basic validation to check if it looks like a diff/patch 1330// A valid patch usually starts with diff or --- lines 1331func isPatchValid(patch string) bool { 1332 // Basic validation to check if it looks like a diff/patch 1333 // A valid patch usually starts with diff or --- lines 1334 if len(patch) == 0 { 1335 return false 1336 } 1337 1338 lines := strings.Split(patch, "\n") 1339 if len(lines) < 2 { 1340 return false 1341 } 1342 1343 // Check for common patch format markers 1344 firstLine := strings.TrimSpace(lines[0]) 1345 return strings.HasPrefix(firstLine, "diff ") || 1346 strings.HasPrefix(firstLine, "--- ") || 1347 strings.HasPrefix(firstLine, "Index: ") || 1348 strings.HasPrefix(firstLine, "+++ ") || 1349 strings.HasPrefix(firstLine, "@@ ") 1350}