Monorepo for Tangled tangled.org

Compare changes

Choose any two refs to compare.

+1159 -1449
-416
api/tangled/cbor_gen.go
··· 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)
··· 561 562 return nil 563 } 564 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 565 if t == nil { 566 _, 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 - }
···
-202
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/api/tangled" 14 - "tangled.org/core/appview/models" 15 - "tangled.org/core/orm" 16 - ) 17 - 18 - func PutComment(tx *sql.Tx, c *models.Comment) error { 19 - if c.Collection == "" { 20 - c.Collection = tangled.CommentNSID 21 - } 22 - result, err := tx.Exec( 23 - `insert into comments ( 24 - did, 25 - collection, 26 - rkey, 27 - subject_at, 28 - reply_to, 29 - body, 30 - pull_submission_id, 31 - created 32 - ) 33 - values (?, ?, ?, ?, ?, ?, ?, ?) 34 - on conflict(did, collection, rkey) do update set 35 - subject_at = excluded.subject_at, 36 - reply_to = excluded.reply_to, 37 - body = excluded.body, 38 - edited = case 39 - when 40 - comments.subject_at != excluded.subject_at 41 - or comments.body != excluded.body 42 - or comments.reply_to != excluded.reply_to 43 - then ? 44 - else comments.edited 45 - end`, 46 - c.Did, 47 - c.Collection, 48 - c.Rkey, 49 - c.Subject, 50 - c.ReplyTo, 51 - c.Body, 52 - c.PullSubmissionId, 53 - c.Created.Format(time.RFC3339), 54 - time.Now().Format(time.RFC3339), 55 - ) 56 - if err != nil { 57 - return err 58 - } 59 - 60 - c.Id, err = result.LastInsertId() 61 - if err != nil { 62 - return err 63 - } 64 - 65 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 66 - return fmt.Errorf("put reference_links: %w", err) 67 - } 68 - 69 - return nil 70 - } 71 - 72 - func DeleteComments(e Execer, filters ...orm.Filter) error { 73 - var conditions []string 74 - var args []any 75 - for _, filter := range filters { 76 - conditions = append(conditions, filter.Condition()) 77 - args = append(args, filter.Arg()...) 78 - } 79 - 80 - whereClause := "" 81 - if conditions != nil { 82 - whereClause = " where " + strings.Join(conditions, " and ") 83 - } 84 - 85 - query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 86 - 87 - _, err := e.Exec(query, args...) 88 - return err 89 - } 90 - 91 - func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 92 - commentMap := make(map[string]*models.Comment) 93 - 94 - var conditions []string 95 - var args []any 96 - for _, filter := range filters { 97 - conditions = append(conditions, filter.Condition()) 98 - args = append(args, filter.Arg()...) 99 - } 100 - 101 - whereClause := "" 102 - if conditions != nil { 103 - whereClause = " where " + strings.Join(conditions, " and ") 104 - } 105 - 106 - query := fmt.Sprintf(` 107 - select 108 - id, 109 - did, 110 - collection, 111 - rkey, 112 - subject_at, 113 - reply_to, 114 - body, 115 - pull_submission_id, 116 - created, 117 - edited, 118 - deleted 119 - from 120 - comments 121 - %s 122 - `, whereClause) 123 - 124 - rows, err := e.Query(query, args...) 125 - if err != nil { 126 - return nil, err 127 - } 128 - 129 - for rows.Next() { 130 - var comment models.Comment 131 - var created string 132 - var edited, deleted, replyTo sql.Null[string] 133 - err := rows.Scan( 134 - &comment.Id, 135 - &comment.Did, 136 - &comment.Collection, 137 - &comment.Rkey, 138 - &comment.Subject, 139 - &replyTo, 140 - &comment.Body, 141 - &comment.PullSubmissionId, 142 - &created, 143 - &edited, 144 - &deleted, 145 - ) 146 - if err != nil { 147 - return nil, err 148 - } 149 - 150 - if t, err := time.Parse(time.RFC3339, created); err == nil { 151 - comment.Created = t 152 - } 153 - 154 - if edited.Valid { 155 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 156 - comment.Edited = &t 157 - } 158 - } 159 - 160 - if deleted.Valid { 161 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 162 - comment.Deleted = &t 163 - } 164 - } 165 - 166 - if replyTo.Valid { 167 - rt := syntax.ATURI(replyTo.V) 168 - comment.ReplyTo = &rt 169 - } 170 - 171 - atUri := comment.AtUri().String() 172 - commentMap[atUri] = &comment 173 - } 174 - 175 - if err := rows.Err(); err != nil { 176 - return nil, err 177 - } 178 - defer rows.Close() 179 - 180 - // collect references from each comments 181 - commentAts := slices.Collect(maps.Keys(commentMap)) 182 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 183 - if err != nil { 184 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 185 - } 186 - for commentAt, references := range allReferencs { 187 - if comment, ok := commentMap[commentAt.String()]; ok { 188 - comment.References = references 189 - } 190 - } 191 - 192 - var comments []models.Comment 193 - for _, c := range commentMap { 194 - comments = append(comments, *c) 195 - } 196 - 197 - sort.Slice(comments, func(i, j int) bool { 198 - return comments[i].Created.Before(comments[j].Created) 199 - }) 200 - 201 - return comments, nil 202 - }
···
-81
appview/db/db.go
··· 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, collection, 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,
··· 1173 return err 1174 }) 1175 1176 return &DB{ 1177 db, 1178 logger,
+186 -6
appview/db/issues.go
··· 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 } ··· 292 293 func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 294 return GetIssuesPaginated(e, pagination.Page{}, filters...) 295 } 296 297 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[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 } ··· 293 294 func GetIssues(e Execer, filters ...orm.Filter) ([]models.Issue, error) { 295 return GetIssuesPaginated(e, pagination.Page{}, filters...) 296 + } 297 + 298 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 299 + result, err := tx.Exec( 300 + `insert into issue_comments ( 301 + did, 302 + rkey, 303 + issue_at, 304 + body, 305 + reply_to, 306 + created, 307 + edited 308 + ) 309 + values (?, ?, ?, ?, ?, ?, null) 310 + on conflict(did, rkey) do update set 311 + issue_at = excluded.issue_at, 312 + body = excluded.body, 313 + edited = case 314 + when 315 + issue_comments.issue_at != excluded.issue_at 316 + or issue_comments.body != excluded.body 317 + or issue_comments.reply_to != excluded.reply_to 318 + then ? 319 + else issue_comments.edited 320 + end`, 321 + c.Did, 322 + c.Rkey, 323 + c.IssueAt, 324 + c.Body, 325 + c.ReplyTo, 326 + c.Created.Format(time.RFC3339), 327 + time.Now().Format(time.RFC3339), 328 + ) 329 + if err != nil { 330 + return 0, err 331 + } 332 + 333 + id, err := result.LastInsertId() 334 + if err != nil { 335 + return 0, err 336 + } 337 + 338 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 339 + return 0, fmt.Errorf("put reference_links: %w", err) 340 + } 341 + 342 + return id, nil 343 + } 344 + 345 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 346 + var conditions []string 347 + var args []any 348 + for _, filter := range filters { 349 + conditions = append(conditions, filter.Condition()) 350 + args = append(args, filter.Arg()...) 351 + } 352 + 353 + whereClause := "" 354 + if conditions != nil { 355 + whereClause = " where " + strings.Join(conditions, " and ") 356 + } 357 + 358 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 359 + 360 + _, err := e.Exec(query, args...) 361 + return err 362 + } 363 + 364 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 365 + commentMap := make(map[string]*models.IssueComment) 366 + 367 + var conditions []string 368 + var args []any 369 + for _, filter := range filters { 370 + conditions = append(conditions, filter.Condition()) 371 + args = append(args, filter.Arg()...) 372 + } 373 + 374 + whereClause := "" 375 + if conditions != nil { 376 + whereClause = " where " + strings.Join(conditions, " and ") 377 + } 378 + 379 + query := fmt.Sprintf(` 380 + select 381 + id, 382 + did, 383 + rkey, 384 + issue_at, 385 + reply_to, 386 + body, 387 + created, 388 + edited, 389 + deleted 390 + from 391 + issue_comments 392 + %s 393 + `, whereClause) 394 + 395 + rows, err := e.Query(query, args...) 396 + if err != nil { 397 + return nil, err 398 + } 399 + defer rows.Close() 400 + 401 + for rows.Next() { 402 + var comment models.IssueComment 403 + var created string 404 + var rkey, edited, deleted, replyTo sql.Null[string] 405 + err := rows.Scan( 406 + &comment.Id, 407 + &comment.Did, 408 + &rkey, 409 + &comment.IssueAt, 410 + &replyTo, 411 + &comment.Body, 412 + &created, 413 + &edited, 414 + &deleted, 415 + ) 416 + if err != nil { 417 + return nil, err 418 + } 419 + 420 + // this is a remnant from old times, newer comments always have rkey 421 + if rkey.Valid { 422 + comment.Rkey = rkey.V 423 + } 424 + 425 + if t, err := time.Parse(time.RFC3339, created); err == nil { 426 + comment.Created = t 427 + } 428 + 429 + if edited.Valid { 430 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 431 + comment.Edited = &t 432 + } 433 + } 434 + 435 + if deleted.Valid { 436 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 437 + comment.Deleted = &t 438 + } 439 + } 440 + 441 + if replyTo.Valid { 442 + comment.ReplyTo = &replyTo.V 443 + } 444 + 445 + atUri := comment.AtUri().String() 446 + commentMap[atUri] = &comment 447 + } 448 + 449 + if err = rows.Err(); err != nil { 450 + return nil, err 451 + } 452 + 453 + // collect references for each comments 454 + commentAts := slices.Collect(maps.Keys(commentMap)) 455 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 456 + if err != nil { 457 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 458 + } 459 + for commentAt, references := range allReferencs { 460 + if comment, ok := commentMap[commentAt.String()]; ok { 461 + comment.References = references 462 + } 463 + } 464 + 465 + var comments []models.IssueComment 466 + for _, c := range commentMap { 467 + comments = append(comments, *c) 468 + } 469 + 470 + sort.Slice(comments, func(i, j int) bool { 471 + return comments[i].Created.After(comments[j].Created) 472 + }) 473 + 474 + return comments, nil 475 } 476 477 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+23
appview/db/profile.go
··· 98 }) 99 } 100 101 return &timeline, nil 102 } 103
··· 98 }) 99 } 100 101 + punchcard, err := MakePunchcard( 102 + e, 103 + orm.FilterEq("did", forDid), 104 + orm.FilterGte("date", time.Now().AddDate(0, -TimeframeMonths, 0)), 105 + ) 106 + if err != nil { 107 + return nil, fmt.Errorf("error getting commits by did: %w", err) 108 + } 109 + for _, punch := range punchcard.Punches { 110 + if punch.Date.After(now) { 111 + continue 112 + } 113 + 114 + monthsAgo := monthsBetween(punch.Date, now) 115 + if monthsAgo >= TimeframeMonths { 116 + // shouldn't happen; but times are weird 117 + continue 118 + } 119 + 120 + idx := monthsAgo 121 + timeline.ByMonth[idx].Commits += punch.Count 122 + } 123 + 124 return &timeline, nil 125 } 126
+121 -6
appview/db/pulls.go
··· 391 return nil, err 392 } 393 394 - // Get comments for all submissions using GetComments 395 submissionIds := slices.Collect(maps.Keys(submissionMap)) 396 - comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) 397 if err != nil { 398 return nil, fmt.Errorf("failed to get pull comments: %w", err) 399 } 400 for _, comment := range comments { 401 - if comment.PullSubmissionId != nil { 402 - if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { 403 - submission.Comments = append(submission.Comments, comment) 404 - } 405 } 406 } 407 ··· 421 return m, nil 422 } 423 424 // timeframe here is directly passed into the sql query filter, and any 425 // timeframe in the past should be negative; e.g.: "-3 months" 426 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 495 } 496 497 return pulls, nil 498 } 499 500 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
··· 391 return nil, err 392 } 393 394 + // Get comments for all submissions using GetPullComments 395 submissionIds := slices.Collect(maps.Keys(submissionMap)) 396 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 397 if err != nil { 398 return nil, fmt.Errorf("failed to get pull comments: %w", err) 399 } 400 for _, comment := range comments { 401 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 402 + submission.Comments = append(submission.Comments, comment) 403 } 404 } 405 ··· 419 return m, nil 420 } 421 422 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 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 + pull_id, 439 + submission_id, 440 + repo_at, 441 + owner_did, 442 + comment_at, 443 + body, 444 + created 445 + from 446 + pull_comments 447 + %s 448 + order by 449 + created asc 450 + `, whereClause) 451 + 452 + rows, err := e.Query(query, args...) 453 + if err != nil { 454 + return nil, err 455 + } 456 + defer rows.Close() 457 + 458 + commentMap := make(map[string]*models.PullComment) 459 + for rows.Next() { 460 + var comment models.PullComment 461 + var createdAt string 462 + err := rows.Scan( 463 + &comment.ID, 464 + &comment.PullId, 465 + &comment.SubmissionId, 466 + &comment.RepoAt, 467 + &comment.OwnerDid, 468 + &comment.CommentAt, 469 + &comment.Body, 470 + &createdAt, 471 + ) 472 + if err != nil { 473 + return nil, err 474 + } 475 + 476 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 477 + comment.Created = t 478 + } 479 + 480 + atUri := comment.AtUri().String() 481 + commentMap[atUri] = &comment 482 + } 483 + 484 + if err := rows.Err(); err != nil { 485 + return nil, err 486 + } 487 + 488 + // collect references for each comments 489 + commentAts := slices.Collect(maps.Keys(commentMap)) 490 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 491 + if err != nil { 492 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 493 + } 494 + for commentAt, references := range allReferencs { 495 + if comment, ok := commentMap[commentAt.String()]; ok { 496 + comment.References = references 497 + } 498 + } 499 + 500 + var comments []models.PullComment 501 + for _, c := range commentMap { 502 + comments = append(comments, *c) 503 + } 504 + 505 + sort.Slice(comments, func(i, j int) bool { 506 + return comments[i].Created.Before(comments[j].Created) 507 + }) 508 + 509 + return comments, nil 510 + } 511 + 512 // timeframe here is directly passed into the sql query filter, and any 513 // timeframe in the past should be negative; e.g.: "-3 months" 514 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 583 } 584 585 return pulls, nil 586 + } 587 + 588 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 589 + query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 590 + res, err := tx.Exec( 591 + query, 592 + comment.OwnerDid, 593 + comment.RepoAt, 594 + comment.SubmissionId, 595 + comment.CommentAt, 596 + comment.PullId, 597 + comment.Body, 598 + ) 599 + if err != nil { 600 + return 0, err 601 + } 602 + 603 + i, err := res.LastInsertId() 604 + if err != nil { 605 + return 0, err 606 + } 607 + 608 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 609 + return 0, fmt.Errorf("put reference_links: %w", err) 610 + } 611 + 612 + return i, nil 613 } 614 615 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
+32 -20
appview/db/reference.go
··· 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 ),
··· 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 ),
+6 -86
appview/ingester.go
··· 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.RepoIssueCommentNSID: 85 err = i.ingestIssueComment(e) 86 case tangled.LabelDefinitionNSID: ··· 885 } 886 887 switch e.Commit.Operation { 888 - case jmodels.CommitOperationUpdate: 889 raw := json.RawMessage(e.Commit.Record) 890 record := tangled.RepoIssueComment{} 891 err = json.Unmarshal(raw, &record) ··· 893 return fmt.Errorf("invalid record: %w", err) 894 } 895 896 - // convert 'sh.tangled.repo.issue.comment' to 'sh.tangled.comment' 897 - comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), tangled.Comment{ 898 - Body: record.Body, 899 - CreatedAt: record.CreatedAt, 900 - Mentions: record.Mentions, 901 - References: record.References, 902 - ReplyTo: record.ReplyTo, 903 - Subject: record.Issue, 904 - }) 905 if err != nil { 906 return fmt.Errorf("failed to parse comment from record: %w", err) 907 } 908 909 - if err := comment.Validate(); err != nil { 910 return fmt.Errorf("failed to validate comment: %w", err) 911 } 912 ··· 916 } 917 defer tx.Rollback() 918 919 - err = db.PutComment(tx, comment) 920 if err != nil { 921 - return fmt.Errorf("failed to create comment: %w", err) 922 } 923 924 return tx.Commit() 925 926 case jmodels.CommitOperationDelete: 927 - if err := db.DeleteComments( 928 ddb, 929 orm.FilterEq("did", did), 930 - orm.FilterEq("collection", e.Commit.Collection), 931 orm.FilterEq("rkey", rkey), 932 ); err != nil { 933 return fmt.Errorf("failed to delete issue comment record: %w", err) 934 - } 935 - 936 - return nil 937 - } 938 - 939 - return nil 940 - } 941 - 942 - func (i *Ingester) ingestComment(e *jmodels.Event) error { 943 - did := e.Did 944 - rkey := e.Commit.RKey 945 - 946 - var err error 947 - 948 - l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 949 - l.Info("ingesting record") 950 - 951 - ddb, ok := i.Db.Execer.(*db.DB) 952 - if !ok { 953 - return fmt.Errorf("failed to index issue comment record, invalid db cast") 954 - } 955 - 956 - switch e.Commit.Operation { 957 - case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 958 - raw := json.RawMessage(e.Commit.Record) 959 - record := tangled.Comment{} 960 - err = json.Unmarshal(raw, &record) 961 - if err != nil { 962 - return fmt.Errorf("invalid record: %w", err) 963 - } 964 - 965 - comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), record) 966 - if err != nil { 967 - return fmt.Errorf("failed to parse comment from record: %w", err) 968 - } 969 - 970 - // TODO: ingest pull comments 971 - // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. 972 - // so we cannot know which round this comment is pointing to 973 - if comment.Subject.Collection().String() == tangled.RepoPullNSID { 974 - l.Info("skip ingesting pull comments") 975 - return nil 976 - } 977 - 978 - if err := comment.Validate(); err != nil { 979 - return fmt.Errorf("failed to validate comment: %w", err) 980 - } 981 - 982 - tx, err := ddb.Begin() 983 - if err != nil { 984 - return fmt.Errorf("failed to start transaction: %w", err) 985 - } 986 - defer tx.Rollback() 987 - 988 - err = db.PutComment(tx, comment) 989 - if err != nil { 990 - return fmt.Errorf("failed to create comment: %w", err) 991 - } 992 - 993 - return tx.Commit() 994 - 995 - case jmodels.CommitOperationDelete: 996 - if err := db.DeleteComments( 997 - ddb, 998 - orm.FilterEq("did", did), 999 - orm.FilterEq("collection", e.Commit.Collection), 1000 - orm.FilterEq("rkey", rkey), 1001 - ); err != nil { 1002 - return fmt.Errorf("failed to delete comment record: %w", err) 1003 } 1004 1005 return nil
··· 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: ··· 883 } 884 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) ··· 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
+37 -39
appview/issues/issues.go
··· 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(replyToRaw) 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.Active.Did), 425 - Collection: tangled.CommentNSID, 426 Rkey: tid.TID(), 427 - Subject: issue.AtUri(), 428 ReplyTo: replyTo, 429 Body: body, 430 Created: time.Now(), 431 Mentions: mentions, 432 References: references, 433 } 434 - if err = comment.Validate(); err != nil { 435 l.Error("failed to validate comment", "err", err) 436 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 437 return 438 } 439 440 client, err := rp.oauth.AuthorizedClient(r) 441 if err != nil { ··· 446 447 // create a record first 448 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 - Collection: comment.Collection.String(), 450 - Repo: comment.Did.String(), 451 Rkey: comment.Rkey, 452 Record: &lexutil.LexiconTypeDecoder{ 453 - Val: comment.AsRecord(), 454 }, 455 }) 456 if err != nil { ··· 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.Active.Did { 563 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 564 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 return ··· 588 newComment.Body = newBody 589 newComment.Edited = &now 590 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 591 592 tx, err := rp.db.Begin() 593 if err != nil { ··· 597 } 598 defer tx.Rollback() 599 600 - err = db.PutComment(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.") ··· 607 608 // rkey is optional, it was introduced later 609 if newComment.Rkey != "" { 610 - // TODO: update correct comment 611 - 612 // update the record on pds 613 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", newComment.Collection.String(), newComment.Did.String(), newComment.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 comment, no record found on PDS.") 617 return 618 } 619 620 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 621 - Collection: newComment.Collection.String(), 622 - Repo: newComment.Did.String(), 623 Rkey: newComment.Rkey, 624 SwapRecord: ex.Cid, 625 Record: &lexutil.LexiconTypeDecoder{ 626 - Val: newComment.AsRecord(), 627 }, 628 }) 629 if err != nil { ··· 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.Active.Did { 745 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.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: comment.Collection.String(), 774 - Repo: comment.Did.String(), 775 Rkey: comment.Rkey, 776 }) 777 if err != nil {
··· 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.Active.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 432 } 433 + record := comment.AsRecord() 434 435 client, err := rp.oauth.AuthorizedClient(r) 436 if err != nil { ··· 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, 449 }, 450 }) 451 if err != nil { ··· 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.Active.Did { 561 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did) 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 563 return ··· 586 newComment.Body = newBody 587 newComment.Edited = &now 588 newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody) 589 + 590 + record := newComment.AsRecord() 591 592 tx, err := rp.db.Begin() 593 if err != nil { ··· 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.") ··· 607 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.Active.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.") 615 return 616 } 617 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 619 + Collection: tangled.RepoIssueCommentNSID, 620 + Repo: user.Active.Did, 621 Rkey: newComment.Rkey, 622 SwapRecord: ex.Cid, 623 Record: &lexutil.LexiconTypeDecoder{ 624 + Val: &record, 625 }, 626 }) 627 if err != nil { ··· 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.Active.Did { 743 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.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.Active.Did, 773 Rkey: comment.Rkey, 774 }) 775 if err != nil {
-138
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 - "github.com/whyrusleeping/cbor-gen" 10 - "tangled.org/core/api/tangled" 11 - ) 12 - 13 - type Comment struct { 14 - Id int64 15 - Did syntax.DID 16 - Collection syntax.NSID 17 - Rkey string 18 - Subject syntax.ATURI 19 - ReplyTo *syntax.ATURI 20 - Body string 21 - Created time.Time 22 - Edited *time.Time 23 - Deleted *time.Time 24 - Mentions []syntax.DID 25 - References []syntax.ATURI 26 - PullSubmissionId *int 27 - } 28 - 29 - func (c *Comment) AtUri() syntax.ATURI { 30 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) 31 - } 32 - 33 - func (c *Comment) AsRecord() typegen.CBORMarshaler { 34 - mentions := make([]string, len(c.Mentions)) 35 - for i, did := range c.Mentions { 36 - mentions[i] = string(did) 37 - } 38 - references := make([]string, len(c.References)) 39 - for i, uri := range c.References { 40 - references[i] = string(uri) 41 - } 42 - var replyTo *string 43 - if c.ReplyTo != nil { 44 - replyToStr := c.ReplyTo.String() 45 - replyTo = &replyToStr 46 - } 47 - switch c.Collection { 48 - case tangled.RepoIssueCommentNSID: 49 - return &tangled.RepoIssueComment{ 50 - Issue: c.Subject.String(), 51 - Body: c.Body, 52 - CreatedAt: c.Created.Format(time.RFC3339), 53 - ReplyTo: replyTo, 54 - Mentions: mentions, 55 - References: references, 56 - } 57 - case tangled.RepoPullCommentNSID: 58 - return &tangled.RepoPullComment{ 59 - Pull: c.Subject.String(), 60 - Body: c.Body, 61 - CreatedAt: c.Created.Format(time.RFC3339), 62 - Mentions: mentions, 63 - References: references, 64 - } 65 - default: // default to CommentNSID 66 - return &tangled.Comment{ 67 - Subject: c.Subject.String(), 68 - Body: c.Body, 69 - CreatedAt: c.Created.Format(time.RFC3339), 70 - ReplyTo: replyTo, 71 - Mentions: mentions, 72 - References: references, 73 - } 74 - } 75 - } 76 - 77 - func (c *Comment) IsTopLevel() bool { 78 - return c.ReplyTo == nil 79 - } 80 - 81 - func (c *Comment) IsReply() bool { 82 - return c.ReplyTo != nil 83 - } 84 - 85 - func (c *Comment) Validate() error { 86 - // TODO: sanitize the body and then trim space 87 - if sb := strings.TrimSpace(c.Body); sb == "" { 88 - return fmt.Errorf("body is empty after HTML sanitization") 89 - } 90 - 91 - // if it's for PR, PullSubmissionId should not be nil 92 - if c.Subject.Collection().String() == tangled.RepoPullNSID { 93 - if c.PullSubmissionId == nil { 94 - return fmt.Errorf("PullSubmissionId should not be nil") 95 - } 96 - } 97 - return nil 98 - } 99 - 100 - func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.Comment) (*Comment, error) { 101 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 102 - if err != nil { 103 - created = time.Now() 104 - } 105 - 106 - if _, err = syntax.ParseATURI(record.Subject); err != nil { 107 - return nil, err 108 - } 109 - 110 - i := record 111 - mentions := make([]syntax.DID, len(record.Mentions)) 112 - for i, did := range record.Mentions { 113 - mentions[i] = syntax.DID(did) 114 - } 115 - references := make([]syntax.ATURI, len(record.References)) 116 - for i, uri := range i.References { 117 - references[i] = syntax.ATURI(uri) 118 - } 119 - var replyTo *syntax.ATURI 120 - if record.ReplyTo != nil { 121 - replyToAtUri := syntax.ATURI(*record.ReplyTo) 122 - replyTo = &replyToAtUri 123 - } 124 - 125 - comment := Comment{ 126 - Did: did, 127 - Collection: tangled.CommentNSID, 128 - Rkey: rkey.String(), 129 - Body: record.Body, 130 - Subject: syntax.ATURI(record.Subject), 131 - ReplyTo: replyTo, 132 - Created: created, 133 - Mentions: mentions, 134 - References: references, 135 - } 136 - 137 - return &comment, nil 138 - }
···
+89 -8
appview/models/issue.go
··· 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 }
··· 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 + }
+28 -2
appview/models/pull.go
··· 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) TotalComments() int { ··· 253 addParticipant(s.PullAt.Authority().String()) 254 255 for _, c := range s.Comments { 256 - addParticipant(c.Did.String()) 257 } 258 259 return participants
··· 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 *Pull) TotalComments() int { ··· 279 addParticipant(s.PullAt.Authority().String()) 280 281 for _, c := range s.Comments { 282 + addParticipant(c.OwnerDid) 283 } 284 285 return participants
+113 -110
appview/notify/db/db.go
··· 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.GetPulls( 138 - n.db, 139 - orm.FilterEq("owner_did", subjectDid), 140 - orm.FilterEq("rkey", comment.Subject.RecordKey()), 141 - ) 142 - if err != nil { 143 - log.Printf("NewComment: failed to get pulls: %v", err) 144 - return 145 - } 146 - if len(pulls) == 0 { 147 - log.Printf("NewComment: no pull found for %s", comment.Subject) 148 - return 149 - } 150 - pull := pulls[0] 151 - 152 - pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 153 - if err != nil { 154 - log.Printf("NewComment: failed to get repos: %v", err) 155 - return 156 - } 157 - 158 - recipients.Insert(syntax.DID(pull.Repo.Did)) 159 - for _, p := range pull.Participants() { 160 - recipients.Insert(syntax.DID(p)) 161 - } 162 - 163 - entityType = "pull" 164 - entityId = pull.AtUri().String() 165 - repoId = &pull.Repo.Id 166 - p := int64(pull.ID) 167 - pullId = &p 168 - default: 169 - return // no-op 170 } 171 - 172 - for _, m := range comment.Mentions { 173 recipients.Remove(m) 174 } 175 176 n.notifyEvent( 177 - comment.Did, 178 recipients, 179 - models.NotificationTypeIssueCommented, 180 entityType, 181 entityId, 182 repoId, ··· 184 pullId, 185 ) 186 n.notifyEvent( 187 - comment.Did, 188 - sets.Collect(slices.Values(comment.Mentions)), 189 models.NotificationTypeUserMentioned, 190 entityType, 191 entityId, ··· 195 ) 196 } 197 198 - func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 199 - // no-op 200 - } 201 - 202 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 203 - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 204 if err != nil { 205 - log.Printf("failed to fetch collaborators: %v", err) 206 return 207 } 208 209 - // build the recipients list 210 - // - owner of the repo 211 - // - collaborators in the repo 212 - // - remove users already mentioned 213 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 214 - for _, c := range collaborators { 215 - recipients.Insert(c.SubjectDid) 216 } 217 for _, m := range mentions { 218 recipients.Remove(m) 219 } 220 221 - actorDid := syntax.DID(issue.Did) 222 entityType := "issue" 223 entityId := issue.AtUri().String() 224 repoId := &issue.Repo.Id ··· 228 n.notifyEvent( 229 actorDid, 230 recipients, 231 - models.NotificationTypeIssueCreated, 232 entityType, 233 entityId, 234 repoId, ··· 308 actorDid, 309 recipients, 310 eventType, 311 entityType, 312 entityId, 313 repoId,
··· 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,
+8 -8
appview/notify/merged_notifier.go
··· 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) {
··· 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) {
+7 -7
appview/notify/notifier.go
··· 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) {}
··· 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) {}
+20 -5
appview/notify/posthog/notifier.go
··· 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 {
··· 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 {
+18
appview/pages/funcmap.go
··· 332 } 333 return dict, nil 334 }, 335 "deref": func(v any) any { 336 val := reflect.ValueOf(v) 337 if val.Kind() == reflect.Pointer && !val.IsNil() {
··· 332 } 333 return dict, nil 334 }, 335 + "queryParams": func(params ...any) (url.Values, error) { 336 + if len(params)%2 != 0 { 337 + return nil, errors.New("invalid queryParams call") 338 + } 339 + vals := make(url.Values, len(params)/2) 340 + for i := 0; i < len(params); i += 2 { 341 + key, ok := params[i].(string) 342 + if !ok { 343 + return nil, errors.New("queryParams keys must be strings") 344 + } 345 + v, ok := params[i+1].(string) 346 + if !ok { 347 + return nil, errors.New("queryParams values must be strings") 348 + } 349 + vals.Add(key, v) 350 + } 351 + return vals, nil 352 + }, 353 "deref": func(v any) any { 354 val := reflect.ValueOf(v) 355 if val.Kind() == reflect.Pointer && !val.IsNil() {
+4 -4
appview/pages/pages.go
··· 1004 LoggedInUser *oauth.MultiAccountUser 1005 RepoInfo repoinfo.RepoInfo 1006 Issue *models.Issue 1007 - Comment *models.Comment 1008 } 1009 1010 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1015 LoggedInUser *oauth.MultiAccountUser 1016 RepoInfo repoinfo.RepoInfo 1017 Issue *models.Issue 1018 - Comment *models.Comment 1019 } 1020 1021 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1026 LoggedInUser *oauth.MultiAccountUser 1027 RepoInfo repoinfo.RepoInfo 1028 Issue *models.Issue 1029 - Comment *models.Comment 1030 } 1031 1032 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1037 LoggedInUser *oauth.MultiAccountUser 1038 RepoInfo repoinfo.RepoInfo 1039 Issue *models.Issue 1040 - Comment *models.Comment 1041 } 1042 1043 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
··· 1004 LoggedInUser *oauth.MultiAccountUser 1005 RepoInfo repoinfo.RepoInfo 1006 Issue *models.Issue 1007 + Comment *models.IssueComment 1008 } 1009 1010 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1015 LoggedInUser *oauth.MultiAccountUser 1016 RepoInfo repoinfo.RepoInfo 1017 Issue *models.Issue 1018 + Comment *models.IssueComment 1019 } 1020 1021 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1026 LoggedInUser *oauth.MultiAccountUser 1027 RepoInfo repoinfo.RepoInfo 1028 Issue *models.Issue 1029 + Comment *models.IssueComment 1030 } 1031 1032 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1037 LoggedInUser *oauth.MultiAccountUser 1038 RepoInfo repoinfo.RepoInfo 1039 Issue *models.Issue 1040 + Comment *models.IssueComment 1041 } 1042 1043 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+1 -1
appview/pages/templates/banner.html
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 - href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
··· 30 <div class="mx-6"> 31 These services may not be fully accessible until upgraded. 32 <a class="underline text-red-800 dark:text-red-200" 33 + href="https://docs.tangled.org/migrating-knots-and-spindles.html"> 34 Click to read the upgrade guide</a>. 35 </div> 36 </details>
+2 -2
appview/pages/templates/fragments/pagination.html
··· 1 {{ define "fragments/pagination" }} 2 - {{/* Params: Page (pagination.Page), TotalCount (int), BasePath (string), QueryParams (string) */}} 3 {{ $page := .Page }} 4 {{ $totalCount := .TotalCount }} 5 {{ $basePath := .BasePath }} 6 - {{ $queryParams := .QueryParams }} 7 8 {{ $prev := $page.Previous.Offset }} 9 {{ $next := $page.Next.Offset }}
··· 1 {{ define "fragments/pagination" }} 2 + {{/* Params: Page (pagination.Page), TotalCount (int), BasePath (string), QueryParams (url.Values) */}} 3 {{ $page := .Page }} 4 {{ $totalCount := .TotalCount }} 5 {{ $basePath := .BasePath }} 6 + {{ $queryParams := safeUrl .QueryParams.Encode }} 7 8 {{ $prev := $page.Previous.Offset }} 9 {{ $next := $page.Next.Offset }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 24 {{ $rhs = printf "%s" $v }} 25 {{ end }} 26 27 - {{ $chipClasses := "w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm" }} 28 29 {{ if $isDid }} 30 <a href="/{{ $resolvedVal }}" class="{{ $chipClasses }} no-underline hover:underline">
··· 24 {{ $rhs = printf "%s" $v }} 25 {{ end }} 26 27 + {{ $chipClasses := "w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-inherit" }} 28 29 {{ if $isDid }} 30 <a href="/{{ $resolvedVal }}" class="{{ $chipClasses }} no-underline hover:underline">
+35 -28
appview/pages/templates/layouts/fragments/topbar.html
··· 53 /> 54 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 55 </summary> 56 - <div class="absolute right-0 mt-4 p-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50" style="width: 14rem;"> 57 {{ $active := .Active.Did }} 58 - 59 - <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 60 - <div class="flex items-center gap-2"> 61 - <img src="{{ tinyAvatar $active }}" alt="" class="rounded-full h-8 w-8 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 62 - <div class="flex-1 overflow-hidden"> 63 - <p class="font-medium text-sm truncate">{{ $active | resolve }}</p> 64 - <p class="text-xs text-green-600 dark:text-green-400">active</p> 65 - </div> 66 - </div> 67 - </div> 68 69 {{ $others := .Accounts | otherAccounts $active }} 70 {{ if $others }} 71 - <div class="pb-2 mb-2 border-b border-gray-200 dark:border-gray-700"> 72 - <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Switch Account</p> 73 - {{ range $others }} 74 <button 75 type="button" 76 hx-post="/account/switch" 77 hx-vals='{"did": "{{ .Did }}"}' 78 hx-swap="none" 79 - class="flex items-center gap-2 w-full py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left" 80 > 81 - <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full h-6 w-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 82 - <span class="text-sm truncate flex-1">{{ .Did | resolve }}</span> 83 </button> 84 - {{ end }} 85 - </div> 86 {{ end }} 87 88 - <a href="/login?mode=add_account" class="flex items-center gap-2 py-1 text-sm"> 89 - {{ i "plus" "w-4 h-4 flex-shrink-0" }} 90 - <span>Add another account</span> 91 </a> 92 93 - <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1"> 94 - <a href="/{{ $active }}" class="block py-1 text-sm">profile</a> 95 - <a href="/{{ $active }}?tab=repos" class="block py-1 text-sm">repositories</a> 96 - <a href="/{{ $active }}?tab=strings" class="block py-1 text-sm">strings</a> 97 - <a href="/settings" class="block py-1 text-sm">settings</a> 98 <a href="#" 99 hx-post="/logout" 100 hx-swap="none" 101 - class="block py-1 text-sm text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 102 logout 103 </a> 104 </div>
··· 53 /> 54 <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 55 </summary> 56 + <div class="absolute right-0 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg z-50 text-sm" style="width: 14rem;"> 57 {{ $active := .Active.Did }} 58 + {{ $linkStyle := "flex items-center gap-3 px-4 py-2 hover:no-underline hover:bg-gray-50 hover:dark:bg-gray-700/50" }} 59 60 {{ $others := .Accounts | otherAccounts $active }} 61 {{ if $others }} 62 + <div class="text-sm text-gray-500 dark:text-gray-400 px-3 py-1 pt-2">switch account</div> 63 + {{ range $others }} 64 <button 65 type="button" 66 hx-post="/account/switch" 67 hx-vals='{"did": "{{ .Did }}"}' 68 hx-swap="none" 69 + class="{{$linkStyle}} w-full text-left pl-3" 70 > 71 + <img src="{{ tinyAvatar .Did }}" alt="" class="rounded-full size-6 flex-shrink-0 border border-gray-300 dark:border-gray-700" /> 72 + <span class="truncate flex-1">{{ .Did | resolve }}</span> 73 </button> 74 + {{ end }} 75 {{ end }} 76 77 + <a href="/login?mode=add_account" class="{{$linkStyle}} pl-3"> 78 + <div class="size-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 79 + {{ i "plus" "size-3" }} 80 + </div> 81 + 82 + <div class="text-left flex-1 min-w-0 block truncate"> 83 + add account 84 + </div> 85 </a> 86 87 + <div class="border-t border-gray-200 dark:border-gray-700"> 88 + <a href="/{{ $active }}" class="{{$linkStyle}}"> 89 + {{ i "user" "size-4" }} 90 + profile 91 + </a> 92 + <a href="/{{ $active }}?tab=repos" class="{{$linkStyle}}"> 93 + {{ i "book-marked" "size-4" }} 94 + repositories 95 + </a> 96 + <a href="/{{ $active }}?tab=strings" class="{{$linkStyle}}"> 97 + {{ i "line-squiggle" "size-4" }} 98 + strings 99 + </a> 100 + <a href="/settings" class="{{$linkStyle}}"> 101 + {{ i "cog" "size-4" }} 102 + settings 103 + </a> 104 <a href="#" 105 hx-post="/logout" 106 hx-swap="none" 107 + class="{{$linkStyle}} text-red-400 hover:text-red-400 hover:bg-red-100 dark:hover:bg-red-700/20 pb-2"> 108 + {{ i "log-out" "size-4" }} 109 logout 110 </a> 111 </div>
+38 -4
appview/pages/templates/repo/fragments/diff.html
··· 4 #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 5 #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 6 #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; margin-right: 1rem; } 7 - #filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; } 8 </style> 9 10 {{ template "diffTopbar" . }} ··· 14 {{ define "diffTopbar" }} 15 {{ $diff := index . 0 }} 16 {{ $opts := index . 1 }} 17 18 {{ block "filesCheckbox" $ }} {{ end }} 19 {{ block "subsCheckbox" $ }} {{ end }} 20 21 <!-- top bar --> 22 - <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2"> 23 <!-- left panel toggle --> 24 {{ template "filesToggle" . }} 25 ··· 27 {{ $stat := $diff.Stats }} 28 {{ $count := len $diff.ChangedFiles }} 29 {{ template "repo/fragments/diffStatPill" $stat }} 30 - {{ $count }} changed file{{ if ne $count 1 }}s{{ end }} 31 32 <!-- spacer --> 33 <div class="flex-grow"></div> ··· 137 {{ end }} 138 139 {{ define "collapseToggle" }} 140 - <label 141 title="Expand/Collapse diffs" 142 for="collapseToggle" 143 class="btn font-normal normal-case p-2"
··· 4 #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 5 #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 6 #filesToggle:checked ~ div div#files { width: fit-content; max-width: 15vw; margin-right: 1rem; } 7 + #filesToggle:not(:checked) ~ div div#files { width: 0; display: none; margin-right: 0; } 8 </style> 9 10 {{ template "diffTopbar" . }} ··· 14 {{ define "diffTopbar" }} 15 {{ $diff := index . 0 }} 16 {{ $opts := index . 1 }} 17 + {{ $root := "" }} 18 + {{ if gt (len .) 2 }} 19 + {{ $root = index . 2 }} 20 + {{ end }} 21 22 {{ block "filesCheckbox" $ }} {{ end }} 23 {{ block "subsCheckbox" $ }} {{ end }} 24 25 <!-- top bar --> 26 + <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12 p-2 {{ if $root }}mt-4{{ end }}"> 27 <!-- left panel toggle --> 28 {{ template "filesToggle" . }} 29 ··· 31 {{ $stat := $diff.Stats }} 32 {{ $count := len $diff.ChangedFiles }} 33 {{ template "repo/fragments/diffStatPill" $stat }} 34 + <span class="text-xs text-gray-600 dark:text-gray-400 hidden md:inline-flex">{{ $count }} changed file{{ if ne $count 1 }}s{{ end }}</span> 35 + 36 + {{ if $root }} 37 + {{ if $root.IsInterdiff }} 38 + <!-- interdiff indicator --> 39 + <div class="flex items-center gap-2 before:content-['|'] before:text-gray-300 dark:before:text-gray-600 before:mr-2"> 40 + <span class="text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wide">Interdiff</span> 41 + <a 42 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ sub $root.ActiveRound 1 }}" 43 + class="px-2 py-0.5 bg-white dark:bg-gray-700 rounded font-mono text-xs hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600" 44 + > 45 + #{{ sub $root.ActiveRound 1 }} 46 + </a> 47 + <span class="text-gray-400 text-xs">โ†’</span> 48 + <a 49 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $root.ActiveRound }}" 50 + class="px-2 py-0.5 bg-white dark:bg-gray-700 rounded font-mono text-xs hover:bg-gray-50 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600" 51 + > 52 + #{{ $root.ActiveRound }} 53 + </a> 54 + </div> 55 + {{ else if ne $root.ActiveRound nil }} 56 + <!-- diff round indicator --> 57 + <div class="flex items-center gap-2 before:content-['|'] before:text-gray-300 dark:before:text-gray-600 before:mr-2"> 58 + <span class="text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wide">Diff</span> 59 + <span class="px-2 py-0.5 bg-white dark:bg-gray-700 rounded font-mono text-xs border border-gray-300 dark:border-gray-600"> 60 + <span class="hidden md:inline">round </span>#{{ $root.ActiveRound }} 61 + </span> 62 + </div> 63 + {{ end }} 64 + {{ end }} 65 66 <!-- spacer --> 67 <div class="flex-grow"></div> ··· 171 {{ end }} 172 173 {{ define "collapseToggle" }} 174 + <label 175 title="Expand/Collapse diffs" 176 for="collapseToggle" 177 class="btn font-normal normal-case p-2"
+3 -3
appview/pages/templates/repo/fragments/splitDiff.html
··· 1 {{ define "repo/fragments/splitDiff" }} 2 {{ $name := .Id }} 3 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 - {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
··· 1 {{ define "repo/fragments/splitDiff" }} 2 {{ $name := .Id }} 3 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 group-target/line:bg-yellow-200/30 group-target/line:dark:bg-yellow-600/30" -}} 4 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline group-target/line:text-black group-target/line:dark:text-white" -}} 5 {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200/50 target:dark:bg-yellow-700/50 scroll-mt-48 group/line" -}} 7 {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
+3 -3
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 3 <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 {{- $oldStart := .OldPosition -}} 5 {{- $newStart := .NewPosition -}} 6 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 - {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200 target:dark:bg-yellow-700 scroll-mt-48" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
··· 3 <div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</span> 4 {{- $oldStart := .OldPosition -}} 5 {{- $newStart := .NewPosition -}} 6 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 group-target/line:bg-yellow-200/30 group-target/line:dark:bg-yellow-600/30" -}} 7 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline group-target/line:text-black group-target/line:dark:text-white" -}} 8 {{- $lineNrSepStyle1 := "" -}} 9 {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "inline-flex w-full items-center target:bg-yellow-200/30 target:dark:bg-yellow-700/30 scroll-mt-48 group/line" -}} 11 {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
+2 -2
appview/pages/templates/repo/issues/fragments/commentList.html
··· 42 <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 <div class="flex-shrink-0"> 44 <img 45 - src="{{ tinyAvatar .Comment.Did.String }}" 46 alt="" 47 class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 48 /> ··· 58 <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 59 <div class="flex-shrink-0"> 60 <img 61 - src="{{ tinyAvatar .Comment.Did.String }}" 62 alt="" 63 class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 64 />
··· 42 <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 "> 43 <div class="flex-shrink-0"> 44 <img 45 + src="{{ tinyAvatar .Comment.Did }}" 46 alt="" 47 class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 48 /> ··· 58 <div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 "> 59 <div class="flex-shrink-0"> 60 <img 61 + src="{{ tinyAvatar .Comment.Did }}" 62 alt="" 63 class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 64 />
+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 - {{ resolve .Comment.Did.String }} 4 {{ template "hats" $ }} 5 <span class="before:content-['ยท']"></span> 6 {{ template "timestamp" . }} 7 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 8 {{ if and $isCommentOwner (not .Comment.Deleted) }} 9 {{ template "editIssueComment" . }} 10 {{ 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 + {{ resolve .Comment.Did }} 4 {{ template "hats" $ }} 5 <span class="before:content-['ยท']"></span> 6 {{ template "timestamp" . }} 7 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 8 {{ if and $isCommentOwner (not .Comment.Deleted) }} 9 {{ template "editIssueComment" . }} 10 {{ template "deleteIssueComment" . }}
+1 -1
appview/pages/templates/repo/issues/issues.html
··· 80 "Page" .Page 81 "TotalCount" .IssueCount 82 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 83 - "QueryParams" (printf "state=%s&q=%s" $state .FilterQuery) 84 ) }} 85 {{ end }} 86 {{ end }}
··· 80 "Page" .Page 81 "TotalCount" .IssueCount 82 "BasePath" (printf "/%s/issues" .RepoInfo.FullName) 83 + "QueryParams" (queryParams "state" $state "q" .FilterQuery) 84 ) }} 85 {{ end }} 86 {{ end }}
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 12 > 13 <textarea 14 name="body" 15 - class="w-full p-2 rounded border border-gray-200" 16 rows=8 17 placeholder="Add to the discussion..."></textarea 18 >
··· 12 > 13 <textarea 14 name="body" 15 + class="w-full p-2 rounded border" 16 rows=8 17 placeholder="Add to the discussion..."></textarea 18 >
+110 -74
appview/pages/templates/repo/pulls/pull.html
··· 8 9 {{ define "mainLayout" }} 10 <div class="px-1 flex-grow flex flex-col gap-4"> 11 - <div class="max-w-screen-lg mx-auto"> 12 {{ block "contentLayout" . }} 13 {{ block "content" . }}{{ end }} 14 {{ end }} ··· 22 <script> 23 (function() { 24 const details = document.getElementById('bottomSheet'); 25 const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; 26 27 // close on mobile initially 28 if (!isDesktop()) { 29 details.open = false; 30 } 31 32 // prevent closing on desktop 33 details.addEventListener('toggle', function(e) { 34 if (isDesktop() && !this.open) { 35 this.open = true; 36 } 37 }); 38 39 const mediaQuery = window.matchMedia('(min-width: 768px)'); ··· 45 // switched to mobile - close 46 details.open = false; 47 } 48 }); 49 })(); 50 </script> 51 {{ end }} 52 53 {{ define "repoContentLayout" }} 54 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4"> 55 <section class="bg-white col-span-1 md:col-span-8 dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 56 {{ block "repoContent" . }}{{ end }} 57 </section> ··· 91 <div class="flex col-span-full"> 92 <!-- left panel --> 93 <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 94 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 95 {{ template "repo/fragments/fileTree" $diff.FileTree }} 96 </section> 97 </div> ··· 109 {{ define "subsPanel" }} 110 {{ $root := index . 2 }} 111 {{ $pull := $root.Pull }} 112 - 113 <!-- backdrop overlay - only visible on mobile when open --> 114 - <div class=" 115 - fixed inset-0 bg-black/50 z-50 md:hidden opacity-0 116 - pointer-events-none transition-opacity duration-300 117 - has-[~#subs_details[open]]:opacity-100 has-[~#subs_details[open]]:pointer-events-auto"> 118 - </div> 119 <!-- right panel - bottom sheet on mobile, side panel on desktop --> 120 <div id="subs" class="fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden"> 121 - <details open id="bottomSheet" class="group rounded-t-2xl md:rounded-t-sm drop-shadow-lg md:drop-shadow-none"> 122 <summary class=" 123 flex gap-4 items-center justify-between 124 - rounded-t-2xl md:rounded-t-sm cursor-pointer list-none p-4 md:h-12 125 text-white md:text-black md:dark:text-white 126 - bg-green-600 dark:bg-green-600 127 md:bg-white md:dark:bg-gray-800 128 drop-shadow-sm 129 - md:border-b md:border-x border-gray-200 dark:border-gray-700"> 130 - <h2 class="">Review Panel </h2> 131 {{ template "subsPanelSummary" $ }} 132 </summary> 133 <div class="max-h-[85vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 md:bg-transparent"> ··· 142 {{ $pull := $root.Pull }} 143 {{ $latest := $pull.LastRoundNumber }} 144 <div class="flex items-center gap-2 text-sm"> 145 - {{ if $root.IsInterdiff }} 146 - <span> 147 - viewing interdiff of 148 - <span class="font-mono">#{{ $root.ActiveRound }}</span> 149 - and 150 - <span class="font-mono">#{{ sub $root.ActiveRound 1 }}</span> 151 - </span> 152 - {{ else }} 153 - <span> 154 - viewing round 155 - <span class="font-mono">#{{ $root.ActiveRound }}</span> 156 - </span> 157 - {{ if ne $root.ActiveRound $latest }} 158 - <span>(outdated)</span> 159 - <span class="before:content-['ยท']"></span> 160 - <a class="underline" href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $latest }}?{{ safeUrl $root.DiffOpts.Encode }}"> 161 - view latest 162 - </a> 163 - {{ end }} 164 - {{ end }} 165 <span class="md:hidden inline"> 166 - <span class="inline group-open:hidden">{{ i "chevron-up" "size-4" }}</span> 167 - <span class="hidden group-open:inline">{{ i "chevron-down" "size-4" }}</span> 168 </span> 169 </div> 170 {{ end }} ··· 176 {{ define "subsToggle" }} 177 <style> 178 /* Mobile: full width */ 179 - #subsToggle:checked ~ div div#subs { 180 width: 100%; 181 margin-left: 0; 182 } ··· 186 187 /* Desktop: 25vw with left margin */ 188 @media (min-width: 768px) { 189 - #subsToggle:checked ~ div div#subs { 190 width: 25vw; 191 margin-left: 1rem; 192 } ··· 207 208 {{ define "submissions" }} 209 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 210 {{ range $ridx, $item := reverse .Pull.Submissions }} 211 {{ $idx := sub $lastIdx $ridx }} 212 {{ template "submission" (list $item $idx $lastIdx $) }} ··· 218 {{ $idx := index . 1 }} 219 {{ $lastIdx := index . 2 }} 220 {{ $root := index . 3 }} 221 - <div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50"> 222 {{ template "submissionHeader" $ }} 223 {{ template "submissionComments" $ }} 224 - 225 - {{ if eq $lastIdx $item.RoundNumber }} 226 - {{ block "mergeStatus" $root }} {{ end }} 227 - {{ block "resubmitStatus" $root }} {{ end }} 228 - {{ end }} 229 - 230 - {{ if $root.LoggedInUser }} 231 - {{ template "repo/pulls/fragments/pullActions" 232 - (dict 233 - "LoggedInUser" $root.LoggedInUser 234 - "Pull" $root.Pull 235 - "RepoInfo" $root.RepoInfo 236 - "RoundNumber" $item.RoundNumber 237 - "MergeCheck" $root.MergeCheck 238 - "ResubmitCheck" $root.ResubmitCheck 239 - "BranchDeleteStatus" $root.BranchDeleteStatus 240 - "Stack" $root.Stack) }} 241 - {{ else }} 242 - {{ template "loginPrompt" $ }} 243 - {{ end }} 244 </div> 245 {{ end }} 246 ··· 249 {{ $lastIdx := index . 2 }} 250 {{ $root := index . 3 }} 251 {{ $round := $item.RoundNumber }} 252 - <div class="rounded px-6 py-4 pr-2 pt-2 bg-white dark:bg-gray-800 flex gap-2 sticky top-0 z-20 border-b border-gray-200 dark:border-gray-700"> 253 <!-- left column: just profile picture --> 254 <div class="flex-shrink-0 pt-2"> 255 <img ··· 277 {{ $round := $item.RoundNumber }} 278 <div class="flex gap-2 items-center justify-between mb-1"> 279 <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 pt-2"> 280 - {{ resolve $root.Pull.OwnerDid }} submitted v{{ $round }} 281 <span class="select-none before:content-['\00B7']"></span> 282 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}"> 283 - {{ template "repo/fragments/shortTimeAgo" $item.Created }} 284 </a> 285 </span> 286 <div class="flex gap-2 items-center"> 287 {{ if ne $root.ActiveRound $round }} 288 <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 289 - href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}"> 290 {{ i "diff" "w-4 h-4" }} 291 diff 292 </a> ··· 497 498 {{ define "submissionComments" }} 499 {{ $item := index . 0 }} 500 - <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 501 - {{ range $item.Comments }} 502 - {{ template "submissionComment" . }} 503 - {{ end }} 504 - </div> 505 {{ end }} 506 507 {{ define "submissionComment" }} 508 - <div id="comment-{{.Id}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 509 <!-- left column: profile picture --> 510 - <div class="flex-shrink-0"> 511 <img 512 - src="{{ tinyAvatar .Did.String }}" 513 alt="" 514 - class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 515 /> 516 </div> 517 <!-- right column: name and body in two rows --> 518 <div class="flex-1 min-w-0"> 519 <!-- Row 1: Author and timestamp --> 520 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 521 - <span>{{ resolve .Did.String }}</span> 522 <span class="before:content-['ยท']"></span> 523 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.Id}}"> 524 {{ template "repo/fragments/time" .Created }} 525 </a> 526 </div>
··· 8 9 {{ define "mainLayout" }} 10 <div class="px-1 flex-grow flex flex-col gap-4"> 11 + <div class="max-w-full md:max-w-screen-lg mx-auto"> 12 {{ block "contentLayout" . }} 13 {{ block "content" . }}{{ end }} 14 {{ end }} ··· 22 <script> 23 (function() { 24 const details = document.getElementById('bottomSheet'); 25 + const backdrop = document.getElementById('bottomSheetBackdrop'); 26 const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; 27 28 + // function to update backdrop 29 + const updateBackdrop = () => { 30 + if (backdrop) { 31 + if (details.open && !isDesktop()) { 32 + backdrop.classList.remove('opacity-0', 'pointer-events-none'); 33 + backdrop.classList.add('opacity-100', 'pointer-events-auto'); 34 + } else { 35 + backdrop.classList.remove('opacity-100', 'pointer-events-auto'); 36 + backdrop.classList.add('opacity-0', 'pointer-events-none'); 37 + } 38 + } 39 + }; 40 + 41 // close on mobile initially 42 if (!isDesktop()) { 43 details.open = false; 44 } 45 + updateBackdrop(); // initialize backdrop 46 47 // prevent closing on desktop 48 details.addEventListener('toggle', function(e) { 49 if (isDesktop() && !this.open) { 50 this.open = true; 51 } 52 + updateBackdrop(); 53 }); 54 55 const mediaQuery = window.matchMedia('(min-width: 768px)'); ··· 61 // switched to mobile - close 62 details.open = false; 63 } 64 + updateBackdrop(); 65 }); 66 + 67 + // close when clicking backdrop 68 + if (backdrop) { 69 + backdrop.addEventListener('click', () => { 70 + if (!isDesktop()) { 71 + details.open = false; 72 + } 73 + }); 74 + } 75 })(); 76 </script> 77 {{ end }} 78 79 {{ define "repoContentLayout" }} 80 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 81 <section class="bg-white col-span-1 md:col-span-8 dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full flex-shrink"> 82 {{ block "repoContent" . }}{{ end }} 83 </section> ··· 117 <div class="flex col-span-full"> 118 <!-- left panel --> 119 <div id="files" class="w-0 hidden md:block overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 120 + <section class="overflow-x-auto text-sm px-6 py-2 border-b border-x border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded-b rounded-t-none bg-white dark:bg-gray-800 drop-shadow-sm"> 121 {{ template "repo/fragments/fileTree" $diff.FileTree }} 122 </section> 123 </div> ··· 135 {{ define "subsPanel" }} 136 {{ $root := index . 2 }} 137 {{ $pull := $root.Pull }} 138 <!-- backdrop overlay - only visible on mobile when open --> 139 + <div id="bottomSheetBackdrop" class="fixed inset-0 bg-black/50 md:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-40"></div> 140 <!-- right panel - bottom sheet on mobile, side panel on desktop --> 141 <div id="subs" class="fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden"> 142 + <details open id="bottomSheet" class="rounded-t-2xl md:rounded-t drop-shadow-lg md:drop-shadow-none group/panel"> 143 <summary class=" 144 flex gap-4 items-center justify-between 145 + rounded-t-2xl md:rounded-t cursor-pointer list-none p-4 md:h-12 146 text-white md:text-black md:dark:text-white 147 + bg-green-600 dark:bg-green-700 148 md:bg-white md:dark:bg-gray-800 149 drop-shadow-sm 150 + border-t md:border-x md:border-t-0 border-gray-200 dark:border-gray-700"> 151 + <h2 class="">Comments</h2> 152 {{ template "subsPanelSummary" $ }} 153 </summary> 154 <div class="max-h-[85vh] md:max-h-[calc(100vh-3rem-3rem)] w-full flex flex-col-reverse gap-4 overflow-y-auto bg-slate-100 dark:bg-gray-900 md:bg-transparent"> ··· 163 {{ $pull := $root.Pull }} 164 {{ $latest := $pull.LastRoundNumber }} 165 <div class="flex items-center gap-2 text-sm"> 166 <span class="md:hidden inline"> 167 + <span class="inline group-open/panel:hidden">{{ i "chevron-up" "size-4" }}</span> 168 + <span class="hidden group-open/panel:inline">{{ i "chevron-down" "size-4" }}</span> 169 </span> 170 </div> 171 {{ end }} ··· 177 {{ define "subsToggle" }} 178 <style> 179 /* Mobile: full width */ 180 + #subsToggle:checked ~ div div#subs { 181 width: 100%; 182 margin-left: 0; 183 } ··· 187 188 /* Desktop: 25vw with left margin */ 189 @media (min-width: 768px) { 190 + #subsToggle:checked ~ div div#subs { 191 width: 25vw; 192 margin-left: 1rem; 193 } ··· 208 209 {{ define "submissions" }} 210 {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 211 + {{ if not .LoggedInUser }} 212 + {{ template "loginPrompt" $ }} 213 + {{ end }} 214 {{ range $ridx, $item := reverse .Pull.Submissions }} 215 {{ $idx := sub $lastIdx $ridx }} 216 {{ template "submission" (list $item $idx $lastIdx $) }} ··· 222 {{ $idx := index . 1 }} 223 {{ $lastIdx := index . 2 }} 224 {{ $root := index . 3 }} 225 + <div class="{{ if eq $item.RoundNumber 0 }}rounded-b border-t-0{{ else }}rounded{{ end }} border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-900"> 226 {{ template "submissionHeader" $ }} 227 {{ template "submissionComments" $ }} 228 </div> 229 {{ end }} 230 ··· 233 {{ $lastIdx := index . 2 }} 234 {{ $root := index . 3 }} 235 {{ $round := $item.RoundNumber }} 236 + <div class=" 237 + {{ if eq $round 0 }}rounded-b{{ else }}rounded{{ end }} 238 + px-6 py-4 pr-2 pt-2 239 + {{ if eq $root.ActiveRound $round }} 240 + bg-blue-100 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-700 241 + {{ else }} 242 + bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 243 + {{ end }} 244 + flex gap-2 sticky top-0 z-20"> 245 <!-- left column: just profile picture --> 246 <div class="flex-shrink-0 pt-2"> 247 <img ··· 269 {{ $round := $item.RoundNumber }} 270 <div class="flex gap-2 items-center justify-between mb-1"> 271 <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 pt-2"> 272 + {{ resolve $root.Pull.OwnerDid }} submitted 273 + <span class="px-2 py-0.5 {{ if eq $root.ActiveRound $round }}text-white bg-blue-600 dark:bg-blue-500 border-blue-700 dark:border-blue-600{{ else }}text-black dark:text-white bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600{{ end }} rounded font-mono text-xs border"> 274 + #{{ $round }} 275 + </span> 276 <span class="select-none before:content-['\00B7']"></span> 277 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}"> 278 + {{ template "repo/fragments/shortTime" $item.Created }} 279 </a> 280 </span> 281 <div class="flex gap-2 items-center"> 282 {{ if ne $root.ActiveRound $round }} 283 <a class="btn-flat flex items-center gap-2 no-underline hover:no-underline text-sm" 284 + href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $round }}?{{ safeUrl $root.DiffOpts.Encode }}#round-#{{ $round }}"> 285 {{ i "diff" "w-4 h-4" }} 286 diff 287 </a> ··· 492 493 {{ define "submissionComments" }} 494 {{ $item := index . 0 }} 495 + {{ $idx := index . 1 }} 496 + {{ $lastIdx := index . 2 }} 497 + {{ $root := index . 3 }} 498 + {{ $c := len $item.Comments }} 499 + <details class="relative ml-10 group/comments" open> 500 + <summary class="cursor-pointer list-none"> 501 + <div class="hidden group-open/comments:block absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center group/border z-4"> 502 + <div class="absolute left-1/2 -translate-x-1/2 top-0 bottom-0 w-0.5 group-open/comments:bg-gray-200 dark:group-open/comments:bg-gray-700 group-hover/border:bg-gray-400 dark:group-hover/border:bg-gray-500 transition-colors"> </div> 503 + </div> 504 + <div class="group-open/comments:hidden block relative group/summary py-4"> 505 + <div class="absolute -left-8 top-0 bottom-0 w-16 transition-colors flex items-center justify-center z-4"> 506 + <div class="absolute left-1/2 -translate-x-1/2 h-1/3 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-hover/summary:bg-gray-400 dark:group-hover/summary:bg-gray-500 transition-colors"></div> 507 + </div> 508 + <span class="text-gray-500 dark:text-gray-400 text-sm group-hover/summary:text-gray-600 dark:group-hover/summary:text-gray-300 transition-colors flex items-center gap-2 -ml-2 relative"> 509 + {{ i "circle-plus" "size-4 z-5" }} 510 + expand {{ $c }} comment{{ if ne $c 1 }}s{{ end }} 511 + </span> 512 + </div> 513 + </summary> 514 + <div> 515 + {{ range $item.Comments }} 516 + {{ template "submissionComment" . }} 517 + {{ end }} 518 + </div> 519 + 520 + <div class="relative -ml-10"> 521 + {{ if eq $lastIdx $item.RoundNumber }} 522 + {{ block "mergeStatus" $root }} {{ end }} 523 + {{ block "resubmitStatus" $root }} {{ end }} 524 + {{ end }} 525 + </div> 526 + <div class="relative -ml-10 bg-gray-50 dark:bg-gray-900"> 527 + {{ if $root.LoggedInUser }} 528 + {{ template "repo/pulls/fragments/pullActions" 529 + (dict 530 + "LoggedInUser" $root.LoggedInUser 531 + "Pull" $root.Pull 532 + "RepoInfo" $root.RepoInfo 533 + "RoundNumber" $item.RoundNumber 534 + "MergeCheck" $root.MergeCheck 535 + "ResubmitCheck" $root.ResubmitCheck 536 + "BranchDeleteStatus" $root.BranchDeleteStatus 537 + "Stack" $root.Stack) }} 538 + {{ end }} 539 + </div> 540 + </details> 541 {{ end }} 542 543 {{ define "submissionComment" }} 544 + <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 545 <!-- left column: profile picture --> 546 + <div class="flex-shrink-0 h-fit relative"> 547 <img 548 + src="{{ tinyAvatar .OwnerDid }}" 549 alt="" 550 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-5" 551 /> 552 </div> 553 <!-- right column: name and body in two rows --> 554 <div class="flex-1 min-w-0"> 555 <!-- Row 1: Author and timestamp --> 556 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 557 + <span>{{ resolve .OwnerDid }}</span> 558 <span class="before:content-['ยท']"></span> 559 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"> 560 {{ template "repo/fragments/time" .Created }} 561 </a> 562 </div>
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 166 "Page" .Page 167 "TotalCount" .PullCount 168 "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 169 - "QueryParams" (printf "state=%s&q=%s" .FilteringBy.String .FilterQuery) 170 ) }} 171 {{ end }} 172 {{ end }}
··· 166 "Page" .Page 167 "TotalCount" .PullCount 168 "BasePath" (printf "/%s/pulls" .RepoInfo.FullName) 169 + "QueryParams" (queryParams "state" .FilteringBy.String "q" .FilterQuery) 170 ) }} 171 {{ end }} 172 {{ end }}
+24 -16
appview/pages/templates/strings/string.html
··· 10 11 {{ define "content" }} 12 {{ $ownerId := resolve .Owner.DID.String }} 13 - <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 - <div class="text-lg flex items-center justify-between"> 15 - <div> 16 - <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 17 - <span class="select-none">/</span> 18 - <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 </div> 20 - <div class="flex gap-2 items-stretch text-base"> 21 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 22 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 hx-boost="true" 24 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 25 - {{ i "pencil" "size-4" }} 26 <span class="hidden md:inline">edit</span> 27 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 28 </a> 29 <button 30 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 31 title="Delete string" 32 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 33 hx-swap="none" 34 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 35 > 36 - {{ i "trash-2" "size-4" }} 37 <span class="hidden md:inline">delete</span> 38 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 </button> ··· 44 "StarCount" .StarCount) }} 45 </div> 46 </div> 47 - <span> 48 - {{ with .String.Description }} 49 - {{ . }} 50 - {{ end }} 51 - </span> 52 </section> 53 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 54 <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
··· 10 11 {{ define "content" }} 12 {{ $ownerId := resolve .Owner.DID.String }} 13 + <section id="string-header" class="mb-2 py-2 px-4 dark:text-white"> 14 + <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 15 + <!-- left items --> 16 + <div class="flex flex-col gap-2"> 17 + <!-- string owner / string name --> 18 + <div class="flex items-center gap-2 flex-wrap"> 19 + {{ template "user/fragments/picHandleLink" .Owner.DID.String }} 20 + <span class="select-none">/</span> 21 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 22 + </div> 23 + 24 + <span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300"> 25 + {{ if .String.Description }} 26 + {{ .String.Description }} 27 + {{ else }} 28 + <span class="italic">this string has no description</span> 29 + {{ end }} 30 + </span> 31 </div> 32 + 33 + <div class="w-full sm:w-fit grid grid-cols-3 gap-2 z-auto"> 34 {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 35 + <a class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 36 hx-boost="true" 37 href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 38 + {{ i "pencil" "w-4 h-4" }} 39 <span class="hidden md:inline">edit</span> 40 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 41 </a> 42 <button 43 + class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-2 group" 44 title="Delete string" 45 hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 46 hx-swap="none" 47 hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 48 > 49 + {{ i "trash-2" "w-4 h-4" }} 50 <span class="hidden md:inline">delete</span> 51 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 </button> ··· 57 "StarCount" .StarCount) }} 58 </div> 59 </div> 60 </section> 61 <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 62 <div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
+2 -2
appview/pulls/opengraph.go
··· 199 currentX += commentTextWidth + 40 200 201 // Draw files changed 202 - err = statusStatsArea.DrawLucideIcon("static/icons/file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 203 if err != nil { 204 log.Printf("failed to draw file diff icon: %v", err) 205 } ··· 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 }
··· 199 currentX += commentTextWidth + 40 200 201 // Draw files changed 202 + err = statusStatsArea.DrawLucideIcon("file-diff", currentX, statsY+iconBaselineOffset-iconSize/2+5, iconSize, iconColor) 203 if err != nil { 204 log.Printf("failed to draw file diff icon: %v", err) 205 } ··· 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 }
+25 -27
appview/pulls/pulls.go
··· 228 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 229 if err != nil { 230 log.Println("failed to get pull reactions") 231 - s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 232 } 233 234 userReactions := map[models.ReactionKind]bool{} ··· 722 } 723 defer tx.Rollback() 724 725 - comment := models.Comment{ 726 - Did: syntax.DID(user.Active.Did), 727 - Collection: tangled.CommentNSID, 728 - Rkey: tid.TID(), 729 - Subject: pull.AtUri(), 730 - ReplyTo: nil, 731 - Body: body, 732 - Created: time.Now(), 733 - Mentions: mentions, 734 - References: references, 735 - PullSubmissionId: &pull.Submissions[roundNumber].ID, 736 - } 737 - if err = comment.Validate(); err != nil { 738 - log.Println("failed to validate comment", err) 739 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 740 - return 741 - } 742 743 client, err := s.oauth.AuthorizedClient(r) 744 if err != nil { ··· 746 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 747 return 748 } 749 - 750 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 751 - Collection: comment.Collection.String(), 752 - Repo: comment.Did.String(), 753 - Rkey: comment.Rkey, 754 Record: &lexutil.LexiconTypeDecoder{ 755 - Val: comment.AsRecord(), 756 }, 757 }) 758 if err != nil { ··· 761 return 762 } 763 764 // Create the pull comment in the database with the commentAt field 765 - err = db.PutComment(tx, &comment) 766 if err != nil { 767 log.Println("failed to create pull comment", err) 768 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 776 return 777 } 778 779 - s.notifier.NewComment(r.Context(), &comment) 780 781 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 782 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 783 return 784 } 785 } ··· 1877 record := pull.AsRecord() 1878 record.PatchBlob = blob.Blob 1879 record.CreatedAt = time.Now().Format(time.RFC3339) 1880 1881 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1882 Collection: tangled.RepoPullNSID,
··· 228 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 229 if err != nil { 230 log.Println("failed to get pull reactions") 231 } 232 233 userReactions := map[models.ReactionKind]bool{} ··· 721 } 722 defer tx.Rollback() 723 724 + createdAt := time.Now().Format(time.RFC3339) 725 726 client, err := s.oauth.AuthorizedClient(r) 727 if err != nil { ··· 729 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 730 return 731 } 732 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 733 + Collection: tangled.RepoPullCommentNSID, 734 + Repo: user.Active.Did, 735 + Rkey: tid.TID(), 736 Record: &lexutil.LexiconTypeDecoder{ 737 + Val: &tangled.RepoPullComment{ 738 + Pull: pull.AtUri().String(), 739 + Body: body, 740 + CreatedAt: createdAt, 741 + }, 742 }, 743 }) 744 if err != nil { ··· 747 return 748 } 749 750 + comment := &models.PullComment{ 751 + OwnerDid: user.Active.Did, 752 + RepoAt: f.RepoAt().String(), 753 + PullId: pull.PullId, 754 + Body: body, 755 + CommentAt: atResp.Uri, 756 + SubmissionId: pull.Submissions[roundNumber].ID, 757 + Mentions: mentions, 758 + References: references, 759 + } 760 + 761 // Create the pull comment in the database with the commentAt field 762 + commentId, err := db.NewPullComment(tx, comment) 763 if err != nil { 764 log.Println("failed to create pull comment", err) 765 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 773 return 774 } 775 776 + s.notifier.NewPullComment(r.Context(), comment, mentions) 777 778 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 779 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 780 return 781 } 782 } ··· 1874 record := pull.AsRecord() 1875 record.PatchBlob = blob.Blob 1876 record.CreatedAt = time.Now().Format(time.RFC3339) 1877 + record.Source.Sha = newSourceRev 1878 1879 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1880 Collection: tangled.RepoPullNSID,
+64 -19
appview/repo/archive.go
··· 2 3 import ( 4 "fmt" 5 "net/http" 6 "net/url" 7 "strings" 8 9 - "tangled.org/core/api/tangled" 10 - xrpcclient "tangled.org/core/appview/xrpcclient" 11 - 12 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 "github.com/go-chi/chi/v5" 14 - "github.com/go-git/go-git/v5/plumbing" 15 ) 16 17 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { ··· 29 scheme = "https" 30 } 31 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 32 - xrpcc := &indigoxrpc.Client{ 33 - Host: host, 34 - } 35 didSlashRepo := f.DidSlashRepo() 36 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 37 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 38 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 39 rp.pages.Error503(w) 40 return 41 } 42 - // Set headers for file download, just pass along whatever the knot specifies 43 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 44 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 45 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 46 - w.Header().Set("Content-Type", "application/gzip") 47 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 48 - // Write the archive data directly 49 - w.Write(archiveBytes) 50 }
··· 2 3 import ( 4 "fmt" 5 + "io" 6 "net/http" 7 "net/url" 8 "strings" 9 10 "github.com/go-chi/chi/v5" 11 ) 12 13 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { ··· 25 scheme = "https" 26 } 27 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 28 didSlashRepo := f.DidSlashRepo() 29 + 30 + // build the xrpc url 31 + u, err := url.Parse(host) 32 + if err != nil { 33 + l.Error("failed to parse host URL", "err", err) 34 rp.pages.Error503(w) 35 return 36 } 37 + 38 + u.Path = "/xrpc/sh.tangled.repo.archive" 39 + query := url.Values{} 40 + query.Set("format", "tar.gz") 41 + query.Set("prefix", r.URL.Query().Get("prefix")) 42 + query.Set("ref", ref) 43 + query.Set("repo", didSlashRepo) 44 + u.RawQuery = query.Encode() 45 + 46 + xrpcURL := u.String() 47 + 48 + // make the get request 49 + resp, err := http.Get(xrpcURL) 50 + if err != nil { 51 + l.Error("failed to call XRPC repo.archive", "err", err) 52 + rp.pages.Error503(w) 53 + return 54 + } 55 + 56 + // pass through headers from upstream response 57 + if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" { 58 + w.Header().Set("Content-Disposition", contentDisposition) 59 + } 60 + if contentType := resp.Header.Get("Content-Type"); contentType != "" { 61 + w.Header().Set("Content-Type", contentType) 62 + } 63 + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { 64 + w.Header().Set("Content-Length", contentLength) 65 + } 66 + if link := resp.Header.Get("Link"); link != "" { 67 + if resolvedRef, err := extractImmutableLink(link); err == nil { 68 + newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"", 69 + rp.config.Core.AppviewHost, f.DidSlashRepo(), resolvedRef) 70 + w.Header().Set("Link", newLink) 71 + } 72 + } 73 + 74 + // stream the archive data directly 75 + if _, err := io.Copy(w, resp.Body); err != nil { 76 + l.Error("failed to write response", "err", err) 77 + } 78 + } 79 + 80 + func extractImmutableLink(linkHeader string) (string, error) { 81 + trimmed := strings.TrimPrefix(linkHeader, "<") 82 + trimmed = strings.TrimSuffix(trimmed, ">; rel=\"immutable\"") 83 + 84 + parsedLink, err := url.Parse(trimmed) 85 + if err != nil { 86 + return "", err 87 + } 88 + 89 + resolvedRef := parsedLink.Query().Get("ref") 90 + if resolvedRef == "" { 91 + return "", fmt.Errorf("no ref found in link header") 92 + } 93 + 94 + return resolvedRef, nil 95 }
+1 -1
appview/state/knotstream.go
··· 122 if ce == nil { 123 continue 124 } 125 - if ce.Email == ke.Address { 126 count += int(ce.Count) 127 } 128 }
··· 122 if ce == nil { 123 continue 124 } 125 + if ce.Email == ke.Address || ce.Email == record.CommitterDid { 126 count += int(ce.Count) 127 } 128 }
-11
appview/state/profile.go
··· 162 l.Error("failed to create timeline", "err", err) 163 } 164 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 - 176 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 177 LoggedInUser: s.oauth.GetMultiAccountUser(r), 178 Card: profile,
··· 162 l.Error("failed to create timeline", "err", err) 163 } 164 165 s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 LoggedInUser: s.oauth.GetMultiAccountUser(r), 167 Card: profile,
-1
appview/state/state.go
··· 118 tangled.StringNSID, 119 tangled.RepoIssueNSID, 120 tangled.RepoIssueCommentNSID, 121 - tangled.CommentNSID, 122 tangled.LabelDefinitionNSID, 123 tangled.LabelOpNSID, 124 },
··· 118 tangled.StringNSID, 119 tangled.RepoIssueNSID, 120 tangled.RepoIssueCommentNSID, 121 tangled.LabelDefinitionNSID, 122 tangled.LabelOpNSID, 123 },
+27
appview/validator/issue.go
··· 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 == "" {
··· 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 == "" {
-1
cmd/cborgen/cborgen.go
··· 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 tangled.ActorProfile{}, 18 - tangled.Comment{}, 19 tangled.FeedReaction{}, 20 tangled.FeedStar{}, 21 tangled.GitRefUpdate{},
··· 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 tangled.ActorProfile{}, 18 tangled.FeedReaction{}, 19 tangled.FeedStar{}, 20 tangled.GitRefUpdate{},
+23 -10
cmd/dolly/main.go
··· 2 3 import ( 4 "bytes" 5 "flag" 6 "fmt" 7 "image" ··· 16 "github.com/srwiley/oksvg" 17 "github.com/srwiley/rasterx" 18 "golang.org/x/image/draw" 19 - "tangled.org/core/appview/pages" 20 "tangled.org/core/ico" 21 ) 22 23 func main() { 24 var ( 25 - size string 26 - fillColor string 27 - output string 28 ) 29 30 flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)") 31 flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 32 flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 33 flag.Parse() 34 35 width, height, err := parseSize(size) 36 if err != nil { ··· 52 os.Exit(1) 53 } 54 55 - svgData, err := dolly(fillColor) 56 if err != nil { 57 fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 58 os.Exit(1) ··· 84 fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 85 } 86 87 - func dolly(hexColor string) ([]byte, error) { 88 - tpl, err := template.New("dolly"). 89 - ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 90 if err != nil { 91 return nil, err 92 } 93 94 var svgData bytes.Buffer 95 - if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", pages.DollyParams{ 96 - FillColor: hexColor, 97 }); err != nil { 98 return nil, err 99 }
··· 2 3 import ( 4 "bytes" 5 + _ "embed" 6 "flag" 7 "fmt" 8 "image" ··· 17 "github.com/srwiley/oksvg" 18 "github.com/srwiley/rasterx" 19 "golang.org/x/image/draw" 20 "tangled.org/core/ico" 21 ) 22 23 func main() { 24 var ( 25 + size string 26 + fillColor string 27 + output string 28 + templatePath string 29 ) 30 31 + flag.StringVar(&templatePath, "template", "", "Path to dolly go-html template") 32 flag.StringVar(&size, "size", "512x512", "Output size in format WIDTHxHEIGHT (e.g., 512x512)") 33 flag.StringVar(&fillColor, "color", "#000000", "Fill color in hex format (e.g., #FF5733)") 34 flag.StringVar(&output, "output", "dolly.svg", "Output file path (format detected from extension: .svg, .png, or .ico)") 35 flag.Parse() 36 + 37 + if templatePath == "" { 38 + fmt.Fprintf(os.Stderr, "Empty template path") 39 + os.Exit(1) 40 + } 41 42 width, height, err := parseSize(size) 43 if err != nil { ··· 59 os.Exit(1) 60 } 61 62 + tpl, err := os.ReadFile(templatePath) 63 + if err != nil { 64 + fmt.Fprintf(os.Stderr, "Failed to read template from path %s: %v\n", templatePath, err) 65 + os.Exit(1) 66 + } 67 + 68 + svgData, err := dolly(string(tpl), fillColor) 69 if err != nil { 70 fmt.Fprintf(os.Stderr, "Error generating SVG: %v\n", err) 71 os.Exit(1) ··· 97 fmt.Printf("Successfully generated %s (%dx%d)\n", output, width, height) 98 } 99 100 + func dolly(tplString, hexColor string) ([]byte, error) { 101 + tpl, err := template.New("dolly").Parse(tplString) 102 if err != nil { 103 return nil, err 104 } 105 106 var svgData bytes.Buffer 107 + if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", map[string]any{ 108 + "FillColor": hexColor, 109 + "Classes": "", 110 }); err != nil { 111 return nil, err 112 }
+36 -3
docs/DOCS.md
··· 375 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 ``` 377 378 - If you run a Linux distribution that uses systemd, you can use the provided 379 - service file to run the server. Copy 380 - [`knotserver.service`](/systemd/knotserver.service) 381 to `/etc/systemd/system/`. Then, run: 382 383 ``` ··· 692 NODE_ENV: "production" 693 MY_ENV_VAR: "MY_ENV_VALUE" 694 ``` 695 696 ### Steps 697
··· 375 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 376 ``` 377 378 + If you run a Linux distribution that uses systemd, you can 379 + use the provided service file to run the server. Copy 380 + [`knotserver.service`](https://tangled.org/tangled.org/core/blob/master/systemd/knotserver.service) 381 to `/etc/systemd/system/`. Then, run: 382 383 ``` ··· 692 NODE_ENV: "production" 693 MY_ENV_VAR: "MY_ENV_VALUE" 694 ``` 695 + 696 + By default, the following environment variables set: 697 + 698 + - `CI` - Always set to `true` to indicate a CI environment 699 + - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline 700 + - `TANGLED_REPO_KNOT` - The repository's knot hostname 701 + - `TANGLED_REPO_DID` - The DID of the repository owner 702 + - `TANGLED_REPO_NAME` - The name of the repository 703 + - `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the 704 + repository 705 + - `TANGLED_REPO_URL` - The full URL to the repository 706 + 707 + These variables are only available when the pipeline is 708 + triggered by a push: 709 + 710 + - `TANGLED_REF` - The full git reference (e.g., 711 + `refs/heads/main` or `refs/tags/v1.0.0`) 712 + - `TANGLED_REF_NAME` - The short name of the reference 713 + (e.g., `main` or `v1.0.0`) 714 + - `TANGLED_REF_TYPE` - The type of reference, either 715 + `branch` or `tag` 716 + - `TANGLED_SHA` - The commit SHA that triggered the pipeline 717 + - `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA` 718 + 719 + These variables are only available when the pipeline is 720 + triggered by a pull request: 721 + 722 + - `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull 723 + request 724 + - `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull 725 + request 726 + - `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source 727 + branch 728 729 ### Steps 730
+1 -1
input.css
··· 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 97 } 98 textarea { 99 - @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 100 } 101 details summary::-webkit-details-marker { 102 display: none;
··· 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 97 } 98 textarea { 99 + @apply border border-gray-400 block rounded bg-gray-50 focus:outline-none focus:ring-1 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 100 } 101 details summary::-webkit-details-marker { 102 display: none;
+4
knotserver/git/git.go
··· 76 return &g, nil 77 } 78 79 // re-open a repository and update references 80 func (g *GitRepo) Refresh() error { 81 refreshed, err := PlainOpen(g.path)
··· 76 return &g, nil 77 } 78 79 + func (g *GitRepo) Hash() plumbing.Hash { 80 + return g.h 81 + } 82 + 83 // re-open a repository and update references 84 func (g *GitRepo) Refresh() error { 85 refreshed, err := PlainOpen(g.path)
+35
knotserver/xrpc/repo_archive.go
··· 4 "compress/gzip" 5 "fmt" 6 "net/http" 7 "strings" 8 9 "github.com/go-git/go-git/v5/plumbing" 10 11 "tangled.org/core/knotserver/git" 12 xrpcerr "tangled.org/core/xrpc/errors" 13 ) ··· 47 repoParts := strings.Split(repo, "/") 48 repoName := repoParts[len(repoParts)-1] 49 50 safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 51 52 var archivePrefix string ··· 59 filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 60 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 61 w.Header().Set("Content-Type", "application/gzip") 62 63 gw := gzip.NewWriter(w) 64 defer gw.Close() ··· 79 return 80 } 81 }
··· 4 "compress/gzip" 5 "fmt" 6 "net/http" 7 + "net/url" 8 "strings" 9 10 "github.com/go-git/go-git/v5/plumbing" 11 12 + "tangled.org/core/api/tangled" 13 "tangled.org/core/knotserver/git" 14 xrpcerr "tangled.org/core/xrpc/errors" 15 ) ··· 49 repoParts := strings.Split(repo, "/") 50 repoName := repoParts[len(repoParts)-1] 51 52 + immutableLink, err := x.buildImmutableLink(repo, format, gr.Hash().String(), prefix) 53 + if err != nil { 54 + x.Logger.Error( 55 + "failed to build immutable link", 56 + "err", err.Error(), 57 + "repo", repo, 58 + "format", format, 59 + "ref", gr.Hash().String(), 60 + "prefix", prefix, 61 + ) 62 + } 63 + 64 safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 65 66 var archivePrefix string ··· 73 filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 74 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 75 w.Header().Set("Content-Type", "application/gzip") 76 + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 77 78 gw := gzip.NewWriter(w) 79 defer gw.Close() ··· 94 return 95 } 96 } 97 + 98 + func (x *Xrpc) buildImmutableLink(repo string, format string, ref string, prefix string) (string, error) { 99 + scheme := "https" 100 + if x.Config.Server.Dev { 101 + scheme = "http" 102 + } 103 + 104 + u, err := url.Parse(scheme + "://" + x.Config.Server.Hostname + "/xrpc/" + tangled.RepoArchiveNSID) 105 + if err != nil { 106 + return "", err 107 + } 108 + 109 + params := url.Values{} 110 + params.Set("repo", repo) 111 + params.Set("format", format) 112 + params.Set("ref", ref) 113 + params.Set("prefix", prefix) 114 + 115 + return fmt.Sprintf("%s?%s", u.String(), params.Encode()), nil 116 + }
-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 - }
···
+25 -18
nix/pkgs/dolly.nix
··· 1 { 2 buildGoApplication, 3 modules, 4 - src, 5 - }: 6 - buildGoApplication { 7 - pname = "dolly"; 8 - version = "0.1.0"; 9 - inherit src modules; 10 - 11 - # patch the static dir 12 - postUnpack = '' 13 - pushd source 14 - mkdir -p appview/pages/static 15 - touch appview/pages/static/x 16 - popd 17 - ''; 18 - 19 - doCheck = false; 20 - subPackages = ["cmd/dolly"]; 21 - }
··· 1 { 2 + lib, 3 buildGoApplication, 4 modules, 5 + writeShellScriptBin, 6 + }: let 7 + src = lib.fileset.toSource { 8 + root = ../..; 9 + fileset = lib.fileset.unions [ 10 + ../../go.mod 11 + ../../ico 12 + ../../cmd/dolly/main.go 13 + ../../appview/pages/templates/fragments/dolly/logo.html 14 + ]; 15 + }; 16 + dolly-unwrapped = buildGoApplication { 17 + pname = "dolly-unwrapped"; 18 + version = "0.1.0"; 19 + inherit src modules; 20 + doCheck = false; 21 + subPackages = ["cmd/dolly"]; 22 + }; 23 + in 24 + writeShellScriptBin "dolly" '' 25 + exec ${dolly-unwrapped}/bin/dolly \ 26 + -template ${src}/appview/pages/templates/fragments/dolly/logo.html \ 27 + "$@" 28 + ''