this repo has no description
1package state 2 3import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "log" 8 "net/http" 9 "strconv" 10 "strings" 11 "time" 12 13 "github.com/sotangled/tangled/api/tangled" 14 "github.com/sotangled/tangled/appview/db" 15 "github.com/sotangled/tangled/appview/pages" 16 "github.com/sotangled/tangled/types" 17 18 comatproto "github.com/bluesky-social/indigo/api/atproto" 19 lexutil "github.com/bluesky-social/indigo/lex/util" 20) 21 22func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 23 user := s.auth.GetUser(r) 24 f, err := fullyResolvedRepo(r) 25 if err != nil { 26 log.Println("failed to get repo and knot", err) 27 return 28 } 29 30 pull, ok := r.Context().Value("pull").(*db.Pull) 31 if !ok { 32 log.Println("failed to get pull") 33 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 34 return 35 } 36 37 totalIdents := 1 38 for _, submission := range pull.Submissions { 39 totalIdents += len(submission.Comments) 40 } 41 42 identsToResolve := make([]string, totalIdents) 43 44 // populate idents 45 identsToResolve[0] = pull.OwnerDid 46 idx := 1 47 for _, submission := range pull.Submissions { 48 for _, comment := range submission.Comments { 49 identsToResolve[idx] = comment.OwnerDid 50 idx += 1 51 } 52 } 53 54 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 55 didHandleMap := make(map[string]string) 56 for _, identity := range resolvedIds { 57 if !identity.Handle.IsInvalidHandle() { 58 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 59 } else { 60 didHandleMap[identity.DID.String()] = identity.DID.String() 61 } 62 } 63 64 var mergeCheckResponse types.MergeCheckResponse 65 66 // Only perform merge check if the pull request is not already merged 67 if pull.State != db.PullMerged { 68 secret, err := db.GetRegistrationKey(s.db, f.Knot) 69 if err != nil { 70 log.Printf("failed to get registration key for %s", f.Knot) 71 s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.") 72 return 73 } 74 75 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 76 if err == nil { 77 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), pull.OwnerDid, f.RepoName, pull.TargetBranch) 78 if err != nil { 79 log.Println("failed to check for mergeability:", err) 80 } else { 81 respBody, err := io.ReadAll(resp.Body) 82 if err != nil { 83 log.Println("failed to read merge check response body") 84 } else { 85 err = json.Unmarshal(respBody, &mergeCheckResponse) 86 if err != nil { 87 log.Println("failed to unmarshal merge check response", err) 88 } 89 } 90 } 91 } else { 92 log.Printf("failed to setup signed client for %s; ignoring...", f.Knot) 93 } 94 } 95 96 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 97 LoggedInUser: user, 98 RepoInfo: f.RepoInfo(s, user), 99 DidHandleMap: didHandleMap, 100 Pull: *pull, 101 MergeCheck: mergeCheckResponse, 102 }) 103} 104 105func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 106 user := s.auth.GetUser(r) 107 params := r.URL.Query() 108 109 state := db.PullOpen 110 switch params.Get("state") { 111 case "closed": 112 state = db.PullClosed 113 case "merged": 114 state = db.PullMerged 115 } 116 117 f, err := fullyResolvedRepo(r) 118 if err != nil { 119 log.Println("failed to get repo and knot", err) 120 return 121 } 122 123 pulls, err := db.GetPulls(s.db, f.RepoAt, state) 124 if err != nil { 125 log.Println("failed to get pulls", err) 126 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 127 return 128 } 129 130 identsToResolve := make([]string, len(pulls)) 131 for i, pull := range pulls { 132 identsToResolve[i] = pull.OwnerDid 133 } 134 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 135 didHandleMap := make(map[string]string) 136 for _, identity := range resolvedIds { 137 if !identity.Handle.IsInvalidHandle() { 138 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 139 } else { 140 didHandleMap[identity.DID.String()] = identity.DID.String() 141 } 142 } 143 144 s.pages.RepoPulls(w, pages.RepoPullsParams{ 145 LoggedInUser: s.auth.GetUser(r), 146 RepoInfo: f.RepoInfo(s, user), 147 Pulls: pulls, 148 DidHandleMap: didHandleMap, 149 FilteringBy: state, 150 }) 151 return 152} 153 154func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 155 user := s.auth.GetUser(r) 156 f, err := fullyResolvedRepo(r) 157 if err != nil { 158 log.Println("failed to get repo and knot", err) 159 return 160 } 161 162 pull, ok := r.Context().Value("pull").(*db.Pull) 163 if !ok { 164 log.Println("failed to get pull") 165 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 166 return 167 } 168 169 switch r.Method { 170 case http.MethodPost: 171 body := r.FormValue("body") 172 if body == "" { 173 s.pages.Notice(w, "pull", "Comment body is required") 174 return 175 } 176 177 submissionIdstr := r.FormValue("submissionId") 178 submissionId, err := strconv.Atoi(submissionIdstr) 179 if err != nil { 180 s.pages.Notice(w, "pull", "Invalid comment submission.") 181 return 182 } 183 184 // Start a transaction 185 tx, err := s.db.BeginTx(r.Context(), nil) 186 if err != nil { 187 log.Println("failed to start transaction", err) 188 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 189 return 190 } 191 defer tx.Rollback() 192 193 createdAt := time.Now().Format(time.RFC3339) 194 ownerDid := user.Did 195 196 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 197 if err != nil { 198 log.Println("failed to get pull at", err) 199 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 200 return 201 } 202 203 atUri := f.RepoAt.String() 204 client, _ := s.auth.AuthorizedClient(r) 205 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 206 Collection: tangled.RepoPullCommentNSID, 207 Repo: user.Did, 208 Rkey: s.TID(), 209 Record: &lexutil.LexiconTypeDecoder{ 210 Val: &tangled.RepoPullComment{ 211 Repo: &atUri, 212 Pull: pullAt, 213 Owner: &ownerDid, 214 Body: &body, 215 CreatedAt: &createdAt, 216 }, 217 }, 218 }) 219 if err != nil { 220 log.Println("failed to create pull comment", err) 221 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 222 return 223 } 224 225 // Create the pull comment in the database with the commentAt field 226 commentId, err := db.NewPullComment(tx, &db.PullComment{ 227 OwnerDid: user.Did, 228 RepoAt: f.RepoAt.String(), 229 PullId: pull.PullId, 230 Body: body, 231 CommentAt: atResp.Uri, 232 SubmissionId: submissionId, 233 }) 234 if err != nil { 235 log.Println("failed to create pull comment", err) 236 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 237 return 238 } 239 240 // Commit the transaction 241 if err = tx.Commit(); err != nil { 242 log.Println("failed to commit transaction", err) 243 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 244 return 245 } 246 247 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 248 return 249 } 250} 251 252func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 253 user := s.auth.GetUser(r) 254 f, err := fullyResolvedRepo(r) 255 if err != nil { 256 log.Println("failed to get repo and knot", err) 257 return 258 } 259 260 switch r.Method { 261 case http.MethodGet: 262 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 263 if err != nil { 264 log.Printf("failed to create unsigned client for %s", f.Knot) 265 s.pages.Error503(w) 266 return 267 } 268 269 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 270 if err != nil { 271 log.Println("failed to reach knotserver", err) 272 return 273 } 274 275 body, err := io.ReadAll(resp.Body) 276 if err != nil { 277 log.Printf("Error reading response body: %v", err) 278 return 279 } 280 281 var result types.RepoBranchesResponse 282 err = json.Unmarshal(body, &result) 283 if err != nil { 284 log.Println("failed to parse response:", err) 285 return 286 } 287 288 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 289 LoggedInUser: user, 290 RepoInfo: f.RepoInfo(s, user), 291 Branches: result.Branches, 292 }) 293 case http.MethodPost: 294 title := r.FormValue("title") 295 body := r.FormValue("body") 296 targetBranch := r.FormValue("targetBranch") 297 patch := r.FormValue("patch") 298 299 if title == "" || body == "" || patch == "" || targetBranch == "" { 300 s.pages.Notice(w, "pull", "Title, body and patch diff are required.") 301 return 302 } 303 304 // Validate patch format 305 if !isPatchValid(patch) { 306 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 307 return 308 } 309 310 tx, err := s.db.BeginTx(r.Context(), nil) 311 if err != nil { 312 log.Println("failed to start tx") 313 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 314 return 315 } 316 defer tx.Rollback() 317 318 rkey := s.TID() 319 initialSubmission := db.PullSubmission{ 320 Patch: patch, 321 } 322 err = db.NewPull(tx, &db.Pull{ 323 Title: title, 324 Body: body, 325 TargetBranch: targetBranch, 326 OwnerDid: user.Did, 327 RepoAt: f.RepoAt, 328 Rkey: rkey, 329 Submissions: []*db.PullSubmission{ 330 &initialSubmission, 331 }, 332 }) 333 if err != nil { 334 log.Println("failed to create pull request", err) 335 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 336 return 337 } 338 client, _ := s.auth.AuthorizedClient(r) 339 pullId, err := db.NextPullId(s.db, f.RepoAt) 340 if err != nil { 341 log.Println("failed to get pull id", err) 342 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 343 return 344 } 345 346 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 347 Collection: tangled.RepoPullNSID, 348 Repo: user.Did, 349 Rkey: rkey, 350 Record: &lexutil.LexiconTypeDecoder{ 351 Val: &tangled.RepoPull{ 352 Title: title, 353 PullId: int64(pullId), 354 TargetRepo: string(f.RepoAt), 355 TargetBranch: targetBranch, 356 Patch: patch, 357 }, 358 }, 359 }) 360 361 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 362 if err != nil { 363 log.Println("failed to get pull id", err) 364 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 365 return 366 } 367 368 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 369 return 370 } 371} 372 373func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 374 user := s.auth.GetUser(r) 375 f, err := fullyResolvedRepo(r) 376 if err != nil { 377 log.Println("failed to get repo and knot", err) 378 return 379 } 380 381 pull, ok := r.Context().Value("pull").(*db.Pull) 382 if !ok { 383 log.Println("failed to get pull") 384 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 385 return 386 } 387 388 switch r.Method { 389 case http.MethodPost: 390 patch := r.FormValue("patch") 391 392 if patch == "" { 393 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 394 return 395 } 396 397 if patch == pull.LatestPatch() { 398 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 399 return 400 } 401 402 // Validate patch format 403 if !isPatchValid(patch) { 404 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 405 return 406 } 407 408 tx, err := s.db.BeginTx(r.Context(), nil) 409 if err != nil { 410 log.Println("failed to start tx") 411 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 412 return 413 } 414 defer tx.Rollback() 415 416 err = db.ResubmitPull(tx, pull, patch) 417 if err != nil { 418 log.Println("failed to create pull request", err) 419 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 420 return 421 } 422 client, _ := s.auth.AuthorizedClient(r) 423 424 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 425 if err != nil { 426 // failed to get record 427 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 428 return 429 } 430 431 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 432 Collection: tangled.RepoPullNSID, 433 Repo: user.Did, 434 Rkey: pull.Rkey, 435 SwapRecord: ex.Cid, 436 Record: &lexutil.LexiconTypeDecoder{ 437 Val: &tangled.RepoPull{ 438 Title: pull.Title, 439 PullId: int64(pull.PullId), 440 TargetRepo: string(f.RepoAt), 441 TargetBranch: pull.TargetBranch, 442 Patch: patch, // new patch 443 }, 444 }, 445 }) 446 if err != nil { 447 log.Println("failed to update record", err) 448 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 449 return 450 } 451 452 if err = tx.Commit(); err != nil { 453 log.Println("failed to commit transaction", err) 454 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 455 return 456 } 457 458 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 459 return 460 } 461} 462 463func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 464 user := s.auth.GetUser(r) 465 f, err := fullyResolvedRepo(r) 466 if err != nil { 467 log.Println("failed to resolve repo:", err) 468 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 469 return 470 } 471 472 pull, ok := r.Context().Value("pull").(*db.Pull) 473 if !ok { 474 log.Println("failed to get pull") 475 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 476 return 477 } 478 479 secret, err := db.GetRegistrationKey(s.db, f.Knot) 480 if err != nil { 481 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 482 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 483 return 484 } 485 486 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 487 if err != nil { 488 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 489 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 490 return 491 } 492 493 // Merge the pull request 494 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), user.Did, f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "") 495 if err != nil { 496 log.Printf("failed to merge pull request: %s", err) 497 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 498 return 499 } 500 501 if resp.StatusCode == http.StatusOK { 502 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 503 if err != nil { 504 log.Printf("failed to update pull request status in database: %s", err) 505 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 506 return 507 } 508 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 509 } else { 510 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 511 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 512 } 513} 514 515func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 516 user := s.auth.GetUser(r) 517 518 f, err := fullyResolvedRepo(r) 519 if err != nil { 520 log.Println("malformed middleware") 521 return 522 } 523 524 pull, ok := r.Context().Value("pull").(*db.Pull) 525 if !ok { 526 log.Println("failed to get pull") 527 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 528 return 529 } 530 531 // auth filter: only owner or collaborators can close 532 roles := RolesInRepo(s, user, f) 533 isCollaborator := roles.IsCollaborator() 534 isPullAuthor := user.Did == pull.OwnerDid 535 isCloseAllowed := isCollaborator || isPullAuthor 536 if !isCloseAllowed { 537 log.Println("failed to close pull") 538 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 539 return 540 } 541 542 // Start a transaction 543 tx, err := s.db.BeginTx(r.Context(), nil) 544 if err != nil { 545 log.Println("failed to start transaction", err) 546 s.pages.Notice(w, "pull-close", "Failed to close pull.") 547 return 548 } 549 550 // Close the pull in the database 551 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 552 if err != nil { 553 log.Println("failed to close pull", err) 554 s.pages.Notice(w, "pull-close", "Failed to close pull.") 555 return 556 } 557 558 // Commit the transaction 559 if err = tx.Commit(); err != nil { 560 log.Println("failed to commit transaction", err) 561 s.pages.Notice(w, "pull-close", "Failed to close pull.") 562 return 563 } 564 565 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 566 return 567} 568 569func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 570 user := s.auth.GetUser(r) 571 572 f, err := fullyResolvedRepo(r) 573 if err != nil { 574 log.Println("failed to resolve repo", err) 575 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 576 return 577 } 578 579 pull, ok := r.Context().Value("pull").(*db.Pull) 580 if !ok { 581 log.Println("failed to get pull") 582 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 583 return 584 } 585 586 // auth filter: only owner or collaborators can close 587 roles := RolesInRepo(s, user, f) 588 isCollaborator := roles.IsCollaborator() 589 isPullAuthor := user.Did == pull.OwnerDid 590 isCloseAllowed := isCollaborator || isPullAuthor 591 if !isCloseAllowed { 592 log.Println("failed to close pull") 593 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 594 return 595 } 596 597 // Start a transaction 598 tx, err := s.db.BeginTx(r.Context(), nil) 599 if err != nil { 600 log.Println("failed to start transaction", err) 601 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 602 return 603 } 604 605 // Reopen the pull in the database 606 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 607 if err != nil { 608 log.Println("failed to reopen pull", err) 609 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 610 return 611 } 612 613 // Commit the transaction 614 if err = tx.Commit(); err != nil { 615 log.Println("failed to commit transaction", err) 616 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 617 return 618 } 619 620 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 621 return 622} 623 624// Very basic validation to check if it looks like a diff/patch 625// A valid patch usually starts with diff or --- lines 626func isPatchValid(patch string) bool { 627 // Basic validation to check if it looks like a diff/patch 628 // A valid patch usually starts with diff or --- lines 629 if len(patch) == 0 { 630 return false 631 } 632 633 lines := strings.Split(patch, "\n") 634 if len(lines) < 2 { 635 return false 636 } 637 638 // Check for common patch format markers 639 firstLine := strings.TrimSpace(lines[0]) 640 return strings.HasPrefix(firstLine, "diff ") || 641 strings.HasPrefix(firstLine, "--- ") || 642 strings.HasPrefix(firstLine, "Index: ") || 643 strings.HasPrefix(firstLine, "+++ ") || 644 strings.HasPrefix(firstLine, "@@ ") 645}