this repo has no description

Compare changes

Choose any two refs to compare.

-416
api/tangled/cbor_gen.go
··· 561 561 562 562 return nil 563 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 564 func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 981 565 if t == nil { 982 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 - }
-199
appview/db/comments.go
··· 1 - package db 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "maps" 7 - "slices" 8 - "sort" 9 - "strings" 10 - "time" 11 - 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/appview/models" 14 - "tangled.org/core/orm" 15 - ) 16 - 17 - func PutComment(tx *sql.Tx, c *models.Comment) error { 18 - result, err := tx.Exec( 19 - `insert into comments ( 20 - did, 21 - rkey, 22 - subject_at, 23 - reply_to, 24 - body, 25 - pull_submission_id, 26 - created 27 - ) 28 - values (?, ?, ?, ?, ?, ?, ?) 29 - on conflict(did, rkey) do update set 30 - subject_at = excluded.subject_at, 31 - reply_to = excluded.reply_to, 32 - body = excluded.body, 33 - edited = case 34 - when 35 - comments.subject_at != excluded.subject_at 36 - or comments.body != excluded.body 37 - or comments.reply_to != excluded.reply_to 38 - then ? 39 - else comments.edited 40 - end`, 41 - c.Did, 42 - c.Rkey, 43 - c.Subject, 44 - c.ReplyTo, 45 - c.Body, 46 - c.PullSubmissionId, 47 - c.Created.Format(time.RFC3339), 48 - time.Now().Format(time.RFC3339), 49 - ) 50 - if err != nil { 51 - return err 52 - } 53 - 54 - c.Id, err = result.LastInsertId() 55 - if err != nil { 56 - return err 57 - } 58 - 59 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 60 - return fmt.Errorf("put reference_links: %w", err) 61 - } 62 - 63 - return nil 64 - } 65 - 66 - func DeleteComments(e Execer, filters ...orm.Filter) error { 67 - var conditions []string 68 - var args []any 69 - for _, filter := range filters { 70 - conditions = append(conditions, filter.Condition()) 71 - args = append(args, filter.Arg()...) 72 - } 73 - 74 - whereClause := "" 75 - if conditions != nil { 76 - whereClause = " where " + strings.Join(conditions, " and ") 77 - } 78 - 79 - query := fmt.Sprintf(`update comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 80 - 81 - _, err := e.Exec(query, args...) 82 - return err 83 - } 84 - 85 - func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 86 - commentMap := make(map[string]*models.Comment) 87 - 88 - var conditions []string 89 - var args []any 90 - for _, filter := range filters { 91 - conditions = append(conditions, filter.Condition()) 92 - args = append(args, filter.Arg()...) 93 - } 94 - 95 - whereClause := "" 96 - if conditions != nil { 97 - whereClause = " where " + strings.Join(conditions, " and ") 98 - } 99 - 100 - query := fmt.Sprintf(` 101 - select 102 - id, 103 - did, 104 - rkey, 105 - subject_at, 106 - reply_to, 107 - body, 108 - pull_submission_id, 109 - created, 110 - edited, 111 - deleted 112 - from 113 - comments 114 - %s 115 - `, whereClause) 116 - 117 - rows, err := e.Query(query, args...) 118 - if err != nil { 119 - return nil, err 120 - } 121 - 122 - for rows.Next() { 123 - var comment models.Comment 124 - var created string 125 - var rkey, edited, deleted, replyTo sql.Null[string] 126 - err := rows.Scan( 127 - &comment.Id, 128 - &comment.Did, 129 - &rkey, 130 - &comment.Subject, 131 - &replyTo, 132 - &comment.Body, 133 - &comment.PullSubmissionId, 134 - &created, 135 - &edited, 136 - &deleted, 137 - ) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - // this is a remnant from old times, newer comments always have rkey 143 - if rkey.Valid { 144 - comment.Rkey = rkey.V 145 - } 146 - 147 - if t, err := time.Parse(time.RFC3339, created); err == nil { 148 - comment.Created = t 149 - } 150 - 151 - if edited.Valid { 152 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 153 - comment.Edited = &t 154 - } 155 - } 156 - 157 - if deleted.Valid { 158 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 159 - comment.Deleted = &t 160 - } 161 - } 162 - 163 - if replyTo.Valid { 164 - rt := syntax.ATURI(replyTo.V) 165 - comment.ReplyTo = &rt 166 - } 167 - 168 - atUri := comment.AtUri().String() 169 - commentMap[atUri] = &comment 170 - } 171 - 172 - if err := rows.Err(); err != nil { 173 - return nil, err 174 - } 175 - defer rows.Close() 176 - 177 - // collect references from each comments 178 - commentAts := slices.Collect(maps.Keys(commentMap)) 179 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 180 - if err != nil { 181 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 182 - } 183 - for commentAt, references := range allReferencs { 184 - if comment, ok := commentMap[commentAt.String()]; ok { 185 - comment.References = references 186 - } 187 - } 188 - 189 - var comments []models.Comment 190 - for _, c := range commentMap { 191 - comments = append(comments, *c) 192 - } 193 - 194 - sort.Slice(comments, func(i, j int) bool { 195 - return comments[i].Created.After(comments[j].Created) 196 - }) 197 - 198 - return comments, nil 199 - }
-81
appview/db/db.go
··· 1173 1173 return err 1174 1174 }) 1175 1175 1176 - orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1177 - _, err := tx.Exec(` 1178 - drop table if exists comments; 1179 - 1180 - create table comments ( 1181 - -- identifiers 1182 - id integer primary key autoincrement, 1183 - did text not null, 1184 - collection text not null default 'sh.tangled.comment', 1185 - rkey text not null, 1186 - at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, 1187 - 1188 - -- at identifiers 1189 - subject_at text not null, 1190 - reply_to text, -- at_uri of parent comment 1191 - 1192 - pull_submission_id integer, -- dirty fix until we atprotate the pull-rounds 1193 - 1194 - -- content 1195 - body text not null, 1196 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1197 - edited text, 1198 - deleted text, 1199 - 1200 - -- constraints 1201 - unique(did, rkey) 1202 - ); 1203 - 1204 - insert into comments ( 1205 - did, 1206 - collection, 1207 - rkey, 1208 - subject_at, 1209 - reply_to, 1210 - body, 1211 - created, 1212 - edited, 1213 - deleted 1214 - ) 1215 - select 1216 - did, 1217 - 'sh.tangled.repo.issue.comment', 1218 - rkey, 1219 - issue_at, 1220 - reply_to, 1221 - body, 1222 - created, 1223 - edited, 1224 - deleted 1225 - from issue_comments 1226 - where rkey is not null; 1227 - 1228 - insert into comments ( 1229 - did, 1230 - collection, 1231 - rkey, 1232 - subject_at, 1233 - pull_submission_id, 1234 - body, 1235 - created 1236 - ) 1237 - select 1238 - c.owner_did, 1239 - 'sh.tangled.repo.pull.comment', 1240 - substr( 1241 - substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1242 - instr( 1243 - substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1244 - '/' 1245 - ) + 1 1246 - ), -- rkey 1247 - p.at_uri, 1248 - c.submission_id, 1249 - c.body, 1250 - c.created 1251 - from pull_comments c 1252 - join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id; 1253 - `) 1254 - return err 1255 - }) 1256 - 1257 1176 return &DB{ 1258 1177 db, 1259 1178 logger,
+186 -6
appview/db/issues.go
··· 100 100 } 101 101 102 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 103 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 104 104 105 105 var conditions []string 106 106 var args []any ··· 196 196 } 197 197 } 198 198 199 - issueMap[issue.AtUri()] = &issue 199 + atUri := issue.AtUri().String() 200 + issueMap[atUri] = &issue 200 201 } 201 202 202 203 // collect reverse repos ··· 228 229 // collect comments 229 230 issueAts := slices.Collect(maps.Keys(issueMap)) 230 231 231 - comments, err := GetComments(e, orm.FilterIn("subject_at", issueAts)) 232 + comments, err := GetIssueComments(e, orm.FilterIn("issue_at", issueAts)) 232 233 if err != nil { 233 234 return nil, fmt.Errorf("failed to query comments: %w", err) 234 235 } 235 236 for i := range comments { 236 - issueAt := comments[i].Subject 237 + issueAt := comments[i].IssueAt 237 238 if issue, ok := issueMap[issueAt]; ok { 238 239 issue.Comments = append(issue.Comments, comments[i]) 239 240 } ··· 245 246 return nil, fmt.Errorf("failed to query labels: %w", err) 246 247 } 247 248 for issueAt, labels := range allLabels { 248 - if issue, ok := issueMap[issueAt]; ok { 249 + if issue, ok := issueMap[issueAt.String()]; ok { 249 250 issue.Labels = labels 250 251 } 251 252 } ··· 256 257 return nil, fmt.Errorf("failed to query reference_links: %w", err) 257 258 } 258 259 for issueAt, references := range allReferencs { 259 - if issue, ok := issueMap[issueAt]; ok { 260 + if issue, ok := issueMap[issueAt.String()]; ok { 260 261 issue.References = references 261 262 } 262 263 } ··· 348 349 } 349 350 350 351 return ids, nil 352 + } 353 + 354 + func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 355 + result, err := tx.Exec( 356 + `insert into issue_comments ( 357 + did, 358 + rkey, 359 + issue_at, 360 + body, 361 + reply_to, 362 + created, 363 + edited 364 + ) 365 + values (?, ?, ?, ?, ?, ?, null) 366 + on conflict(did, rkey) do update set 367 + issue_at = excluded.issue_at, 368 + body = excluded.body, 369 + edited = case 370 + when 371 + issue_comments.issue_at != excluded.issue_at 372 + or issue_comments.body != excluded.body 373 + or issue_comments.reply_to != excluded.reply_to 374 + then ? 375 + else issue_comments.edited 376 + end`, 377 + c.Did, 378 + c.Rkey, 379 + c.IssueAt, 380 + c.Body, 381 + c.ReplyTo, 382 + c.Created.Format(time.RFC3339), 383 + time.Now().Format(time.RFC3339), 384 + ) 385 + if err != nil { 386 + return 0, err 387 + } 388 + 389 + id, err := result.LastInsertId() 390 + if err != nil { 391 + return 0, err 392 + } 393 + 394 + if err := putReferences(tx, c.AtUri(), c.References); err != nil { 395 + return 0, fmt.Errorf("put reference_links: %w", err) 396 + } 397 + 398 + return id, nil 399 + } 400 + 401 + func DeleteIssueComments(e Execer, filters ...orm.Filter) error { 402 + var conditions []string 403 + var args []any 404 + for _, filter := range filters { 405 + conditions = append(conditions, filter.Condition()) 406 + args = append(args, filter.Arg()...) 407 + } 408 + 409 + whereClause := "" 410 + if conditions != nil { 411 + whereClause = " where " + strings.Join(conditions, " and ") 412 + } 413 + 414 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 415 + 416 + _, err := e.Exec(query, args...) 417 + return err 418 + } 419 + 420 + func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) { 421 + commentMap := make(map[string]*models.IssueComment) 422 + 423 + var conditions []string 424 + var args []any 425 + for _, filter := range filters { 426 + conditions = append(conditions, filter.Condition()) 427 + args = append(args, filter.Arg()...) 428 + } 429 + 430 + whereClause := "" 431 + if conditions != nil { 432 + whereClause = " where " + strings.Join(conditions, " and ") 433 + } 434 + 435 + query := fmt.Sprintf(` 436 + select 437 + id, 438 + did, 439 + rkey, 440 + issue_at, 441 + reply_to, 442 + body, 443 + created, 444 + edited, 445 + deleted 446 + from 447 + issue_comments 448 + %s 449 + `, whereClause) 450 + 451 + rows, err := e.Query(query, args...) 452 + if err != nil { 453 + return nil, err 454 + } 455 + defer rows.Close() 456 + 457 + for rows.Next() { 458 + var comment models.IssueComment 459 + var created string 460 + var rkey, edited, deleted, replyTo sql.Null[string] 461 + err := rows.Scan( 462 + &comment.Id, 463 + &comment.Did, 464 + &rkey, 465 + &comment.IssueAt, 466 + &replyTo, 467 + &comment.Body, 468 + &created, 469 + &edited, 470 + &deleted, 471 + ) 472 + if err != nil { 473 + return nil, err 474 + } 475 + 476 + // this is a remnant from old times, newer comments always have rkey 477 + if rkey.Valid { 478 + comment.Rkey = rkey.V 479 + } 480 + 481 + if t, err := time.Parse(time.RFC3339, created); err == nil { 482 + comment.Created = t 483 + } 484 + 485 + if edited.Valid { 486 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 487 + comment.Edited = &t 488 + } 489 + } 490 + 491 + if deleted.Valid { 492 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 493 + comment.Deleted = &t 494 + } 495 + } 496 + 497 + if replyTo.Valid { 498 + comment.ReplyTo = &replyTo.V 499 + } 500 + 501 + atUri := comment.AtUri().String() 502 + commentMap[atUri] = &comment 503 + } 504 + 505 + if err = rows.Err(); err != nil { 506 + return nil, err 507 + } 508 + 509 + // collect references for each comments 510 + commentAts := slices.Collect(maps.Keys(commentMap)) 511 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 512 + if err != nil { 513 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 514 + } 515 + for commentAt, references := range allReferencs { 516 + if comment, ok := commentMap[commentAt.String()]; ok { 517 + comment.References = references 518 + } 519 + } 520 + 521 + var comments []models.IssueComment 522 + for _, c := range commentMap { 523 + comments = append(comments, *c) 524 + } 525 + 526 + sort.Slice(comments, func(i, j int) bool { 527 + return comments[i].Created.After(comments[j].Created) 528 + }) 529 + 530 + return comments, nil 351 531 } 352 532 353 533 func DeleteIssues(tx *sql.Tx, did, rkey string) error {
+121 -6
appview/db/pulls.go
··· 447 447 return nil, err 448 448 } 449 449 450 - // Get comments for all submissions using GetComments 450 + // Get comments for all submissions using GetPullComments 451 451 submissionIds := slices.Collect(maps.Keys(submissionMap)) 452 - comments, err := GetComments(e, orm.FilterIn("pull_submission_id", submissionIds)) 452 + comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 453 453 if err != nil { 454 454 return nil, fmt.Errorf("failed to get pull comments: %w", err) 455 455 } 456 456 for _, comment := range comments { 457 - if comment.PullSubmissionId != nil { 458 - if submission, ok := submissionMap[*comment.PullSubmissionId]; ok { 459 - submission.Comments = append(submission.Comments, comment) 460 - } 457 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 458 + submission.Comments = append(submission.Comments, comment) 461 459 } 462 460 } 463 461 ··· 477 475 return m, nil 478 476 } 479 477 478 + func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 479 + var conditions []string 480 + var args []any 481 + for _, filter := range filters { 482 + conditions = append(conditions, filter.Condition()) 483 + args = append(args, filter.Arg()...) 484 + } 485 + 486 + whereClause := "" 487 + if conditions != nil { 488 + whereClause = " where " + strings.Join(conditions, " and ") 489 + } 490 + 491 + query := fmt.Sprintf(` 492 + select 493 + id, 494 + pull_id, 495 + submission_id, 496 + repo_at, 497 + owner_did, 498 + comment_at, 499 + body, 500 + created 501 + from 502 + pull_comments 503 + %s 504 + order by 505 + created asc 506 + `, whereClause) 507 + 508 + rows, err := e.Query(query, args...) 509 + if err != nil { 510 + return nil, err 511 + } 512 + defer rows.Close() 513 + 514 + commentMap := make(map[string]*models.PullComment) 515 + for rows.Next() { 516 + var comment models.PullComment 517 + var createdAt string 518 + err := rows.Scan( 519 + &comment.ID, 520 + &comment.PullId, 521 + &comment.SubmissionId, 522 + &comment.RepoAt, 523 + &comment.OwnerDid, 524 + &comment.CommentAt, 525 + &comment.Body, 526 + &createdAt, 527 + ) 528 + if err != nil { 529 + return nil, err 530 + } 531 + 532 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 533 + comment.Created = t 534 + } 535 + 536 + atUri := comment.AtUri().String() 537 + commentMap[atUri] = &comment 538 + } 539 + 540 + if err := rows.Err(); err != nil { 541 + return nil, err 542 + } 543 + 544 + // collect references for each comments 545 + commentAts := slices.Collect(maps.Keys(commentMap)) 546 + allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 547 + if err != nil { 548 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 549 + } 550 + for commentAt, references := range allReferencs { 551 + if comment, ok := commentMap[commentAt.String()]; ok { 552 + comment.References = references 553 + } 554 + } 555 + 556 + var comments []models.PullComment 557 + for _, c := range commentMap { 558 + comments = append(comments, *c) 559 + } 560 + 561 + sort.Slice(comments, func(i, j int) bool { 562 + return comments[i].Created.Before(comments[j].Created) 563 + }) 564 + 565 + return comments, nil 566 + } 567 + 480 568 // timeframe here is directly passed into the sql query filter, and any 481 569 // timeframe in the past should be negative; e.g.: "-3 months" 482 570 func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { ··· 551 639 } 552 640 553 641 return pulls, nil 642 + } 643 + 644 + func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 645 + query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 646 + res, err := tx.Exec( 647 + query, 648 + comment.OwnerDid, 649 + comment.RepoAt, 650 + comment.SubmissionId, 651 + comment.CommentAt, 652 + comment.PullId, 653 + comment.Body, 654 + ) 655 + if err != nil { 656 + return 0, err 657 + } 658 + 659 + i, err := res.LastInsertId() 660 + if err != nil { 661 + return 0, err 662 + } 663 + 664 + if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 665 + return 0, fmt.Errorf("put reference_links: %w", err) 666 + } 667 + 668 + return i, nil 554 669 } 555 670 556 671 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
+32 -20
appview/db/reference.go
··· 11 11 "tangled.org/core/orm" 12 12 ) 13 13 14 - // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 14 + // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 15 15 // It will ignore missing refLinks. 16 16 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 17 17 var ( ··· 53 53 values %s 54 54 ) 55 55 select 56 - i.at_uri, c.at_uri 56 + i.did, i.rkey, 57 + c.did, c.rkey 57 58 from input inp 58 59 join repos r 59 60 on r.did = inp.owner_did ··· 61 62 join issues i 62 63 on i.repo_at = r.at_uri 63 64 and i.issue_id = inp.issue_id 64 - left join comments c 65 + left join issue_comments c 65 66 on inp.comment_id is not null 66 - and c.subject_at = i.at_uri 67 + and c.issue_at = i.at_uri 67 68 and c.id = inp.comment_id 68 69 `, 69 70 strings.Join(vals, ","), ··· 78 79 79 80 for rows.Next() { 80 81 // Scan rows 81 - var issueUri string 82 - var commentUri sql.NullString 82 + var issueOwner, issueRkey string 83 + var commentOwner, commentRkey sql.NullString 83 84 var uri syntax.ATURI 84 - if err := rows.Scan(&issueUri, &commentUri); err != nil { 85 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 85 86 return nil, err 86 87 } 87 - if commentUri.Valid { 88 - uri = syntax.ATURI(commentUri.String) 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 + )) 89 95 } else { 90 - uri = syntax.ATURI(issueUri) 96 + uri = syntax.ATURI(fmt.Sprintf( 97 + "at://%s/%s/%s", 98 + issueOwner, 99 + tangled.RepoIssueNSID, 100 + issueRkey, 101 + )) 91 102 } 92 103 uris = append(uris, uri) 93 104 } ··· 113 124 values %s 114 125 ) 115 126 select 116 - p.owner_did, p.rkey, c.at_uri 127 + p.owner_did, p.rkey, 128 + c.comment_at 117 129 from input inp 118 130 join repos r 119 131 on r.did = inp.owner_did ··· 121 133 join pulls p 122 134 on p.repo_at = r.at_uri 123 135 and p.pull_id = inp.pull_id 124 - left join comments c 136 + left join pull_comments c 125 137 on inp.comment_id is not null 126 - and c.subject_at = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) 138 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 127 139 and c.id = inp.comment_id 128 140 `, 129 141 strings.Join(vals, ","), ··· 271 283 return nil, fmt.Errorf("get issue backlinks: %w", err) 272 284 } 273 285 backlinks = append(backlinks, ls...) 274 - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 286 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 275 287 if err != nil { 276 288 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 277 289 } ··· 281 293 return nil, fmt.Errorf("get pull backlinks: %w", err) 282 294 } 283 295 backlinks = append(backlinks, ls...) 284 - ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 296 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 285 297 if err != nil { 286 298 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 287 299 } ··· 340 352 rows, err := e.Query( 341 353 fmt.Sprintf( 342 354 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 343 - from comments c 355 + from issue_comments c 344 356 join issues i 345 - on i.at_uri = c.subject_at 357 + on i.at_uri = c.issue_at 346 358 join repos r 347 359 on r.at_uri = i.repo_at 348 360 where %s`, ··· 416 428 if len(aturis) == 0 { 417 429 return nil, nil 418 430 } 419 - filter := orm.FilterIn("c.at_uri", aturis) 431 + filter := orm.FilterIn("c.comment_at", aturis) 420 432 rows, err := e.Query( 421 433 fmt.Sprintf( 422 434 `select r.did, r.name, p.pull_id, c.id, p.title, p.state 423 435 from repos r 424 436 join pulls p 425 437 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 438 + join pull_comments c 439 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 428 440 where %s`, 429 441 filter.Condition(), 430 442 ),
+11 -19
appview/ingester.go
··· 79 79 err = i.ingestString(e) 80 80 case tangled.RepoIssueNSID: 81 81 err = i.ingestIssue(ctx, e) 82 - case tangled.CommentNSID: 83 - err = i.ingestComment(e) 82 + case tangled.RepoIssueCommentNSID: 83 + err = i.ingestIssueComment(e) 84 84 case tangled.LabelDefinitionNSID: 85 85 err = i.ingestLabelDefinition(e) 86 86 case tangled.LabelOpNSID: ··· 868 868 return nil 869 869 } 870 870 871 - func (i *Ingester) ingestComment(e *jmodels.Event) error { 871 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 872 872 did := e.Did 873 873 rkey := e.Commit.RKey 874 874 875 875 var err error 876 876 877 - l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 877 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 878 878 l.Info("ingesting record") 879 879 880 880 ddb, ok := i.Db.Execer.(*db.DB) ··· 885 885 switch e.Commit.Operation { 886 886 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 887 887 raw := json.RawMessage(e.Commit.Record) 888 - record := tangled.Comment{} 888 + record := tangled.RepoIssueComment{} 889 889 err = json.Unmarshal(raw, &record) 890 890 if err != nil { 891 891 return fmt.Errorf("invalid record: %w", err) 892 892 } 893 893 894 - comment, err := models.CommentFromRecord(did, rkey, record) 894 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 895 895 if err != nil { 896 896 return fmt.Errorf("failed to parse comment from record: %w", err) 897 897 } 898 898 899 - // TODO: ingest pull comments 900 - // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. 901 - // so we cannot know which round this comment is pointing to 902 - if comment.Subject.Collection().String() == tangled.RepoPullNSID { 903 - l.Info("skip ingesting pull comments") 904 - return nil 905 - } 906 - 907 - if err := comment.Validate(); err != nil { 899 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 908 900 return fmt.Errorf("failed to validate comment: %w", err) 909 901 } 910 902 ··· 914 906 } 915 907 defer tx.Rollback() 916 908 917 - err = db.PutComment(tx, comment) 909 + _, err = db.AddIssueComment(tx, *comment) 918 910 if err != nil { 919 - return fmt.Errorf("failed to create comment: %w", err) 911 + return fmt.Errorf("failed to create issue comment: %w", err) 920 912 } 921 913 922 914 return tx.Commit() 923 915 924 916 case jmodels.CommitOperationDelete: 925 - if err := db.DeleteComments( 917 + if err := db.DeleteIssueComments( 926 918 ddb, 927 919 orm.FilterEq("did", did), 928 920 orm.FilterEq("rkey", rkey), 929 921 ); err != nil { 930 - return fmt.Errorf("failed to delete comment record: %w", err) 922 + return fmt.Errorf("failed to delete issue comment record: %w", err) 931 923 } 932 924 933 925 return nil
+29 -31
appview/issues/issues.go
··· 403 403 404 404 body := r.FormValue("body") 405 405 if body == "" { 406 - rp.pages.Notice(w, "issue-comment", "Body is required") 406 + rp.pages.Notice(w, "issue", "Body is required") 407 407 return 408 408 } 409 409 410 - var replyTo *syntax.ATURI 411 - replyToRaw := r.FormValue("reply-to") 412 - if replyToRaw != "" { 413 - aturi, err := syntax.ParseATURI(r.FormValue("reply-to")) 414 - if err != nil { 415 - rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 416 - return 417 - } 418 - replyTo = &aturi 410 + replyToUri := r.FormValue("reply-to") 411 + var replyTo *string 412 + if replyToUri != "" { 413 + replyTo = &replyToUri 419 414 } 420 415 421 416 mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) 422 417 423 - comment := models.Comment{ 424 - Did: syntax.DID(user.Did), 418 + comment := models.IssueComment{ 419 + Did: user.Did, 425 420 Rkey: tid.TID(), 426 - Subject: issue.AtUri(), 421 + IssueAt: issue.AtUri().String(), 427 422 ReplyTo: replyTo, 428 423 Body: body, 429 424 Created: time.Now(), 430 425 Mentions: mentions, 431 426 References: references, 432 427 } 433 - if err = comment.Validate(); err != nil { 428 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 434 429 l.Error("failed to validate comment", "err", err) 435 430 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 436 431 return ··· 446 441 447 442 // create a record first 448 443 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 449 - Collection: tangled.CommentNSID, 450 - Repo: user.Did, 444 + Collection: tangled.RepoIssueCommentNSID, 445 + Repo: comment.Did, 451 446 Rkey: comment.Rkey, 452 447 Record: &lexutil.LexiconTypeDecoder{ 453 448 Val: &record, ··· 473 468 } 474 469 defer tx.Rollback() 475 470 476 - err = db.PutComment(tx, &comment) 471 + commentId, err := db.AddIssueComment(tx, comment) 477 472 if err != nil { 478 473 l.Error("failed to create comment", "err", err) 479 474 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 489 484 // reset atUri to make rollback a no-op 490 485 atUri = "" 491 486 492 - rp.notifier.NewComment(r.Context(), &comment) 487 + // notify about the new comment 488 + comment.Id = commentId 489 + 490 + rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 493 491 494 492 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 495 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 493 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 496 494 } 497 495 498 496 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 507 505 } 508 506 509 507 commentId := chi.URLParam(r, "commentId") 510 - comments, err := db.GetComments( 508 + comments, err := db.GetIssueComments( 511 509 rp.db, 512 510 orm.FilterEq("id", commentId), 513 511 ) ··· 543 541 } 544 542 545 543 commentId := chi.URLParam(r, "commentId") 546 - comments, err := db.GetComments( 544 + comments, err := db.GetIssueComments( 547 545 rp.db, 548 546 orm.FilterEq("id", commentId), 549 547 ) ··· 559 557 } 560 558 comment := comments[0] 561 559 562 - if comment.Did.String() != user.Did { 560 + if comment.Did != user.Did { 563 561 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 564 562 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 565 563 return ··· 599 597 } 600 598 defer tx.Rollback() 601 599 602 - err = db.PutComment(tx, &newComment) 600 + _, err = db.AddIssueComment(tx, newComment) 603 601 if err != nil { 604 602 l.Error("failed to perferom update-description query", "err", err) 605 603 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 610 608 // rkey is optional, it was introduced later 611 609 if newComment.Rkey != "" { 612 610 // update the record on pds 613 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey) 611 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 614 612 if err != nil { 615 613 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 616 614 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 618 616 } 619 617 620 618 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 621 - Collection: tangled.CommentNSID, 619 + Collection: tangled.RepoIssueCommentNSID, 622 620 Repo: user.Did, 623 621 Rkey: newComment.Rkey, 624 622 SwapRecord: ex.Cid, ··· 653 651 } 654 652 655 653 commentId := chi.URLParam(r, "commentId") 656 - comments, err := db.GetComments( 654 + comments, err := db.GetIssueComments( 657 655 rp.db, 658 656 orm.FilterEq("id", commentId), 659 657 ) ··· 689 687 } 690 688 691 689 commentId := chi.URLParam(r, "commentId") 692 - comments, err := db.GetComments( 690 + comments, err := db.GetIssueComments( 693 691 rp.db, 694 692 orm.FilterEq("id", commentId), 695 693 ) ··· 725 723 } 726 724 727 725 commentId := chi.URLParam(r, "commentId") 728 - comments, err := db.GetComments( 726 + comments, err := db.GetIssueComments( 729 727 rp.db, 730 728 orm.FilterEq("id", commentId), 731 729 ) ··· 741 739 } 742 740 comment := comments[0] 743 741 744 - if comment.Did.String() != user.Did { 742 + if comment.Did != user.Did { 745 743 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 746 744 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 747 745 return ··· 754 752 755 753 // optimistic deletion 756 754 deleted := time.Now() 757 - err = db.DeleteComments(rp.db, orm.FilterEq("id", comment.Id)) 755 + err = db.DeleteIssueComments(rp.db, orm.FilterEq("id", comment.Id)) 758 756 if err != nil { 759 757 l.Error("failed to delete comment", "err", err) 760 758 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 770 768 return 771 769 } 772 770 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 773 - Collection: tangled.CommentNSID, 771 + Collection: tangled.RepoIssueCommentNSID, 774 772 Repo: user.Did, 775 773 Rkey: comment.Rkey, 776 774 })
+2 -2
appview/issues/opengraph.go
··· 193 193 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 194 194 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 195 195 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 196 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 196 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 197 197 if err != nil { 198 - log.Printf("dolly silhouette not available (this is ok): %v", err) 198 + log.Printf("dolly not available (this is ok): %v", err) 199 199 } 200 200 201 201 // Draw "opened by @author" and date at the bottom with more spacing
-117
appview/models/comment.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - "time" 7 - 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - "tangled.org/core/api/tangled" 10 - ) 11 - 12 - type Comment struct { 13 - Id int64 14 - Did syntax.DID 15 - Rkey string 16 - Subject syntax.ATURI 17 - ReplyTo *syntax.ATURI 18 - Body string 19 - Created time.Time 20 - Edited *time.Time 21 - Deleted *time.Time 22 - Mentions []syntax.DID 23 - References []syntax.ATURI 24 - PullSubmissionId *int 25 - } 26 - 27 - func (c *Comment) AtUri() syntax.ATURI { 28 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, tangled.CommentNSID, c.Rkey)) 29 - } 30 - 31 - func (c *Comment) AsRecord() tangled.Comment { 32 - mentions := make([]string, len(c.Mentions)) 33 - for i, did := range c.Mentions { 34 - mentions[i] = string(did) 35 - } 36 - references := make([]string, len(c.References)) 37 - for i, uri := range c.References { 38 - references[i] = string(uri) 39 - } 40 - var replyTo *string 41 - if c.ReplyTo != nil { 42 - replyToStr := c.ReplyTo.String() 43 - replyTo = &replyToStr 44 - } 45 - return tangled.Comment{ 46 - Subject: c.Subject.String(), 47 - Body: c.Body, 48 - CreatedAt: c.Created.Format(time.RFC3339), 49 - ReplyTo: replyTo, 50 - Mentions: mentions, 51 - References: references, 52 - } 53 - } 54 - 55 - func (c *Comment) IsTopLevel() bool { 56 - return c.ReplyTo == nil 57 - } 58 - 59 - func (c *Comment) IsReply() bool { 60 - return c.ReplyTo != nil 61 - } 62 - 63 - func (c *Comment) Validate() error { 64 - // TODO: sanitize the body and then trim space 65 - if sb := strings.TrimSpace(c.Body); sb == "" { 66 - return fmt.Errorf("body is empty after HTML sanitization") 67 - } 68 - 69 - // if it's for PR, PullSubmissionId should not be nil 70 - if c.Subject.Collection().String() == tangled.RepoPullNSID { 71 - if c.PullSubmissionId == nil { 72 - return fmt.Errorf("PullSubmissionId should not be nil") 73 - } 74 - } 75 - return nil 76 - } 77 - 78 - func CommentFromRecord(did, rkey string, record tangled.Comment) (*Comment, error) { 79 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 80 - if err != nil { 81 - created = time.Now() 82 - } 83 - 84 - ownerDid := did 85 - 86 - if _, err = syntax.ParseATURI(record.Subject); err != nil { 87 - return nil, err 88 - } 89 - 90 - i := record 91 - mentions := make([]syntax.DID, len(record.Mentions)) 92 - for i, did := range record.Mentions { 93 - mentions[i] = syntax.DID(did) 94 - } 95 - references := make([]syntax.ATURI, len(record.References)) 96 - for i, uri := range i.References { 97 - references[i] = syntax.ATURI(uri) 98 - } 99 - var replyTo *syntax.ATURI 100 - if record.ReplyTo != nil { 101 - replyToAtUri := syntax.ATURI(*record.ReplyTo) 102 - replyTo = &replyToAtUri 103 - } 104 - 105 - comment := Comment{ 106 - Did: syntax.DID(ownerDid), 107 - Rkey: rkey, 108 - Body: record.Body, 109 - Subject: syntax.ATURI(record.Subject), 110 - ReplyTo: replyTo, 111 - Created: created, 112 - Mentions: mentions, 113 - References: references, 114 - } 115 - 116 - return &comment, nil 117 - }
+89 -8
appview/models/issue.go
··· 26 26 27 27 // optionally, populate this when querying for reverse mappings 28 28 // like comment counts, parent repo etc. 29 - Comments []Comment 29 + Comments []IssueComment 30 30 Labels LabelState 31 31 Repo *Repo 32 32 } ··· 62 62 } 63 63 64 64 type CommentListItem struct { 65 - Self *Comment 66 - Replies []*Comment 65 + Self *IssueComment 66 + Replies []*IssueComment 67 67 } 68 68 69 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 88 89 89 func (i *Issue) CommentList() []CommentListItem { 90 90 // Create a map to quickly find comments by their aturi 91 - toplevel := make(map[syntax.ATURI]*CommentListItem) 92 - var replies []*Comment 91 + toplevel := make(map[string]*CommentListItem) 92 + var replies []*IssueComment 93 93 94 94 // collect top level comments into the map 95 95 for _, comment := range i.Comments { 96 96 if comment.IsTopLevel() { 97 - toplevel[comment.AtUri()] = &CommentListItem{ 97 + toplevel[comment.AtUri().String()] = &CommentListItem{ 98 98 Self: &comment, 99 99 } 100 100 } else { ··· 115 115 } 116 116 117 117 // sort everything 118 - sortFunc := func(a, b *Comment) bool { 118 + sortFunc := func(a, b *IssueComment) bool { 119 119 return a.Created.Before(b.Created) 120 120 } 121 121 sort.Slice(listing, func(i, j int) bool { ··· 144 144 addParticipant(i.Did) 145 145 146 146 for _, c := range i.Comments { 147 - addParticipant(c.Did.String()) 147 + addParticipant(c.Did) 148 148 } 149 149 150 150 return participants ··· 171 171 Open: true, // new issues are open by default 172 172 } 173 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 + }
+39
appview/models/pipeline.go
··· 1 1 package models 2 2 3 3 import ( 4 + "fmt" 4 5 "slices" 6 + "strings" 5 7 "time" 6 8 7 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 50 52 } 51 53 52 54 return 0 55 + } 56 + 57 + // produces short summary of successes: 58 + // - "0/4" when zero successes of 4 workflows 59 + // - "4/4" when all successes of 4 workflows 60 + // - "0/0" when no workflows run in this pipeline 61 + func (p Pipeline) ShortStatusSummary() string { 62 + counts := make(map[spindle.StatusKind]int) 63 + for _, w := range p.Statuses { 64 + counts[w.Latest().Status] += 1 65 + } 66 + 67 + total := len(p.Statuses) 68 + successes := counts[spindle.StatusKindSuccess] 69 + 70 + return fmt.Sprintf("%d/%d", successes, total) 71 + } 72 + 73 + // produces a string of the form "3/4 success, 2/4 failed, 1/4 pending" 74 + func (p Pipeline) LongStatusSummary() string { 75 + counts := make(map[spindle.StatusKind]int) 76 + for _, w := range p.Statuses { 77 + counts[w.Latest().Status] += 1 78 + } 79 + 80 + total := len(p.Statuses) 81 + 82 + var result []string 83 + // finish states first, followed by start states 84 + states := append(spindle.FinishStates[:], spindle.StartStates[:]...) 85 + for _, state := range states { 86 + if count, ok := counts[state]; ok { 87 + result = append(result, fmt.Sprintf("%d/%d %s", count, total, state.String())) 88 + } 89 + } 90 + 91 + return strings.Join(result, ", ") 53 92 } 54 93 55 94 func (p Pipeline) Counts() map[string]int {
+46 -2
appview/models/pull.go
··· 138 138 RoundNumber int 139 139 Patch string 140 140 Combined string 141 - Comments []Comment 141 + Comments []PullComment 142 142 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 143 143 144 144 // meta 145 145 Created time.Time 146 146 } 147 + 148 + type PullComment struct { 149 + // ids 150 + ID int 151 + PullId int 152 + SubmissionId int 153 + 154 + // at ids 155 + RepoAt string 156 + OwnerDid string 157 + CommentAt string 158 + 159 + // content 160 + Body string 161 + 162 + // meta 163 + Mentions []syntax.DID 164 + References []syntax.ATURI 165 + 166 + // meta 167 + Created time.Time 168 + } 169 + 170 + func (p *PullComment) AtUri() syntax.ATURI { 171 + return syntax.ATURI(p.CommentAt) 172 + } 173 + 174 + // func (p *PullComment) AsRecord() tangled.RepoPullComment { 175 + // mentions := make([]string, len(p.Mentions)) 176 + // for i, did := range p.Mentions { 177 + // mentions[i] = string(did) 178 + // } 179 + // references := make([]string, len(p.References)) 180 + // for i, uri := range p.References { 181 + // references[i] = string(uri) 182 + // } 183 + // return tangled.RepoPullComment{ 184 + // Pull: p.PullAt, 185 + // Body: p.Body, 186 + // Mentions: mentions, 187 + // References: references, 188 + // CreatedAt: p.Created.Format(time.RFC3339), 189 + // } 190 + // } 147 191 148 192 func (p *Pull) LastRoundNumber() int { 149 193 return len(p.Submissions) - 1 ··· 245 289 addParticipant(s.PullAt.Authority().String()) 246 290 247 291 for _, c := range s.Comments { 248 - addParticipant(c.Did.String()) 292 + addParticipant(c.OwnerDid) 249 293 } 250 294 251 295 return participants
+113 -111
appview/notify/db/db.go
··· 74 74 // no-op 75 75 } 76 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() 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())) 93 79 if err != nil { 94 - log.Printf("NewComment: expected did based at-uri for comment.subject") 80 + log.Printf("failed to fetch collaborators: %v", err) 95 81 return 96 82 } 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 83 114 - recipients.Insert(syntax.DID(issue.Repo.Did)) 115 - if comment.IsReply() { 116 - // if this comment is a reply, then notify everybody in that thread 117 - parentAtUri := *comment.ReplyTo 118 - 119 - // find the parent thread, and add all DIDs from here to the recipient list 120 - for _, t := range issue.CommentList() { 121 - if t.Self.AtUri() == parentAtUri { 122 - for _, p := range t.Participants() { 123 - recipients.Insert(p) 124 - } 125 - } 126 - } 127 - } else { 128 - // not a reply, notify just the issue author 129 - recipients.Insert(syntax.DID(issue.Did)) 130 - } 131 - 132 - entityType = "issue" 133 - entityId = issue.AtUri().String() 134 - repoId = &issue.Repo.Id 135 - issueId = &issue.Id 136 - case tangled.RepoPullNSID: 137 - pulls, err := db.GetPullsWithLimit( 138 - n.db, 139 - 1, 140 - orm.FilterEq("owner_did", subjectDid), 141 - orm.FilterEq("rkey", comment.Subject.RecordKey()), 142 - ) 143 - if err != nil { 144 - log.Printf("NewComment: failed to get pulls: %v", err) 145 - return 146 - } 147 - if len(pulls) == 0 { 148 - log.Printf("NewComment: no pull found for %s", comment.Subject) 149 - return 150 - } 151 - pull := pulls[0] 152 - 153 - pull.Repo, err = db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 154 - if err != nil { 155 - log.Printf("NewComment: failed to get repos: %v", err) 156 - return 157 - } 158 - 159 - recipients.Insert(syntax.DID(pull.Repo.Did)) 160 - for _, p := range pull.Participants() { 161 - recipients.Insert(syntax.DID(p)) 162 - } 163 - 164 - entityType = "pull" 165 - entityId = pull.AtUri().String() 166 - repoId = &pull.Repo.Id 167 - p := int64(pull.ID) 168 - pullId = &p 169 - default: 170 - return // no-op 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) 171 91 } 172 - 173 - for _, m := range comment.Mentions { 92 + for _, m := range mentions { 174 93 recipients.Remove(m) 175 94 } 176 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 + 177 103 n.notifyEvent( 178 - comment.Did, 104 + actorDid, 179 105 recipients, 180 - models.NotificationTypeIssueCommented, 106 + models.NotificationTypeIssueCreated, 181 107 entityType, 182 108 entityId, 183 109 repoId, ··· 185 111 pullId, 186 112 ) 187 113 n.notifyEvent( 188 - comment.Did, 189 - sets.Collect(slices.Values(comment.Mentions)), 114 + actorDid, 115 + sets.Collect(slices.Values(mentions)), 190 116 models.NotificationTypeUserMentioned, 191 117 entityType, 192 118 entityId, ··· 196 122 ) 197 123 } 198 124 199 - func (n *databaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) { 200 - // no-op 201 - } 202 - 203 - func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 204 - collaborators, err := db.GetCollaborators(n.db, orm.FilterEq("repo_at", issue.Repo.RepoAt())) 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)) 205 127 if err != nil { 206 - log.Printf("failed to fetch collaborators: %v", err) 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) 207 133 return 208 134 } 135 + issue := issues[0] 209 136 210 - // build the recipients list 211 - // - owner of the repo 212 - // - collaborators in the repo 213 - // - remove users already mentioned 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 214 142 recipients := sets.Singleton(syntax.DID(issue.Repo.Did)) 215 - for _, c := range collaborators { 216 - recipients.Insert(c.SubjectDid) 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)) 217 159 } 160 + 218 161 for _, m := range mentions { 219 162 recipients.Remove(m) 220 163 } 221 164 222 - actorDid := syntax.DID(issue.Did) 165 + actorDid := syntax.DID(comment.Did) 223 166 entityType := "issue" 224 167 entityId := issue.AtUri().String() 225 168 repoId := &issue.Repo.Id ··· 229 172 n.notifyEvent( 230 173 actorDid, 231 174 recipients, 232 - models.NotificationTypeIssueCreated, 175 + models.NotificationTypeIssueCommented, 233 176 entityType, 234 177 entityId, 235 178 repoId, ··· 309 252 actorDid, 310 253 recipients, 311 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, 312 314 entityType, 313 315 entityId, 314 316 repoId,
+8 -8
appview/notify/merged_notifier.go
··· 53 53 m.fanout("DeleteStar", ctx, star) 54 54 } 55 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 56 func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) { 65 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) 66 62 } 67 63 68 64 func (m *mergedNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) { ··· 83 79 84 80 func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 85 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 86 } 87 87 88 88 func (m *mergedNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {
+7 -7
appview/notify/notifier.go
··· 13 13 NewStar(ctx context.Context, star *models.Star) 14 14 DeleteStar(ctx context.Context, star *models.Star) 15 15 16 - NewComment(ctx context.Context, comment *models.Comment) 17 - DeleteComment(ctx context.Context, comment *models.Comment) 18 - 19 16 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 20 18 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 21 19 DeleteIssue(ctx context.Context, issue *models.Issue) 22 20 ··· 24 22 DeleteFollow(ctx context.Context, follow *models.Follow) 25 23 26 24 NewPull(ctx context.Context, pull *models.Pull) 25 + NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 27 26 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 28 27 29 28 UpdateProfile(ctx context.Context, profile *models.Profile) ··· 43 42 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 44 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 45 44 46 - func (m *BaseNotifier) NewComment(ctx context.Context, comment *models.Comment) {} 47 - func (m *BaseNotifier) DeleteComment(ctx context.Context, comment *models.Comment) {} 48 - 49 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 + } 50 48 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 51 49 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {} 52 50 53 51 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 54 52 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 55 53 56 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 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 57 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 58 58 59 59 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
+20 -5
appview/notify/posthog/notifier.go
··· 86 86 } 87 87 } 88 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 + 89 104 func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 90 105 err := n.client.Enqueue(posthog.Capture{ 91 106 DistinctId: pull.OwnerDid, ··· 165 180 } 166 181 } 167 182 168 - func (n *posthogNotifier) NewComment(ctx context.Context, comment *models.Comment) { 183 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 169 184 err := n.client.Enqueue(posthog.Capture{ 170 - DistinctId: comment.Did.String(), 171 - Event: "new_comment", 185 + DistinctId: comment.Did, 186 + Event: "new_issue_comment", 172 187 Properties: posthog.Properties{ 173 - "subject_at": comment.Subject, 174 - "mentions": comment.Mentions, 188 + "issue_at": comment.IssueAt, 189 + "mentions": mentions, 175 190 }, 176 191 }) 177 192 if err != nil {
+9 -9
appview/ogcard/card.go
··· 334 334 return nil 335 335 } 336 336 337 - func (c *Card) DrawDollySilhouette(x, y, size int, iconColor color.Color) error { 337 + func (c *Card) DrawDolly(x, y, size int, iconColor color.Color) error { 338 338 tpl, err := template.New("dolly"). 339 - ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") 339 + ParseFS(pages.Files, "templates/fragments/dolly/logo.html") 340 340 if err != nil { 341 - return fmt.Errorf("failed to read dolly silhouette template: %w", err) 341 + return fmt.Errorf("failed to read dolly template: %w", err) 342 342 } 343 343 344 344 var svgData bytes.Buffer 345 - if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", nil); err != nil { 346 - return fmt.Errorf("failed to execute dolly silhouette template: %w", err) 345 + if err = tpl.ExecuteTemplate(&svgData, "fragments/dolly/logo", nil); err != nil { 346 + return fmt.Errorf("failed to execute dolly template: %w", err) 347 347 } 348 348 349 349 icon, err := BuildSVGIconFromData(svgData.Bytes(), iconColor) ··· 453 453 454 454 // Handle SVG separately 455 455 if contentType == "image/svg+xml" || strings.HasSuffix(url, ".svg") { 456 - return c.convertSVGToPNG(bodyBytes) 456 + return convertSVGToPNG(bodyBytes) 457 457 } 458 458 459 459 // Support content types are in-sync with the allowed custom avatar file types ··· 493 493 } 494 494 495 495 // convertSVGToPNG converts SVG data to a PNG image 496 - func (c *Card) convertSVGToPNG(svgData []byte) (image.Image, bool) { 496 + func convertSVGToPNG(svgData []byte) (image.Image, bool) { 497 497 // Parse the SVG 498 498 icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 499 499 if err != nil { ··· 547 547 draw.CatmullRom.Scale(scaledImg, scaledImg.Bounds(), img, srcBounds, draw.Src, nil) 548 548 549 549 // Draw the image with circular clipping 550 - for cy := 0; cy < size; cy++ { 551 - for cx := 0; cx < size; cx++ { 550 + for cy := range size { 551 + for cx := range size { 552 552 // Calculate distance from center 553 553 dx := float64(cx - center) 554 554 dy := float64(cy - center)
+20 -7
appview/pages/funcmap.go
··· 334 334 }, 335 335 "deref": func(v any) any { 336 336 val := reflect.ValueOf(v) 337 - if val.Kind() == reflect.Ptr && !val.IsNil() { 337 + if val.Kind() == reflect.Pointer && !val.IsNil() { 338 338 return val.Elem().Interface() 339 339 } 340 340 return nil ··· 366 366 return p.AvatarUrl(handle, "") 367 367 }, 368 368 "langColor": enry.GetColor, 369 - "layoutSide": func() string { 370 - return "col-span-1 md:col-span-2 lg:col-span-3" 371 - }, 372 - "layoutCenter": func() string { 373 - return "col-span-1 md:col-span-8 lg:col-span-6" 374 - }, 369 + "reverse": func(s any) any { 370 + if s == nil { 371 + return nil 372 + } 373 + 374 + v := reflect.ValueOf(s) 375 + 376 + if v.Kind() != reflect.Slice { 377 + return s 378 + } 379 + 380 + length := v.Len() 381 + reversed := reflect.MakeSlice(v.Type(), length, length) 375 382 383 + for i := range length { 384 + reversed.Index(i).Set(v.Index(length - 1 - i)) 385 + } 386 + 387 + return reversed.Interface() 388 + }, 376 389 "normalizeForHtmlId": func(s string) string { 377 390 normalized := strings.ReplaceAll(s, ":", "_") 378 391 normalized = strings.ReplaceAll(normalized, ".", "_")
+18 -5
appview/pages/pages.go
··· 210 210 return tpl.ExecuteTemplate(w, "layouts/base", params) 211 211 } 212 212 213 + type DollyParams struct { 214 + Classes string 215 + FillColor string 216 + } 217 + 218 + func (p *Pages) Dolly(w io.Writer, params DollyParams) error { 219 + return p.executePlain("fragments/dolly/logo", w, params) 220 + } 221 + 213 222 func (p *Pages) Favicon(w io.Writer) error { 214 - return p.executePlain("fragments/dolly/silhouette", w, nil) 223 + return p.Dolly(w, DollyParams{ 224 + Classes: "text-black dark:text-white", 225 + }) 215 226 } 216 227 217 228 type LoginParams struct { ··· 988 999 LoggedInUser *oauth.User 989 1000 RepoInfo repoinfo.RepoInfo 990 1001 Issue *models.Issue 991 - Comment *models.Comment 1002 + Comment *models.IssueComment 992 1003 } 993 1004 994 1005 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 999 1010 LoggedInUser *oauth.User 1000 1011 RepoInfo repoinfo.RepoInfo 1001 1012 Issue *models.Issue 1002 - Comment *models.Comment 1013 + Comment *models.IssueComment 1003 1014 } 1004 1015 1005 1016 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1010 1021 LoggedInUser *oauth.User 1011 1022 RepoInfo repoinfo.RepoInfo 1012 1023 Issue *models.Issue 1013 - Comment *models.Comment 1024 + Comment *models.IssueComment 1014 1025 } 1015 1026 1016 1027 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1021 1032 LoggedInUser *oauth.User 1022 1033 RepoInfo repoinfo.RepoInfo 1023 1034 Issue *models.Issue 1024 - Comment *models.Comment 1035 + Comment *models.IssueComment 1025 1036 } 1026 1037 1027 1038 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1092 1103 MergeCheck types.MergeCheckResponse 1093 1104 ResubmitCheck ResubmitResult 1094 1105 Pipelines map[string]models.Pipeline 1106 + Diff *types.NiceDiff 1107 + DiffOpts types.DiffOpts 1095 1108 1096 1109 OrderedReactionKinds []models.ReactionKind 1097 1110 Reactions map[models.ReactionKind]models.ReactionDisplayData
+9 -29
appview/pages/templates/brand/brand.html
··· 4 4 <div class="grid grid-cols-10"> 5 5 <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 6 <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 - <p class="text-gray-600 dark:text-gray-400 mb-1"> 7 + <p class="text-gray-500 dark:text-gray-300 mb-1"> 8 8 Assets and guidelines for using Tangled's logo and brand elements. 9 9 </p> 10 10 </header> ··· 14 14 15 15 <!-- Introduction Section --> 16 16 <section> 17 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 18 18 Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 19 follow the below guidelines when using Dolly and the logotype. 20 20 </p> 21 - <p class="text-gray-600 dark:text-gray-400 mb-2"> 21 + <p class="text-gray-500 dark:text-gray-300 mb-2"> 22 22 All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 23 </p> 24 24 </section> ··· 34 34 </div> 35 35 <div class="order-1 lg:order-2"> 36 36 <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 37 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on light-colored backgrounds.</p> 38 38 <p class="text-gray-700 dark:text-gray-300"> 39 39 This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 40 backgrounds and designs. ··· 53 53 </div> 54 54 <div class="order-1 lg:order-2"> 55 55 <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 - <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 56 + <p class="text-gray-500 dark:text-gray-300 mb-4">For use on dark-colored backgrounds.</p> 57 57 <p class="text-gray-700 dark:text-gray-300"> 58 58 This version features white text and elements, ideal for dark backgrounds 59 59 and inverted designs. ··· 81 81 </div> 82 82 <div class="order-1 lg:order-2"> 83 83 <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 84 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 85 85 When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 86 </p> 87 87 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 123 123 </div> 124 124 <div class="order-1 lg:order-2"> 125 125 <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 126 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 127 127 White logo mark on colored backgrounds. 128 128 </p> 129 129 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 165 165 </div> 166 166 <div class="order-1 lg:order-2"> 167 167 <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 168 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 169 169 Dark logo mark on lighter, pastel backgrounds. 170 170 </p> 171 171 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 186 186 </div> 187 187 <div class="order-1 lg:order-2"> 188 188 <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 - <p class="text-gray-600 dark:text-gray-400 mb-4"> 189 + <p class="text-gray-500 dark:text-gray-300 mb-4"> 190 190 Custom coloring of the logotype is permitted. 191 191 </p> 192 192 <p class="text-gray-700 dark:text-gray-300 mb-4"> ··· 194 194 </p> 195 195 <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 196 <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 - </p> 198 - </div> 199 - </section> 200 - 201 - <!-- Silhouette Section --> 202 - <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 - <div class="order-2 lg:order-1"> 204 - <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 - <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 - alt="Dolly silhouette" 207 - class="w-full max-w-32 mx-auto" /> 208 - </div> 209 - </div> 210 - <div class="order-1 lg:order-2"> 211 - <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 - <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 - <p class="text-gray-700 dark:text-gray-300"> 214 - The silhouette can be used where a subtle brand presence is needed, 215 - or as a background element. Works on any background color with proper contrast. 216 - For example, we use this as the site's favicon. 217 197 </p> 218 198 </div> 219 199 </section>
+14 -2
appview/pages/templates/fragments/dolly/logo.html
··· 2 2 <svg 3 3 version="1.1" 4 4 id="svg1" 5 - class="{{ . }}" 5 + class="{{ .Classes }}" 6 6 width="25" 7 7 height="25" 8 8 viewBox="0 0 25 25" ··· 17 17 xmlns:svg="http://www.w3.org/2000/svg" 18 18 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 19 19 xmlns:cc="http://creativecommons.org/ns#"> 20 + <style> 21 + .dolly { 22 + color: #000000; 23 + } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + .dolly { 27 + color: #ffffff; 28 + } 29 + } 30 + </style> 20 31 <sodipodi:namedview 21 32 id="namedview1" 22 33 pagecolor="#ffffff" ··· 51 62 id="g1" 52 63 transform="translate(-0.42924038,-0.87777209)"> 53 64 <path 54 - fill="currentColor" 65 + class="dolly" 66 + fill="{{ or .FillColor "currentColor" }}" 55 67 style="stroke-width:0.111183;" 56 68 d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z m 0.686342,-3.497495 c -0.643126,-0.394168 -0.33365,-1.249599 -0.359402,-1.870938 0.064,-0.749774 0.115321,-1.538054 0.452402,-2.221125 0.356724,-0.487008 1.226721,-0.299139 1.265134,0.325689 -0.02558,0.628509 -0.314101,1.25416 -0.279646,1.9057 -0.07482,0.544043 0.05418,1.155133 -0.186476,1.652391 -0.197455,0.275121 -0.599638,0.355105 -0.892012,0.208283 z m -2.808766,-0.358124 c -0.605767,-0.328664 -0.4133176,-1.155655 -0.5083256,-1.73063 0.078762,-0.66567 0.013203,-1.510085 0.5705316,-1.976886 0.545037,-0.380109 1.286917,0.270803 1.029164,0.868384 -0.274913,0.755214 -0.09475,1.580345 -0.08893,2.34609 -0.104009,0.451702 -0.587146,0.691508 -1.002445,0.493042 z" 57 69 id="path4"
-95
appview/pages/templates/fragments/dolly/silhouette.html
··· 1 - {{ define "fragments/dolly/silhouette" }} 2 - <svg 3 - version="1.1" 4 - id="svg1" 5 - width="25" 6 - height="25" 7 - viewBox="0 0 25 25" 8 - sodipodi:docname="tangled_dolly_face_only_black_on_trans.svg" 9 - inkscape:export-filename="tangled_dolly_silhouette_black_on_trans.svg" 10 - inkscape:export-xdpi="96" 11 - inkscape:export-ydpi="96" 12 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 13 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 14 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 15 - xmlns="http://www.w3.org/2000/svg" 16 - xmlns:svg="http://www.w3.org/2000/svg" 17 - xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 18 - xmlns:cc="http://creativecommons.org/ns#"> 19 - <style> 20 - .dolly { 21 - color: #000000; 22 - } 23 - 24 - @media (prefers-color-scheme: dark) { 25 - .dolly { 26 - color: #ffffff; 27 - } 28 - } 29 - </style> 30 - <sodipodi:namedview 31 - id="namedview1" 32 - pagecolor="#ffffff" 33 - bordercolor="#000000" 34 - borderopacity="0.25" 35 - inkscape:showpageshadow="2" 36 - inkscape:pageopacity="0.0" 37 - inkscape:pagecheckerboard="true" 38 - inkscape:deskcolor="#d5d5d5" 39 - inkscape:zoom="64" 40 - inkscape:cx="4.96875" 41 - inkscape:cy="13.429688" 42 - inkscape:window-width="3840" 43 - inkscape:window-height="2160" 44 - inkscape:window-x="0" 45 - inkscape:window-y="0" 46 - inkscape:window-maximized="0" 47 - inkscape:current-layer="g1" 48 - borderlayer="true"> 49 - <inkscape:page 50 - x="0" 51 - y="0" 52 - width="25" 53 - height="25" 54 - id="page2" 55 - margin="0" 56 - bleed="0" /> 57 - </sodipodi:namedview> 58 - <g 59 - inkscape:groupmode="layer" 60 - inkscape:label="Image" 61 - id="g1" 62 - transform="translate(-0.42924038,-0.87777209)"> 63 - <path 64 - class="dolly" 65 - fill="currentColor" 66 - style="stroke-width:0.111183" 67 - d="m 16.775491,24.987061 c -0.78517,-0.0064 -1.384202,-0.234614 -2.033994,-0.631295 -0.931792,-0.490188 -1.643475,-1.31368 -2.152014,-2.221647 C 11.781409,23.136647 10.701392,23.744942 9.4922931,24.0886 8.9774725,24.238111 8.0757679,24.389777 6.5811304,23.84827 4.4270703,23.124679 2.8580086,20.883331 3.0363279,18.599583 3.0037061,17.652919 3.3488675,16.723769 3.8381157,15.925061 2.5329485,15.224503 1.4686756,14.048584 1.0611184,12.606459 0.81344502,11.816973 0.82385989,10.966486 0.91519098,10.154906 1.2422711,8.2387903 2.6795811,6.5725716 4.5299585,5.9732484 5.2685364,4.290122 6.8802592,3.0349975 8.706276,2.7794663 c 1.2124148,-0.1688264 2.46744,0.084987 3.52811,0.7011837 1.545426,-1.7139736 4.237779,-2.2205077 6.293579,-1.1676231 1.568222,0.7488935 2.689625,2.3113526 2.961888,4.0151464 1.492195,0.5977882 2.749007,1.8168898 3.242225,3.3644951 0.329805,0.9581836 0.340709,2.0135956 0.127128,2.9974286 -0.381606,1.535184 -1.465322,2.842146 -2.868035,3.556463 0.0034,0.273204 0.901506,2.243045 0.751284,3.729647 -0.03281,1.858525 -1.211631,3.619894 -2.846433,4.475452 -0.953967,0.556812 -2.084452,0.546309 -3.120531,0.535398 z m -4.470079,-5.349839 c 1.322246,-0.147248 2.189053,-1.300106 2.862307,-2.338363 0.318287,-0.472954 0.561404,-1.002348 0.803,-1.505815 0.313265,0.287151 0.578698,0.828085 1.074141,0.956909 0.521892,0.162542 1.133743,0.03052 1.45325,-0.443554 0.611414,-1.140449 0.31004,-2.516537 -0.04602,-3.698347 C 18.232844,11.92927 17.945151,11.232927 17.397785,10.751793 17.514522,9.9283111 17.026575,9.0919791 16.332883,8.6609491 15.741721,9.1323278 14.842258,9.1294949 14.271975,8.6252369 13.178927,9.7400102 12.177239,9.7029996 11.209704,8.8195135 10.992255,8.6209543 10.577326,10.031484 9.1211947,9.2324497 8.2846288,9.9333947 7.6359672,10.607693 7.0611981,11.578553 6.5026891,12.62523 5.9177873,13.554793 5.867393,14.69141 c -0.024234,0.66432 0.4948601,1.360337 1.1982269,1.306329 0.702996,0.06277 1.1815208,-0.629091 1.7138087,-0.916491 0.079382,0.927141 0.1688108,1.923227 0.4821259,2.828358 0.3596254,1.171275 1.6262605,1.915695 2.8251855,1.745211 0.08481,-0.0066 0.218672,-0.01769 0.218672,-0.0176 z" 68 - id="path7" 69 - sodipodi:nodetypes="sccccccccccccccccccsscccccccccscccccccsc" /> 70 - </g> 71 - <metadata 72 - id="metadata1"> 73 - <rdf:RDF> 74 - <cc:Work 75 - rdf:about=""> 76 - <cc:license 77 - rdf:resource="http://creativecommons.org/licenses/by/4.0/" /> 78 - </cc:Work> 79 - <cc:License 80 - rdf:about="http://creativecommons.org/licenses/by/4.0/"> 81 - <cc:permits 82 - rdf:resource="http://creativecommons.org/ns#Reproduction" /> 83 - <cc:permits 84 - rdf:resource="http://creativecommons.org/ns#Distribution" /> 85 - <cc:requires 86 - rdf:resource="http://creativecommons.org/ns#Notice" /> 87 - <cc:requires 88 - rdf:resource="http://creativecommons.org/ns#Attribution" /> 89 - <cc:permits 90 - rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /> 91 - </cc:License> 92 - </rdf:RDF> 93 - </metadata> 94 - </svg> 95 - {{ end }}
+1 -1
appview/pages/templates/fragments/logotype.html
··· 1 1 {{ define "fragments/logotype" }} 2 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-16 text-black dark:text-white" }} 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-16 text-black dark:text-white") }} 4 4 <span class="font-bold text-4xl not-italic">tangled</span> 5 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 6 alpha
+1 -1
appview/pages/templates/fragments/logotypeSmall.html
··· 1 1 {{ define "fragments/logotypeSmall" }} 2 2 <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 3 + {{ template "fragments/dolly/logo" (dict "Classes" "size-8 text-black dark:text-white")}} 4 4 <span class="font-bold text-xl not-italic">tangled</span> 5 5 <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 6 6 alpha
+1
appview/pages/templates/fragments/tabSelector.html
··· 9 9 {{ range $index, $value := $all }} 10 10 {{ $isActive := eq $value.Key $active }} 11 11 <a href="?{{ $name }}={{ $value.Key }}" 12 + hx-boost=true 12 13 {{ if $include }} 13 14 hx-get="?{{ $name }}={{ $value.Key }}" 14 15 hx-include="{{ $include }}"
+4
appview/pages/templates/layouts/base.html
··· 11 11 <script defer src="/static/htmx-ext-ws.min.js"></script> 12 12 <script defer src="/static/actor-typeahead.js" type="module"></script> 13 13 14 + <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 15 + <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 16 + <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 17 + 14 18 <!-- preconnect to image cdn --> 15 19 <link rel="preconnect" href="https://avatar.tangled.sh" /> 16 20 <link rel="preconnect" href="https://camo.tangled.sh" />
+1 -5
appview/pages/templates/layouts/fragments/topbar.html
··· 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 - <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 - alpha 10 - </span> 6 + {{ template "fragments/logotypeSmall" }} 11 7 </a> 12 8 </div> 13 9
+1 -1
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 p-2 dark:text-white"> 4 + <section id="repo-header" class="mb-2 py-2 px-4 dark:text-white"> 5 5 <div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between"> 6 6 <!-- left items --> 7 7 <div class="flex flex-col gap-2">
+1 -1
appview/pages/templates/repo/fragments/diff.html
··· 18 18 {{ range $idx, $hunk := $diff }} 19 19 {{ with $hunk }} 20 20 <details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 21 - <summary class="list-none cursor-pointer sticky top-0"> 21 + <summary class="list-none cursor-pointer sticky top-12"> 22 22 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 23 23 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 24 24 <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span>
+1 -8
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 1 {{ define "repo/fragments/diffChangedFiles" }} 2 - {{ $stat := .Stat }} 3 2 {{ $fileTree := fileTree .ChangedFiles }} 4 3 <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"> 5 - <div class="diff-stat"> 6 - <div class="flex gap-2 items-center"> 7 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 - {{ template "repo/fragments/diffStatPill" $stat }} 9 - </div> 10 - {{ template "repo/fragments/fileTree" $fileTree }} 11 - </div> 4 + {{ template "repo/fragments/fileTree" $fileTree }} 12 5 </section> 13 6 {{ end }}
+22 -25
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 1 {{ define "repo/fragments/diffOpts" }} 2 - <section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 3 - <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 - {{ $active := "unified" }} 5 - {{ if .Split }} 6 - {{ $active = "split" }} 7 - {{ end }} 2 + {{ $active := "unified" }} 3 + {{ if .Split }} 4 + {{ $active = "split" }} 5 + {{ end }} 8 6 9 - {{ $unified := 10 - (dict 11 - "Key" "unified" 12 - "Value" "unified" 13 - "Icon" "square-split-vertical" 14 - "Meta" "") }} 15 - {{ $split := 16 - (dict 17 - "Key" "split" 18 - "Value" "split" 19 - "Icon" "square-split-horizontal" 20 - "Meta" "") }} 21 - {{ $values := list $unified $split }} 7 + {{ $unified := 8 + (dict 9 + "Key" "unified" 10 + "Value" "unified" 11 + "Icon" "square-split-vertical" 12 + "Meta" "") }} 13 + {{ $split := 14 + (dict 15 + "Key" "split" 16 + "Value" "split" 17 + "Icon" "square-split-horizontal" 18 + "Meta" "") }} 19 + {{ $values := list $unified $split }} 22 20 23 - {{ template "fragments/tabSelector" 24 - (dict 25 - "Name" "diff" 26 - "Values" $values 27 - "Active" $active) }} 28 - </section> 21 + {{ template "fragments/tabSelector" 22 + (dict 23 + "Name" "diff" 24 + "Values" $values 25 + "Active" $active) }} 29 26 {{ end }} 30 27
+35 -22
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 1 {{ define "repo/issues/fragments/commentList" }} 2 - <div class="flex flex-col gap-8"> 2 + <div class="flex flex-col gap-4"> 3 3 {{ range $item := .CommentList }} 4 4 {{ template "commentListing" (list $ .) }} 5 5 {{ end }} ··· 19 19 <div class="rounded border border-gray-200 dark:border-gray-700 w-full overflow-hidden shadow-sm bg-gray-50 dark:bg-gray-800/50"> 20 20 {{ template "topLevelComment" $params }} 21 21 22 - <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 22 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 23 23 {{ range $index, $reply := $comment.Replies }} 24 - <div class="relative "> 25 - <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 - 28 - <div class="pl-2"> 29 - {{ 30 - template "replyComment" 31 - (dict 32 - "RepoInfo" $root.RepoInfo 33 - "LoggedInUser" $root.LoggedInUser 34 - "Issue" $root.Issue 35 - "Comment" $reply) 36 - }} 37 - </div> 24 + <div class="-ml-4"> 25 + {{ 26 + template "replyComment" 27 + (dict 28 + "RepoInfo" $root.RepoInfo 29 + "LoggedInUser" $root.LoggedInUser 30 + "Issue" $root.Issue 31 + "Comment" $reply) 32 + }} 38 33 </div> 39 34 {{ end }} 40 35 </div> ··· 44 39 {{ end }} 45 40 46 41 {{ define "topLevelComment" }} 47 - <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 - {{ template "repo/issues/fragments/issueCommentBody" . }} 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 + /> 49 + </div> 50 + <div class="flex-1 min-w-0"> 51 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 52 + {{ template "repo/issues/fragments/issueCommentBody" . }} 53 + </div> 50 54 </div> 51 55 {{ end }} 52 56 53 57 {{ define "replyComment" }} 54 - <div class="p-4 w-full mx-auto overflow-hidden"> 55 - {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 - {{ template "repo/issues/fragments/issueCommentBody" . }} 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 + /> 65 + </div> 66 + <div class="flex-1 min-w-0"> 67 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 68 + {{ template "repo/issues/fragments/issueCommentBody" . }} 69 + </div> 57 70 </div> 58 71 {{ end }}
-63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 - {{ define "repo/issues/fragments/globalIssueListing" }} 2 - <div class="flex flex-col gap-2"> 3 - {{ range .Issues }} 4 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 - <div class="pb-2 mb-3"> 6 - <div class="flex items-center gap-3 mb-2"> 7 - <a 8 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 - class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 - > 11 - {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 - </a> 13 - </div> 14 - <a 15 - href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 - class="no-underline hover:underline" 17 - > 18 - {{ .Title | description }} 19 - <span class="text-gray-500">#{{ .IssueId }}</span> 20 - </a> 21 - </div> 22 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 - {{ $icon := "ban" }} 25 - {{ $state := "closed" }} 26 - {{ if .Open }} 27 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 - {{ $icon = "circle-dot" }} 29 - {{ $state = "open" }} 30 - {{ end }} 31 - 32 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 - <span class="text-white dark:text-white">{{ $state }}</span> 35 - </span> 36 - 37 - <span class="ml-1"> 38 - {{ template "user/fragments/picHandleLink" .Did }} 39 - </span> 40 - 41 - <span class="before:content-['ยท']"> 42 - {{ template "repo/fragments/time" .Created }} 43 - </span> 44 - 45 - <span class="before:content-['ยท']"> 46 - {{ $s := "s" }} 47 - {{ if eq (len .Comments) 1 }} 48 - {{ $s = "" }} 49 - {{ end }} 50 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 - </span> 52 - 53 - {{ $state := .Labels }} 54 - {{ range $k, $d := $.LabelDefs }} 55 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 - {{ end }} 58 - {{ end }} 59 - </div> 60 - </div> 61 - {{ end }} 62 - </div> 63 - {{ end }}
+3 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did.String }} 3 + {{ resolve .Comment.Did }} 4 4 {{ template "hats" $ }} 5 + <span class="before:content-['ยท']"></span> 5 6 {{ template "timestamp" . }} 6 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 7 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 8 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 9 {{ template "editIssueComment" . }} 9 10 {{ template "deleteIssueComment" . }}
+2 -2
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 21 21 {{ $state = "open" }} 22 22 {{ end }} 23 23 24 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 25 25 {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 - <span class="text-white dark:text-white">{{ $state }}</span> 26 + <span class="text-white dark:text-white text-sm">{{ $state }}</span> 27 27 </span> 28 28 29 29 <span class="ml-1">
+1 -1
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 18 18 <textarea 19 19 name="body" 20 20 id="body" 21 - rows="6" 21 + rows="15" 22 22 class="w-full resize-y" 23 23 placeholder="Describe your issue. Markdown is supported." 24 24 >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea>
+3 -3
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 1 {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 - <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 3 {{ if .LoggedInUser }} 4 4 <img 5 5 src="{{ tinyAvatar .LoggedInUser.Did }}" 6 6 alt="" 7 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 8 /> 9 9 {{ end }} 10 10 <input 11 - class="w-full py-2 border-none focus:outline-none" 11 + class="w-full p-0 border-none focus:outline-none" 12 12 placeholder="Leave a reply..." 13 13 hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 14 hx-trigger="focus"
+5 -5
appview/pages/templates/repo/issues/issue.html
··· 58 58 {{ $icon = "circle-dot" }} 59 59 {{ end }} 60 60 <div class="inline-flex items-center gap-2"> 61 - <div id="state" 62 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 63 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 64 - <span class="text-white">{{ .Issue.State }}</span> 65 - </div> 61 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }}"> 62 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 63 + <span class="text-white dark:text-white text-sm">{{ .Issue.State }}</span> 64 + </span> 65 + 66 66 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 67 67 opened by 68 68 {{ template "user/fragments/picHandleLink" .Issue.Did }}
+60 -69
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 1 1 {{ define "repo/pipelines/fragments/pipelineSymbol" }} 2 - <div class="cursor-pointer"> 3 - {{ $c := .Counts }} 4 - {{ $statuses := .Statuses }} 5 - {{ $total := len $statuses }} 6 - {{ $success := index $c "success" }} 7 - {{ $fail := index $c "failed" }} 8 - {{ $timeout := index $c "timeout" }} 9 - {{ $empty := eq $total 0 }} 10 - {{ $allPass := eq $success $total }} 11 - {{ $allFail := eq $fail $total }} 12 - {{ $allTimeout := eq $timeout $total }} 13 - 14 - {{ if $empty }} 15 - <div class="flex gap-1 items-center"> 16 - {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 17 - <span>0/{{ $total }}</span> 18 - </div> 19 - {{ else if $allPass }} 20 - <div class="flex gap-1 items-center"> 21 - {{ i "check" "size-4 text-green-600" }} 22 - <span>{{ $total }}/{{ $total }}</span> 23 - </div> 24 - {{ else if $allFail }} 25 - <div class="flex gap-1 items-center"> 26 - {{ i "x" "size-4 text-red-500" }} 27 - <span>0/{{ $total }}</span> 28 - </div> 29 - {{ else if $allTimeout }} 30 - <div class="flex gap-1 items-center"> 31 - {{ i "clock-alert" "size-4 text-orange-500" }} 32 - <span>0/{{ $total }}</span> 33 - </div> 2 + <div class="cursor-pointer flex gap-2 items-center"> 3 + {{ template "symbol" .Pipeline }} 4 + {{ if .ShortSummary }} 5 + {{ .Pipeline.ShortStatusSummary }} 34 6 {{ else }} 35 - {{ $radius := f64 8 }} 36 - {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 37 - {{ $offset := 0.0 }} 38 - <div class="flex gap-1 items-center"> 39 - <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 40 - <circle cx="10" cy="10" r="{{ $radius }}" fill="none" stroke="#f3f4f633" stroke-width="2"/> 7 + {{ .Pipeline.LongStatusSummary }} 8 + {{ end }} 9 + </div> 10 + {{ end }} 41 11 42 - {{ range $kind, $count := $c }} 43 - {{ $color := "" }} 44 - {{ if or (eq $kind "pending") (eq $kind "running") }} 45 - {{ $color = "#eab308" }} {{/* amber-500 */}} 46 - {{ else if eq $kind "success" }} 47 - {{ $color = "#10b981" }} {{/* green-500 */}} 48 - {{ else if eq $kind "cancelled" }} 49 - {{ $color = "#6b7280" }} {{/* gray-500 */}} 50 - {{ else if eq $kind "timeout" }} 51 - {{ $color = "#fb923c" }} {{/* orange-400 */}} 52 - {{ else }} 53 - {{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}} 54 - {{ end }} 12 + {{ define "symbol" }} 13 + {{ $c := .Counts }} 14 + {{ $statuses := .Statuses }} 15 + {{ $total := len $statuses }} 16 + {{ $success := index $c "success" }} 17 + {{ $fail := index $c "failed" }} 18 + {{ $timeout := index $c "timeout" }} 19 + {{ $empty := eq $total 0 }} 20 + {{ $allPass := eq $success $total }} 21 + {{ $allFail := eq $fail $total }} 22 + {{ $allTimeout := eq $timeout $total }} 55 23 56 - {{ $percent := divf64 (f64 $count) (f64 $total) }} 57 - {{ $length := mulf64 $percent $circumference }} 58 - 59 - <circle 60 - cx="10" cy="10" r="{{ $radius }}" 61 - fill="none" 62 - stroke="{{ $color }}" 63 - stroke-width="2" 64 - stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 65 - stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 66 - /> 67 - {{ $offset = addf64 $offset $length }} 68 - {{ end }} 69 - </svg> 70 - <span>{{ $success }}/{{ $total }}</span> 71 - </div> 72 - {{ end }} 73 - </div> 24 + {{ if $empty }} 25 + {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 26 + {{ else if $allPass }} 27 + {{ i "check" "size-4 text-green-600 dark:text-green-500" }} 28 + {{ else if $allFail }} 29 + {{ i "x" "size-4 text-red-600 dark:text-red-500" }} 30 + {{ else if $allTimeout }} 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 + {{ else }} 33 + {{ $radius := f64 8 }} 34 + {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} 35 + {{ $offset := 0.0 }} 36 + <svg class="w-4 h-4 transform -rotate-90" viewBox="0 0 20 20"> 37 + <circle cx="10" cy="10" r="{{ $radius }}" fill="none" class="stroke-gray-200 dark:stroke-gray-700" stroke-width="2"/> 38 + {{ range $kind, $count := $c }} 39 + {{ $colorClass := "" }} 40 + {{ if or (eq $kind "pending") (eq $kind "running") }} 41 + {{ $colorClass = "stroke-yellow-600 dark:stroke-yellow-500" }} 42 + {{ else if eq $kind "success" }} 43 + {{ $colorClass = "stroke-green-600 dark:stroke-green-500" }} 44 + {{ else if eq $kind "cancelled" }} 45 + {{ $colorClass = "stroke-gray-600 dark:stroke-gray-500" }} 46 + {{ else if eq $kind "timeout" }} 47 + {{ $colorClass = "stroke-orange-600 dark:stroke-orange-500" }} 48 + {{ else }} 49 + {{ $colorClass = "stroke-red-600 dark:stroke-red-500" }} 50 + {{ end }} 51 + {{ $percent := divf64 (f64 $count) (f64 $total) }} 52 + {{ $length := mulf64 $percent $circumference }} 53 + <circle 54 + cx="10" cy="10" r="{{ $radius }}" 55 + fill="none" 56 + class="{{ $colorClass }}" 57 + stroke-width="2" 58 + stroke-dasharray="{{ printf "%.2f %.2f" $length (subf64 $circumference $length) }}" 59 + stroke-dashoffset="{{ printf "%.2f" (negf64 $offset) }}" 60 + /> 61 + {{ $offset = addf64 $offset $length }} 62 + {{ end }} 63 + </svg> 64 + {{ end }} 74 65 {{ end }}
+1 -1
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
··· 4 4 <div class="relative inline-block"> 5 5 <details class="relative"> 6 6 <summary class="cursor-pointer list-none"> 7 - {{ template "repo/pipelines/fragments/pipelineSymbol" .Pipeline }} 7 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 8 8 </summary> 9 9 {{ template "repo/pipelines/fragments/tooltip" $ }} 10 10 </details>
+17 -17
appview/pages/templates/repo/pulls/fragments/pullActions.html
··· 22 22 {{ $isLastRound := eq $roundNumber $lastIdx }} 23 23 {{ $isSameRepoBranch := .Pull.IsBranchBased }} 24 24 {{ $isUpToDate := .ResubmitCheck.No }} 25 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative"> 25 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2 relative p-2"> 26 26 <button 27 27 hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 28 28 hx-target="#actions-{{$roundNumber}}" 29 29 hx-swap="outerHtml" 30 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 - {{ i "message-square-plus" "w-4 h-4" }} 32 - <span>comment</span> 30 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group"> 31 + {{ i "message-square-plus" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 33 32 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + comment 34 34 </button> 35 35 {{ if .BranchDeleteStatus }} 36 36 <button 37 37 hx-delete="/{{ .BranchDeleteStatus.Repo.Did }}/{{ .BranchDeleteStatus.Repo.Name }}/branches" 38 38 hx-vals='{"branch": "{{ .BranchDeleteStatus.Branch }}" }' 39 39 hx-swap="none" 40 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 40 + class="btn-flat p-2 flex items-center gap-2 no-underline hover:no-underline group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 41 41 {{ i "git-branch" "w-4 h-4" }} 42 42 <span>delete branch</span> 43 43 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} ··· 52 52 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 53 53 hx-swap="none" 54 54 hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 55 - class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 56 - {{ i "git-merge" "w-4 h-4" }} 57 - <span>merge{{if $stackCount}} {{$stackCount}}{{end}}</span> 55 + class="btn-flat p-2 flex items-center gap-2 group" {{ $disabled }}> 56 + {{ i "git-merge" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 57 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + merge{{if $stackCount}} {{$stackCount}}{{end}} 59 59 </button> 60 60 {{ end }} 61 61 ··· 74 74 {{ end }} 75 75 76 76 hx-disabled-elt="#resubmitBtn" 77 - class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 77 + class="btn-flat p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 78 78 79 79 {{ if $disabled }} 80 80 title="Update this branch to resubmit this pull request" ··· 82 82 title="Resubmit this pull request" 83 83 {{ end }} 84 84 > 85 - {{ i "rotate-ccw" "w-4 h-4" }} 86 - <span>resubmit</span> 85 + {{ i "rotate-ccw" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 87 86 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 87 + resubmit 88 88 </button> 89 89 {{ end }} 90 90 ··· 92 92 <button 93 93 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 94 94 hx-swap="none" 95 - class="btn p-2 flex items-center gap-2 group"> 96 - {{ i "ban" "w-4 h-4" }} 97 - <span>close</span> 95 + class="btn-flat p-2 flex items-center gap-2 group"> 96 + {{ i "ban" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 98 97 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 98 + close 99 99 </button> 100 100 {{ end }} 101 101 ··· 103 103 <button 104 104 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 105 105 hx-swap="none" 106 - class="btn p-2 flex items-center gap-2 group"> 107 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 108 - <span>reopen</span> 106 + class="btn-flat p-2 flex items-center gap-2 group"> 107 + {{ i "refresh-ccw-dot" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 109 108 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 109 + reopen 110 110 </button> 111 111 {{ end }} 112 112 </div>
+6 -7
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 - <header class="pb-4"> 2 + <header class="pb-2"> 3 3 <h1 class="text-2xl dark:text-white"> 4 4 {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> ··· 17 17 {{ $icon = "git-merge" }} 18 18 {{ end }} 19 19 20 - <section class="mt-2"> 20 + <section> 21 21 <div class="flex items-center gap-2"> 22 - <div 23 - id="state" 24 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 22 + <span 23 + class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm" 25 24 > 26 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white" }} 27 26 <span class="text-white">{{ .Pull.State.String }}</span> 28 - </div> 27 + </span> 29 28 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 29 opened by 31 30 {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }}
+39 -24
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 2 <div 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 - <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ resolve .LoggedInUser.Did }} 7 - </div> 4 + class="w-full flex flex-col gap-2"> 5 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 8 6 <form 9 7 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 - hx-indicator="#create-comment-spinner" 11 8 hx-swap="none" 12 - class="w-full flex flex-wrap gap-2" 9 + hx-on::after-request="if(event.detail.successful) this.reset()" 10 + hx-disabled-elt="#reply-{{ .RoundNumber }}" 11 + class="w-full flex flex-wrap gap-2 group" 13 12 > 14 13 <textarea 15 14 name="body" 16 15 class="w-full p-2 rounded border border-gray-200" 16 + rows=8 17 17 placeholder="Add to the discussion..."></textarea 18 18 > 19 - <button type="submit" class="btn flex items-center gap-2"> 20 - {{ i "message-square" "w-4 h-4" }} 21 - <span>comment</span> 22 - <span id="create-comment-spinner" class="group"> 23 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 - </span> 25 - </button> 26 - <button 27 - type="button" 28 - class="btn flex items-center gap-2 group" 29 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 30 - hx-swap="outerHTML" 31 - hx-target="#pull-comment-card-{{ .RoundNumber }}" 32 - > 33 - {{ i "x" "w-4 h-4" }} 34 - <span>cancel</span> 35 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 - </button> 19 + {{ template "replyActions" . }} 37 20 <div id="pull-comment"></div> 38 21 </form> 39 22 </div> 40 23 {{ end }} 24 + 25 + {{ define "replyActions" }} 26 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm w-full"> 27 + {{ template "cancel" . }} 28 + {{ template "reply" . }} 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "cancel" }} 33 + <button 34 + type="button" 35 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 36 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 37 + hx-swap="outerHTML" 38 + hx-target="#actions-{{.RoundNumber}}" 39 + > 40 + {{ i "x" "w-4 h-4" }} 41 + <span>cancel</span> 42 + </button> 43 + {{ end }} 44 + 45 + {{ define "reply" }} 46 + <button 47 + type="submit" 48 + id="reply-{{ .RoundNumber }}" 49 + class="btn-create flex items-center gap-2"> 50 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + reply 53 + </button> 54 + {{ end }} 55 +
+20
appview/pages/templates/repo/pulls/fragments/replyPullCommentPlaceholder.html
··· 1 + {{ define "repo/pulls/fragments/replyPullCommentPlaceholder" }} 2 + <div class="py-2 px-6 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full size-8 mr-1 border-2 border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full p-0 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .Submission.ID }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 19 {{ $commentCount := len $lastSubmission.Comments }} 20 20 {{ if and $pipeline $pipeline.Id }} 21 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 22 22 <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 23 {{ end }} 24 24 <span>
+337 -80
appview/pages/templates/repo/pulls/pull.html
··· 6 6 {{ template "repo/pulls/fragments/og" (dict "RepoInfo" .RepoInfo "Pull" .Pull) }} 7 7 {{ end }} 8 8 9 + {{ define "mainLayout" }} 10 + <div class="px-1 col-span-full flex-grow flex flex-col gap-4"> 11 + {{ block "contentLayout" . }} 12 + {{ block "content" . }}{{ end }} 13 + {{ end }} 14 + </div> 15 + {{ end }} 16 + 9 17 {{ define "repoContentLayout" }} 10 - <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 11 - <div class="col-span-1 md:col-span-8"> 12 - <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 18 + <div class="grid grid-cols-1 md:grid-cols-10 gap-y-2 gap-x-4 w-full"> 19 + <div class="col-span-1 md:col-span-7"> 20 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white h-full"> 13 21 {{ block "repoContent" . }}{{ end }} 14 22 </section> 15 23 {{ block "repoAfter" . }}{{ end }} 16 24 </div> 17 - <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 25 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 18 26 {{ template "repo/fragments/labelPanel" 19 27 (dict "RepoInfo" $.RepoInfo 20 28 "Defs" $.LabelDefs ··· 26 34 "Backlinks" $.Backlinks) }} 27 35 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 28 36 </div> 37 + 38 + <style> 39 + #filesToggle:checked ~ div label[for="filesToggle"] .show-text { display: none; } 40 + #filesToggle:checked ~ div label[for="filesToggle"] .hide-text { display: inline; } 41 + #filesToggle:not(:checked) ~ div label[for="filesToggle"] .hide-text { display: none; } 42 + 43 + #filesToggle:checked ~ div div#files { width: 10vw; margin-right: 1rem; } 44 + #filesToggle:not(:checked) ~ div div#files { width: 0; display: hidden; margin-right: 0; } 45 + 46 + #subsToggle:checked ~ div div#subs { width: 25vw; margin-left: 1rem; } 47 + #subsToggle:not(:checked) ~ div div#subs { width: 0; display: hidden; margin-left: 0; } 48 + </style> 49 + 50 + <!-- Checkboxes must come first as siblings --> 51 + <input type="checkbox" id="filesToggle" class="peer/files hidden" checked/> 52 + <input type="checkbox" id="subsToggle" class="peer/subs hidden" checked/> 53 + 54 + <!-- Top bar with controls --> 55 + <div class="sticky top-0 z-30 bg-slate-100 dark:bg-gray-900 flex items-center gap-2 col-span-full h-12"> 56 + <label for="filesToggle" class="inline-flex items-center justify-center rounded cursor-pointer p-2 text-normal font-normal normalcase"> 57 + <span class="show-text">{{ i "panel-left-open" "size-5" }}</span> 58 + <span class="hide-text">{{ i "panel-left-close" "size-5" }}</span> 59 + </label> 60 + {{ template "repo/fragments/diffStatPill" .Diff.Stat }} 61 + {{ .Diff.Stat.FilesChanged }} changed file{{ if ne .Diff.Stat.FilesChanged 1 }}s{{ end }} 62 + <div class="flex-grow"></div> 63 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 64 + <label for="subsToggle" class="inline-flex items-center justify-center rounded cursor-pointer p-2"> 65 + {{ i "message-square-more" "size-5" }} 66 + </label> 67 + </div> 68 + 69 + <div class="flex col-span-full"> 70 + <!-- left panel --> 71 + <div id="files" class="w-0 overflow-hidden sticky top-12 max-h-screen overflow-y-auto pb-12"> 72 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 73 + </div> 74 + 75 + <!-- main content --> 76 + <div class="flex-1 min-w-0 sticky top-12 pb-12"> 77 + {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }} 78 + </div> 79 + 80 + <!-- right panel --> 81 + <div id="subs" class="w-0 overflow-hidden max-h-screen flex flex-col sticky top-12 pb-12"> 82 + <div class="z-20 sticky top-0 rounded-t p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"> 83 + <h2 class="font-bold uppercase">history</h2> 84 + </div> 85 + <div class="flex flex-col-reverse gap-4 overflow-y-auto"> 86 + {{ template "submissions2" . }} 87 + </div> 88 + </div> 89 + </div> 29 90 </div> 30 91 {{ end }} 31 92 32 93 {{ define "repoContent" }} 33 94 {{ template "repo/pulls/fragments/pullHeader" . }} 34 - 35 95 {{ if .Pull.IsStacked }} 36 96 <div class="mt-8"> 37 97 {{ template "repo/pulls/fragments/pullStack" . }} ··· 40 100 {{ end }} 41 101 42 102 {{ define "repoAfter" }} 43 - <section id="submissions" class="mt-4"> 44 - <div class="flex flex-col gap-4"> 45 - {{ block "submissions" . }} {{ end }} 103 + <div id="pull-close"></div> 104 + <div id="pull-reopen"></div> 105 + {{ end }} 106 + 107 + {{ define "submissions2" }} 108 + {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 109 + {{ range $ridx, $item := reverse .Pull.Submissions }} 110 + {{ $idx := sub $lastIdx $ridx }} 111 + <div class="rounded border border-gray-200 dark:border-gray-700 w-full shadow-sm bg-gray-50 dark:bg-gray-800/50"> 112 + {{ with $item }} 113 + {{ $patches := .AsFormatPatch }} 114 + {{ $round := .RoundNumber }} 115 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2"> 116 + <div class="flex-shrink-0"> 117 + <img 118 + src="{{ tinyAvatar $.Pull.OwnerDid }}" 119 + alt="" 120 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 121 + /> 122 + </div> 123 + <!-- right column: name and body in two rows --> 124 + <div class="flex-1 min-w-0 flex flex-col gap-1"> 125 + <div class="flex gap-2 items-center justify-between mb-1"> 126 + <span class="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 127 + {{ resolve $.Pull.OwnerDid }} submitted v{{ $round }} 128 + <span class="select-none before:content-['\00B7']"></span> 129 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ $round }}">{{ template "repo/fragments/shortTimeAgo" .Created }}</a> 130 + </span> 131 + {{ if ne $idx 0 }} 132 + <a class="flex items-center gap-2 no-underline hover:no-underline text-sm" 133 + hx-boost="true" 134 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{$round}}/interdiff"> 135 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 136 + <span class="hidden md:inline">interdiff</span> 137 + </a> 138 + {{ end }} 139 + </div> 140 + <details class="group"> 141 + <summary class="list-none cursor-pointer flex items-center gap-2"> 142 + <span>{{ i "git-commit-horizontal" "w-4 h-4" }}</span> 143 + {{ len $patches }} commit{{ if ne (len $patches) 1 }}s{{ end }} 144 + </summary> 145 + {{ range $patches }} 146 + <div id="commit-{{.SHA}}" class="py-1 relative w-full md:max-w-3/5 md:w-fit flex flex-col text-gray-600 dark:text-gray-300"> 147 + <div class="flex items-baseline gap-2"> 148 + <div> 149 + <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 150 + {{ $fullRepo := "" }} 151 + {{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }} 152 + {{ $fullRepo = printf "%s/%s" $.Pull.OwnerDid $.Pull.PullSource.Repo.Name }} 153 + {{ else if $.Pull.IsBranchBased }} 154 + {{ $fullRepo = $.RepoInfo.FullName }} 155 + {{ end }} 156 + 157 + <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 158 + {{ if $fullRepo }} 159 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-600 dark:text-gray-300">{{ slice .SHA 0 8 }}</a> 160 + {{ else }} 161 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 162 + {{ end }} 163 + </div> 164 + 165 + <div> 166 + <span>{{ .Title | description }}</span> 167 + {{ if gt (len .Body) 0 }} 168 + <button 169 + class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 170 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 171 + > 172 + {{ i "ellipsis" "w-3 h-3" }} 173 + </button> 174 + {{ end }} 175 + {{ if gt (len .Body) 0 }} 176 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">{{ nl2br .Body }}</p> 177 + {{ end }} 178 + </div> 179 + </div> 180 + </div> 181 + {{ end }} 182 + </details> 183 + <div> 184 + {{ block "pipelineStatus" (list $ .) }} {{ end }} 185 + </div> 186 + {{ if eq $lastIdx .RoundNumber }} 187 + {{ block "mergeCheck" $ }} {{ end }} 188 + {{ end }} 189 + </div> 190 + </div> 191 + <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> 192 + {{ range $cidx, $c := .Comments }} 193 + <div id="comment-{{$c.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto"> 194 + <!-- left column: profile picture --> 195 + <div class="flex-shrink-0"> 196 + <img 197 + src="{{ tinyAvatar $c.OwnerDid }}" 198 + alt="" 199 + class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900" 200 + /> 201 + </div> 202 + <!-- right column: name and body in two rows --> 203 + <div class="flex-1 min-w-0"> 204 + <!-- Row 1: Author and timestamp --> 205 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 206 + <span>{{ resolve $c.OwnerDid }}</span> 207 + <span class="before:content-['ยท']"></span> 208 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 209 + </div> 210 + <!-- Row 2: Body text --> 211 + <div class="prose dark:prose-invert mt-1"> 212 + {{ $c.Body | markdown }} 213 + </div> 214 + </div> 215 + </div> 216 + {{ end }} 217 + </div> 218 + {{ end }} 219 + {{ if eq $lastIdx .RoundNumber }} 220 + {{ block "mergeStatus" $ }} {{ end }} 221 + {{ block "resubmitStatus" $ }} {{ end }} 222 + {{ end }} 223 + {{ if $.LoggedInUser }} 224 + {{ template "repo/pulls/fragments/pullActions" 225 + (dict 226 + "LoggedInUser" $.LoggedInUser 227 + "Pull" $.Pull 228 + "RepoInfo" $.RepoInfo 229 + "RoundNumber" .RoundNumber 230 + "MergeCheck" $.MergeCheck 231 + "ResubmitCheck" $.ResubmitCheck 232 + "BranchDeleteStatus" $.BranchDeleteStatus 233 + "Stack" $.Stack) }} 234 + {{ else }} 235 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center"> 236 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 237 + sign up 238 + </a> 239 + <span class="text-gray-500 dark:text-gray-400">or</span> 240 + <a href="/login" class="underline">login</a> 241 + to add to the discussion 242 + </div> 243 + {{ end }} 244 + </div> 245 + {{ end }} 246 + {{ end }} 247 + 248 + {{ define "newComment" }} 249 + {{ $root := index . 0 }} 250 + {{ $submission := index . 1 }} 251 + <form 252 + id="comment-form" 253 + hx-post="/{{ $root.RepoInfo.FullName }}/pulls/{{ $root.Pull.PullId }}/round/{{ $submission.RoundNumber }}/comment" 254 + hx-on::after-request="if(event.detail.successful) this.reset()" 255 + > 256 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 257 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 258 + {{ template "user/fragments/picHandleLink" $root.LoggedInUser.Did }} 46 259 </div> 47 - </section> 260 + <textarea 261 + id="comment-textarea" 262 + name="body" 263 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 264 + placeholder="Add to the discussion" 265 + rows="8" 266 + ></textarea> 267 + <div id="pull-comment"></div> 268 + </div> 269 + {{ template "replyActions" . }} 270 + </form> 271 + {{ end }} 48 272 49 - <div id="pull-close"></div> 50 - <div id="pull-reopen"></div> 273 + {{ define "replyActions" }} 274 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 275 + {{ template "cancel" . }} 276 + {{ template "reply" . }} 277 + </div> 278 + {{ end }} 279 + 280 + {{ define "cancel" }} 281 + <button 282 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 283 + hx-get="TODO" 284 + hx-target="TODO" 285 + hx-swap="outerHTML"> 286 + {{ i "x" "size-4" }} 287 + cancel 288 + </button> 289 + {{ end }} 290 + 291 + {{ define "reply" }} 292 + <button 293 + id="TODO" 294 + type="submit" 295 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 296 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 297 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 298 + reply 299 + </button> 51 300 {{ end }} 52 301 53 302 {{ define "submissions" }} ··· 165 414 166 415 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 167 416 {{ range $cidx, $c := .Comments }} 168 - <div id="comment-{{$c.Id}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 417 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 169 418 {{ if gt $cidx 0 }} 170 419 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 171 420 {{ end }} 172 421 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 173 - {{ template "user/fragments/picHandleLink" $c.Did.String }} 422 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 174 423 <span class="before:content-['ยท']"></span> 175 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.Id}}">{{ template "repo/fragments/time" $c.Created }}</a> 424 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 176 425 </div> 177 426 <div class="prose dark:prose-invert"> 178 427 {{ $c.Body | markdown }} ··· 214 463 {{ end }} 215 464 {{ end }} 216 465 466 + {{ define "mergeCheck" }} 467 + {{ $isOpen := .Pull.State.IsOpen }} 468 + {{ if and $isOpen .MergeCheck .MergeCheck.Error }} 469 + <div class="flex items-center gap-2"> 470 + {{ i "triangle-alert" "w-4 h-4 text-red-500 dark:text-red-300" }} 471 + {{ .MergeCheck.Error }} 472 + </div> 473 + {{ else if and $isOpen .MergeCheck .MergeCheck.IsConflicted }} 474 + <details class="group"> 475 + <summary class="flex items-center justify-between cursor-pointer list-none"> 476 + <div class="flex items-center gap-2 "> 477 + {{ i "triangle-alert" "w-4 h-4" }} 478 + <span class="font-medium">merge conflicts detected</span> 479 + </div> 480 + <div> 481 + <span class="group-open:hidden inline">{{ i "chevrons-up-down" "w-4 h-4" }}</span> 482 + <span class="hidden group-open:inline">{{ i "chevrons-down-up" "w-4 h-4" }}</span> 483 + </div> 484 + </summary> 485 + {{ if gt (len .MergeCheck.Conflicts) 0 }} 486 + <ul class="space-y-1 mt-2"> 487 + {{ range .MergeCheck.Conflicts }} 488 + {{ if .Filename }} 489 + <li class="flex items-center"> 490 + {{ i "file-warning" "inline-flex w-4 h-4 mr-1.5 text-red-500 dark:text-red-300 flex-shrink-0" }} 491 + <span class="font-mono" style="word-break: keep-all; overflow-wrap: break-word;">{{ .Filename }}</span> 492 + </li> 493 + {{ else if .Reason }} 494 + <li class="flex items-center"> 495 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 496 + <span>{{.Reason}}</span> 497 + </li> 498 + {{ end }} 499 + {{ end }} 500 + </ul> 501 + {{ end }} 502 + </details> 503 + {{ else if and $isOpen .MergeCheck }} 504 + <div class="flex items-center gap-2"> 505 + {{ i "check" "w-4 h-4 text-green-600 dark:text-green-500" }} 506 + <span>no conflicts, ready to merge</span> 507 + </div> 508 + {{ end }} 509 + {{ end }} 510 + 217 511 {{ define "mergeStatus" }} 218 512 {{ if .Pull.State.IsClosed }} 219 - <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 513 + <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative"> 220 514 <div class="flex items-center gap-2 text-black dark:text-white"> 221 515 {{ i "ban" "w-4 h-4" }} 222 516 <span class="font-medium">closed without merging</span ··· 224 518 </div> 225 519 </div> 226 520 {{ else if .Pull.State.IsMerged }} 227 - <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 521 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative"> 228 522 <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 229 523 {{ i "git-merge" "w-4 h-4" }} 230 524 <span class="font-medium">pull request successfully merged</span ··· 232 526 </div> 233 527 </div> 234 528 {{ else if .Pull.State.IsDeleted }} 235 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 529 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative"> 236 530 <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 237 531 {{ i "git-pull-request-closed" "w-4 h-4" }} 238 532 <span class="font-medium">This pull has been deleted (possibly by jj abandon or jj squash)</span> 239 533 </div> 240 534 </div> 241 - {{ else if and .MergeCheck .MergeCheck.Error }} 242 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 243 - <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 244 - {{ i "triangle-alert" "w-4 h-4" }} 245 - <span class="font-medium">{{ .MergeCheck.Error }}</span> 246 - </div> 247 - </div> 248 - {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 249 - <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 250 - <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 251 - <div class="flex items-center gap-2"> 252 - {{ i "triangle-alert" "w-4 h-4" }} 253 - <span class="font-medium">merge conflicts detected</span> 254 - </div> 255 - {{ if gt (len .MergeCheck.Conflicts) 0 }} 256 - <ul class="space-y-1"> 257 - {{ range .MergeCheck.Conflicts }} 258 - {{ if .Filename }} 259 - <li class="flex items-center"> 260 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 261 - <span class="font-mono">{{ .Filename }}</span> 262 - </li> 263 - {{ else if .Reason }} 264 - <li class="flex items-center"> 265 - {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 266 - <span>{{.Reason}}</span> 267 - </li> 268 - {{ end }} 269 - {{ end }} 270 - </ul> 271 - {{ end }} 272 - </div> 273 - </div> 274 - {{ else if .MergeCheck }} 275 - <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 276 - <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 277 - {{ i "circle-check-big" "w-4 h-4" }} 278 - <span class="font-medium">no conflicts, ready to merge</span> 279 - </div> 280 - </div> 281 535 {{ end }} 282 536 {{ end }} 283 537 284 538 {{ define "resubmitStatus" }} 285 539 {{ if .ResubmitCheck.Yes }} 286 - <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 540 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative"> 287 541 <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 288 542 {{ i "triangle-alert" "w-4 h-4" }} 289 543 <span class="font-medium">this branch has been updated, consider resubmitting</span> ··· 299 553 {{ with $pipeline }} 300 554 {{ $id := .Id }} 301 555 {{ if .Statuses }} 302 - <div class="max-w-80 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 303 - {{ range $name, $all := .Statuses }} 304 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 305 - <div 306 - class="flex gap-2 items-center justify-between p-2"> 307 - {{ $lastStatus := $all.Latest }} 308 - {{ $kind := $lastStatus.Status.String }} 556 + <details> 557 + <summary class="cursor-pointer list-none">{{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" false) }}</summary> 558 + <div class="my-2 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 559 + {{ range $name, $all := .Statuses }} 560 + <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 561 + <div 562 + class="flex gap-2 items-center justify-between p-2"> 563 + {{ $lastStatus := $all.Latest }} 564 + {{ $kind := $lastStatus.Status.String }} 309 565 310 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 311 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 312 - {{ $name }} 313 - </div> 314 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 315 - <span class="font-bold">{{ $kind }}</span> 316 - {{ if .TimeTaken }} 317 - {{ template "repo/fragments/duration" .TimeTaken }} 318 - {{ else }} 319 - {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 320 - {{ end }} 321 - </div> 566 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 567 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 568 + {{ $name }} 569 + </div> 570 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 571 + <span class="font-bold">{{ $kind }}</span> 572 + {{ if .TimeTaken }} 573 + {{ template "repo/fragments/duration" .TimeTaken }} 574 + {{ else }} 575 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 576 + {{ end }} 577 + </div> 578 + </div> 579 + </a> 580 + {{ end }} 322 581 </div> 323 - </a> 324 - {{ end }} 325 - </div> 582 + </details> 326 583 {{ end }} 327 584 {{ end }} 328 585 {{ end }}
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 136 136 {{ $pipeline := index $.Pipelines .LatestSha }} 137 137 {{ if and $pipeline $pipeline.Id }} 138 138 <span class="before:content-['ยท']"></span> 139 - {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 139 + {{ template "repo/pipelines/fragments/pipelineSymbol" (dict "Pipeline" $pipeline "ShortSummary" true) }} 140 140 {{ end }} 141 141 142 142 {{ $state := .Labels }}
+2 -2
appview/pulls/opengraph.go
··· 242 242 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 243 243 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 244 244 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 245 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 245 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 246 246 if err != nil { 247 247 log.Printf("dolly silhouette not available (this is ok): %v", err) 248 248 } ··· 277 277 } 278 278 279 279 // Get comment count from database 280 - comments, err := db.GetComments(s.db, orm.FilterEq("subject_at", pull.AtUri())) 280 + comments, err := db.GetPullComments(s.db, orm.FilterEq("pull_id", pull.ID)) 281 281 if err != nil { 282 282 log.Printf("failed to get pull comments: %v", err) 283 283 }
+34 -26
appview/pulls/pulls.go
··· 232 232 defs[l.AtUri().String()] = &l 233 233 } 234 234 235 - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 235 + patch := pull.LatestSubmission().CombinedPatch() 236 + diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 237 + var diffOpts types.DiffOpts 238 + if d := r.URL.Query().Get("diff"); d == "split" { 239 + diffOpts.Split = true 240 + } 241 + 242 + log.Println(s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 236 243 LoggedInUser: user, 237 244 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 238 245 Pull: pull, ··· 243 250 MergeCheck: mergeCheckResponse, 244 251 ResubmitCheck: resubmitResult, 245 252 Pipelines: m, 253 + Diff: &diff, 254 + DiffOpts: diffOpts, 246 255 247 256 OrderedReactionKinds: models.OrderedReactionKinds, 248 257 Reactions: reactionMap, 249 258 UserReacted: userReactions, 250 259 251 260 LabelDefs: defs, 252 - }) 261 + })) 253 262 } 254 263 255 264 func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { ··· 741 750 } 742 751 defer tx.Rollback() 743 752 744 - comment := models.Comment{ 745 - Did: syntax.DID(user.Did), 746 - Rkey: tid.TID(), 747 - Subject: pull.AtUri(), 748 - ReplyTo: nil, 749 - Body: body, 750 - Created: time.Now(), 751 - Mentions: mentions, 752 - References: references, 753 - PullSubmissionId: &pull.Submissions[roundNumber].ID, 754 - } 755 - if err = comment.Validate(); err != nil { 756 - log.Println("failed to validate comment", err) 757 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 758 - return 759 - } 760 - record := comment.AsRecord() 753 + createdAt := time.Now().Format(time.RFC3339) 761 754 762 755 client, err := s.oauth.AuthorizedClient(r) 763 756 if err != nil { ··· 765 758 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 766 759 return 767 760 } 768 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 769 - Collection: tangled.CommentNSID, 761 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 762 + Collection: tangled.RepoPullCommentNSID, 770 763 Repo: user.Did, 771 - Rkey: comment.Rkey, 764 + Rkey: tid.TID(), 772 765 Record: &lexutil.LexiconTypeDecoder{ 773 - Val: &record, 766 + Val: &tangled.RepoPullComment{ 767 + Pull: pull.AtUri().String(), 768 + Body: body, 769 + CreatedAt: createdAt, 770 + }, 774 771 }, 775 772 }) 776 773 if err != nil { ··· 779 776 return 780 777 } 781 778 779 + comment := &models.PullComment{ 780 + OwnerDid: user.Did, 781 + RepoAt: f.RepoAt().String(), 782 + PullId: pull.PullId, 783 + Body: body, 784 + CommentAt: atResp.Uri, 785 + SubmissionId: pull.Submissions[roundNumber].ID, 786 + Mentions: mentions, 787 + References: references, 788 + } 789 + 782 790 // Create the pull comment in the database with the commentAt field 783 - err = db.PutComment(tx, &comment) 791 + commentId, err := db.NewPullComment(tx, comment) 784 792 if err != nil { 785 793 log.Println("failed to create pull comment", err) 786 794 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 794 802 return 795 803 } 796 804 797 - s.notifier.NewComment(r.Context(), &comment) 805 + s.notifier.NewPullComment(r.Context(), comment, mentions) 798 806 799 807 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 800 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 808 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 801 809 return 802 810 } 803 811 }
+1 -1
appview/repo/opengraph.go
··· 237 237 dollyX := dollyBounds.Min.X + (dollyBounds.Dx() / 2) - (dollySize / 2) 238 238 dollyY := statsY + iconBaselineOffset - dollySize/2 + 25 239 239 dollyColor := color.RGBA{180, 180, 180, 255} // light gray 240 - err = dollyArea.DrawDollySilhouette(dollyX, dollyY, dollySize, dollyColor) 240 + err = dollyArea.DrawDolly(dollyX, dollyY, dollySize, dollyColor) 241 241 if err != nil { 242 242 log.Printf("dolly silhouette not available (this is ok): %v", err) 243 243 }
+29
appview/state/manifest.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 9 + // https://www.w3.org/TR/appmanifest/ 10 + var manifestData = map[string]any{ 11 + "name": "tangled", 12 + "description": "tightly-knit social coding.", 13 + "icons": []map[string]string{ 14 + { 15 + "src": "/static/logos/dolly.svg", 16 + "sizes": "144x144", 17 + }, 18 + }, 19 + "start_url": "/", 20 + "id": "https://tangled.org", 21 + "display": "standalone", 22 + "background_color": "#111827", 23 + "theme_color": "#111827", 24 + } 25 + 26 + func (p *State) WebAppManifest(w http.ResponseWriter, r *http.Request) { 27 + w.Header().Set("Content-Type", "application/manifest+json") 28 + json.NewEncoder(w).Encode(manifestData) 29 + }
+1 -3
appview/state/router.go
··· 32 32 s.pages, 33 33 ) 34 34 35 - router.Get("/favicon.svg", s.Favicon) 36 - router.Get("/favicon.ico", s.Favicon) 37 - router.Get("/pwa-manifest.json", s.PWAManifest) 35 + router.Get("/pwa-manifest.json", s.WebAppManifest) 38 36 router.Get("/robots.txt", s.RobotsTxt) 39 37 40 38 userRouter := s.UserRouter(&middleware)
+1 -37
appview/state/state.go
··· 117 117 tangled.SpindleNSID, 118 118 tangled.StringNSID, 119 119 tangled.RepoIssueNSID, 120 - tangled.CommentNSID, 120 + tangled.RepoIssueCommentNSID, 121 121 tangled.LabelDefinitionNSID, 122 122 tangled.LabelOpNSID, 123 123 }, ··· 202 202 return s.db.Close() 203 203 } 204 204 205 - func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 206 - w.Header().Set("Content-Type", "image/svg+xml") 207 - w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 208 - w.Header().Set("ETag", `"favicon-svg-v1"`) 209 - 210 - if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 211 - w.WriteHeader(http.StatusNotModified) 212 - return 213 - } 214 - 215 - s.pages.Favicon(w) 216 - } 217 - 218 205 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { 219 206 w.Header().Set("Content-Type", "text/plain") 220 207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day ··· 223 210 Allow: / 224 211 ` 225 212 w.Write([]byte(robotsTxt)) 226 - } 227 - 228 - // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 229 - const manifestJson = `{ 230 - "name": "tangled", 231 - "description": "tightly-knit social coding.", 232 - "icons": [ 233 - { 234 - "src": "/favicon.svg", 235 - "sizes": "144x144" 236 - } 237 - ], 238 - "start_url": "/", 239 - "id": "org.tangled", 240 - 241 - "display": "standalone", 242 - "background_color": "#111827", 243 - "theme_color": "#111827" 244 - }` 245 - 246 - func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 247 - w.Header().Set("Content-Type", "application/json") 248 - w.Write([]byte(manifestJson)) 249 213 } 250 214 251 215 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
+27
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 + "tangled.org/core/appview/db" 7 8 "tangled.org/core/appview/models" 9 + "tangled.org/core/orm" 8 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 + } 9 36 10 37 func (v *Validator) ValidateIssue(issue *models.Issue) error { 11 38 if issue.Title == "" {
-1
cmd/cborgen/cborgen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 - tangled.Comment{}, 19 18 tangled.FeedReaction{}, 20 19 tangled.FeedStar{}, 21 20 tangled.GitRefUpdate{},
+182
cmd/dolly/main.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "flag" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "os" 11 + "path/filepath" 12 + "strconv" 13 + "strings" 14 + "text/template" 15 + 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 { 37 + fmt.Fprintf(os.Stderr, "Error parsing size: %v\n", err) 38 + os.Exit(1) 39 + } 40 + 41 + // Detect format from file extension 42 + ext := strings.ToLower(filepath.Ext(output)) 43 + format := strings.TrimPrefix(ext, ".") 44 + 45 + if format != "svg" && format != "png" && format != "ico" { 46 + fmt.Fprintf(os.Stderr, "Invalid file extension: %s. Must be .svg, .png, or .ico\n", ext) 47 + os.Exit(1) 48 + } 49 + 50 + if fillColor != "currentColor" && !isValidHexColor(fillColor) { 51 + fmt.Fprintf(os.Stderr, "Invalid color format: %s. Use hex format like #FF5733\n", fillColor) 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) 59 + } 60 + 61 + // Create output directory if it doesn't exist 62 + dir := filepath.Dir(output) 63 + if dir != "" && dir != "." { 64 + if err := os.MkdirAll(dir, 0755); err != nil { 65 + fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err) 66 + os.Exit(1) 67 + } 68 + } 69 + 70 + switch format { 71 + case "svg": 72 + err = saveSVG(svgData, output, width, height) 73 + case "png": 74 + err = savePNG(svgData, output, width, height) 75 + case "ico": 76 + err = saveICO(svgData, output, width, height) 77 + } 78 + 79 + if err != nil { 80 + fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err) 81 + os.Exit(1) 82 + } 83 + 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 + } 100 + 101 + return svgData.Bytes(), nil 102 + } 103 + 104 + func svgToImage(svgData []byte, w, h int) (image.Image, error) { 105 + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) 106 + if err != nil { 107 + return nil, fmt.Errorf("error parsing SVG: %v", err) 108 + } 109 + 110 + icon.SetTarget(0, 0, float64(w), float64(h)) 111 + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) 112 + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) 113 + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) 114 + raster := rasterx.NewDasher(w, h, scanner) 115 + icon.Draw(raster, 1.0) 116 + 117 + return rgba, nil 118 + } 119 + 120 + func parseSize(size string) (int, int, error) { 121 + parts := strings.Split(size, "x") 122 + if len(parts) != 2 { 123 + return 0, 0, fmt.Errorf("invalid size format, use WIDTHxHEIGHT") 124 + } 125 + 126 + width, err := strconv.Atoi(parts[0]) 127 + if err != nil { 128 + return 0, 0, fmt.Errorf("invalid width: %v", err) 129 + } 130 + 131 + height, err := strconv.Atoi(parts[1]) 132 + if err != nil { 133 + return 0, 0, fmt.Errorf("invalid height: %v", err) 134 + } 135 + 136 + if width <= 0 || height <= 0 { 137 + return 0, 0, fmt.Errorf("width and height must be positive") 138 + } 139 + 140 + return width, height, nil 141 + } 142 + 143 + func isValidHexColor(hex string) bool { 144 + if len(hex) != 7 || hex[0] != '#' { 145 + return false 146 + } 147 + _, err := strconv.ParseUint(hex[1:], 16, 32) 148 + return err == nil 149 + } 150 + 151 + func saveSVG(svgData []byte, filepath string, _, _ int) error { 152 + return os.WriteFile(filepath, svgData, 0644) 153 + } 154 + 155 + func savePNG(svgData []byte, filepath string, width, height int) error { 156 + img, err := svgToImage(svgData, width, height) 157 + if err != nil { 158 + return err 159 + } 160 + 161 + f, err := os.Create(filepath) 162 + if err != nil { 163 + return err 164 + } 165 + defer f.Close() 166 + 167 + return png.Encode(f, img) 168 + } 169 + 170 + func saveICO(svgData []byte, filepath string, width, height int) error { 171 + img, err := svgToImage(svgData, width, height) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + icoData, err := ico.ImageToIco(img) 177 + if err != nil { 178 + return err 179 + } 180 + 181 + return os.WriteFile(filepath, icoData, 0644) 182 + }
+6
docs/logo.html
··· 1 + <div class="flex items-center gap-2 w-fit mx-auto"> 2 + <span class="w-16 h-16 [&>svg]:w-full [&>svg]:h-full text-black dark:text-white"> 3 + ${ dolly.svg() } 4 + </span> 5 + <span class="font-bold text-4xl not-italic text-black dark:text-white">tangled</span> 6 + </div>
+2
docs/template.html
··· 74 74 ${ x.svg() } 75 75 $if(toc-title)$$toc-title$$else$Table of Contents$endif$ 76 76 </button> 77 + ${ logo.html() } 77 78 ${ search.html() } 78 79 ${ table-of-contents:toc.html() } 79 80 </div> ··· 88 89 class="hidden md:flex md:flex-col gap-4 fixed left-0 top-0 w-80 h-screen 89 90 bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 90 91 p-4 z-50 overflow-y-auto"> 92 + ${ logo.html() } 91 93 ${ search.html() } 92 94 <div class="flex-1"> 93 95 $if(toc-title)$
+17 -2
flake.nix
··· 94 94 spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 95 95 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 96 96 knot = self.callPackage ./nix/pkgs/knot.nix {}; 97 + dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 97 98 }); 98 99 in { 99 100 overlays.default = final: prev: { 100 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs; 101 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 101 102 }; 102 103 103 104 packages = forAllSystems (system: let ··· 106 107 staticPackages = mkPackageSet pkgs.pkgsStatic; 107 108 crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 108 109 in { 109 - inherit (packages) appview appview-static-files lexgen goat spindle knot knot-unwrapped sqlite-lib docs; 110 + inherit 111 + (packages) 112 + appview 113 + appview-static-files 114 + lexgen 115 + goat 116 + spindle 117 + knot 118 + knot-unwrapped 119 + sqlite-lib 120 + docs 121 + dolly 122 + ; 110 123 111 124 pkgsStatic-appview = staticPackages.appview; 112 125 pkgsStatic-knot = staticPackages.knot; 113 126 pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 114 127 pkgsStatic-spindle = staticPackages.spindle; 115 128 pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 129 + pkgsStatic-dolly = staticPackages.dolly; 116 130 117 131 pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 118 132 pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 119 133 pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 120 134 pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 135 + pkgsCross-gnu64-pkgsStatic-dolly = crossPackages.dolly; 121 136 122 137 treefmt-wrapper = pkgs.treefmt.withConfig { 123 138 settings.formatter = {
+88
ico/ico.go
··· 1 + package ico 2 + 3 + import ( 4 + "bytes" 5 + "encoding/binary" 6 + "fmt" 7 + "image" 8 + "image/png" 9 + ) 10 + 11 + type IconDir struct { 12 + Reserved uint16 // must be 0 13 + Type uint16 // 1 for ICO, 2 for CUR 14 + Count uint16 // number of images 15 + } 16 + 17 + type IconDirEntry struct { 18 + Width uint8 // 0 means 256 19 + Height uint8 // 0 means 256 20 + ColorCount uint8 21 + Reserved uint8 // must be 0 22 + ColorPlanes uint16 // 0 or 1 23 + BitsPerPixel uint16 24 + SizeInBytes uint32 25 + Offset uint32 26 + } 27 + 28 + func ImageToIco(img image.Image) ([]byte, error) { 29 + // encode image as png 30 + var pngBuf bytes.Buffer 31 + if err := png.Encode(&pngBuf, img); err != nil { 32 + return nil, fmt.Errorf("failed to encode PNG: %w", err) 33 + } 34 + pngData := pngBuf.Bytes() 35 + 36 + // get image dimensions 37 + bounds := img.Bounds() 38 + width := bounds.Dx() 39 + height := bounds.Dy() 40 + 41 + // prepare output buffer 42 + var icoBuf bytes.Buffer 43 + 44 + iconDir := IconDir{ 45 + Reserved: 0, 46 + Type: 1, // ICO format 47 + Count: 1, // One image 48 + } 49 + 50 + w := uint8(width) 51 + h := uint8(height) 52 + 53 + // width/height of 256 should be stored as 0 54 + if width == 256 { 55 + w = 0 56 + } 57 + if height == 256 { 58 + h = 0 59 + } 60 + 61 + iconDirEntry := IconDirEntry{ 62 + Width: w, 63 + Height: h, 64 + ColorCount: 0, // 0 for PNG (32-bit) 65 + Reserved: 0, 66 + ColorPlanes: 1, 67 + BitsPerPixel: 32, // PNG with alpha 68 + SizeInBytes: uint32(len(pngData)), 69 + Offset: 6 + 16, // Size of ICONDIR + ICONDIRENTRY 70 + } 71 + 72 + // write IconDir 73 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDir); err != nil { 74 + return nil, fmt.Errorf("failed to write ICONDIR: %w", err) 75 + } 76 + 77 + // write IconDirEntry 78 + if err := binary.Write(&icoBuf, binary.LittleEndian, iconDirEntry); err != nil { 79 + return nil, fmt.Errorf("failed to write ICONDIRENTRY: %w", err) 80 + } 81 + 82 + // write PNG data directly 83 + if _, err := icoBuf.Write(pngData); err != nil { 84 + return nil, fmt.Errorf("failed to write PNG data: %w", err) 85 + } 86 + 87 + return icoBuf.Bytes(), nil 88 + }
+13
input.css
··· 124 124 dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 125 } 126 126 127 + .btn-flat { 128 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 129 + bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 130 + before:absolute before:inset-0 before:-z-10 before:block before:rounded 131 + before:border before:border-gray-200 before:bg-white 132 + before:content-[''] before:transition-all before:duration-150 before:ease-in-out 133 + hover:before:bg-gray-50 134 + dark:hover:before:bg-gray-700 135 + focus:outline-none focus-visible:before:outline focus-visible:before:outline-2 focus-visible:before:outline-gray-400 136 + disabled:cursor-not-allowed disabled:opacity-50 137 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 138 + } 139 + 127 140 .btn-create { 128 141 @apply btn text-white 129 142 before:bg-green-600 hover:before:bg-green-700
-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 - }
+6 -1
nix/pkgs/appview-static-files.nix
··· 8 8 actor-typeahead-src, 9 9 sqlite-lib, 10 10 tailwindcss, 11 + dolly, 11 12 src, 12 13 }: 13 14 runCommandLocal "appview-static-files" { ··· 17 18 (allow file-read* (subpath "/System/Library/OpenSSL")) 18 19 ''; 19 20 } '' 20 - mkdir -p $out/{fonts,icons} && cd $out 21 + mkdir -p $out/{fonts,icons,logos} && cd $out 21 22 cp -f ${htmx-src} htmx.min.js 22 23 cp -f ${htmx-ws-src} htmx-ext-ws.min.js 23 24 cp -rf ${lucide-src}/*.svg icons/ ··· 26 27 cp -f ${inter-fonts-src}/InterVariable*.ttf fonts/ 27 28 cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 28 29 cp -f ${actor-typeahead-src}/actor-typeahead.js . 30 + 31 + ${dolly}/bin/dolly -output logos/dolly.png -size 180x180 32 + ${dolly}/bin/dolly -output logos/dolly.ico -size 48x48 33 + ${dolly}/bin/dolly -output logos/dolly.svg -color currentColor 29 34 # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 30 35 # for whatever reason (produces broken css), so we are doing this instead 31 36 cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+4
nix/pkgs/docs.nix
··· 5 5 inter-fonts-src, 6 6 ibm-plex-mono-src, 7 7 lucide-src, 8 + dolly, 8 9 src, 9 10 }: 10 11 runCommandLocal "docs" {} '' ··· 17 18 18 19 # icons 19 20 cp -rf ${lucide-src}/*.svg working/ 21 + 22 + # logo 23 + ${dolly}/bin/dolly -output working/dolly.svg -color currentColor 20 24 21 25 # content - chunked 22 26 ${pandoc}/bin/pandoc ${src}/docs/DOCS.md \
+21
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 + }
+2 -2
spindle/models/models.go
··· 53 53 StatusKindRunning, 54 54 } 55 55 FinishStates [4]StatusKind = [4]StatusKind{ 56 - StatusKindCancelled, 57 56 StatusKindFailed, 58 - StatusKindSuccess, 59 57 StatusKindTimeout, 58 + StatusKindCancelled, 59 + StatusKindSuccess, 60 60 } 61 61 ) 62 62
+4 -2
types/diff.go
··· 27 27 } 28 28 29 29 type DiffStat struct { 30 - Insertions int64 31 - Deletions int64 30 + Insertions int64 31 + Deletions int64 32 + FilesChanged int 32 33 } 33 34 34 35 func (d *Diff) Stats() DiffStat { ··· 37 38 stats.Insertions += f.LinesAdded 38 39 stats.Deletions += f.LinesDeleted 39 40 } 41 + stats.FilesChanged = len(d.TextFragments) 40 42 return stats 41 43 } 42 44