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