Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

+416
api/tangled/cbor_gen.go
··· 561 562 return nil 563 } 564 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 565 if t == nil { 566 _, err := w.Write(cbg.CborNull)
··· 561 562 return nil 563 } 564 + func (t *Comment) MarshalCBOR(w io.Writer) error { 565 + if t == nil { 566 + _, err := w.Write(cbg.CborNull) 567 + return err 568 + } 569 + 570 + cw := cbg.NewCborWriter(w) 571 + fieldCount := 7 572 + 573 + if t.Mentions == nil { 574 + fieldCount-- 575 + } 576 + 577 + if t.References == nil { 578 + fieldCount-- 579 + } 580 + 581 + if t.ReplyTo == nil { 582 + fieldCount-- 583 + } 584 + 585 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 586 + return err 587 + } 588 + 589 + // t.Body (string) (string) 590 + if len("body") > 1000000 { 591 + return xerrors.Errorf("Value in field \"body\" was too long") 592 + } 593 + 594 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 595 + return err 596 + } 597 + if _, err := cw.WriteString(string("body")); err != nil { 598 + return err 599 + } 600 + 601 + if len(t.Body) > 1000000 { 602 + return xerrors.Errorf("Value in field t.Body was too long") 603 + } 604 + 605 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 606 + return err 607 + } 608 + if _, err := cw.WriteString(string(t.Body)); err != nil { 609 + return err 610 + } 611 + 612 + // t.LexiconTypeID (string) (string) 613 + if len("$type") > 1000000 { 614 + return xerrors.Errorf("Value in field \"$type\" was too long") 615 + } 616 + 617 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 618 + return err 619 + } 620 + if _, err := cw.WriteString(string("$type")); err != nil { 621 + return err 622 + } 623 + 624 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.comment"))); err != nil { 625 + return err 626 + } 627 + if _, err := cw.WriteString(string("sh.tangled.comment")); err != nil { 628 + return err 629 + } 630 + 631 + // t.ReplyTo (string) (string) 632 + if t.ReplyTo != nil { 633 + 634 + if len("replyTo") > 1000000 { 635 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 636 + } 637 + 638 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 639 + return err 640 + } 641 + if _, err := cw.WriteString(string("replyTo")); err != nil { 642 + return err 643 + } 644 + 645 + if t.ReplyTo == nil { 646 + if _, err := cw.Write(cbg.CborNull); err != nil { 647 + return err 648 + } 649 + } else { 650 + if len(*t.ReplyTo) > 1000000 { 651 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 652 + } 653 + 654 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 655 + return err 656 + } 657 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 658 + return err 659 + } 660 + } 661 + } 662 + 663 + // t.Subject (string) (string) 664 + if len("subject") > 1000000 { 665 + return xerrors.Errorf("Value in field \"subject\" was too long") 666 + } 667 + 668 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 669 + return err 670 + } 671 + if _, err := cw.WriteString(string("subject")); err != nil { 672 + return err 673 + } 674 + 675 + if len(t.Subject) > 1000000 { 676 + return xerrors.Errorf("Value in field t.Subject was too long") 677 + } 678 + 679 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 680 + return err 681 + } 682 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 683 + return err 684 + } 685 + 686 + // t.Mentions ([]string) (slice) 687 + if t.Mentions != nil { 688 + 689 + if len("mentions") > 1000000 { 690 + return xerrors.Errorf("Value in field \"mentions\" was too long") 691 + } 692 + 693 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("mentions"))); err != nil { 694 + return err 695 + } 696 + if _, err := cw.WriteString(string("mentions")); err != nil { 697 + return err 698 + } 699 + 700 + if len(t.Mentions) > 8192 { 701 + return xerrors.Errorf("Slice value in field t.Mentions was too long") 702 + } 703 + 704 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Mentions))); err != nil { 705 + return err 706 + } 707 + for _, v := range t.Mentions { 708 + if len(v) > 1000000 { 709 + return xerrors.Errorf("Value in field v was too long") 710 + } 711 + 712 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 713 + return err 714 + } 715 + if _, err := cw.WriteString(string(v)); err != nil { 716 + return err 717 + } 718 + 719 + } 720 + } 721 + 722 + // t.CreatedAt (string) (string) 723 + if len("createdAt") > 1000000 { 724 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 725 + } 726 + 727 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 728 + return err 729 + } 730 + if _, err := cw.WriteString(string("createdAt")); err != nil { 731 + return err 732 + } 733 + 734 + if len(t.CreatedAt) > 1000000 { 735 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 736 + } 737 + 738 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 739 + return err 740 + } 741 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 742 + return err 743 + } 744 + 745 + // t.References ([]string) (slice) 746 + if t.References != nil { 747 + 748 + if len("references") > 1000000 { 749 + return xerrors.Errorf("Value in field \"references\" was too long") 750 + } 751 + 752 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("references"))); err != nil { 753 + return err 754 + } 755 + if _, err := cw.WriteString(string("references")); err != nil { 756 + return err 757 + } 758 + 759 + if len(t.References) > 8192 { 760 + return xerrors.Errorf("Slice value in field t.References was too long") 761 + } 762 + 763 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.References))); err != nil { 764 + return err 765 + } 766 + for _, v := range t.References { 767 + if len(v) > 1000000 { 768 + return xerrors.Errorf("Value in field v was too long") 769 + } 770 + 771 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 772 + return err 773 + } 774 + if _, err := cw.WriteString(string(v)); err != nil { 775 + return err 776 + } 777 + 778 + } 779 + } 780 + return nil 781 + } 782 + 783 + func (t *Comment) UnmarshalCBOR(r io.Reader) (err error) { 784 + *t = Comment{} 785 + 786 + cr := cbg.NewCborReader(r) 787 + 788 + maj, extra, err := cr.ReadHeader() 789 + if err != nil { 790 + return err 791 + } 792 + defer func() { 793 + if err == io.EOF { 794 + err = io.ErrUnexpectedEOF 795 + } 796 + }() 797 + 798 + if maj != cbg.MajMap { 799 + return fmt.Errorf("cbor input should be of type map") 800 + } 801 + 802 + if extra > cbg.MaxLength { 803 + return fmt.Errorf("Comment: map struct too large (%d)", extra) 804 + } 805 + 806 + n := extra 807 + 808 + nameBuf := make([]byte, 10) 809 + for i := uint64(0); i < n; i++ { 810 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 811 + if err != nil { 812 + return err 813 + } 814 + 815 + if !ok { 816 + // Field doesn't exist on this type, so ignore it 817 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 818 + return err 819 + } 820 + continue 821 + } 822 + 823 + switch string(nameBuf[:nameLen]) { 824 + // t.Body (string) (string) 825 + case "body": 826 + 827 + { 828 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 829 + if err != nil { 830 + return err 831 + } 832 + 833 + t.Body = string(sval) 834 + } 835 + // t.LexiconTypeID (string) (string) 836 + case "$type": 837 + 838 + { 839 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 840 + if err != nil { 841 + return err 842 + } 843 + 844 + t.LexiconTypeID = string(sval) 845 + } 846 + // t.ReplyTo (string) (string) 847 + case "replyTo": 848 + 849 + { 850 + b, err := cr.ReadByte() 851 + if err != nil { 852 + return err 853 + } 854 + if b != cbg.CborNull[0] { 855 + if err := cr.UnreadByte(); err != nil { 856 + return err 857 + } 858 + 859 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 860 + if err != nil { 861 + return err 862 + } 863 + 864 + t.ReplyTo = (*string)(&sval) 865 + } 866 + } 867 + // t.Subject (string) (string) 868 + case "subject": 869 + 870 + { 871 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 872 + if err != nil { 873 + return err 874 + } 875 + 876 + t.Subject = string(sval) 877 + } 878 + // t.Mentions ([]string) (slice) 879 + case "mentions": 880 + 881 + maj, extra, err = cr.ReadHeader() 882 + if err != nil { 883 + return err 884 + } 885 + 886 + if extra > 8192 { 887 + return fmt.Errorf("t.Mentions: array too large (%d)", extra) 888 + } 889 + 890 + if maj != cbg.MajArray { 891 + return fmt.Errorf("expected cbor array") 892 + } 893 + 894 + if extra > 0 { 895 + t.Mentions = make([]string, extra) 896 + } 897 + 898 + for i := 0; i < int(extra); i++ { 899 + { 900 + var maj byte 901 + var extra uint64 902 + var err error 903 + _ = maj 904 + _ = extra 905 + _ = err 906 + 907 + { 908 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 909 + if err != nil { 910 + return err 911 + } 912 + 913 + t.Mentions[i] = string(sval) 914 + } 915 + 916 + } 917 + } 918 + // t.CreatedAt (string) (string) 919 + case "createdAt": 920 + 921 + { 922 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 923 + if err != nil { 924 + return err 925 + } 926 + 927 + t.CreatedAt = string(sval) 928 + } 929 + // t.References ([]string) (slice) 930 + case "references": 931 + 932 + maj, extra, err = cr.ReadHeader() 933 + if err != nil { 934 + return err 935 + } 936 + 937 + if extra > 8192 { 938 + return fmt.Errorf("t.References: array too large (%d)", extra) 939 + } 940 + 941 + if maj != cbg.MajArray { 942 + return fmt.Errorf("expected cbor array") 943 + } 944 + 945 + if extra > 0 { 946 + t.References = make([]string, extra) 947 + } 948 + 949 + for i := 0; i < int(extra); i++ { 950 + { 951 + var maj byte 952 + var extra uint64 953 + var err error 954 + _ = maj 955 + _ = extra 956 + _ = err 957 + 958 + { 959 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 960 + if err != nil { 961 + return err 962 + } 963 + 964 + t.References[i] = string(sval) 965 + } 966 + 967 + } 968 + } 969 + 970 + default: 971 + // Field doesn't exist on this type, so ignore it 972 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 973 + return err 974 + } 975 + } 976 + } 977 + 978 + return nil 979 + } 980 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 981 if t == nil { 982 _, err := w.Write(cbg.CborNull)
+27
api/tangled/tangledcomment.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.comment 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + CommentNSID = "sh.tangled.comment" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.comment", &Comment{}) 17 + } // 18 + // RECORDTYPE: Comment 19 + type Comment struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.comment" cborgen:"$type,const=sh.tangled.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"` 24 + References []string `json:"references,omitempty" cborgen:"references,omitempty"` 25 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 26 + Subject string `json:"subject" cborgen:"subject"` 27 + }
+199
appview/db/comments.go
···
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/orm" 15 + ) 16 + 17 + func PutComment(tx *sql.Tx, c *models.Comment) error { 18 + result, err := tx.Exec( 19 + `insert into comments ( 20 + did, 21 + rkey, 22 + subject_at, 23 + reply_to, 24 + body, 25 + pull_submission_id, 26 + created 27 + ) 28 + values (?, ?, ?, ?, ?, ?, ?) 29 + on conflict(did, rkey) do update set 30 + subject_at = excluded.subject_at, 31 + reply_to = excluded.reply_to, 32 + body = excluded.body, 33 + edited = case 34 + when 35 + comments.subject_at != excluded.subject_at 36 + or comments.body != excluded.body 37 + or comments.reply_to != excluded.reply_to 38 + then ? 39 + else comments.edited 40 + end`, 41 + c.Did, 42 + c.Rkey, 43 + c.Subject, 44 + c.ReplyTo, 45 + c.Body, 46 + c.PullSubmissionId, 47 + c.Created.Format(time.RFC3339), 48 + time.Now().Format(time.RFC3339), 49 + ) 50 + if err != nil { 51 + return err 52 + } 53 + 54 + c.Id, err = result.LastInsertId() 55 + if err != nil { 56 + return err 57 + } 58 + 59 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 60 + return fmt.Errorf("put reference_links: %w", err) 61 + } 62 + 63 + return nil 64 + } 65 + 66 + func DeleteComments(e Execer, filters ...orm.Filter) error { 67 + var conditions []string 68 + var args []any 69 + for _, filter := range filters { 70 + conditions = append(conditions, filter.Condition()) 71 + args = append(args, filter.Arg()...) 72 + } 73 + 74 + whereClause := "" 75 + if conditions != nil { 76 + whereClause = " where " + strings.Join(conditions, " and ") 77 + } 78 + 79 + query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 80 + 81 + _, err := e.Exec(query, args...) 82 + return err 83 + } 84 + 85 + func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 86 + commentMap := make(map[string]*models.Comment) 87 + 88 + var conditions []string 89 + var args []any 90 + for _, filter := range filters { 91 + conditions = append(conditions, filter.Condition()) 92 + args = append(args, filter.Arg()...) 93 + } 94 + 95 + whereClause := "" 96 + if conditions != nil { 97 + whereClause = " where " + strings.Join(conditions, " and ") 98 + } 99 + 100 + query := fmt.Sprintf(` 101 + select 102 + id, 103 + did, 104 + rkey, 105 + subject_at, 106 + reply_to, 107 + body, 108 + pull_submission_id, 109 + created, 110 + edited, 111 + deleted 112 + from 113 + comments 114 + %s 115 + `, whereClause) 116 + 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 120 + } 121 + 122 + for rows.Next() { 123 + var comment models.Comment 124 + var created string 125 + var rkey, edited, deleted, replyTo sql.Null[string] 126 + err := rows.Scan( 127 + &comment.Id, 128 + &comment.Did, 129 + &rkey, 130 + &comment.Subject, 131 + &replyTo, 132 + &comment.Body, 133 + &comment.PullSubmissionId, 134 + &created, 135 + &edited, 136 + &deleted, 137 + ) 138 + if err != nil { 139 + return nil, err 140 + } 141 + 142 + // this is a remnant from old times, newer comments always have rkey 143 + if rkey.Valid { 144 + comment.Rkey = rkey.V 145 + } 146 + 147 + if t, err := time.Parse(time.RFC3339, created); err == nil { 148 + comment.Created = t 149 + } 150 + 151 + if edited.Valid { 152 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 153 + comment.Edited = &t 154 + } 155 + } 156 + 157 + if deleted.Valid { 158 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 159 + comment.Deleted = &t 160 + } 161 + } 162 + 163 + if replyTo.Valid { 164 + rt := syntax.ATURI(replyTo.V) 165 + comment.ReplyTo = &rt 166 + } 167 + 168 + atUri := comment.AtUri().String() 169 + commentMap[atUri] = &comment 170 + } 171 + 172 + if err := rows.Err(); err != nil { 173 + return nil, err 174 + } 175 + defer rows.Close() 176 + 177 + // collect references from each comments 178 + commentAts := slices.Collect(maps.Keys(commentMap)) 179 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 180 + if err != nil { 181 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 182 + } 183 + for commentAt, references := range allReferencs { 184 + if comment, ok := commentMap[commentAt.String()]; ok { 185 + comment.References = references 186 + } 187 + } 188 + 189 + var comments []models.Comment 190 + for _, c := range commentMap { 191 + comments = append(comments, *c) 192 + } 193 + 194 + sort.Slice(comments, func(i, j int) bool { 195 + return comments[i].Created.After(comments[j].Created) 196 + }) 197 + 198 + return comments, nil 199 + }
+81
appview/db/db.go
··· 1173 return err 1174 }) 1175 1176 return &DB{ 1177 db, 1178 logger,
··· 1173 return err 1174 }) 1175 1176 + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1177 + _, err := tx.Exec(` 1178 + drop table if exists comments; 1179 + 1180 + create table comments ( 1181 + -- identifiers 1182 + id integer primary key autoincrement, 1183 + did text not null, 1184 + collection text not null default 'sh.tangled.comment', 1185 + rkey text not null, 1186 + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, 1187 + 1188 + -- at identifiers 1189 + subject_at text not null, 1190 + reply_to text, -- at_uri of parent comment 1191 + 1192 + pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds 1193 + 1194 + -- content 1195 + body text not null, 1196 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1197 + edited text, 1198 + deleted text, 1199 + 1200 + -- constraints 1201 + unique(did, rkey) 1202 + ); 1203 + 1204 + insert into comments ( 1205 + did, 1206 + collection, 1207 + rkey, 1208 + subject_at, 1209 + reply_to, 1210 + body, 1211 + created, 1212 + edited, 1213 + deleted 1214 + ) 1215 + select 1216 + did, 1217 + 'sh.tangled.repo.issue.comment', 1218 + rkey, 1219 + issue_at, 1220 + reply_to, 1221 + body, 1222 + created, 1223 + edited, 1224 + deleted 1225 + from issue_comments 1226 + where rkey is not null; 1227 + 1228 + insert into comments ( 1229 + did, 1230 + collection, 1231 + rkey, 1232 + subject_at, 1233 + pull_submission_id, 1234 + body, 1235 + created 1236 + ) 1237 + select 1238 + c.owner_did, 1239 + 'sh.tangled.repo.pull.comment', 1240 + substr( 1241 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1242 + instr( 1243 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1244 + '/' 1245 + ) + 1 1246 + ), -- rkey 1247 + p.at_uri, 1248 + c.submission_id, 1249 + c.body, 1250 + c.created 1251 + from pull_comments c 1252 + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id; 1253 + `) 1254 + return err 1255 + }) 1256 + 1257 return &DB{ 1258 db, 1259 logger,
+6 -186
appview/db/issues.go
··· 100 } 101 102 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 - issueMap := make(map[string]*models.Issue) // at-uri -> issue 104 105 var conditions []string 106 var args []any ··· 196 } 197 } 198 199 - atUri := issue.AtUri().String() 200 - issueMap[atUri] = &issue 201 } 202 203 // collect reverse repos ··· 229 // collect comments 230 issueAts := slices.Collect(maps.Keys(issueMap)) 231 232 - comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 233 if err != nil { 234 return nil, fmt.Errorf("failed to query comments: %w", err) 235 } 236 for i := range comments { 237 - issueAt := comments[i].IssueAt 238 if issue, ok := issueMap[issueAt]; ok { 239 issue.Comments = append(issue.Comments, comments[i]) 240 } ··· 246 return nil, fmt.Errorf("failed to query labels: %w", err) 247 } 248 for issueAt, labels := range allLabels { 249 - if issue, ok := issueMap[issueAt.String()]; ok { 250 issue.Labels = labels 251 } 252 } ··· 257 return nil, fmt.Errorf("failed to query reference_links: %w", err) 258 } 259 for issueAt, references := range allReferencs { 260 - if issue, ok := issueMap[issueAt.String()]; ok { 261 issue.References = references 262 } 263 } ··· 349 } 350 351 return ids, nil 352 - } 353 - 354 - func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 - result, err := tx.Exec( 356 - `insert into issue_comments ( 357 - did, 358 - rkey, 359 - issue_at, 360 - body, 361 - reply_to, 362 - created, 363 - edited 364 - ) 365 - values (?, ?, ?, ?, ?, ?, null) 366 - on conflict(did, rkey) do update set 367 - issue_at = excluded.issue_at, 368 - body = excluded.body, 369 - edited = case 370 - when 371 - issue_comments.issue_at != excluded.issue_at 372 - or issue_comments.body != excluded.body 373 - or issue_comments.reply_to != excluded.reply_to 374 - then ? 375 - else issue_comments.edited 376 - end`, 377 - c.Did, 378 - c.Rkey, 379 - c.IssueAt, 380 - c.Body, 381 - c.ReplyTo, 382 - c.Created.Format(time.RFC3339), 383 - time.Now().Format(time.RFC3339), 384 - ) 385 - if err != nil { 386 - return 0, err 387 - } 388 - 389 - id, err := result.LastInsertId() 390 - if err != nil { 391 - return 0, err 392 - } 393 - 394 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 - return 0, fmt.Errorf("put reference_links: %w", err) 396 - } 397 - 398 - return id, nil 399 - } 400 - 401 - func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 402 - var conditions []string 403 - var args []any 404 - for _, filter := range filters { 405 - conditions = append(conditions, filter.Condition()) 406 - args = append(args, filter.Arg()...) 407 - } 408 - 409 - whereClause := "" 410 - if conditions != nil { 411 - whereClause = " where " + strings.Join(conditions, " and ") 412 - } 413 - 414 - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 415 - 416 - _, err := e.Exec(query, args...) 417 - return err 418 - } 419 - 420 - func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 - commentMap := make(map[string]*models.IssueComment) 422 - 423 - var conditions []string 424 - var args []any 425 - for _, filter := range filters { 426 - conditions = append(conditions, filter.Condition()) 427 - args = append(args, filter.Arg()...) 428 - } 429 - 430 - whereClause := "" 431 - if conditions != nil { 432 - whereClause = " where " + strings.Join(conditions, " and ") 433 - } 434 - 435 - query := fmt.Sprintf(` 436 - select 437 - id, 438 - did, 439 - rkey, 440 - issue_at, 441 - reply_to, 442 - body, 443 - created, 444 - edited, 445 - deleted 446 - from 447 - issue_comments 448 - %s 449 - `, whereClause) 450 - 451 - rows, err := e.Query(query, args...) 452 - if err != nil { 453 - return nil, err 454 - } 455 - defer rows.Close() 456 - 457 - for rows.Next() { 458 - var comment models.IssueComment 459 - var created string 460 - var rkey, edited, deleted, replyTo sql.Null[string] 461 - err := rows.Scan( 462 - &comment.Id, 463 - &comment.Did, 464 - &rkey, 465 - &comment.IssueAt, 466 - &replyTo, 467 - &comment.Body, 468 - &created, 469 - &edited, 470 - &deleted, 471 - ) 472 - if err != nil { 473 - return nil, err 474 - } 475 - 476 - // this is a remnant from old times, newer comments always have rkey 477 - if rkey.Valid { 478 - comment.Rkey = rkey.V 479 - } 480 - 481 - if t, err := time.Parse(time.RFC3339, created); err == nil { 482 - comment.Created = t 483 - } 484 - 485 - if edited.Valid { 486 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 487 - comment.Edited = &t 488 - } 489 - } 490 - 491 - if deleted.Valid { 492 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 493 - comment.Deleted = &t 494 - } 495 - } 496 - 497 - if replyTo.Valid { 498 - comment.ReplyTo = &replyTo.V 499 - } 500 - 501 - atUri := comment.AtUri().String() 502 - commentMap[atUri] = &comment 503 - } 504 - 505 - if err = rows.Err(); err != nil { 506 - return nil, err 507 - } 508 - 509 - // collect references for each comments 510 - commentAts := slices.Collect(maps.Keys(commentMap)) 511 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 512 - if err != nil { 513 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 514 - } 515 - for commentAt, references := range allReferencs { 516 - if comment, ok := commentMap[commentAt.String()]; ok { 517 - comment.References = references 518 - } 519 - } 520 - 521 - var comments []models.IssueComment 522 - for _, c := range commentMap { 523 - comments = append(comments, *c) 524 - } 525 - 526 - sort.Slice(comments, func(i, j int) bool { 527 - return comments[i].Created.After(comments[j].Created) 528 - }) 529 - 530 - return comments, nil 531 } 532 533 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
··· 100 } 101 102 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...orm.Filter) ([]models.Issue, error) { 103 + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue 104 105 var conditions []string 106 var args []any ··· 196 } 197 } 198 199 + issueMap[issue.AtUri()] = &issue 200 } 201 202 // collect reverse repos ··· 228 // collect comments 229 issueAts := slices.Collect(maps.Keys(issueMap)) 230 231 + comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts)) 232 if err != nil { 233 return nil, fmt.Errorf("failed to query comments: %w", err) 234 } 235 for i := range comments { 236 + issueAt := comments[i].Subject 237 if issue, ok := issueMap[issueAt]; ok { 238 issue.Comments = append(issue.Comments, comments[i]) 239 } ··· 245 return nil, fmt.Errorf("failed to query labels: %w", err) 246 } 247 for issueAt, labels := range allLabels { 248 + if issue, ok := issueMap[issueAt]; ok { 249 issue.Labels = labels 250 } 251 } ··· 256 return nil, fmt.Errorf("failed to query reference_links: %w", err) 257 } 258 for issueAt, references := range allReferencs { 259 + if issue, ok := issueMap[issueAt]; ok { 260 issue.References = references 261 } 262 } ··· 348 } 349 350 return ids, nil 351 } 352 353 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+38 -22
appview/db/profile.go
··· 16 17 const TimeframeMonths = 7 18 19 - func MonthsApart(from, to time.Time) int { 20 - fromYear, fromMonth, _ := from.Date() 21 - toYear, toMonth, _ := to.Date() 22 - return (toYear-fromYear)*12 + int(toMonth-fromMonth) 23 - } 24 - 25 func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 26 timeline := models.ProfileTimeline{ 27 ByMonth: make([]models.ByMonth, TimeframeMonths), ··· 36 37 // group pulls by month 38 for _, pull := range pulls { 39 - idx := MonthsApart(pull.Created, now) 40 41 - if 0 <= idx && idx < TimeframeMonths { 42 - items := &timeline.ByMonth[idx].PullEvents.Items 43 - *items = append(*items, &pull) 44 } 45 } 46 47 issues, err := GetIssues( ··· 54 } 55 56 for _, issue := range issues { 57 - idx := MonthsApart(issue.Created, now) 58 59 - if 0 <= idx && idx < TimeframeMonths { 60 - items := &timeline.ByMonth[idx].IssueEvents.Items 61 - *items = append(*items, &issue) 62 } 63 } 64 65 repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid)) ··· 73 if repo.Source != "" { 74 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 75 if err != nil { 76 - return nil, err 77 } 78 } 79 80 - idx := MonthsApart(repo.Created, now) 81 82 - if 0 <= idx && idx < TimeframeMonths { 83 - items := &timeline.ByMonth[idx].RepoEvents 84 - *items = append(*items, models.RepoEvent{ 85 - Repo: &repo, 86 - Source: sourceRepo, 87 - }) 88 } 89 } 90 91 return &timeline, nil 92 } 93 94 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
··· 16 17 const TimeframeMonths = 7 18 19 func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 20 timeline := models.ProfileTimeline{ 21 ByMonth: make([]models.ByMonth, TimeframeMonths), ··· 30 31 // group pulls by month 32 for _, pull := range pulls { 33 + monthsAgo := monthsBetween(pull.Created, now) 34 35 + if monthsAgo >= TimeframeMonths { 36 + // shouldn't happen; but times are weird 37 + continue 38 } 39 + 40 + idx := monthsAgo 41 + items := &timeline.ByMonth[idx].PullEvents.Items 42 + 43 + *items = append(*items, &pull) 44 } 45 46 issues, err := GetIssues( ··· 53 } 54 55 for _, issue := range issues { 56 + monthsAgo := monthsBetween(issue.Created, now) 57 58 + if monthsAgo >= TimeframeMonths { 59 + // shouldn't happen; but times are weird 60 + continue 61 } 62 + 63 + idx := monthsAgo 64 + items := &timeline.ByMonth[idx].IssueEvents.Items 65 + 66 + *items = append(*items, &issue) 67 } 68 69 repos, err := GetRepos(e, 0, orm.FilterEq("did", forDid)) ··· 77 if repo.Source != "" { 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 if err != nil { 80 + // the source repo was not found, skip this bit 81 + log.Println("profile", "err", err) 82 } 83 } 84 85 + monthsAgo := monthsBetween(repo.Created, now) 86 87 + if monthsAgo >= TimeframeMonths { 88 + // shouldn't happen; but times are weird 89 + continue 90 } 91 + 92 + idx := monthsAgo 93 + 94 + items := &timeline.ByMonth[idx].RepoEvents 95 + *items = append(*items, models.RepoEvent{ 96 + Repo: &repo, 97 + Source: sourceRepo, 98 + }) 99 } 100 101 return &timeline, nil 102 + } 103 + 104 + func monthsBetween(from, to time.Time) int { 105 + years := to.Year() - from.Year() 106 + months := int(to.Month() - from.Month()) 107 + return years*12 + months 108 } 109 110 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+6 -121
appview/db/pulls.go
··· 447 return nil, err 448 } 449 450 - // Get comments for all submissions using GetPullComments 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 452 - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 453 if err != nil { 454 return nil, fmt.Errorf("failed to get pull comments: %w", err) 455 } 456 for _, comment := range comments { 457 - if submission, ok := submissionMap[comment.SubmissionId]; ok { 458 - submission.Comments = append(submission.Comments, comment) 459 } 460 } 461 ··· 475 return m, nil 476 } 477 478 - func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 479 - var conditions []string 480 - var args []any 481 - for _, filter := range filters { 482 - conditions = append(conditions, filter.Condition()) 483 - args = append(args, filter.Arg()...) 484 - } 485 - 486 - whereClause := "" 487 - if conditions != nil { 488 - whereClause = " where " + strings.Join(conditions, " and ") 489 - } 490 - 491 - query := fmt.Sprintf(` 492 - select 493 - id, 494 - pull_id, 495 - submission_id, 496 - repo_at, 497 - owner_did, 498 - comment_at, 499 - body, 500 - created 501 - from 502 - pull_comments 503 - %s 504 - order by 505 - created asc 506 - `, whereClause) 507 - 508 - rows, err := e.Query(query, args...) 509 - if err != nil { 510 - return nil, err 511 - } 512 - defer rows.Close() 513 - 514 - commentMap := make(map[string]*models.PullComment) 515 - for rows.Next() { 516 - var comment models.PullComment 517 - var createdAt string 518 - err := rows.Scan( 519 - &comment.ID, 520 - &comment.PullId, 521 - &comment.SubmissionId, 522 - &comment.RepoAt, 523 - &comment.OwnerDid, 524 - &comment.CommentAt, 525 - &comment.Body, 526 - &createdAt, 527 - ) 528 - if err != nil { 529 - return nil, err 530 - } 531 - 532 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 533 - comment.Created = t 534 - } 535 - 536 - atUri := comment.AtUri().String() 537 - commentMap[atUri] = &comment 538 - } 539 - 540 - if err := rows.Err(); err != nil { 541 - return nil, err 542 - } 543 - 544 - // collect references for each comments 545 - commentAts := slices.Collect(maps.Keys(commentMap)) 546 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 547 - if err != nil { 548 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 549 - } 550 - for commentAt, references := range allReferencs { 551 - if comment, ok := commentMap[commentAt.String()]; ok { 552 - comment.References = references 553 - } 554 - } 555 - 556 - var comments []models.PullComment 557 - for _, c := range commentMap { 558 - comments = append(comments, *c) 559 - } 560 - 561 - sort.Slice(comments, func(i, j int) bool { 562 - return comments[i].Created.Before(comments[j].Created) 563 - }) 564 - 565 - return comments, nil 566 - } 567 - 568 // timeframe here is directly passed into the sql query filter, and any 569 // timeframe in the past should be negative; e.g.: "-3 months" 570 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 639 } 640 641 return pulls, nil 642 - } 643 - 644 - func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 645 - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 646 - res, err := tx.Exec( 647 - query, 648 - comment.OwnerDid, 649 - comment.RepoAt, 650 - comment.SubmissionId, 651 - comment.CommentAt, 652 - comment.PullId, 653 - comment.Body, 654 - ) 655 - if err != nil { 656 - return 0, err 657 - } 658 - 659 - i, err := res.LastInsertId() 660 - if err != nil { 661 - return 0, err 662 - } 663 - 664 - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 - return 0, fmt.Errorf("put reference_links: %w", err) 666 - } 667 - 668 - return i, nil 669 } 670 671 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
··· 447 return nil, err 448 } 449 450 + // Get comments for all submissions using GetComments 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 452 + comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) 453 if err != nil { 454 return nil, fmt.Errorf("failed to get pull comments: %w", err) 455 } 456 for _, comment := range comments { 457 + if comment.PullSubmissionId != nil { 458 + if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { 459 + submission.Comments = append(submission.Comments, comment) 460 + } 461 } 462 } 463 ··· 477 return m, nil 478 } 479 480 // timeframe here is directly passed into the sql query filter, and any 481 // timeframe in the past should be negative; e.g.: "-3 months" 482 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 551 } 552 553 return pulls, nil 554 } 555 556 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
+1 -1
appview/db/punchcard.go
··· 78 punch.Count = int(count.Int64) 79 } 80 81 - punchcard.Punches[punch.Date.YearDay()] = punch 82 punchcard.Total += punch.Count 83 } 84
··· 78 punch.Count = int(count.Int64) 79 } 80 81 + punchcard.Punches[punch.Date.YearDay()-1] = punch 82 punchcard.Total += punch.Count 83 } 84
+20 -32
appview/db/reference.go
··· 11 "tangled.org/core/orm" 12 ) 13 14 - // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 15 // It will ignore missing refLinks. 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 var ( ··· 53 values %s 54 ) 55 select 56 - i.did, i.rkey, 57 - c.did, c.rkey 58 from input inp 59 join repos r 60 on r.did = inp.owner_did ··· 62 join issues i 63 on i.repo_at = r.at_uri 64 and i.issue_id = inp.issue_id 65 - left join issue_comments c 66 on inp.comment_id is not null 67 - and c.issue_at = i.at_uri 68 and c.id = inp.comment_id 69 `, 70 strings.Join(vals, ","), ··· 79 80 for rows.Next() { 81 // Scan rows 82 - var issueOwner, issueRkey string 83 - var commentOwner, commentRkey sql.NullString 84 var uri syntax.ATURI 85 - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 86 return nil, err 87 } 88 - if commentOwner.Valid && commentRkey.Valid { 89 - uri = syntax.ATURI(fmt.Sprintf( 90 - "at://%s/%s/%s", 91 - commentOwner.String, 92 - tangled.RepoIssueCommentNSID, 93 - commentRkey.String, 94 - )) 95 } else { 96 - uri = syntax.ATURI(fmt.Sprintf( 97 - "at://%s/%s/%s", 98 - issueOwner, 99 - tangled.RepoIssueNSID, 100 - issueRkey, 101 - )) 102 } 103 uris = append(uris, uri) 104 } ··· 124 values %s 125 ) 126 select 127 - p.owner_did, p.rkey, 128 - c.comment_at 129 from input inp 130 join repos r 131 on r.did = inp.owner_did ··· 133 join pulls p 134 on p.repo_at = r.at_uri 135 and p.pull_id = inp.pull_id 136 - left join pull_comments c 137 on inp.comment_id is not null 138 - and c.repo_at = r.at_uri and c.pull_id = p.pull_id 139 and c.id = inp.comment_id 140 `, 141 strings.Join(vals, ","), ··· 283 return nil, fmt.Errorf("get issue backlinks: %w", err) 284 } 285 backlinks = append(backlinks, ls...) 286 - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 287 if err != nil { 288 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 289 } ··· 293 return nil, fmt.Errorf("get pull backlinks: %w", err) 294 } 295 backlinks = append(backlinks, ls...) 296 - ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 297 if err != nil { 298 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 } ··· 352 rows, err := e.Query( 353 fmt.Sprintf( 354 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 355 - from issue_comments c 356 join issues i 357 - on i.at_uri = c.issue_at 358 join repos r 359 on r.at_uri = i.repo_at 360 where %s`, ··· 428 if len(aturis) == 0 { 429 return nil, nil 430 } 431 - filter := orm.FilterIn("c.comment_at", aturis) 432 rows, err := e.Query( 433 fmt.Sprintf( 434 `select r.did, r.name, p.pull_id, c.id, p.title, p.state 435 from repos r 436 join pulls p 437 on r.at_uri = p.repo_at 438 - join pull_comments c 439 - on r.at_uri = c.repo_at and p.pull_id = c.pull_id 440 where %s`, 441 filter.Condition(), 442 ),
··· 11 "tangled.org/core/orm" 12 ) 13 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 15 // It will ignore missing refLinks. 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 var ( ··· 53 values %s 54 ) 55 select 56 + i.at_uri, c.at_uri 57 from input inp 58 join repos r 59 on r.did = inp.owner_did ··· 61 join issues i 62 on i.repo_at = r.at_uri 63 and i.issue_id = inp.issue_id 64 + left join comments c 65 on inp.comment_id is not null 66 + and c.subject_at = i.at_uri 67 and c.id = inp.comment_id 68 `, 69 strings.Join(vals, ","), ··· 78 79 for rows.Next() { 80 // Scan rows 81 + var issueUri string 82 + var commentUri sql.NullString 83 var uri syntax.ATURI 84 + if err := rows.Scan(&issueUri, &commentUri); err != nil { 85 return nil, err 86 } 87 + if commentUri.Valid { 88 + uri = syntax.ATURI(commentUri.String) 89 } else { 90 + uri = syntax.ATURI(issueUri) 91 } 92 uris = append(uris, uri) 93 } ··· 113 values %s 114 ) 115 select 116 + p.owner_did, p.rkey, c.at_uri 117 from input inp 118 join repos r 119 on r.did = inp.owner_did ··· 121 join pulls p 122 on p.repo_at = r.at_uri 123 and p.pull_id = inp.pull_id 124 + left join comments c 125 on inp.comment_id is not null 126 + and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) 127 and c.id = inp.comment_id 128 `, 129 strings.Join(vals, ","), ··· 271 return nil, fmt.Errorf("get issue backlinks: %w", err) 272 } 273 backlinks = append(backlinks, ls...) 274 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 275 if err != nil { 276 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 277 } ··· 281 return nil, fmt.Errorf("get pull backlinks: %w", err) 282 } 283 backlinks = append(backlinks, ls...) 284 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 285 if err != nil { 286 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 287 } ··· 340 rows, err := e.Query( 341 fmt.Sprintf( 342 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 343 + from comments c 344 join issues i 345 + on i.at_uri = c.subject_at 346 join repos r 347 on r.at_uri = i.repo_at 348 where %s`, ··· 416 if len(aturis) == 0 { 417 return nil, nil 418 } 419 + filter := orm.FilterIn("c.at_uri", aturis) 420 rows, err := e.Query( 421 fmt.Sprintf( 422 `select r.did, r.name, p.pull_id, c.id, p.title, p.state 423 from repos r 424 join pulls p 425 on r.at_uri = p.repo_at 426 + join comments c 427 + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_at 428 where %s`, 429 filter.Condition(), 430 ),
+19 -11
appview/ingester.go
··· 79 err = i.ingestString(e) 80 case tangled.RepoIssueNSID: 81 err = i.ingestIssue(ctx, e) 82 - case tangled.RepoIssueCommentNSID: 83 - err = i.ingestIssueComment(e) 84 case tangled.LabelDefinitionNSID: 85 err = i.ingestLabelDefinition(e) 86 case tangled.LabelOpNSID: ··· 868 return nil 869 } 870 871 - func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 872 did := e.Did 873 rkey := e.Commit.RKey 874 875 var err error 876 877 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 878 l.Info("ingesting record") 879 880 ddb, ok := i.Db.Execer.(*db.DB) ··· 885 switch e.Commit.Operation { 886 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 887 raw := json.RawMessage(e.Commit.Record) 888 - record := tangled.RepoIssueComment{} 889 err = json.Unmarshal(raw, &record) 890 if err != nil { 891 return fmt.Errorf("invalid record: %w", err) 892 } 893 894 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 895 if err != nil { 896 return fmt.Errorf("failed to parse comment from record: %w", err) 897 } 898 899 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 900 return fmt.Errorf("failed to validate comment: %w", err) 901 } 902 ··· 906 } 907 defer tx.Rollback() 908 909 - _, err = db.AddIssueComment(tx, *comment) 910 if err != nil { 911 - return fmt.Errorf("failed to create issue comment: %w", err) 912 } 913 914 return tx.Commit() 915 916 case jmodels.CommitOperationDelete: 917 - if err := db.DeleteIssueComments( 918 ddb, 919 orm.FilterEq("did", did), 920 orm.FilterEq("rkey", rkey), 921 ); err != nil { 922 - return fmt.Errorf("failed to delete issue comment record: %w", err) 923 } 924 925 return nil
··· 79 err = i.ingestString(e) 80 case tangled.RepoIssueNSID: 81 err = i.ingestIssue(ctx, e) 82 + case tangled.CommentNSID: 83 + err = i.ingestComment(e) 84 case tangled.LabelDefinitionNSID: 85 err = i.ingestLabelDefinition(e) 86 case tangled.LabelOpNSID: ··· 868 return nil 869 } 870 871 + func (i *Ingester) ingestComment(e *jmodels.Event) error { 872 did := e.Did 873 rkey := e.Commit.RKey 874 875 var err error 876 877 + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 878 l.Info("ingesting record") 879 880 ddb, ok := i.Db.Execer.(*db.DB) ··· 885 switch e.Commit.Operation { 886 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 887 raw := json.RawMessage(e.Commit.Record) 888 + record := tangled.Comment{} 889 err = json.Unmarshal(raw, &record) 890 if err != nil { 891 return fmt.Errorf("invalid record: %w", err) 892 } 893 894 + comment, err := models.CommentFromRecord(did, rkey, record) 895 if err != nil { 896 return fmt.Errorf("failed to parse comment from record: %w", err) 897 } 898 899 + // TODO: ingest pull comments 900 + // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. 901 + // so we cannot know which round this comment is pointing to 902 + if comment.Subject.Collection().String() == tangled.RepoPullNSID { 903 + l.Info("skip ingesting pull comments") 904 + return nil 905 + } 906 + 907 + if err := comment.Validate(); err != nil { 908 return fmt.Errorf("failed to validate comment: %w", err) 909 } 910 ··· 914 } 915 defer tx.Rollback() 916 917 + err = db.PutComment(tx, comment) 918 if err != nil { 919 + return fmt.Errorf("failed to create comment: %w", err) 920 } 921 922 return tx.Commit() 923 924 case jmodels.CommitOperationDelete: 925 + if err := db.DeleteComments( 926 ddb, 927 orm.FilterEq("did", did), 928 orm.FilterEq("rkey", rkey), 929 ); err != nil { 930 + return fmt.Errorf("failed to delete comment record: %w", err) 931 } 932 933 return nil
+31 -29
appview/issues/issues.go
··· 403 404 body := r.FormValue("body") 405 if body == "" { 406 - rp.pages.Notice(w, "issue", "Body is required") 407 return 408 } 409 410 - replyToUri := r.FormValue("reply-to") 411 - var replyTo *string 412 - if replyToUri != "" { 413 - replyTo = &replyToUri 414 } 415 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 417 418 - comment := models.IssueComment{ 419 - Did: user.Did, 420 Rkey: tid.TID(), 421 - IssueAt: issue.AtUri().String(), 422 ReplyTo: replyTo, 423 Body: body, 424 Created: time.Now(), 425 Mentions: mentions, 426 References: references, 427 } 428 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 429 l.Error("failed to validate comment", "err", err) 430 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 431 return ··· 441 442 // create a record first 443 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 444 - Collection: tangled.RepoIssueCommentNSID, 445 - Repo: comment.Did, 446 Rkey: comment.Rkey, 447 Record: &lexutil.LexiconTypeDecoder{ 448 Val: &record, ··· 468 } 469 defer tx.Rollback() 470 471 - commentId, err := db.AddIssueComment(tx, comment) 472 if err != nil { 473 l.Error("failed to create comment", "err", err) 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 484 // reset atUri to make rollback a no-op 485 atUri = "" 486 487 - // notify about the new comment 488 - comment.Id = commentId 489 - 490 - rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 491 492 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 493 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 494 } 495 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 505 } 506 507 commentId := chi.URLParam(r, "commentId") 508 - comments, err := db.GetIssueComments( 509 rp.db, 510 orm.FilterEq("id", commentId), 511 ) ··· 541 } 542 543 commentId := chi.URLParam(r, "commentId") 544 - comments, err := db.GetIssueComments( 545 rp.db, 546 orm.FilterEq("id", commentId), 547 ) ··· 557 } 558 comment := comments[0] 559 560 - if comment.Did != user.Did { 561 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 return ··· 597 } 598 defer tx.Rollback() 599 600 - _, err = db.AddIssueComment(tx, newComment) 601 if err != nil { 602 l.Error("failed to perferom update-description query", "err", err) 603 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 608 // rkey is optional, it was introduced later 609 if newComment.Rkey != "" { 610 // update the record on pds 611 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 612 if err != nil { 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 616 } 617 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 - Collection: tangled.RepoIssueCommentNSID, 620 Repo: user.Did, 621 Rkey: newComment.Rkey, 622 SwapRecord: ex.Cid, ··· 651 } 652 653 commentId := chi.URLParam(r, "commentId") 654 - comments, err := db.GetIssueComments( 655 rp.db, 656 orm.FilterEq("id", commentId), 657 ) ··· 687 } 688 689 commentId := chi.URLParam(r, "commentId") 690 - comments, err := db.GetIssueComments( 691 rp.db, 692 orm.FilterEq("id", commentId), 693 ) ··· 723 } 724 725 commentId := chi.URLParam(r, "commentId") 726 - comments, err := db.GetIssueComments( 727 rp.db, 728 orm.FilterEq("id", commentId), 729 ) ··· 739 } 740 comment := comments[0] 741 742 - if comment.Did != user.Did { 743 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 745 return ··· 752 753 // optimistic deletion 754 deleted := time.Now() 755 - err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 756 if err != nil { 757 l.Error("failed to delete comment", "err", err) 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 768 return 769 } 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 771 - Collection: tangled.RepoIssueCommentNSID, 772 Repo: user.Did, 773 Rkey: comment.Rkey, 774 })
··· 403 404 body := r.FormValue("body") 405 if body == "" { 406 + rp.pages.Notice(w, "issue-comment", "Body is required") 407 return 408 } 409 410 + var replyTo *syntax.ATURI 411 + replyToRaw := r.FormValue("reply-to") 412 + if replyToRaw != "" { 413 + aturi, err := syntax.ParseATURI(r.FormValue("reply-to")) 414 + if err != nil { 415 + rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 416 + return 417 + } 418 + replyTo = &aturi 419 } 420 421 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 422 423 + comment := models.Comment{ 424 + Did: syntax.DID(user.Did), 425 Rkey: tid.TID(), 426 + Subject: issue.AtUri(), 427 ReplyTo: replyTo, 428 Body: body, 429 Created: time.Now(), 430 Mentions: mentions, 431 References: references, 432 } 433 + if err = comment.Validate(); err != nil { 434 l.Error("failed to validate comment", "err", err) 435 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 436 return ··· 446 447 // create a record first 448 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 + Collection: tangled.CommentNSID, 450 + Repo: user.Did, 451 Rkey: comment.Rkey, 452 Record: &lexutil.LexiconTypeDecoder{ 453 Val: &record, ··· 473 } 474 defer tx.Rollback() 475 476 + err = db.PutComment(tx, &comment) 477 if err != nil { 478 l.Error("failed to create comment", "err", err) 479 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 489 // reset atUri to make rollback a no-op 490 atUri = "" 491 492 + rp.notifier.NewComment(r.Context(), &comment) 493 494 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 495 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 496 } 497 498 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 507 } 508 509 commentId := chi.URLParam(r, "commentId") 510 + comments, err := db.GetComments( 511 rp.db, 512 orm.FilterEq("id", commentId), 513 ) ··· 543 } 544 545 commentId := chi.URLParam(r, "commentId") 546 + comments, err := db.GetComments( 547 rp.db, 548 orm.FilterEq("id", commentId), 549 ) ··· 559 } 560 comment := comments[0] 561 562 + if comment.Did.String() != user.Did { 563 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 564 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 return ··· 599 } 600 defer tx.Rollback() 601 602 + err = db.PutComment(tx, &newComment) 603 if err != nil { 604 l.Error("failed to perferom update-description query", "err", err) 605 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 610 // rkey is optional, it was introduced later 611 if newComment.Rkey != "" { 612 // update the record on pds 613 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey) 614 if err != nil { 615 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 616 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 618 } 619 620 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 621 + Collection: tangled.CommentNSID, 622 Repo: user.Did, 623 Rkey: newComment.Rkey, 624 SwapRecord: ex.Cid, ··· 653 } 654 655 commentId := chi.URLParam(r, "commentId") 656 + comments, err := db.GetComments( 657 rp.db, 658 orm.FilterEq("id", commentId), 659 ) ··· 689 } 690 691 commentId := chi.URLParam(r, "commentId") 692 + comments, err := db.GetComments( 693 rp.db, 694 orm.FilterEq("id", commentId), 695 ) ··· 725 } 726 727 commentId := chi.URLParam(r, "commentId") 728 + comments, err := db.GetComments( 729 rp.db, 730 orm.FilterEq("id", commentId), 731 ) ··· 741 } 742 comment := comments[0] 743 744 + if comment.Did.String() != user.Did { 745 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 746 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 747 return ··· 754 755 // optimistic deletion 756 deleted := time.Now() 757 + err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 758 if err != nil { 759 l.Error("failed to delete comment", "err", err) 760 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 770 return 771 } 772 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 773 + Collection: tangled.CommentNSID, 774 Repo: user.Did, 775 Rkey: comment.Rkey, 776 })
-5
appview/knots/knots.go
··· 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 return 668 } 669 - if memberId.Handle.IsInvalidHandle() { 670 - l.Error("failed to resolve member identity to handle") 671 - k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 672 - return 673 - } 674 675 // remove from enforcer 676 err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
··· 666 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 667 return 668 } 669 670 // remove from enforcer 671 err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+4
appview/middleware/middleware.go
··· 223 ) 224 if err != nil { 225 log.Println("failed to resolve repo", "err", err) 226 mw.pages.ErrorKnot404(w) 227 return 228 } ··· 240 f, err := mw.repoResolver.Resolve(r) 241 if err != nil { 242 log.Println("failed to fully resolve repo", err) 243 mw.pages.ErrorKnot404(w) 244 return 245 } ··· 288 f, err := mw.repoResolver.Resolve(r) 289 if err != nil { 290 log.Println("failed to fully resolve repo", err) 291 mw.pages.ErrorKnot404(w) 292 return 293 } ··· 324 f, err := mw.repoResolver.Resolve(r) 325 if err != nil { 326 log.Println("failed to fully resolve repo", err) 327 mw.pages.ErrorKnot404(w) 328 return 329 }
··· 223 ) 224 if err != nil { 225 log.Println("failed to resolve repo", "err", err) 226 + w.WriteHeader(http.StatusNotFound) 227 mw.pages.ErrorKnot404(w) 228 return 229 } ··· 241 f, err := mw.repoResolver.Resolve(r) 242 if err != nil { 243 log.Println("failed to fully resolve repo", err) 244 + w.WriteHeader(http.StatusNotFound) 245 mw.pages.ErrorKnot404(w) 246 return 247 } ··· 290 f, err := mw.repoResolver.Resolve(r) 291 if err != nil { 292 log.Println("failed to fully resolve repo", err) 293 + w.WriteHeader(http.StatusNotFound) 294 mw.pages.ErrorKnot404(w) 295 return 296 } ··· 327 f, err := mw.repoResolver.Resolve(r) 328 if err != nil { 329 log.Println("failed to fully resolve repo", err) 330 + w.WriteHeader(http.StatusNotFound) 331 mw.pages.ErrorKnot404(w) 332 return 333 }
+117
appview/models/comment.go
···
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Comment struct { 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + Subject syntax.ATURI 17 + ReplyTo *syntax.ATURI 18 + Body string 19 + Created time.Time 20 + Edited *time.Time 21 + Deleted *time.Time 22 + Mentions []syntax.DID 23 + References []syntax.ATURI 24 + PullSubmissionId *int 25 + } 26 + 27 + func (c *Comment) AtUri() syntax.ATURI { 28 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.CommentNSID, c.Rkey)) 29 + } 30 + 31 + func (c *Comment) AsRecord() tangled.Comment { 32 + mentions := make([]string, len(c.Mentions)) 33 + for i, did := range c.Mentions { 34 + mentions[i] = string(did) 35 + } 36 + references := make([]string, len(c.References)) 37 + for i, uri := range c.References { 38 + references[i] = string(uri) 39 + } 40 + var replyTo *string 41 + if c.ReplyTo != nil { 42 + replyToStr := c.ReplyTo.String() 43 + replyTo = &replyToStr 44 + } 45 + return tangled.Comment{ 46 + Subject: c.Subject.String(), 47 + Body: c.Body, 48 + CreatedAt: c.Created.Format(time.RFC3339), 49 + ReplyTo: replyTo, 50 + Mentions: mentions, 51 + References: references, 52 + } 53 + } 54 + 55 + func (c *Comment) IsTopLevel() bool { 56 + return c.ReplyTo == nil 57 + } 58 + 59 + func (c *Comment) IsReply() bool { 60 + return c.ReplyTo != nil 61 + } 62 + 63 + func (c *Comment) Validate() error { 64 + // TODO: sanitize the body and then trim space 65 + if sb := strings.TrimSpace(c.Body); sb == "" { 66 + return fmt.Errorf("body is empty after HTML sanitization") 67 + } 68 + 69 + // if it's for PR, PullSubmissionId should not be nil 70 + if c.Subject.Collection().String() == tangled.RepoPullNSID { 71 + if c.PullSubmissionId == nil { 72 + return fmt.Errorf("PullSubmissionId should not be nil") 73 + } 74 + } 75 + return nil 76 + } 77 + 78 + func CommentFromRecord(did, rkey string, record tangled.Comment) (*Comment, error) { 79 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 80 + if err != nil { 81 + created = time.Now() 82 + } 83 + 84 + ownerDid := did 85 + 86 + if _, err = syntax.ParseATURI(record.Subject); err != nil { 87 + return nil, err 88 + } 89 + 90 + i := record 91 + mentions := make([]syntax.DID, len(record.Mentions)) 92 + for i, did := range record.Mentions { 93 + mentions[i] = syntax.DID(did) 94 + } 95 + references := make([]syntax.ATURI, len(record.References)) 96 + for i, uri := range i.References { 97 + references[i] = syntax.ATURI(uri) 98 + } 99 + var replyTo *syntax.ATURI 100 + if record.ReplyTo != nil { 101 + replyToAtUri := syntax.ATURI(*record.ReplyTo) 102 + replyTo = &replyToAtUri 103 + } 104 + 105 + comment := Comment{ 106 + Did: syntax.DID(ownerDid), 107 + Rkey: rkey, 108 + Body: record.Body, 109 + Subject: syntax.ATURI(record.Subject), 110 + ReplyTo: replyTo, 111 + Created: created, 112 + Mentions: mentions, 113 + References: references, 114 + } 115 + 116 + return &comment, nil 117 + }
+8 -89
appview/models/issue.go
··· 26 27 // optionally, populate this when querying for reverse mappings 28 // like comment counts, parent repo etc. 29 - Comments []IssueComment 30 Labels LabelState 31 Repo *Repo 32 } ··· 62 } 63 64 type CommentListItem struct { 65 - Self *IssueComment 66 - Replies []*IssueComment 67 } 68 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 89 func (i *Issue) CommentList() []CommentListItem { 90 // Create a map to quickly find comments by their aturi 91 - toplevel := make(map[string]*CommentListItem) 92 - var replies []*IssueComment 93 94 // collect top level comments into the map 95 for _, comment := range i.Comments { 96 if comment.IsTopLevel() { 97 - toplevel[comment.AtUri().String()] = &CommentListItem{ 98 Self: &comment, 99 } 100 } else { ··· 115 } 116 117 // sort everything 118 - sortFunc := func(a, b *IssueComment) bool { 119 return a.Created.Before(b.Created) 120 } 121 sort.Slice(listing, func(i, j int) bool { ··· 144 addParticipant(i.Did) 145 146 for _, c := range i.Comments { 147 - addParticipant(c.Did) 148 } 149 150 return participants ··· 171 Open: true, // new issues are open by default 172 } 173 } 174 - 175 - type IssueComment struct { 176 - Id int64 177 - Did string 178 - Rkey string 179 - IssueAt string 180 - ReplyTo *string 181 - Body string 182 - Created time.Time 183 - Edited *time.Time 184 - Deleted *time.Time 185 - Mentions []syntax.DID 186 - References []syntax.ATURI 187 - } 188 - 189 - func (i *IssueComment) AtUri() syntax.ATURI { 190 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 191 - } 192 - 193 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 194 - mentions := make([]string, len(i.Mentions)) 195 - for i, did := range i.Mentions { 196 - mentions[i] = string(did) 197 - } 198 - references := make([]string, len(i.References)) 199 - for i, uri := range i.References { 200 - references[i] = string(uri) 201 - } 202 - return tangled.RepoIssueComment{ 203 - Body: i.Body, 204 - Issue: i.IssueAt, 205 - CreatedAt: i.Created.Format(time.RFC3339), 206 - ReplyTo: i.ReplyTo, 207 - Mentions: mentions, 208 - References: references, 209 - } 210 - } 211 - 212 - func (i *IssueComment) IsTopLevel() bool { 213 - return i.ReplyTo == nil 214 - } 215 - 216 - func (i *IssueComment) IsReply() bool { 217 - return i.ReplyTo != nil 218 - } 219 - 220 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 221 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 222 - if err != nil { 223 - created = time.Now() 224 - } 225 - 226 - ownerDid := did 227 - 228 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 229 - return nil, err 230 - } 231 - 232 - i := record 233 - mentions := make([]syntax.DID, len(record.Mentions)) 234 - for i, did := range record.Mentions { 235 - mentions[i] = syntax.DID(did) 236 - } 237 - references := make([]syntax.ATURI, len(record.References)) 238 - for i, uri := range i.References { 239 - references[i] = syntax.ATURI(uri) 240 - } 241 - 242 - comment := IssueComment{ 243 - Did: ownerDid, 244 - Rkey: rkey, 245 - Body: record.Body, 246 - IssueAt: record.Issue, 247 - ReplyTo: record.ReplyTo, 248 - Created: created, 249 - Mentions: mentions, 250 - References: references, 251 - } 252 - 253 - return &comment, nil 254 - }
··· 26 27 // optionally, populate this when querying for reverse mappings 28 // like comment counts, parent repo etc. 29 + Comments []Comment 30 Labels LabelState 31 Repo *Repo 32 } ··· 62 } 63 64 type CommentListItem struct { 65 + Self *Comment 66 + Replies []*Comment 67 } 68 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 89 func (i *Issue) CommentList() []CommentListItem { 90 // Create a map to quickly find comments by their aturi 91 + toplevel := make(map[syntax.ATURI]*CommentListItem) 92 + var replies []*Comment 93 94 // collect top level comments into the map 95 for _, comment := range i.Comments { 96 if comment.IsTopLevel() { 97 + toplevel[comment.AtUri()] = &CommentListItem{ 98 Self: &comment, 99 } 100 } else { ··· 115 } 116 117 // sort everything 118 + sortFunc := func(a, b *Comment) bool { 119 return a.Created.Before(b.Created) 120 } 121 sort.Slice(listing, func(i, j int) bool { ··· 144 addParticipant(i.Did) 145 146 for _, c := range i.Comments { 147 + addParticipant(c.Did.String()) 148 } 149 150 return participants ··· 171 Open: true, // new issues are open by default 172 } 173 }
+2 -46
appview/models/pull.go
··· 138 RoundNumber int 139 Patch string 140 Combined string 141 - Comments []PullComment 142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 143 144 // meta 145 Created time.Time 146 } 147 - 148 - type PullComment struct { 149 - // ids 150 - ID int 151 - PullId int 152 - SubmissionId int 153 - 154 - // at ids 155 - RepoAt string 156 - OwnerDid string 157 - CommentAt string 158 - 159 - // content 160 - Body string 161 - 162 - // meta 163 - Mentions []syntax.DID 164 - References []syntax.ATURI 165 - 166 - // meta 167 - Created time.Time 168 - } 169 - 170 - func (p *PullComment) AtUri() syntax.ATURI { 171 - return syntax.ATURI(p.CommentAt) 172 - } 173 - 174 - // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 - // mentions := make([]string, len(p.Mentions)) 176 - // for i, did := range p.Mentions { 177 - // mentions[i] = string(did) 178 - // } 179 - // references := make([]string, len(p.References)) 180 - // for i, uri := range p.References { 181 - // references[i] = string(uri) 182 - // } 183 - // return tangled.RepoPullComment{ 184 - // Pull: p.PullAt, 185 - // Body: p.Body, 186 - // Mentions: mentions, 187 - // References: references, 188 - // CreatedAt: p.Created.Format(time.RFC3339), 189 - // } 190 - // } 191 192 func (p *Pull) LastRoundNumber() int { 193 return len(p.Submissions) - 1 ··· 289 addParticipant(s.PullAt.Authority().String()) 290 291 for _, c := range s.Comments { 292 - addParticipant(c.OwnerDid) 293 } 294 295 return participants
··· 138 RoundNumber int 139 Patch string 140 Combined string 141 + Comments []Comment 142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 143 144 // meta 145 Created time.Time 146 } 147 148 func (p *Pull) LastRoundNumber() int { 149 return len(p.Submissions) - 1 ··· 245 addParticipant(s.PullAt.Authority().String()) 246 247 for _, c := range s.Comments { 248 + addParticipant(c.Did.String()) 249 } 250 251 return participants
+111 -113
appview/notify/db/db.go
··· 74 // no-op 75 } 76 77 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 78 - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 79 if err != nil { 80 - log.Printf("failed to fetch collaborators: %v", err) 81 return 82 } 83 84 - // build the recipients list 85 - // - owner of the repo 86 - // - collaborators in the repo 87 - // - remove users already mentioned 88 - recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 89 - for _, c := range collaborators { 90 - recipients.Insert(c.SubjectDid) 91 } 92 - for _, m := range mentions { 93 recipients.Remove(m) 94 } 95 96 - actorDid := syntax.DID(issue.Did) 97 - entityType := "issue" 98 - entityId := issue.AtUri().String() 99 - repoId := &issue.Repo.Id 100 - issueId := &issue.Id 101 - var pullId *int64 102 - 103 n.notifyEvent( 104 - actorDid, 105 recipients, 106 - models.NotificationTypeIssueCreated, 107 entityType, 108 entityId, 109 repoId, ··· 111 pullId, 112 ) 113 n.notifyEvent( 114 - actorDid, 115 - sets.Collect(slices.Values(mentions)), 116 models.NotificationTypeUserMentioned, 117 entityType, 118 entityId, ··· 122 ) 123 } 124 125 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 126 - issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt)) 127 if err != nil { 128 - log.Printf("NewIssueComment: failed to get issues: %v", err) 129 return 130 } 131 - if len(issues) == 0 { 132 - log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 133 - return 134 - } 135 - issue := issues[0] 136 137 - // built the recipients list: 138 - // - the owner of the repo 139 - // - | if the comment is a reply -> everybody on that thread 140 - // | if the comment is a top level -> just the issue owner 141 - // - remove mentioned users from the recipients list 142 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 143 - 144 - if comment.IsReply() { 145 - // if this comment is a reply, then notify everybody in that thread 146 - parentAtUri := *comment.ReplyTo 147 - 148 - // find the parent thread, and add all DIDs from here to the recipient list 149 - for _, t := range issue.CommentList() { 150 - if t.Self.AtUri().String() == parentAtUri { 151 - for _, p := range t.Participants() { 152 - recipients.Insert(p) 153 - } 154 - } 155 - } 156 - } else { 157 - // not a reply, notify just the issue author 158 - recipients.Insert(syntax.DID(issue.Did)) 159 } 160 - 161 for _, m := range mentions { 162 recipients.Remove(m) 163 } 164 165 - actorDid := syntax.DID(comment.Did) 166 entityType := "issue" 167 entityId := issue.AtUri().String() 168 repoId := &issue.Repo.Id ··· 172 n.notifyEvent( 173 actorDid, 174 recipients, 175 - models.NotificationTypeIssueCommented, 176 entityType, 177 entityId, 178 repoId, ··· 252 actorDid, 253 recipients, 254 eventType, 255 - entityType, 256 - entityId, 257 - repoId, 258 - issueId, 259 - pullId, 260 - ) 261 - } 262 - 263 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 264 - pull, err := db.GetPull(n.db, 265 - syntax.ATURI(comment.RepoAt), 266 - comment.PullId, 267 - ) 268 - if err != nil { 269 - log.Printf("NewPullComment: failed to get pulls: %v", err) 270 - return 271 - } 272 - 273 - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 274 - if err != nil { 275 - log.Printf("NewPullComment: failed to get repos: %v", err) 276 - return 277 - } 278 - 279 - // build up the recipients list: 280 - // - repo owner 281 - // - all pull participants 282 - // - remove those already mentioned 283 - recipients := sets.Singleton(syntax.DID(repo.Did)) 284 - for _, p := range pull.Participants() { 285 - recipients.Insert(syntax.DID(p)) 286 - } 287 - for _, m := range mentions { 288 - recipients.Remove(m) 289 - } 290 - 291 - actorDid := syntax.DID(comment.OwnerDid) 292 - eventType := models.NotificationTypePullCommented 293 - entityType := "pull" 294 - entityId := pull.AtUri().String() 295 - repoId := &repo.Id 296 - var issueId *int64 297 - p := int64(pull.ID) 298 - pullId := &p 299 - 300 - n.notifyEvent( 301 - actorDid, 302 - recipients, 303 - eventType, 304 - entityType, 305 - entityId, 306 - repoId, 307 - issueId, 308 - pullId, 309 - ) 310 - n.notifyEvent( 311 - actorDid, 312 - sets.Collect(slices.Values(mentions)), 313 - models.NotificationTypeUserMentioned, 314 entityType, 315 entityId, 316 repoId,
··· 74 // no-op 75 } 76 77 + func (n *databaseNotifier) NewComment(ctx context.Context, comment *models.Comment) { 78 + var ( 79 + // built the recipients list: 80 + // - the owner of the repo 81 + // - | if the comment is a reply -> everybody on that thread 82 + // | if the comment is a top level -> just the issue owner 83 + // - remove mentioned users from the recipients list 84 + recipients = sets.New[syntax.DID]() 85 + entityType string 86 + entityId string 87 + repoId *int64 88 + issueId *int64 89 + pullId *int64 90 + ) 91 + 92 + subjectDid, err := comment.Subject.Authority().AsDID() 93 if err != nil { 94 + log.Printf("NewComment: expected did based at-uri for comment.subject") 95 return 96 } 97 + switch comment.Subject.Collection() { 98 + case tangled.RepoIssueNSID: 99 + issues, err := db.GetIssues( 100 + n.db, 101 + orm.FilterEq("did", subjectDid), 102 + orm.FilterEq("rkey", comment.Subject.RecordKey()), 103 + ) 104 + if err != nil { 105 + log.Printf("NewComment: failed to get issues: %v", err) 106 + return 107 + } 108 + if len(issues) == 0 { 109 + log.Printf("NewComment: no issue found for %s", comment.Subject) 110 + return 111 + } 112 + issue := issues[0] 113 114 + recipients.Insert(syntax.DID(issue.Repo.Did)) 115 + if comment.IsReply() { 116 + // if this comment is a reply, then notify everybody in that thread 117 + parentAtUri := *comment.ReplyTo 118 + 119 + // find the parent thread, and add all DIDs from here to the recipient list 120 + for _, t := range issue.CommentList() { 121 + if t.Self.AtUri() == parentAtUri { 122 + for _, p := range t.Participants() { 123 + recipients.Insert(p) 124 + } 125 + } 126 + } 127 + } else { 128 + // not a reply, notify just the issue author 129 + recipients.Insert(syntax.DID(issue.Did)) 130 + } 131 + 132 + entityType = "issue" 133 + entityId = issue.AtUri().String() 134 + repoId = &issue.Repo.Id 135 + issueId = &issue.Id 136 + case tangled.RepoPullNSID: 137 + pulls, err := db.GetPullsWithLimit( 138 + n.db, 139 + 1, 140 + orm.FilterEq("owner_did", subjectDid), 141 + orm.FilterEq("rkey", comment.Subject.RecordKey()), 142 + ) 143 + if err != nil { 144 + log.Printf("NewComment: failed to get pulls: %v", err) 145 + return 146 + } 147 + if len(pulls) == 0 { 148 + log.Printf("NewComment: no pull found for %s", comment.Subject) 149 + return 150 + } 151 + pull := pulls[0] 152 + 153 + pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 154 + if err != nil { 155 + log.Printf("NewComment: failed to get repos: %v", err) 156 + return 157 + } 158 + 159 + recipients.Insert(syntax.DID(pull.Repo.Did)) 160 + for _, p := range pull.Participants() { 161 + recipients.Insert(syntax.DID(p)) 162 + } 163 + 164 + entityType = "pull" 165 + entityId = pull.AtUri().String() 166 + repoId = &pull.Repo.Id 167 + p := int64(pull.ID) 168 + pullId = &p 169 + default: 170 + return // no-op 171 } 172 + 173 + for _, m := range comment.Mentions { 174 recipients.Remove(m) 175 } 176 177 n.notifyEvent( 178 + comment.Did, 179 recipients, 180 + models.NotificationTypeIssueCommented, 181 entityType, 182 entityId, 183 repoId, ··· 185 pullId, 186 ) 187 n.notifyEvent( 188 + comment.Did, 189 + sets.Collect(slices.Values(comment.Mentions)), 190 models.NotificationTypeUserMentioned, 191 entityType, 192 entityId, ··· 196 ) 197 } 198 199 + func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 200 + // no-op 201 + } 202 + 203 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 204 + collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 205 if err != nil { 206 + log.Printf("failed to fetch collaborators: %v", err) 207 return 208 } 209 210 + // build the recipients list 211 + // - owner of the repo 212 + // - collaborators in the repo 213 + // - remove users already mentioned 214 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 215 + for _, c := range collaborators { 216 + recipients.Insert(c.SubjectDid) 217 } 218 for _, m := range mentions { 219 recipients.Remove(m) 220 } 221 222 + actorDid := syntax.DID(issue.Did) 223 entityType := "issue" 224 entityId := issue.AtUri().String() 225 repoId := &issue.Repo.Id ··· 229 n.notifyEvent( 230 actorDid, 231 recipients, 232 + models.NotificationTypeIssueCreated, 233 entityType, 234 entityId, 235 repoId, ··· 309 actorDid, 310 recipients, 311 eventType, 312 entityType, 313 entityId, 314 repoId,
+8 -8
appview/notify/merged_notifier.go
··· 53 m.fanout("DeleteStar", ctx, star) 54 } 55 56 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 57 - m.fanout("NewIssue", ctx, issue, mentions) 58 } 59 60 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 61 - m.fanout("NewIssueComment", ctx, comment, mentions) 62 } 63 64 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 79 80 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 81 m.fanout("NewPull", ctx, pull) 82 - } 83 - 84 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 85 - m.fanout("NewPullComment", ctx, comment, mentions) 86 } 87 88 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
··· 53 m.fanout("DeleteStar", ctx, star) 54 } 55 56 + func (m *mergedNotifier) NewComment(ctx context.Context, comment *models.Comment) { 57 + m.fanout("NewComment", ctx, comment) 58 } 59 60 + func (m *mergedNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 61 + m.fanout("DeleteComment", ctx, comment) 62 + } 63 + 64 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 65 + m.fanout("NewIssue", ctx, issue, mentions) 66 } 67 68 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 83 84 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 85 m.fanout("NewPull", ctx, pull) 86 } 87 88 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+7 -7
appview/notify/notifier.go
··· 13 NewStar(ctx context.Context, star *models.Star) 14 DeleteStar(ctx context.Context, star *models.Star) 15 16 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 DeleteIssue(ctx context.Context, issue *models.Issue) 20 ··· 22 DeleteFollow(ctx context.Context, follow *models.Follow) 23 24 NewPull(ctx context.Context, pull *models.Pull) 25 - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 26 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 27 28 UpdateProfile(ctx context.Context, profile *models.Profile) ··· 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 45 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 - } 48 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 50 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 53 54 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 55 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 56 - } 57 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 58 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
··· 13 NewStar(ctx context.Context, star *models.Star) 14 DeleteStar(ctx context.Context, star *models.Star) 15 16 + NewComment(ctx context.Context, comment *models.Comment) 17 + DeleteComment(ctx context.Context, comment *models.Comment) 18 + 19 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 20 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 21 DeleteIssue(ctx context.Context, issue *models.Issue) 22 ··· 24 DeleteFollow(ctx context.Context, follow *models.Follow) 25 26 NewPull(ctx context.Context, pull *models.Pull) 27 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 28 29 UpdateProfile(ctx context.Context, profile *models.Profile) ··· 43 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 44 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 45 46 + func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {} 47 + func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} 48 + 49 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 50 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 51 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 52 53 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 54 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 55 56 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 57 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 58 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+5 -20
appview/notify/posthog/notifier.go
··· 86 } 87 } 88 89 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 90 - err := n.client.Enqueue(posthog.Capture{ 91 - DistinctId: comment.OwnerDid, 92 - Event: "new_pull_comment", 93 - Properties: posthog.Properties{ 94 - "repo_at": comment.RepoAt, 95 - "pull_id": comment.PullId, 96 - "mentions": mentions, 97 - }, 98 - }) 99 - if err != nil { 100 - log.Println("failed to enqueue posthog event:", err) 101 - } 102 - } 103 - 104 func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 105 err := n.client.Enqueue(posthog.Capture{ 106 DistinctId: pull.OwnerDid, ··· 180 } 181 } 182 183 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 184 err := n.client.Enqueue(posthog.Capture{ 185 - DistinctId: comment.Did, 186 - Event: "new_issue_comment", 187 Properties: posthog.Properties{ 188 - "issue_at": comment.IssueAt, 189 - "mentions": mentions, 190 }, 191 }) 192 if err != nil {
··· 86 } 87 } 88 89 func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 90 err := n.client.Enqueue(posthog.Capture{ 91 DistinctId: pull.OwnerDid, ··· 165 } 166 } 167 168 + func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) { 169 err := n.client.Enqueue(posthog.Capture{ 170 + DistinctId: comment.Did.String(), 171 + Event: "new_comment", 172 Properties: posthog.Properties{ 173 + "subject_at": comment.Subject, 174 + "mentions": comment.Mentions, 175 }, 176 }) 177 if err != nil {
+4 -4
appview/pages/pages.go
··· 988 LoggedInUser *oauth.User 989 RepoInfo repoinfo.RepoInfo 990 Issue *models.Issue 991 - Comment *models.IssueComment 992 } 993 994 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 999 LoggedInUser *oauth.User 1000 RepoInfo repoinfo.RepoInfo 1001 Issue *models.Issue 1002 - Comment *models.IssueComment 1003 } 1004 1005 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1010 LoggedInUser *oauth.User 1011 RepoInfo repoinfo.RepoInfo 1012 Issue *models.Issue 1013 - Comment *models.IssueComment 1014 } 1015 1016 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1021 LoggedInUser *oauth.User 1022 RepoInfo repoinfo.RepoInfo 1023 Issue *models.Issue 1024 - Comment *models.IssueComment 1025 } 1026 1027 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
··· 988 LoggedInUser *oauth.User 989 RepoInfo repoinfo.RepoInfo 990 Issue *models.Issue 991 + Comment *models.Comment 992 } 993 994 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 999 LoggedInUser *oauth.User 1000 RepoInfo repoinfo.RepoInfo 1001 Issue *models.Issue 1002 + Comment *models.Comment 1003 } 1004 1005 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1010 LoggedInUser *oauth.User 1011 RepoInfo repoinfo.RepoInfo 1012 Issue *models.Issue 1013 + Comment *models.Comment 1014 } 1015 1016 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1021 LoggedInUser *oauth.User 1022 RepoInfo repoinfo.RepoInfo 1023 Issue *models.Issue 1024 + Comment *models.Comment 1025 } 1026 1027 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 {{ template "hats" $ }} 5 {{ template "timestamp" . }} 6 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 {{ template "editIssueComment" . }} 9 {{ template "deleteIssueComment" . }}
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did.String }} 4 {{ template "hats" $ }} 5 {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 7 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 {{ template "editIssueComment" . }} 9 {{ template "deleteIssueComment" . }}
+3 -3
appview/pages/templates/repo/pulls/pull.html
··· 165 166 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 {{ if gt $cidx 0 }} 170 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 {{ end }} 172 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 174 <span class="before:content-['ยท']"></span> 175 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 </div> 177 <div class="prose dark:prose-invert"> 178 {{ $c.Body | markdown }}
··· 165 166 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 {{ range $cidx, $c := .Comments }} 168 + <div id="comment-{{$c.Id}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 {{ if gt $cidx 0 }} 170 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 {{ end }} 172 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 + {{ template "user/fragments/picHandleLink" $c.Did.String }} 174 <span class="before:content-['ยท']"></span> 175 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.Id}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 </div> 177 <div class="prose dark:prose-invert"> 178 {{ $c.Body | markdown }}
+1 -1
appview/pulls/opengraph.go
··· 277 } 278 279 // Get comment count from database 280 - comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 281 if err != nil { 282 log.Printf("failed to get pull comments: %v", err) 283 }
··· 277 } 278 279 // Get comment count from database 280 + comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri())) 281 if err != nil { 282 log.Printf("failed to get pull comments: %v", err) 283 }
+24 -23
appview/pulls/pulls.go
··· 741 } 742 defer tx.Rollback() 743 744 - createdAt := time.Now().Format(time.RFC3339) 745 746 client, err := s.oauth.AuthorizedClient(r) 747 if err != nil { ··· 749 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 750 return 751 } 752 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 753 - Collection: tangled.RepoPullCommentNSID, 754 Repo: user.Did, 755 - Rkey: tid.TID(), 756 Record: &lexutil.LexiconTypeDecoder{ 757 - Val: &tangled.RepoPullComment{ 758 - Pull: pull.AtUri().String(), 759 - Body: body, 760 - CreatedAt: createdAt, 761 - }, 762 }, 763 }) 764 if err != nil { ··· 767 return 768 } 769 770 - comment := &models.PullComment{ 771 - OwnerDid: user.Did, 772 - RepoAt: f.RepoAt().String(), 773 - PullId: pull.PullId, 774 - Body: body, 775 - CommentAt: atResp.Uri, 776 - SubmissionId: pull.Submissions[roundNumber].ID, 777 - Mentions: mentions, 778 - References: references, 779 - } 780 - 781 // Create the pull comment in the database with the commentAt field 782 - commentId, err := db.NewPullComment(tx, comment) 783 if err != nil { 784 log.Println("failed to create pull comment", err) 785 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 793 return 794 } 795 796 - s.notifier.NewPullComment(r.Context(), comment, mentions) 797 798 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 799 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 800 return 801 } 802 }
··· 741 } 742 defer tx.Rollback() 743 744 + comment := models.Comment{ 745 + Did: syntax.DID(user.Did), 746 + Rkey: tid.TID(), 747 + Subject: pull.AtUri(), 748 + ReplyTo: nil, 749 + Body: body, 750 + Created: time.Now(), 751 + Mentions: mentions, 752 + References: references, 753 + PullSubmissionId: &pull.Submissions[roundNumber].ID, 754 + } 755 + if err = comment.Validate(); err != nil { 756 + log.Println("failed to validate comment", err) 757 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 758 + return 759 + } 760 + record := comment.AsRecord() 761 762 client, err := s.oauth.AuthorizedClient(r) 763 if err != nil { ··· 765 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 766 return 767 } 768 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 769 + Collection: tangled.CommentNSID, 770 Repo: user.Did, 771 + Rkey: comment.Rkey, 772 Record: &lexutil.LexiconTypeDecoder{ 773 + Val: &record, 774 }, 775 }) 776 if err != nil { ··· 779 return 780 } 781 782 // Create the pull comment in the database with the commentAt field 783 + err = db.PutComment(tx, &comment) 784 if err != nil { 785 log.Println("failed to create pull comment", err) 786 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 794 return 795 } 796 797 + s.notifier.NewComment(r.Context(), &comment) 798 799 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 800 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 801 return 802 } 803 }
+1
appview/repo/archive.go
··· 18 l := rp.logger.With("handler", "DownloadArchive") 19 ref := chi.URLParam(r, "ref") 20 ref, _ = url.PathUnescape(ref) 21 f, err := rp.repoResolver.Resolve(r) 22 if err != nil { 23 l.Error("failed to get repo and knot", "err", err)
··· 18 l := rp.logger.With("handler", "DownloadArchive") 19 ref := chi.URLParam(r, "ref") 20 ref, _ = url.PathUnescape(ref) 21 + ref = strings.TrimSuffix(ref, ".tar.gz") 22 f, err := rp.repoResolver.Resolve(r) 23 if err != nil { 24 l.Error("failed to get repo and knot", "err", err)
+26 -1
appview/reporesolver/resolver.go
··· 63 } 64 65 // get dir/ref 66 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 67 ref := chi.URLParam(r, "ref") 68 69 repoAt := repo.RepoAt() ··· 130 } 131 132 return repoInfo 133 } 134 135 // extractPathAfterRef gets the actual repository path
··· 63 } 64 65 // get dir/ref 66 + currentDir := extractCurrentDir(r.URL.EscapedPath()) 67 ref := chi.URLParam(r, "ref") 68 69 repoAt := repo.RepoAt() ··· 130 } 131 132 return repoInfo 133 + } 134 + 135 + // extractCurrentDir gets the current directory for markdown link resolution. 136 + // for blob paths, returns the parent dir. for tree paths, returns the path itself. 137 + // 138 + // /@user/repo/blob/main/docs/README.md => docs 139 + // /@user/repo/tree/main/docs => docs 140 + func extractCurrentDir(fullPath string) string { 141 + fullPath = strings.TrimPrefix(fullPath, "/") 142 + 143 + blobPattern := regexp.MustCompile(`blob/[^/]+/(.*)$`) 144 + if matches := blobPattern.FindStringSubmatch(fullPath); len(matches) > 1 { 145 + return path.Dir(matches[1]) 146 + } 147 + 148 + treePattern := regexp.MustCompile(`tree/[^/]+/(.*)$`) 149 + if matches := treePattern.FindStringSubmatch(fullPath); len(matches) > 1 { 150 + dir := strings.TrimSuffix(matches[1], "/") 151 + if dir == "" { 152 + return "." 153 + } 154 + return dir 155 + } 156 + 157 + return "." 158 } 159 160 // extractPathAfterRef gets the actual repository path
+22
appview/reporesolver/resolver_test.go
···
··· 1 + package reporesolver 2 + 3 + import "testing" 4 + 5 + func TestExtractCurrentDir(t *testing.T) { 6 + tests := []struct { 7 + path string 8 + want string 9 + }{ 10 + {"/@user/repo/blob/main/docs/README.md", "docs"}, 11 + {"/@user/repo/blob/main/README.md", "."}, 12 + {"/@user/repo/tree/main/docs", "docs"}, 13 + {"/@user/repo/tree/main/docs/", "docs"}, 14 + {"/@user/repo/tree/main", "."}, 15 + } 16 + 17 + for _, tt := range tests { 18 + if got := extractCurrentDir(tt.path); got != tt.want { 19 + t.Errorf("extractCurrentDir(%q) = %q, want %q", tt.path, got, tt.want) 20 + } 21 + } 22 + }
-5
appview/spindles/spindles.go
··· 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 } 656 - if memberId.Handle.IsInvalidHandle() { 657 - l.Error("failed to resolve member identity to handle") 658 - s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 659 - return 660 - } 661 662 tx, err := s.Db.Begin() 663 if err != nil {
··· 653 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 654 return 655 } 656 657 tx, err := s.Db.Begin() 658 if err != nil {
+5 -4
appview/state/profile.go
··· 165 // populate commit counts in the timeline, using the punchcard 166 now := time.Now() 167 for _, p := range profile.Punchcard.Punches { 168 - idx := db.MonthsApart(p.Date, now) 169 - 170 - if 0 <= idx && idx < len(timeline.ByMonth) { 171 - timeline.ByMonth[idx].Commits += p.Count 172 } 173 } 174
··· 165 // populate commit counts in the timeline, using the punchcard 166 now := time.Now() 167 for _, p := range profile.Punchcard.Punches { 168 + years := now.Year() - p.Date.Year() 169 + months := int(now.Month() - p.Date.Month()) 170 + monthsAgo := years*12 + months 171 + if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) { 172 + timeline.ByMonth[monthsAgo].Commits += p.Count 173 } 174 } 175
+2
appview/state/router.go
··· 109 }) 110 111 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 112 s.pages.Error404(w) 113 }) 114 ··· 182 r.Get("/brand", s.Brand) 183 184 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 185 s.pages.Error404(w) 186 }) 187 return r
··· 109 }) 110 111 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 112 + w.WriteHeader(http.StatusNotFound) 113 s.pages.Error404(w) 114 }) 115 ··· 183 r.Get("/brand", s.Brand) 184 185 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 186 + w.WriteHeader(http.StatusNotFound) 187 s.pages.Error404(w) 188 }) 189 return r
+1 -1
appview/state/state.go
··· 117 tangled.SpindleNSID, 118 tangled.StringNSID, 119 tangled.RepoIssueNSID, 120 - tangled.RepoIssueCommentNSID, 121 tangled.LabelDefinitionNSID, 122 tangled.LabelOpNSID, 123 },
··· 117 tangled.SpindleNSID, 118 tangled.StringNSID, 119 tangled.RepoIssueNSID, 120 + tangled.CommentNSID, 121 tangled.LabelDefinitionNSID, 122 tangled.LabelOpNSID, 123 },
-27
appview/validator/issue.go
··· 4 "fmt" 5 "strings" 6 7 - "tangled.org/core/appview/db" 8 "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 37 func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 if issue.Title == "" {
··· 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) 9 10 func (v *Validator) ValidateIssue(issue *models.Issue) error { 11 if issue.Title == "" {
+1
cmd/cborgen/cborgen.go
··· 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 tangled.ActorProfile{}, 18 tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{},
··· 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 tangled.ActorProfile{}, 18 + tangled.Comment{}, 19 tangled.FeedReaction{}, 20 tangled.FeedStar{}, 21 tangled.GitRefUpdate{},
+23 -25
docs/DOCS.md
··· 2 title: Tangled docs 3 author: The Tangled Contributors 4 date: 21 Sun, Dec 2025 5 - --- 6 - 7 - # Introduction 8 - 9 - Tangled is a decentralized code hosting and collaboration 10 - platform. Every component of Tangled is open-source and 11 - self-hostable. [tangled.org](https://tangled.org) also 12 - provides hosting and CI services that are free to use. 13 14 - There are several models for decentralized code 15 - collaboration platforms, ranging from ActivityPubโ€™s 16 - (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 17 - Our approach attempts to be the best of both worlds by 18 - adopting the AT Protocolโ€”a protocol for building decentralized 19 - social applications with a central identity 20 21 - Our approach to this is the idea of โ€œknotsโ€. Knots are 22 - lightweight, headless servers that enable users to host Git 23 - repositories with ease. Knots are designed for either single 24 - or multi-tenant use which is perfect for self-hosting on a 25 - Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 26 - default, Tangled provides managed knots where you can host 27 - your repositories for free. 28 29 - The appview at tangled.org acts as a consolidated "view" 30 - into the whole network, allowing users to access, clone and 31 - contribute to repositories hosted across different knots 32 - seamlessly. 33 34 # Quick start guide 35
··· 2 title: Tangled docs 3 author: The Tangled Contributors 4 date: 21 Sun, Dec 2025 5 + abstract: | 6 + Tangled is a decentralized code hosting and collaboration 7 + platform. Every component of Tangled is open-source and 8 + self-hostable. [tangled.org](https://tangled.org) also 9 + provides hosting and CI services that are free to use. 10 11 + There are several models for decentralized code 12 + collaboration platforms, ranging from ActivityPubโ€™s 13 + (Forgejo) federated model, to Radicleโ€™s entirely P2P model. 14 + Our approach attempts to be the best of both worlds by 15 + adopting the AT Protocolโ€”a protocol for building decentralized 16 + social applications with a central identity 17 18 + Our approach to this is the idea of โ€œknotsโ€. Knots are 19 + lightweight, headless servers that enable users to host Git 20 + repositories with ease. Knots are designed for either single 21 + or multi-tenant use which is perfect for self-hosting on a 22 + Raspberry Pi at home, or larger โ€œcommunityโ€ servers. By 23 + default, Tangled provides managed knots where you can host 24 + your repositories for free. 25 26 + The appview at tangled.org acts as a consolidated "view" 27 + into the whole network, allowing users to access, clone and 28 + contribute to repositories hosted across different knots 29 + seamlessly. 30 + --- 31 32 # Quick start guide 33
+3
docs/mode.html
···
··· 1 + <a class="px-4 py-2 mt-8 block text-center w-full rounded-sm shadow-sm border border-gray-200 dark:border-gray-700 no-underline hover:no-underline" href="$if(single-page)$/$else$/single-page.html$endif$"> 2 + $if(single-page)$View as multi-page$else$View as single-page$endif$ 3 + </a>
+7
docs/search.html
···
··· 1 + <form action="https://google.com/search" role="search" aria-label="Sitewide" class="w-full"> 2 + <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 3 + <label> 4 + <span style="display:none;">Search</span> 5 + <input type="text" name="q" placeholder="Search docs ..." class="w-full font-normal"> 6 + </label> 7 + </form>
+74 -35
docs/template.html
··· 37 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 39 </head> 40 - <body class="bg-white dark:bg-gray-900 min-h-screen flex flex-col min-h-screen"> 41 $for(include-before)$ 42 $include-before$ 43 $endfor$ 44 45 $if(toc)$ 46 - <!-- mobile topbar toc --> 47 - <details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4"> 48 - <summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white"> 49 $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 50 - <span class="group-open:hidden inline">${ menu.svg() }</span> 51 - <span class="hidden group-open:inline">${ x.svg() }</span> 52 - </summary> 53 - ${ table-of-contents:toc.html() } 54 - </details> 55 <!-- desktop sidebar toc --> 56 - <nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50"> 57 - $if(toc-title)$ 58 - <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 59 - $endif$ 60 - ${ table-of-contents:toc.html() } 61 </nav> 62 $endif$ 63 64 <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 65 <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 66 $if(top)$ 67 - $-- only print title block if this is NOT the top page 68 $else$ 69 $if(title)$ 70 - <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 71 - <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 72 - $if(subtitle)$ 73 - <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 74 - $endif$ 75 - $for(author)$ 76 - <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 77 - $endfor$ 78 - $if(date)$ 79 - <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 80 - $endif$ 81 - $if(abstract)$ 82 - <div class="mt-6 p-4 bg-gray-50 rounded-lg"> 83 - <div class="text-sm font-semibold text-gray-700 uppercase mb-2">$abstract-title$</div> 84 - <div class="text-gray-700">$abstract$</div> 85 - </div> 86 - $endif$ 87 - $endif$ 88 - </header> 89 $endif$ 90 <article class="prose dark:prose-invert max-w-none"> 91 $body$ 92 </article> 93 </main> 94 - <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 "> 95 <div class="max-w-4xl mx-auto px-8 py-4"> 96 <div class="flex justify-between gap-4"> 97 <span class="flex-1">
··· 37 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 38 39 </head> 40 + <body class="bg-white dark:bg-gray-900 flex flex-col min-h-svh"> 41 $for(include-before)$ 42 $include-before$ 43 $endfor$ 44 45 $if(toc)$ 46 + <!-- mobile TOC trigger --> 47 + <div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700"> 48 + <button 49 + type="button" 50 + popovertarget="mobile-toc-popover" 51 + popovertargetaction="toggle" 52 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white" 53 + > 54 + ${ menu.svg() } 55 $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 56 + </button> 57 + </div> 58 + 59 + <div 60 + id="mobile-toc-popover" 61 + popover 62 + class="mobile-toc-popover 63 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 64 + h-full overflow-y-auto shadow-sm 65 + px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0" 66 + > 67 + <div class="flex flex-col min-h-full"> 68 + <div class="flex-1 space-y-4"> 69 + <button 70 + type="button" 71 + popovertarget="mobile-toc-popover" 72 + popovertargetaction="toggle" 73 + class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4"> 74 + ${ x.svg() } 75 + $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 + </button> 77 + ${ search.html() } 78 + ${ table-of-contents:toc.html() } 79 + </div> 80 + ${ single-page:mode.html() } 81 + </div> 82 + </div> 83 + 84 <!-- desktop sidebar toc --> 85 + <nav 86 + id="$idprefix$TOC" 87 + role="doc-toc" 88 + class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 89 + bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 90 + p-4 z-50 overflow-y-auto"> 91 + ${ search.html() } 92 + <div class="flex-1"> 93 + $if(toc-title)$ 94 + <h2 id="$idprefix$toc-title" class="text-lg font-semibold mb-4 text-gray-900">$toc-title$</h2> 95 + $endif$ 96 + ${ table-of-contents:toc.html() } 97 + </div> 98 + ${ single-page:mode.html() } 99 </nav> 100 $endif$ 101 102 <div class="$if(toc)$md:ml-80$endif$ flex-1 flex flex-col"> 103 <main class="max-w-4xl w-full mx-auto p-6 flex-1"> 104 $if(top)$ 105 + $-- only print title block if this is NOT the top page 106 $else$ 107 $if(title)$ 108 + <header id="title-block-header" class="mb-8 pb-8 border-b border-gray-200 dark:border-gray-700"> 109 + <h1 class="text-4xl font-bold mb-2 text-black dark:text-white">$title$</h1> 110 + $if(subtitle)$ 111 + <p class="text-xl text-gray-500 dark:text-gray-400 mb-2">$subtitle$</p> 112 + $endif$ 113 + $for(author)$ 114 + <p class="text-sm text-gray-500 dark:text-gray-400">$author$</p> 115 + $endfor$ 116 + $if(date)$ 117 + <p class="text-sm text-gray-500 dark:text-gray-400">Updated on $date$</p> 118 + $endif$ 119 + $endif$ 120 + </header> 121 + $endif$ 122 + 123 + $if(abstract)$ 124 + <article class="prose dark:prose-invert max-w-none"> 125 + $abstract$ 126 + </article> 127 $endif$ 128 + 129 <article class="prose dark:prose-invert max-w-none"> 130 $body$ 131 </article> 132 </main> 133 + <nav id="sitenav" class="border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> 134 <div class="max-w-4xl mx-auto px-8 py-4"> 135 <div class="flex justify-between gap-4"> 136 <span class="flex-1">
+1 -1
flake.nix
··· 76 }; 77 buildGoApplication = 78 (self.callPackage "${gomod2nix}/builder" { 79 - gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
··· 76 }; 77 buildGoApplication = 78 (self.callPackage "${gomod2nix}/builder" { 79 + gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix; 80 }).buildGoApplication; 81 modules = ./nix/gomod2nix.toml; 82 sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
+1
input.css
··· 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 } 257 } 258 } 259 260 /* Background */
··· 255 @apply py-1 text-gray-900 dark:text-gray-100; 256 } 257 } 258 + 259 } 260 261 /* Background */
+51
lexicons/comment/comment.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.comment", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "body", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "body": { 23 + "type": "string" 24 + }, 25 + "createdAt": { 26 + "type": "string", 27 + "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 32 + }, 33 + "mentions": { 34 + "type": "array", 35 + "items": { 36 + "type": "string", 37 + "format": "did" 38 + } 39 + }, 40 + "references": { 41 + "type": "array", 42 + "items": { 43 + "type": "string", 44 + "format": "at-uri" 45 + } 46 + } 47 + } 48 + } 49 + } 50 + } 51 + }
+3 -1
lexicons/pulls/pull.json
··· 32 }, 33 "patchBlob": { 34 "type": "blob", 35 - "accept": "text/x-patch", 36 "description": "patch content" 37 }, 38 "source": {
··· 32 }, 33 "patchBlob": { 34 "type": "blob", 35 + "accept": [ 36 + "text/x-patch" 37 + ], 38 "description": "patch content" 39 }, 40 "source": {
+13 -1
nix/pkgs/docs.nix
··· 18 # icons 19 cp -rf ${lucide-src}/*.svg working/ 20 21 - # content 22 ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 -o $out/ \ 24 -t chunkedhtml \ 25 --variable toc \ 26 --toc-depth=2 \ 27 --css=stylesheet.css \ 28 --chunk-template="%i.html" \ 29 --highlight-style=working/highlight.theme \ 30 --template=working/template.html 31
··· 18 # icons 19 cp -rf ${lucide-src}/*.svg working/ 20 21 + # content - chunked 22 ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 23 -o $out/ \ 24 -t chunkedhtml \ 25 --variable toc \ 26 + --variable-json single-page=false \ 27 --toc-depth=2 \ 28 --css=stylesheet.css \ 29 --chunk-template="%i.html" \ 30 + --highlight-style=working/highlight.theme \ 31 + --template=working/template.html 32 + 33 + # content - single page 34 + ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \ 35 + -o $out/single-page.html \ 36 + --toc \ 37 + --variable toc \ 38 + --variable single-page \ 39 + --toc-depth=2 \ 40 + --css=stylesheet.css \ 41 --highlight-style=working/highlight.theme \ 42 --template=working/template.html 43
+31 -13
spindle/server.go
··· 8 "log/slog" 9 "maps" 10 "net/http" 11 12 "github.com/go-chi/chi/v5" 13 "tangled.org/core/api/tangled" ··· 30 ) 31 32 //go:embed motd 33 - var motd []byte 34 35 const ( 36 rbacDomain = "thisserver" 37 ) 38 39 type Spindle struct { 40 - jc *jetstream.JetstreamClient 41 - db *db.DB 42 - e *rbac.Enforcer 43 - l *slog.Logger 44 - n *notifier.Notifier 45 - engs map[string]models.Engine 46 - jq *queue.Queue 47 - cfg *config.Config 48 - ks *eventconsumer.Consumer 49 - res *idresolver.Resolver 50 - vault secrets.Manager 51 } 52 53 // New creates a new Spindle server with the provided configuration and engines. ··· 128 cfg: cfg, 129 res: resolver, 130 vault: vault, 131 } 132 133 err = e.AddSpindle(rbacDomain) ··· 201 return s.e 202 } 203 204 // Start starts the Spindle server (blocking). 205 func (s *Spindle) Start(ctx context.Context) error { 206 // starts a job queue runner in the background ··· 246 mux := chi.NewRouter() 247 248 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 249 - w.Write(motd) 250 }) 251 mux.HandleFunc("/events", s.Events) 252 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
··· 8 "log/slog" 9 "maps" 10 "net/http" 11 + "sync" 12 13 "github.com/go-chi/chi/v5" 14 "tangled.org/core/api/tangled" ··· 31 ) 32 33 //go:embed motd 34 + var defaultMotd []byte 35 36 const ( 37 rbacDomain = "thisserver" 38 ) 39 40 type Spindle struct { 41 + jc *jetstream.JetstreamClient 42 + db *db.DB 43 + e *rbac.Enforcer 44 + l *slog.Logger 45 + n *notifier.Notifier 46 + engs map[string]models.Engine 47 + jq *queue.Queue 48 + cfg *config.Config 49 + ks *eventconsumer.Consumer 50 + res *idresolver.Resolver 51 + vault secrets.Manager 52 + motd []byte 53 + motdMu sync.RWMutex 54 } 55 56 // New creates a new Spindle server with the provided configuration and engines. ··· 131 cfg: cfg, 132 res: resolver, 133 vault: vault, 134 + motd: defaultMotd, 135 } 136 137 err = e.AddSpindle(rbacDomain) ··· 205 return s.e 206 } 207 208 + // SetMotdContent sets custom MOTD content, replacing the embedded default. 209 + func (s *Spindle) SetMotdContent(content []byte) { 210 + s.motdMu.Lock() 211 + defer s.motdMu.Unlock() 212 + s.motd = content 213 + } 214 + 215 + // GetMotdContent returns the current MOTD content. 216 + func (s *Spindle) GetMotdContent() []byte { 217 + s.motdMu.RLock() 218 + defer s.motdMu.RUnlock() 219 + return s.motd 220 + } 221 + 222 // Start starts the Spindle server (blocking). 223 func (s *Spindle) Start(ctx context.Context) error { 224 // starts a job queue runner in the background ··· 264 mux := chi.NewRouter() 265 266 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 267 + w.Write(s.GetMotdContent()) 268 }) 269 mux.HandleFunc("/events", s.Events) 270 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)