forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 "math/rand/v2" 10 "net/http" 11 "path" 12 "slices" 13 "strconv" 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/data" 18 "github.com/bluesky-social/indigo/atproto/identity" 19 "github.com/bluesky-social/indigo/atproto/syntax" 20 securejoin "github.com/cyphar/filepath-securejoin" 21 "github.com/go-chi/chi/v5" 22 "tangled.sh/tangled.sh/core/api/tangled" 23 "tangled.sh/tangled.sh/core/appview/auth" 24 "tangled.sh/tangled.sh/core/appview/db" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 "tangled.sh/tangled.sh/core/types" 27 28 comatproto "github.com/bluesky-social/indigo/api/atproto" 29 lexutil "github.com/bluesky-social/indigo/lex/util" 30) 31 32func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 33 ref := chi.URLParam(r, "ref") 34 f, err := fullyResolvedRepo(r) 35 if err != nil { 36 log.Println("failed to fully resolve repo", err) 37 return 38 } 39 40 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 41 if err != nil { 42 log.Printf("failed to create unsigned client for %s", f.Knot) 43 s.pages.Error503(w) 44 return 45 } 46 47 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 48 if err != nil { 49 s.pages.Error503(w) 50 log.Println("failed to reach knotserver", err) 51 return 52 } 53 defer resp.Body.Close() 54 55 body, err := io.ReadAll(resp.Body) 56 if err != nil { 57 log.Printf("Error reading response body: %v", err) 58 return 59 } 60 61 var result types.RepoIndexResponse 62 err = json.Unmarshal(body, &result) 63 if err != nil { 64 log.Printf("Error unmarshalling response body: %v", err) 65 return 66 } 67 68 tagMap := make(map[string][]string) 69 for _, tag := range result.Tags { 70 hash := tag.Hash 71 tagMap[hash] = append(tagMap[hash], tag.Name) 72 } 73 74 for _, branch := range result.Branches { 75 hash := branch.Hash 76 tagMap[hash] = append(tagMap[hash], branch.Name) 77 } 78 79 emails := uniqueEmails(result.Commits) 80 81 user := s.auth.GetUser(r) 82 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 83 LoggedInUser: user, 84 RepoInfo: f.RepoInfo(s, user), 85 TagMap: tagMap, 86 RepoIndexResponse: result, 87 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 88 }) 89 return 90} 91 92func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 93 f, err := fullyResolvedRepo(r) 94 if err != nil { 95 log.Println("failed to fully resolve repo", err) 96 return 97 } 98 99 page := 1 100 if r.URL.Query().Get("page") != "" { 101 page, err = strconv.Atoi(r.URL.Query().Get("page")) 102 if err != nil { 103 page = 1 104 } 105 } 106 107 ref := chi.URLParam(r, "ref") 108 109 protocol := "http" 110 if !s.config.Dev { 111 protocol = "https" 112 } 113 114 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page)) 115 if err != nil { 116 log.Println("failed to reach knotserver", err) 117 return 118 } 119 120 body, err := io.ReadAll(resp.Body) 121 if err != nil { 122 log.Printf("error reading response body: %v", err) 123 return 124 } 125 126 var repolog types.RepoLogResponse 127 err = json.Unmarshal(body, &repolog) 128 if err != nil { 129 log.Println("failed to parse json response", err) 130 return 131 } 132 133 user := s.auth.GetUser(r) 134 s.pages.RepoLog(w, pages.RepoLogParams{ 135 LoggedInUser: user, 136 RepoInfo: f.RepoInfo(s, user), 137 RepoLogResponse: repolog, 138 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 139 }) 140 return 141} 142 143func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 144 f, err := fullyResolvedRepo(r) 145 if err != nil { 146 log.Println("failed to get repo and knot", err) 147 w.WriteHeader(http.StatusBadRequest) 148 return 149 } 150 151 user := s.auth.GetUser(r) 152 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 153 RepoInfo: f.RepoInfo(s, user), 154 }) 155 return 156} 157 158func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 159 f, err := fullyResolvedRepo(r) 160 if err != nil { 161 log.Println("failed to get repo and knot", err) 162 w.WriteHeader(http.StatusBadRequest) 163 return 164 } 165 166 repoAt := f.RepoAt 167 rkey := repoAt.RecordKey().String() 168 if rkey == "" { 169 log.Println("invalid aturi for repo", err) 170 w.WriteHeader(http.StatusInternalServerError) 171 return 172 } 173 174 user := s.auth.GetUser(r) 175 176 switch r.Method { 177 case http.MethodGet: 178 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 179 RepoInfo: f.RepoInfo(s, user), 180 }) 181 return 182 case http.MethodPut: 183 user := s.auth.GetUser(r) 184 newDescription := r.FormValue("description") 185 client, _ := s.auth.AuthorizedClient(r) 186 187 // optimistic update 188 err = db.UpdateDescription(s.db, string(repoAt), newDescription) 189 if err != nil { 190 log.Println("failed to perferom update-description query", err) 191 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 192 return 193 } 194 195 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 196 // 197 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 198 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey) 199 if err != nil { 200 // failed to get record 201 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 202 return 203 } 204 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 205 Collection: tangled.RepoNSID, 206 Repo: user.Did, 207 Rkey: rkey, 208 SwapRecord: ex.Cid, 209 Record: &lexutil.LexiconTypeDecoder{ 210 Val: &tangled.Repo{ 211 Knot: f.Knot, 212 Name: f.RepoName, 213 Owner: user.Did, 214 AddedAt: &f.AddedAt, 215 Description: &newDescription, 216 }, 217 }, 218 }) 219 220 if err != nil { 221 log.Println("failed to perferom update-description query", err) 222 // failed to get record 223 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 224 return 225 } 226 227 newRepoInfo := f.RepoInfo(s, user) 228 newRepoInfo.Description = newDescription 229 230 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 231 RepoInfo: newRepoInfo, 232 }) 233 return 234 } 235} 236 237func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 238 f, err := fullyResolvedRepo(r) 239 if err != nil { 240 log.Println("failed to fully resolve repo", err) 241 return 242 } 243 ref := chi.URLParam(r, "ref") 244 protocol := "http" 245 if !s.config.Dev { 246 protocol = "https" 247 } 248 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 249 if err != nil { 250 log.Println("failed to reach knotserver", err) 251 return 252 } 253 254 body, err := io.ReadAll(resp.Body) 255 if err != nil { 256 log.Printf("Error reading response body: %v", err) 257 return 258 } 259 260 var result types.RepoCommitResponse 261 err = json.Unmarshal(body, &result) 262 if err != nil { 263 log.Println("failed to parse response:", err) 264 return 265 } 266 267 user := s.auth.GetUser(r) 268 s.pages.RepoCommit(w, pages.RepoCommitParams{ 269 LoggedInUser: user, 270 RepoInfo: f.RepoInfo(s, user), 271 RepoCommitResponse: result, 272 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 273 }) 274 return 275} 276 277func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 278 f, err := fullyResolvedRepo(r) 279 if err != nil { 280 log.Println("failed to fully resolve repo", err) 281 return 282 } 283 284 ref := chi.URLParam(r, "ref") 285 treePath := chi.URLParam(r, "*") 286 protocol := "http" 287 if !s.config.Dev { 288 protocol = "https" 289 } 290 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 291 if err != nil { 292 log.Println("failed to reach knotserver", err) 293 return 294 } 295 296 body, err := io.ReadAll(resp.Body) 297 if err != nil { 298 log.Printf("Error reading response body: %v", err) 299 return 300 } 301 302 var result types.RepoTreeResponse 303 err = json.Unmarshal(body, &result) 304 if err != nil { 305 log.Println("failed to parse response:", err) 306 return 307 } 308 309 user := s.auth.GetUser(r) 310 311 var breadcrumbs [][]string 312 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 313 if treePath != "" { 314 for idx, elem := range strings.Split(treePath, "/") { 315 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 316 } 317 } 318 319 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath) 320 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath) 321 322 s.pages.RepoTree(w, pages.RepoTreeParams{ 323 LoggedInUser: user, 324 BreadCrumbs: breadcrumbs, 325 BaseTreeLink: baseTreeLink, 326 BaseBlobLink: baseBlobLink, 327 RepoInfo: f.RepoInfo(s, user), 328 RepoTreeResponse: result, 329 }) 330 return 331} 332 333func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 334 f, err := fullyResolvedRepo(r) 335 if err != nil { 336 log.Println("failed to get repo and knot", err) 337 return 338 } 339 340 protocol := "http" 341 if !s.config.Dev { 342 protocol = "https" 343 } 344 345 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName)) 346 if err != nil { 347 log.Println("failed to reach knotserver", err) 348 return 349 } 350 351 body, err := io.ReadAll(resp.Body) 352 if err != nil { 353 log.Printf("Error reading response body: %v", err) 354 return 355 } 356 357 var result types.RepoTagsResponse 358 err = json.Unmarshal(body, &result) 359 if err != nil { 360 log.Println("failed to parse response:", err) 361 return 362 } 363 364 user := s.auth.GetUser(r) 365 s.pages.RepoTags(w, pages.RepoTagsParams{ 366 LoggedInUser: user, 367 RepoInfo: f.RepoInfo(s, user), 368 RepoTagsResponse: result, 369 }) 370 return 371} 372 373func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 374 f, err := fullyResolvedRepo(r) 375 if err != nil { 376 log.Println("failed to get repo and knot", err) 377 return 378 } 379 380 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 381 if err != nil { 382 log.Println("failed to create unsigned client", err) 383 return 384 } 385 386 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 387 if err != nil { 388 log.Println("failed to reach knotserver", err) 389 return 390 } 391 392 body, err := io.ReadAll(resp.Body) 393 if err != nil { 394 log.Printf("Error reading response body: %v", err) 395 return 396 } 397 398 var result types.RepoBranchesResponse 399 err = json.Unmarshal(body, &result) 400 if err != nil { 401 log.Println("failed to parse response:", err) 402 return 403 } 404 405 user := s.auth.GetUser(r) 406 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 407 LoggedInUser: user, 408 RepoInfo: f.RepoInfo(s, user), 409 RepoBranchesResponse: result, 410 }) 411 return 412} 413 414func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 415 f, err := fullyResolvedRepo(r) 416 if err != nil { 417 log.Println("failed to get repo and knot", err) 418 return 419 } 420 421 ref := chi.URLParam(r, "ref") 422 filePath := chi.URLParam(r, "*") 423 protocol := "http" 424 if !s.config.Dev { 425 protocol = "https" 426 } 427 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 428 if err != nil { 429 log.Println("failed to reach knotserver", err) 430 return 431 } 432 433 body, err := io.ReadAll(resp.Body) 434 if err != nil { 435 log.Printf("Error reading response body: %v", err) 436 return 437 } 438 439 var result types.RepoBlobResponse 440 err = json.Unmarshal(body, &result) 441 if err != nil { 442 log.Println("failed to parse response:", err) 443 return 444 } 445 446 var breadcrumbs [][]string 447 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 448 if filePath != "" { 449 for idx, elem := range strings.Split(filePath, "/") { 450 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 451 } 452 } 453 454 user := s.auth.GetUser(r) 455 s.pages.RepoBlob(w, pages.RepoBlobParams{ 456 LoggedInUser: user, 457 RepoInfo: f.RepoInfo(s, user), 458 RepoBlobResponse: result, 459 BreadCrumbs: breadcrumbs, 460 }) 461 return 462} 463 464func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 465 f, err := fullyResolvedRepo(r) 466 if err != nil { 467 log.Println("failed to get repo and knot", err) 468 return 469 } 470 471 ref := chi.URLParam(r, "ref") 472 filePath := chi.URLParam(r, "*") 473 474 protocol := "http" 475 if !s.config.Dev { 476 protocol = "https" 477 } 478 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 479 if err != nil { 480 log.Println("failed to reach knotserver", err) 481 return 482 } 483 484 body, err := io.ReadAll(resp.Body) 485 if err != nil { 486 log.Printf("Error reading response body: %v", err) 487 return 488 } 489 490 var result types.RepoBlobResponse 491 err = json.Unmarshal(body, &result) 492 if err != nil { 493 log.Println("failed to parse response:", err) 494 return 495 } 496 497 if result.IsBinary { 498 w.Header().Set("Content-Type", "application/octet-stream") 499 w.Write(body) 500 return 501 } 502 503 w.Header().Set("Content-Type", "text/plain") 504 w.Write([]byte(result.Contents)) 505 return 506} 507 508func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 509 f, err := fullyResolvedRepo(r) 510 if err != nil { 511 log.Println("failed to get repo and knot", err) 512 return 513 } 514 515 collaborator := r.FormValue("collaborator") 516 if collaborator == "" { 517 http.Error(w, "malformed form", http.StatusBadRequest) 518 return 519 } 520 521 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 522 if err != nil { 523 w.Write([]byte("failed to resolve collaborator did to a handle")) 524 return 525 } 526 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 527 528 // TODO: create an atproto record for this 529 530 secret, err := db.GetRegistrationKey(s.db, f.Knot) 531 if err != nil { 532 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 533 return 534 } 535 536 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 537 if err != nil { 538 log.Println("failed to create client to ", f.Knot) 539 return 540 } 541 542 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 543 if err != nil { 544 log.Printf("failed to make request to %s: %s", f.Knot, err) 545 return 546 } 547 548 if ksResp.StatusCode != http.StatusNoContent { 549 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 550 return 551 } 552 553 tx, err := s.db.BeginTx(r.Context(), nil) 554 if err != nil { 555 log.Println("failed to start tx") 556 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 557 return 558 } 559 defer func() { 560 tx.Rollback() 561 err = s.enforcer.E.LoadPolicy() 562 if err != nil { 563 log.Println("failed to rollback policies") 564 } 565 }() 566 567 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 568 if err != nil { 569 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 570 return 571 } 572 573 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 574 if err != nil { 575 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 576 return 577 } 578 579 err = tx.Commit() 580 if err != nil { 581 log.Println("failed to commit changes", err) 582 http.Error(w, err.Error(), http.StatusInternalServerError) 583 return 584 } 585 586 err = s.enforcer.E.SavePolicy() 587 if err != nil { 588 log.Println("failed to update ACLs", err) 589 http.Error(w, err.Error(), http.StatusInternalServerError) 590 return 591 } 592 593 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 594 595} 596 597func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 598 f, err := fullyResolvedRepo(r) 599 if err != nil { 600 log.Println("failed to get repo and knot", err) 601 return 602 } 603 604 branch := r.FormValue("branch") 605 if branch == "" { 606 http.Error(w, "malformed form", http.StatusBadRequest) 607 return 608 } 609 610 secret, err := db.GetRegistrationKey(s.db, f.Knot) 611 if err != nil { 612 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 613 return 614 } 615 616 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 617 if err != nil { 618 log.Println("failed to create client to ", f.Knot) 619 return 620 } 621 622 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 623 if err != nil { 624 log.Printf("failed to make request to %s: %s", f.Knot, err) 625 return 626 } 627 628 if ksResp.StatusCode != http.StatusNoContent { 629 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 630 return 631 } 632 633 w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 634} 635 636func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 637 f, err := fullyResolvedRepo(r) 638 if err != nil { 639 log.Println("failed to get repo and knot", err) 640 return 641 } 642 643 switch r.Method { 644 case http.MethodGet: 645 // for now, this is just pubkeys 646 user := s.auth.GetUser(r) 647 repoCollaborators, err := f.Collaborators(r.Context(), s) 648 if err != nil { 649 log.Println("failed to get collaborators", err) 650 } 651 652 isCollaboratorInviteAllowed := false 653 if user != nil { 654 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 655 if err == nil && ok { 656 isCollaboratorInviteAllowed = true 657 } 658 } 659 660 var branchNames []string 661 var defaultBranch string 662 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 663 if err != nil { 664 log.Println("failed to create unsigned client", err) 665 } else { 666 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 667 if err != nil { 668 log.Println("failed to reach knotserver", err) 669 } else { 670 defer resp.Body.Close() 671 672 body, err := io.ReadAll(resp.Body) 673 if err != nil { 674 log.Printf("Error reading response body: %v", err) 675 } else { 676 var result types.RepoBranchesResponse 677 err = json.Unmarshal(body, &result) 678 if err != nil { 679 log.Println("failed to parse response:", err) 680 } else { 681 for _, branch := range result.Branches { 682 branchNames = append(branchNames, branch.Name) 683 } 684 } 685 } 686 } 687 688 resp, err = us.DefaultBranch(f.OwnerDid(), f.RepoName) 689 if err != nil { 690 log.Println("failed to reach knotserver", err) 691 } else { 692 defer resp.Body.Close() 693 694 body, err := io.ReadAll(resp.Body) 695 if err != nil { 696 log.Printf("Error reading response body: %v", err) 697 } else { 698 var result types.RepoDefaultBranchResponse 699 err = json.Unmarshal(body, &result) 700 if err != nil { 701 log.Println("failed to parse response:", err) 702 } else { 703 defaultBranch = result.Branch 704 } 705 } 706 } 707 } 708 709 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 710 LoggedInUser: user, 711 RepoInfo: f.RepoInfo(s, user), 712 Collaborators: repoCollaborators, 713 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 714 Branches: branchNames, 715 DefaultBranch: defaultBranch, 716 }) 717 } 718} 719 720type FullyResolvedRepo struct { 721 Knot string 722 OwnerId identity.Identity 723 RepoName string 724 RepoAt syntax.ATURI 725 Description string 726 AddedAt string 727} 728 729func (f *FullyResolvedRepo) OwnerDid() string { 730 return f.OwnerId.DID.String() 731} 732 733func (f *FullyResolvedRepo) OwnerHandle() string { 734 return f.OwnerId.Handle.String() 735} 736 737func (f *FullyResolvedRepo) OwnerSlashRepo() string { 738 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 739 return p 740} 741 742func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 743 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 744 if err != nil { 745 return nil, err 746 } 747 748 var collaborators []pages.Collaborator 749 for _, item := range repoCollaborators { 750 // currently only two roles: owner and member 751 var role string 752 if item[3] == "repo:owner" { 753 role = "owner" 754 } else if item[3] == "repo:collaborator" { 755 role = "collaborator" 756 } else { 757 continue 758 } 759 760 did := item[0] 761 762 c := pages.Collaborator{ 763 Did: did, 764 Handle: "", 765 Role: role, 766 } 767 collaborators = append(collaborators, c) 768 } 769 770 // populate all collborators with handles 771 identsToResolve := make([]string, len(collaborators)) 772 for i, collab := range collaborators { 773 identsToResolve[i] = collab.Did 774 } 775 776 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 777 for i, resolved := range resolvedIdents { 778 if resolved != nil { 779 collaborators[i].Handle = resolved.Handle.String() 780 } 781 } 782 783 return collaborators, nil 784} 785 786func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 787 isStarred := false 788 if u != nil { 789 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 790 } 791 792 starCount, err := db.GetStarCount(s.db, f.RepoAt) 793 if err != nil { 794 log.Println("failed to get star count for ", f.RepoAt) 795 } 796 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 797 if err != nil { 798 log.Println("failed to get issue count for ", f.RepoAt) 799 } 800 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 801 if err != nil { 802 log.Println("failed to get issue count for ", f.RepoAt) 803 } 804 805 knot := f.Knot 806 if knot == "knot1.tangled.sh" { 807 knot = "tangled.sh" 808 } 809 810 return pages.RepoInfo{ 811 OwnerDid: f.OwnerDid(), 812 OwnerHandle: f.OwnerHandle(), 813 Name: f.RepoName, 814 RepoAt: f.RepoAt, 815 Description: f.Description, 816 IsStarred: isStarred, 817 Knot: knot, 818 Roles: RolesInRepo(s, u, f), 819 Stats: db.RepoStats{ 820 StarCount: starCount, 821 IssueCount: issueCount, 822 PullCount: pullCount, 823 }, 824 } 825} 826 827func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 828 user := s.auth.GetUser(r) 829 f, err := fullyResolvedRepo(r) 830 if err != nil { 831 log.Println("failed to get repo and knot", err) 832 return 833 } 834 835 issueId := chi.URLParam(r, "issue") 836 issueIdInt, err := strconv.Atoi(issueId) 837 if err != nil { 838 http.Error(w, "bad issue id", http.StatusBadRequest) 839 log.Println("failed to parse issue id", err) 840 return 841 } 842 843 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 844 if err != nil { 845 log.Println("failed to get issue and comments", err) 846 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 847 return 848 } 849 850 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 851 if err != nil { 852 log.Println("failed to resolve issue owner", err) 853 } 854 855 identsToResolve := make([]string, len(comments)) 856 for i, comment := range comments { 857 identsToResolve[i] = comment.OwnerDid 858 } 859 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 860 didHandleMap := make(map[string]string) 861 for _, identity := range resolvedIds { 862 if !identity.Handle.IsInvalidHandle() { 863 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 864 } else { 865 didHandleMap[identity.DID.String()] = identity.DID.String() 866 } 867 } 868 869 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 870 LoggedInUser: user, 871 RepoInfo: f.RepoInfo(s, user), 872 Issue: *issue, 873 Comments: comments, 874 875 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 876 DidHandleMap: didHandleMap, 877 }) 878 879} 880 881func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 882 user := s.auth.GetUser(r) 883 f, err := fullyResolvedRepo(r) 884 if err != nil { 885 log.Println("failed to get repo and knot", err) 886 return 887 } 888 889 issueId := chi.URLParam(r, "issue") 890 issueIdInt, err := strconv.Atoi(issueId) 891 if err != nil { 892 http.Error(w, "bad issue id", http.StatusBadRequest) 893 log.Println("failed to parse issue id", err) 894 return 895 } 896 897 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 898 if err != nil { 899 log.Println("failed to get issue", err) 900 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 901 return 902 } 903 904 collaborators, err := f.Collaborators(r.Context(), s) 905 if err != nil { 906 log.Println("failed to fetch repo collaborators: %w", err) 907 } 908 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 909 return user.Did == collab.Did 910 }) 911 isIssueOwner := user.Did == issue.OwnerDid 912 913 // TODO: make this more granular 914 if isIssueOwner || isCollaborator { 915 916 closed := tangled.RepoIssueStateClosed 917 918 client, _ := s.auth.AuthorizedClient(r) 919 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 920 Collection: tangled.RepoIssueStateNSID, 921 Repo: user.Did, 922 Rkey: s.TID(), 923 Record: &lexutil.LexiconTypeDecoder{ 924 Val: &tangled.RepoIssueState{ 925 Issue: issue.IssueAt, 926 State: &closed, 927 }, 928 }, 929 }) 930 931 if err != nil { 932 log.Println("failed to update issue state", err) 933 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 934 return 935 } 936 937 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 938 if err != nil { 939 log.Println("failed to close issue", err) 940 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 941 return 942 } 943 944 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 945 return 946 } else { 947 log.Println("user is not permitted to close issue") 948 http.Error(w, "for biden", http.StatusUnauthorized) 949 return 950 } 951} 952 953func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 954 user := s.auth.GetUser(r) 955 f, err := fullyResolvedRepo(r) 956 if err != nil { 957 log.Println("failed to get repo and knot", err) 958 return 959 } 960 961 issueId := chi.URLParam(r, "issue") 962 issueIdInt, err := strconv.Atoi(issueId) 963 if err != nil { 964 http.Error(w, "bad issue id", http.StatusBadRequest) 965 log.Println("failed to parse issue id", err) 966 return 967 } 968 969 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 970 if err != nil { 971 log.Println("failed to get issue", err) 972 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 973 return 974 } 975 976 collaborators, err := f.Collaborators(r.Context(), s) 977 if err != nil { 978 log.Println("failed to fetch repo collaborators: %w", err) 979 } 980 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 981 return user.Did == collab.Did 982 }) 983 isIssueOwner := user.Did == issue.OwnerDid 984 985 if isCollaborator || isIssueOwner { 986 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 987 if err != nil { 988 log.Println("failed to reopen issue", err) 989 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 990 return 991 } 992 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 993 return 994 } else { 995 log.Println("user is not the owner of the repo") 996 http.Error(w, "forbidden", http.StatusUnauthorized) 997 return 998 } 999} 1000 1001func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1002 user := s.auth.GetUser(r) 1003 f, err := fullyResolvedRepo(r) 1004 if err != nil { 1005 log.Println("failed to get repo and knot", err) 1006 return 1007 } 1008 1009 issueId := chi.URLParam(r, "issue") 1010 issueIdInt, err := strconv.Atoi(issueId) 1011 if err != nil { 1012 http.Error(w, "bad issue id", http.StatusBadRequest) 1013 log.Println("failed to parse issue id", err) 1014 return 1015 } 1016 1017 switch r.Method { 1018 case http.MethodPost: 1019 body := r.FormValue("body") 1020 if body == "" { 1021 s.pages.Notice(w, "issue", "Body is required") 1022 return 1023 } 1024 1025 commentId := rand.IntN(1000000) 1026 rkey := s.TID() 1027 1028 err := db.NewIssueComment(s.db, &db.Comment{ 1029 OwnerDid: user.Did, 1030 RepoAt: f.RepoAt, 1031 Issue: issueIdInt, 1032 CommentId: commentId, 1033 Body: body, 1034 Rkey: rkey, 1035 }) 1036 if err != nil { 1037 log.Println("failed to create comment", err) 1038 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1039 return 1040 } 1041 1042 createdAt := time.Now().Format(time.RFC3339) 1043 commentIdInt64 := int64(commentId) 1044 ownerDid := user.Did 1045 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1046 if err != nil { 1047 log.Println("failed to get issue at", err) 1048 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1049 return 1050 } 1051 1052 atUri := f.RepoAt.String() 1053 client, _ := s.auth.AuthorizedClient(r) 1054 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1055 Collection: tangled.RepoIssueCommentNSID, 1056 Repo: user.Did, 1057 Rkey: rkey, 1058 Record: &lexutil.LexiconTypeDecoder{ 1059 Val: &tangled.RepoIssueComment{ 1060 Repo: &atUri, 1061 Issue: issueAt, 1062 CommentId: &commentIdInt64, 1063 Owner: &ownerDid, 1064 Body: &body, 1065 CreatedAt: &createdAt, 1066 }, 1067 }, 1068 }) 1069 if err != nil { 1070 log.Println("failed to create comment", err) 1071 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1072 return 1073 } 1074 1075 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1076 return 1077 } 1078} 1079 1080func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1081 user := s.auth.GetUser(r) 1082 f, err := fullyResolvedRepo(r) 1083 if err != nil { 1084 log.Println("failed to get repo and knot", err) 1085 return 1086 } 1087 1088 issueId := chi.URLParam(r, "issue") 1089 issueIdInt, err := strconv.Atoi(issueId) 1090 if err != nil { 1091 http.Error(w, "bad issue id", http.StatusBadRequest) 1092 log.Println("failed to parse issue id", err) 1093 return 1094 } 1095 1096 commentId := chi.URLParam(r, "comment_id") 1097 commentIdInt, err := strconv.Atoi(commentId) 1098 if err != nil { 1099 http.Error(w, "bad comment id", http.StatusBadRequest) 1100 log.Println("failed to parse issue id", err) 1101 return 1102 } 1103 1104 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1105 if err != nil { 1106 log.Println("failed to get issue", err) 1107 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1108 return 1109 } 1110 1111 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1112 if err != nil { 1113 http.Error(w, "bad comment id", http.StatusBadRequest) 1114 return 1115 } 1116 1117 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1118 if err != nil { 1119 log.Println("failed to resolve did") 1120 return 1121 } 1122 1123 didHandleMap := make(map[string]string) 1124 if !identity.Handle.IsInvalidHandle() { 1125 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1126 } else { 1127 didHandleMap[identity.DID.String()] = identity.DID.String() 1128 } 1129 1130 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1131 LoggedInUser: user, 1132 RepoInfo: f.RepoInfo(s, user), 1133 DidHandleMap: didHandleMap, 1134 Issue: issue, 1135 Comment: comment, 1136 }) 1137} 1138 1139func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1140 user := s.auth.GetUser(r) 1141 f, err := fullyResolvedRepo(r) 1142 if err != nil { 1143 log.Println("failed to get repo and knot", err) 1144 return 1145 } 1146 1147 issueId := chi.URLParam(r, "issue") 1148 issueIdInt, err := strconv.Atoi(issueId) 1149 if err != nil { 1150 http.Error(w, "bad issue id", http.StatusBadRequest) 1151 log.Println("failed to parse issue id", err) 1152 return 1153 } 1154 1155 commentId := chi.URLParam(r, "comment_id") 1156 commentIdInt, err := strconv.Atoi(commentId) 1157 if err != nil { 1158 http.Error(w, "bad comment id", http.StatusBadRequest) 1159 log.Println("failed to parse issue id", err) 1160 return 1161 } 1162 1163 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1164 if err != nil { 1165 log.Println("failed to get issue", err) 1166 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1167 return 1168 } 1169 1170 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1171 if err != nil { 1172 http.Error(w, "bad comment id", http.StatusBadRequest) 1173 return 1174 } 1175 1176 if comment.OwnerDid != user.Did { 1177 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1178 return 1179 } 1180 1181 switch r.Method { 1182 case http.MethodGet: 1183 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1184 LoggedInUser: user, 1185 RepoInfo: f.RepoInfo(s, user), 1186 Issue: issue, 1187 Comment: comment, 1188 }) 1189 case http.MethodPost: 1190 // extract form value 1191 newBody := r.FormValue("body") 1192 client, _ := s.auth.AuthorizedClient(r) 1193 rkey := comment.Rkey 1194 1195 // optimistic update 1196 edited := time.Now() 1197 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1198 if err != nil { 1199 log.Println("failed to perferom update-description query", err) 1200 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1201 return 1202 } 1203 1204 // rkey is optional, it was introduced later 1205 if comment.Rkey != "" { 1206 // update the record on pds 1207 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1208 if err != nil { 1209 // failed to get record 1210 log.Println(err, rkey) 1211 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1212 return 1213 } 1214 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1215 record, _ := data.UnmarshalJSON(value) 1216 1217 repoAt := record["repo"].(string) 1218 issueAt := record["issue"].(string) 1219 createdAt := record["createdAt"].(string) 1220 commentIdInt64 := int64(commentIdInt) 1221 1222 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1223 Collection: tangled.RepoIssueCommentNSID, 1224 Repo: user.Did, 1225 Rkey: rkey, 1226 SwapRecord: ex.Cid, 1227 Record: &lexutil.LexiconTypeDecoder{ 1228 Val: &tangled.RepoIssueComment{ 1229 Repo: &repoAt, 1230 Issue: issueAt, 1231 CommentId: &commentIdInt64, 1232 Owner: &comment.OwnerDid, 1233 Body: &newBody, 1234 CreatedAt: &createdAt, 1235 }, 1236 }, 1237 }) 1238 if err != nil { 1239 log.Println(err) 1240 } 1241 } 1242 1243 // optimistic update for htmx 1244 didHandleMap := map[string]string{ 1245 user.Did: user.Handle, 1246 } 1247 comment.Body = newBody 1248 comment.Edited = &edited 1249 1250 // return new comment body with htmx 1251 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1252 LoggedInUser: user, 1253 RepoInfo: f.RepoInfo(s, user), 1254 DidHandleMap: didHandleMap, 1255 Issue: issue, 1256 Comment: comment, 1257 }) 1258 return 1259 1260 } 1261 1262} 1263 1264func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1265 user := s.auth.GetUser(r) 1266 f, err := fullyResolvedRepo(r) 1267 if err != nil { 1268 log.Println("failed to get repo and knot", err) 1269 return 1270 } 1271 1272 issueId := chi.URLParam(r, "issue") 1273 issueIdInt, err := strconv.Atoi(issueId) 1274 if err != nil { 1275 http.Error(w, "bad issue id", http.StatusBadRequest) 1276 log.Println("failed to parse issue id", err) 1277 return 1278 } 1279 1280 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1281 if err != nil { 1282 log.Println("failed to get issue", err) 1283 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1284 return 1285 } 1286 1287 commentId := chi.URLParam(r, "comment_id") 1288 commentIdInt, err := strconv.Atoi(commentId) 1289 if err != nil { 1290 http.Error(w, "bad comment id", http.StatusBadRequest) 1291 log.Println("failed to parse issue id", err) 1292 return 1293 } 1294 1295 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1296 if err != nil { 1297 http.Error(w, "bad comment id", http.StatusBadRequest) 1298 return 1299 } 1300 1301 if comment.OwnerDid != user.Did { 1302 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1303 return 1304 } 1305 1306 if comment.Deleted != nil { 1307 http.Error(w, "comment already deleted", http.StatusBadRequest) 1308 return 1309 } 1310 1311 // optimistic deletion 1312 deleted := time.Now() 1313 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1314 if err != nil { 1315 log.Println("failed to delete comment") 1316 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1317 return 1318 } 1319 1320 // delete from pds 1321 if comment.Rkey != "" { 1322 client, _ := s.auth.AuthorizedClient(r) 1323 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1324 Collection: tangled.GraphFollowNSID, 1325 Repo: user.Did, 1326 Rkey: comment.Rkey, 1327 }) 1328 if err != nil { 1329 log.Println(err) 1330 } 1331 } 1332 1333 // optimistic update for htmx 1334 didHandleMap := map[string]string{ 1335 user.Did: user.Handle, 1336 } 1337 comment.Body = "" 1338 comment.Deleted = &deleted 1339 1340 // htmx fragment of comment after deletion 1341 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1342 LoggedInUser: user, 1343 RepoInfo: f.RepoInfo(s, user), 1344 DidHandleMap: didHandleMap, 1345 Issue: issue, 1346 Comment: comment, 1347 }) 1348 return 1349} 1350 1351func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1352 params := r.URL.Query() 1353 state := params.Get("state") 1354 isOpen := true 1355 switch state { 1356 case "open": 1357 isOpen = true 1358 case "closed": 1359 isOpen = false 1360 default: 1361 isOpen = true 1362 } 1363 1364 user := s.auth.GetUser(r) 1365 f, err := fullyResolvedRepo(r) 1366 if err != nil { 1367 log.Println("failed to get repo and knot", err) 1368 return 1369 } 1370 1371 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 1372 if err != nil { 1373 log.Println("failed to get issues", err) 1374 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1375 return 1376 } 1377 1378 identsToResolve := make([]string, len(issues)) 1379 for i, issue := range issues { 1380 identsToResolve[i] = issue.OwnerDid 1381 } 1382 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1383 didHandleMap := make(map[string]string) 1384 for _, identity := range resolvedIds { 1385 if !identity.Handle.IsInvalidHandle() { 1386 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1387 } else { 1388 didHandleMap[identity.DID.String()] = identity.DID.String() 1389 } 1390 } 1391 1392 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1393 LoggedInUser: s.auth.GetUser(r), 1394 RepoInfo: f.RepoInfo(s, user), 1395 Issues: issues, 1396 DidHandleMap: didHandleMap, 1397 FilteringByOpen: isOpen, 1398 }) 1399 return 1400} 1401 1402func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1403 user := s.auth.GetUser(r) 1404 1405 f, err := fullyResolvedRepo(r) 1406 if err != nil { 1407 log.Println("failed to get repo and knot", err) 1408 return 1409 } 1410 1411 switch r.Method { 1412 case http.MethodGet: 1413 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1414 LoggedInUser: user, 1415 RepoInfo: f.RepoInfo(s, user), 1416 }) 1417 case http.MethodPost: 1418 title := r.FormValue("title") 1419 body := r.FormValue("body") 1420 1421 if title == "" || body == "" { 1422 s.pages.Notice(w, "issues", "Title and body are required") 1423 return 1424 } 1425 1426 tx, err := s.db.BeginTx(r.Context(), nil) 1427 if err != nil { 1428 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1429 return 1430 } 1431 1432 err = db.NewIssue(tx, &db.Issue{ 1433 RepoAt: f.RepoAt, 1434 Title: title, 1435 Body: body, 1436 OwnerDid: user.Did, 1437 }) 1438 if err != nil { 1439 log.Println("failed to create issue", err) 1440 s.pages.Notice(w, "issues", "Failed to create issue.") 1441 return 1442 } 1443 1444 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1445 if err != nil { 1446 log.Println("failed to get issue id", err) 1447 s.pages.Notice(w, "issues", "Failed to create issue.") 1448 return 1449 } 1450 1451 client, _ := s.auth.AuthorizedClient(r) 1452 atUri := f.RepoAt.String() 1453 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1454 Collection: tangled.RepoIssueNSID, 1455 Repo: user.Did, 1456 Rkey: s.TID(), 1457 Record: &lexutil.LexiconTypeDecoder{ 1458 Val: &tangled.RepoIssue{ 1459 Repo: atUri, 1460 Title: title, 1461 Body: &body, 1462 Owner: user.Did, 1463 IssueId: int64(issueId), 1464 }, 1465 }, 1466 }) 1467 if err != nil { 1468 log.Println("failed to create issue", err) 1469 s.pages.Notice(w, "issues", "Failed to create issue.") 1470 return 1471 } 1472 1473 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1474 if err != nil { 1475 log.Println("failed to set issue at", err) 1476 s.pages.Notice(w, "issues", "Failed to create issue.") 1477 return 1478 } 1479 1480 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1481 return 1482 } 1483}