forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

+10411 -4829
+6
.tangled/workflows/test.yml
··· 14 14 command: | 15 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 16 17 + - name: run linter 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go vet -v ./... 22 + 17 23 - name: run all tests 18 24 environment: 19 25 CGO_ENABLED: 1
+72 -26
api/tangled/cbor_gen.go
··· 5812 5812 fieldCount-- 5813 5813 } 5814 5814 5815 + if t.Labels == nil { 5816 + fieldCount-- 5817 + } 5818 + 5815 5819 if t.Source == nil { 5816 5820 fieldCount-- 5817 5821 } ··· 5889 5893 return err 5890 5894 } 5891 5895 5892 - // t.Owner (string) (string) 5893 - if len("owner") > 1000000 { 5894 - return xerrors.Errorf("Value in field \"owner\" was too long") 5895 - } 5896 + // t.Labels ([]string) (slice) 5897 + if t.Labels != nil { 5898 + 5899 + if len("labels") > 1000000 { 5900 + return xerrors.Errorf("Value in field \"labels\" was too long") 5901 + } 5902 + 5903 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("labels"))); err != nil { 5904 + return err 5905 + } 5906 + if _, err := cw.WriteString(string("labels")); err != nil { 5907 + return err 5908 + } 5896 5909 5897 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5898 - return err 5899 - } 5900 - if _, err := cw.WriteString(string("owner")); err != nil { 5901 - return err 5902 - } 5910 + if len(t.Labels) > 8192 { 5911 + return xerrors.Errorf("Slice value in field t.Labels was too long") 5912 + } 5913 + 5914 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Labels))); err != nil { 5915 + return err 5916 + } 5917 + for _, v := range t.Labels { 5918 + if len(v) > 1000000 { 5919 + return xerrors.Errorf("Value in field v was too long") 5920 + } 5903 5921 5904 - if len(t.Owner) > 1000000 { 5905 - return xerrors.Errorf("Value in field t.Owner was too long") 5906 - } 5922 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 5923 + return err 5924 + } 5925 + if _, err := cw.WriteString(string(v)); err != nil { 5926 + return err 5927 + } 5907 5928 5908 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil { 5909 - return err 5910 - } 5911 - if _, err := cw.WriteString(string(t.Owner)); err != nil { 5912 - return err 5929 + } 5913 5930 } 5914 5931 5915 5932 // t.Source (string) (string) ··· 6107 6124 6108 6125 t.LexiconTypeID = string(sval) 6109 6126 } 6110 - // t.Owner (string) (string) 6111 - case "owner": 6127 + // t.Labels ([]string) (slice) 6128 + case "labels": 6129 + 6130 + maj, extra, err = cr.ReadHeader() 6131 + if err != nil { 6132 + return err 6133 + } 6134 + 6135 + if extra > 8192 { 6136 + return fmt.Errorf("t.Labels: array too large (%d)", extra) 6137 + } 6138 + 6139 + if maj != cbg.MajArray { 6140 + return fmt.Errorf("expected cbor array") 6141 + } 6142 + 6143 + if extra > 0 { 6144 + t.Labels = make([]string, extra) 6145 + } 6146 + 6147 + for i := 0; i < int(extra); i++ { 6148 + { 6149 + var maj byte 6150 + var extra uint64 6151 + var err error 6152 + _ = maj 6153 + _ = extra 6154 + _ = err 6112 6155 6113 - { 6114 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6115 - if err != nil { 6116 - return err 6156 + { 6157 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6158 + if err != nil { 6159 + return err 6160 + } 6161 + 6162 + t.Labels[i] = string(sval) 6163 + } 6164 + 6117 6165 } 6118 - 6119 - t.Owner = string(sval) 6120 6166 } 6121 6167 // t.Source (string) (string) 6122 6168 case "source":
+10
api/tangled/repotree.go
··· 31 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 32 // parent: The parent path in the tree 33 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 34 36 // ref: The git reference used 35 37 Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 36 46 } 37 47 38 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+3 -2
api/tangled/tangledrepo.go
··· 22 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 23 // knot: knot where the repo was created 24 24 Knot string `json:"knot" cborgen:"knot"` 25 + // labels: List of labels that this repo subscribes to 26 + Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"` 25 27 // name: name of the repo 26 - Name string `json:"name" cborgen:"name"` 27 - Owner string `json:"owner" cborgen:"owner"` 28 + Name string `json:"name" cborgen:"name"` 28 29 // source: source of the repo 29 30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 30 31 // spindle: CI runner to send jobs to and receive results from
+2 -1
appview/commitverify/verify.go
··· 5 5 6 6 "github.com/go-git/go-git/v5/plumbing/object" 7 7 "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 8 9 "tangled.org/core/crypto" 9 10 "tangled.org/core/types" 10 11 ) ··· 45 46 func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 46 47 vcs := VerifiedCommits{} 47 48 48 - didPubkeyCache := make(map[string][]db.PublicKey) 49 + didPubkeyCache := make(map[string][]models.PublicKey) 49 50 50 51 for _, commit := range ndCommits { 51 52 c := commit.Commit
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+5 -25
appview/db/artifact.go
··· 5 5 "strings" 6 6 "time" 7 7 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 8 "github.com/go-git/go-git/v5/plumbing" 10 9 "github.com/ipfs/go-cid" 11 - "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/models" 12 11 ) 13 12 14 - type Artifact struct { 15 - Id uint64 16 - Did string 17 - Rkey string 18 - 19 - RepoAt syntax.ATURI 20 - Tag plumbing.Hash 21 - CreatedAt time.Time 22 - 23 - BlobCid cid.Cid 24 - Name string 25 - Size uint64 26 - MimeType string 27 - } 28 - 29 - func (a *Artifact) ArtifactAt() syntax.ATURI { 30 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 31 - } 32 - 33 - func AddArtifact(e Execer, artifact Artifact) error { 13 + func AddArtifact(e Execer, artifact models.Artifact) error { 34 14 _, err := e.Exec( 35 15 `insert or ignore into artifacts ( 36 16 did, ··· 57 37 return err 58 38 } 59 39 60 - func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 61 - var artifacts []Artifact 40 + func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + var artifacts []models.Artifact 62 42 63 43 var conditions []string 64 44 var args []any ··· 94 74 defer rows.Close() 95 75 96 76 for rows.Next() { 97 - var artifact Artifact 77 + var artifact models.Artifact 98 78 var createdAt string 99 79 var tag []byte 100 80 var blobCid string
+3 -18
appview/db/collaborators.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 - "time" 7 6 8 - "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 9 8 ) 10 9 11 - type Collaborator struct { 12 - // identifiers for the record 13 - Id int64 14 - Did syntax.DID 15 - Rkey string 16 - 17 - // content 18 - SubjectDid syntax.DID 19 - RepoAt syntax.ATURI 20 - 21 - // meta 22 - Created time.Time 23 - } 24 - 25 - func AddCollaborator(e Execer, c Collaborator) error { 10 + func AddCollaborator(e Execer, c models.Collaborator) error { 26 11 _, err := e.Exec( 27 12 `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 13 c.Did, c.Rkey, c.SubjectDid, c.RepoAt, ··· 49 34 return err 50 35 } 51 36 52 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 37 + func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) { 53 38 rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 39 if err != nil { 55 40 return nil, err
+185 -18
appview/db/db.go
··· 483 483 )), 484 484 value_format text not null default "any", 485 485 value_enum text, -- comma separated list 486 - scope text not null, 486 + scope text not null, -- comma separated list of nsid 487 487 color text, 488 488 multiple integer not null default 0, 489 489 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), ··· 527 527 -- label to subscribe to 528 528 label_at text not null, 529 529 530 - unique (repo_at, label_at), 531 - foreign key (label_at) references label_definitions (at_uri) 530 + unique (repo_at, label_at) 531 + ); 532 + 533 + create table if not exists notifications ( 534 + id integer primary key autoincrement, 535 + recipient_did text not null, 536 + actor_did text not null, 537 + type text not null, 538 + entity_type text not null, 539 + entity_id text not null, 540 + read integer not null default 0, 541 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 542 + repo_id integer references repos(id), 543 + issue_id integer references issues(id), 544 + pull_id integer references pulls(id) 545 + ); 546 + 547 + create table if not exists notification_preferences ( 548 + id integer primary key autoincrement, 549 + user_did text not null unique, 550 + repo_starred integer not null default 1, 551 + issue_created integer not null default 1, 552 + issue_commented integer not null default 1, 553 + pull_created integer not null default 1, 554 + pull_commented integer not null default 1, 555 + followed integer not null default 1, 556 + pull_merged integer not null default 1, 557 + issue_closed integer not null default 1, 558 + email_notifications integer not null default 0 532 559 ); 533 560 534 561 create table if not exists migrations ( ··· 536 563 name text unique 537 564 ); 538 565 539 - -- indexes for better star query performance 566 + -- indexes for better performance 567 + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 568 + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 540 569 create index if not exists idx_stars_created on stars(created); 541 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 542 571 `) ··· 788 817 _, err := tx.Exec(` 789 818 alter table spindles add column needs_upgrade integer not null default 0; 790 819 `) 791 - if err != nil { 792 - return err 793 - } 794 - 795 - _, err = tx.Exec(` 796 - update spindles set needs_upgrade = 1; 797 - `) 798 820 return err 799 821 }) 800 822 ··· 932 954 return err 933 955 }) 934 956 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 + 935 1097 return &DB{db}, nil 936 1098 } 937 1099 ··· 996 1158 } 997 1159 } 998 1160 999 - func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1000 - func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1001 - func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1002 - func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1003 - func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1004 - func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1005 - func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1161 + func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 1162 + func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 1163 + func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 1164 + func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 1165 + func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 1166 + func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 1167 + func FilterIn(key string, arg any) filter { return newFilter(key, "in", arg) } 1168 + func FilterLike(key string, arg any) filter { return newFilter(key, "like", arg) } 1169 + func FilterNotLike(key string, arg any) filter { return newFilter(key, "not like", arg) } 1170 + func FilterContains(key string, arg any) filter { 1171 + return newFilter(key, "like", fmt.Sprintf("%%%v%%", arg)) 1172 + } 1006 1173 1007 1174 func (f filter) Condition() string { 1008 1175 rv := reflect.ValueOf(f.arg)
+29 -34
appview/db/email.go
··· 3 3 import ( 4 4 "strings" 5 5 "time" 6 - ) 7 6 8 - type Email struct { 9 - ID int64 10 - Did string 11 - Address string 12 - Verified bool 13 - Primary bool 14 - VerificationCode string 15 - LastSent *time.Time 16 - CreatedAt time.Time 17 - } 7 + "tangled.org/core/appview/models" 8 + ) 18 9 19 - func GetPrimaryEmail(e Execer, did string) (Email, error) { 10 + func GetPrimaryEmail(e Execer, did string) (models.Email, error) { 20 11 query := ` 21 12 select id, did, email, verified, is_primary, verification_code, last_sent, created 22 13 from emails 23 14 where did = ? and is_primary = true 24 15 ` 25 - var email Email 16 + var email models.Email 26 17 var createdStr string 27 18 var lastSent string 28 19 err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 29 20 if err != nil { 30 - return Email{}, err 21 + return models.Email{}, err 31 22 } 32 23 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 33 24 if err != nil { 34 - return Email{}, err 25 + return models.Email{}, err 35 26 } 36 27 parsedTime, err := time.Parse(time.RFC3339, lastSent) 37 28 if err != nil { 38 - return Email{}, err 29 + return models.Email{}, err 39 30 } 40 31 email.LastSent = &parsedTime 41 32 return email, nil 42 33 } 43 34 44 - func GetEmail(e Execer, did string, em string) (Email, error) { 35 + func GetEmail(e Execer, did string, em string) (models.Email, error) { 45 36 query := ` 46 37 select id, did, email, verified, is_primary, verification_code, last_sent, created 47 38 from emails 48 39 where did = ? and email = ? 49 40 ` 50 - var email Email 41 + var email models.Email 51 42 var createdStr string 52 43 var lastSent string 53 44 err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 54 45 if err != nil { 55 - return Email{}, err 46 + return models.Email{}, err 56 47 } 57 48 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 58 49 if err != nil { 59 - return Email{}, err 50 + return models.Email{}, err 60 51 } 61 52 parsedTime, err := time.Parse(time.RFC3339, lastSent) 62 53 if err != nil { 63 - return Email{}, err 54 + return models.Email{}, err 64 55 } 65 56 email.LastSent = &parsedTime 66 57 return email, nil ··· 80 71 return did, nil 81 72 } 82 73 83 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 84 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 85 76 return make(map[string]string), nil 86 77 } 87 78 ··· 90 81 verifiedFilter = 1 91 82 } 92 83 84 + assoc := make(map[string]string) 85 + 93 86 // Create placeholders for the IN clause 94 - placeholders := make([]string, len(ems)) 95 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 96 89 97 90 args[0] = verifiedFilter 98 - for i, em := range ems { 99 - placeholders[i] = "?" 100 - args[i+1] = em 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 101 98 } 102 99 103 100 query := ` ··· 113 110 return nil, err 114 111 } 115 112 defer rows.Close() 116 - 117 - assoc := make(map[string]string) 118 113 119 114 for rows.Next() { 120 115 var email, did string ··· 187 182 return count > 0, nil 188 183 } 189 184 190 - func AddEmail(e Execer, email Email) error { 185 + func AddEmail(e Execer, email models.Email) error { 191 186 // Check if this is the first email for this DID 192 187 countQuery := ` 193 188 select count(*) ··· 254 249 return err 255 250 } 256 251 257 - func GetAllEmails(e Execer, did string) ([]Email, error) { 252 + func GetAllEmails(e Execer, did string) ([]models.Email, error) { 258 253 query := ` 259 254 select did, email, verified, is_primary, verification_code, last_sent, created 260 255 from emails ··· 266 261 } 267 262 defer rows.Close() 268 263 269 - var emails []Email 264 + var emails []models.Email 270 265 for rows.Next() { 271 - var email Email 266 + var email models.Email 272 267 var createdStr string 273 268 var lastSent string 274 269 err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+26 -57
appview/db/follow.go
··· 5 5 "log" 6 6 "strings" 7 7 "time" 8 + 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type Follow struct { 11 - UserDid string 12 - SubjectDid string 13 - FollowedAt time.Time 14 - Rkey string 15 - } 16 - 17 - func AddFollow(e Execer, follow *Follow) error { 12 + func AddFollow(e Execer, follow *models.Follow) error { 18 13 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 19 14 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 20 15 return err 21 16 } 22 17 23 18 // Get a follow record 24 - func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) { 19 + func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 25 20 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 26 21 row := e.QueryRow(query, userDid, subjectDid) 27 22 28 - var follow Follow 23 + var follow models.Follow 29 24 var followedAt string 30 25 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 31 26 if err != nil { ··· 55 50 return err 56 51 } 57 52 58 - type FollowStats struct { 59 - Followers int64 60 - Following int64 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 53 + func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) { 64 54 var followers, following int64 65 55 err := e.QueryRow( 66 56 `SELECT ··· 68 58 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 59 FROM follows;`, did, did).Scan(&followers, &following) 70 60 if err != nil { 71 - return FollowStats{}, err 61 + return models.FollowStats{}, err 72 62 } 73 - return FollowStats{ 63 + return models.FollowStats{ 74 64 Followers: followers, 75 65 Following: following, 76 66 }, nil 77 67 } 78 68 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 69 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) { 80 70 if len(dids) == 0 { 81 71 return nil, nil 82 72 } ··· 112 102 ) g on f.did = g.did`, 113 103 placeholderStr, placeholderStr) 114 104 115 - result := make(map[string]FollowStats) 105 + result := make(map[string]models.FollowStats) 116 106 117 107 rows, err := e.Query(query, args...) 118 108 if err != nil { ··· 126 116 if err := rows.Scan(&did, &followers, &following); err != nil { 127 117 return nil, err 128 118 } 129 - result[did] = FollowStats{ 119 + result[did] = models.FollowStats{ 130 120 Followers: followers, 131 121 Following: following, 132 122 } ··· 134 124 135 125 for _, did := range dids { 136 126 if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 127 + result[did] = models.FollowStats{ 138 128 Followers: 0, 139 129 Following: 0, 140 130 } ··· 144 134 return result, nil 145 135 } 146 136 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 - var follows []Follow 137 + func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + var follows []models.Follow 149 139 150 140 var conditions []string 151 141 var args []any ··· 177 167 return nil, err 178 168 } 179 169 for rows.Next() { 180 - var follow Follow 170 + var follow models.Follow 181 171 var followedAt string 182 172 err := rows.Scan( 183 173 &follow.UserDid, ··· 200 190 return follows, nil 201 191 } 202 192 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 193 + func GetFollowers(e Execer, did string) ([]models.Follow, error) { 204 194 return GetFollows(e, 0, FilterEq("subject_did", did)) 205 195 } 206 196 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 197 + func GetFollowing(e Execer, did string) ([]models.Follow, error) { 208 198 return GetFollows(e, 0, FilterEq("user_did", did)) 209 199 } 210 200 211 - type FollowStatus int 212 - 213 - const ( 214 - IsNotFollowing FollowStatus = iota 215 - IsFollowing 216 - IsSelf 217 - ) 218 - 219 - func (s FollowStatus) String() string { 220 - switch s { 221 - case IsNotFollowing: 222 - return "IsNotFollowing" 223 - case IsFollowing: 224 - return "IsFollowing" 225 - case IsSelf: 226 - return "IsSelf" 227 - default: 228 - return "IsNotFollowing" 229 - } 230 - } 231 - 232 - func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) { 201 + func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 233 202 if len(subjectDids) == 0 || userDid == "" { 234 - return make(map[string]FollowStatus), nil 203 + return make(map[string]models.FollowStatus), nil 235 204 } 236 205 237 - result := make(map[string]FollowStatus) 206 + result := make(map[string]models.FollowStatus) 238 207 239 208 for _, subjectDid := range subjectDids { 240 209 if userDid == subjectDid { 241 - result[subjectDid] = IsSelf 210 + result[subjectDid] = models.IsSelf 242 211 } else { 243 - result[subjectDid] = IsNotFollowing 212 + result[subjectDid] = models.IsNotFollowing 244 213 } 245 214 } 246 215 ··· 281 250 if err := rows.Scan(&subjectDid); err != nil { 282 251 return nil, err 283 252 } 284 - result[subjectDid] = IsFollowing 253 + result[subjectDid] = models.IsFollowing 285 254 } 286 255 287 256 return result, nil 288 257 } 289 258 290 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 259 + func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus { 291 260 statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 292 261 if err != nil { 293 - return IsNotFollowing 262 + return models.IsNotFollowing 294 263 } 295 264 return statuses[subjectDid] 296 265 } 297 266 298 - func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) { 267 + func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 299 268 return getFollowStatuses(e, userDid, subjectDids) 300 269 }
+290 -188
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/models" 14 14 "tangled.org/core/appview/pagination" 15 15 ) 16 16 17 - type Issue struct { 18 - Id int64 19 - Did string 20 - Rkey string 21 - RepoAt syntax.ATURI 22 - IssueId int 23 - Created time.Time 24 - Edited *time.Time 25 - Deleted *time.Time 26 - Title string 27 - Body string 28 - Open bool 29 - 30 - // optionally, populate this when querying for reverse mappings 31 - // like comment counts, parent repo etc. 32 - Comments []IssueComment 33 - Repo *Repo 34 - } 35 - 36 - func (i *Issue) AtUri() syntax.ATURI { 37 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 38 - } 39 - 40 - func (i *Issue) AsRecord() tangled.RepoIssue { 41 - return tangled.RepoIssue{ 42 - Repo: i.RepoAt.String(), 43 - Title: i.Title, 44 - Body: &i.Body, 45 - CreatedAt: i.Created.Format(time.RFC3339), 46 - } 47 - } 48 - 49 - func (i *Issue) State() string { 50 - if i.Open { 51 - return "open" 52 - } 53 - return "closed" 54 - } 55 - 56 - type CommentListItem struct { 57 - Self *IssueComment 58 - Replies []*IssueComment 59 - } 60 - 61 - func (i *Issue) CommentList() []CommentListItem { 62 - // Create a map to quickly find comments by their aturi 63 - toplevel := make(map[string]*CommentListItem) 64 - var replies []*IssueComment 65 - 66 - // collect top level comments into the map 67 - for _, comment := range i.Comments { 68 - if comment.IsTopLevel() { 69 - toplevel[comment.AtUri().String()] = &CommentListItem{ 70 - Self: &comment, 71 - } 72 - } else { 73 - replies = append(replies, &comment) 74 - } 75 - } 76 - 77 - for _, r := range replies { 78 - parentAt := *r.ReplyTo 79 - if parent, exists := toplevel[parentAt]; exists { 80 - parent.Replies = append(parent.Replies, r) 81 - } 82 - } 83 - 84 - var listing []CommentListItem 85 - for _, v := range toplevel { 86 - listing = append(listing, *v) 87 - } 88 - 89 - // sort everything 90 - sortFunc := func(a, b *IssueComment) bool { 91 - return a.Created.Before(b.Created) 92 - } 93 - sort.Slice(listing, func(i, j int) bool { 94 - return sortFunc(listing[i].Self, listing[j].Self) 95 - }) 96 - for _, r := range listing { 97 - sort.Slice(r.Replies, func(i, j int) bool { 98 - return sortFunc(r.Replies[i], r.Replies[j]) 99 - }) 100 - } 101 - 102 - return listing 103 - } 104 - 105 - func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 107 - if err != nil { 108 - created = time.Now() 109 - } 110 - 111 - body := "" 112 - if record.Body != nil { 113 - body = *record.Body 114 - } 115 - 116 - return Issue{ 117 - RepoAt: syntax.ATURI(record.Repo), 118 - Did: did, 119 - Rkey: rkey, 120 - Created: created, 121 - Title: record.Title, 122 - Body: body, 123 - Open: true, // new issues are open by default 124 - } 125 - } 126 - 127 - type IssueComment struct { 128 - Id int64 129 - Did string 130 - Rkey string 131 - IssueAt string 132 - ReplyTo *string 133 - Body string 134 - Created time.Time 135 - Edited *time.Time 136 - Deleted *time.Time 137 - } 138 - 139 - func (i *IssueComment) AtUri() syntax.ATURI { 140 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 141 - } 142 - 143 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 - return tangled.RepoIssueComment{ 145 - Body: i.Body, 146 - Issue: i.IssueAt, 147 - CreatedAt: i.Created.Format(time.RFC3339), 148 - ReplyTo: i.ReplyTo, 149 - } 150 - } 151 - 152 - func (i *IssueComment) IsTopLevel() bool { 153 - return i.ReplyTo == nil 154 - } 155 - 156 - func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 158 - if err != nil { 159 - created = time.Now() 160 - } 161 - 162 - ownerDid := did 163 - 164 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 165 - return nil, err 166 - } 167 - 168 - comment := IssueComment{ 169 - Did: ownerDid, 170 - Rkey: rkey, 171 - Body: record.Body, 172 - IssueAt: record.Issue, 173 - ReplyTo: record.ReplyTo, 174 - Created: created, 175 - } 176 - 177 - return &comment, nil 178 - } 179 - 180 - func PutIssue(tx *sql.Tx, issue *Issue) error { 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 181 18 // ensure sequence exists 182 19 _, err := tx.Exec(` 183 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 212 49 } 213 50 } 214 51 215 - func createNewIssue(tx *sql.Tx, issue *Issue) error { 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 216 53 // get next issue_id 217 54 var newIssueId int 218 55 err := tx.QueryRow(` ··· 235 72 return row.Scan(&issue.Id, &issue.IssueId) 236 73 } 237 74 238 - func updateIssue(tx *sql.Tx, issue *Issue) error { 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 239 76 // update existing issue 240 77 _, err := tx.Exec(` 241 78 update issues ··· 245 82 return err 246 83 } 247 84 248 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 - issueMap := make(map[string]*Issue) // at-uri -> issue 85 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 250 87 251 88 var conditions []string 252 89 var args []any ··· 301 138 defer rows.Close() 302 139 303 140 for rows.Next() { 304 - var issue Issue 141 + var issue models.Issue 305 142 var createdAt string 306 143 var editedAt, deletedAt sql.Null[string] 307 144 var rowNum int64 ··· 354 191 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 192 } 356 193 357 - repoMap := make(map[string]*Repo) 194 + repoMap := make(map[string]*models.Repo) 358 195 for i := range repos { 359 196 repoMap[string(repos[i].RepoAt())] = &repos[i] 360 197 } ··· 371 208 372 209 // collect comments 373 210 issueAts := slices.Collect(maps.Keys(issueMap)) 211 + 374 212 comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 213 if err != nil { 376 214 return nil, fmt.Errorf("failed to query comments: %w", err) 377 215 } 378 - 379 216 for i := range comments { 380 217 issueAt := comments[i].IssueAt 381 218 if issue, ok := issueMap[issueAt]; ok { ··· 383 220 } 384 221 } 385 222 386 - var issues []Issue 223 + // collect allLabels for each issue 224 + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 225 + if err != nil { 226 + return nil, fmt.Errorf("failed to query labels: %w", err) 227 + } 228 + for issueAt, labels := range allLabels { 229 + if issue, ok := issueMap[issueAt.String()]; ok { 230 + issue.Labels = labels 231 + } 232 + } 233 + 234 + var issues []models.Issue 387 235 for _, i := range issueMap { 388 236 issues = append(issues, *i) 389 237 } 390 238 391 239 sort.Slice(issues, func(i, j int) bool { 240 + if issues[i].Created.Equal(issues[j].Created) { 241 + // Tiebreaker: use issue_id for stable sort 242 + return issues[i].IssueId > issues[j].IssueId 243 + } 392 244 return issues[i].Created.After(issues[j].Created) 393 245 }) 394 246 395 247 return issues, nil 396 248 } 397 249 398 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 250 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 399 251 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 252 } 401 253 402 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 254 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 403 255 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 404 256 row := e.QueryRow(query, repoAt, issueId) 405 257 406 - var issue Issue 258 + var issue models.Issue 407 259 var createdAt string 408 260 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 409 261 if err != nil { ··· 419 271 return &issue, nil 420 272 } 421 273 422 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 274 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 423 275 result, err := e.Exec( 424 276 `insert into issue_comments ( 425 277 did, ··· 481 333 return err 482 334 } 483 335 484 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 - var comments []IssueComment 336 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 337 + var comments []models.IssueComment 486 338 487 339 var conditions []string 488 340 var args []any ··· 518 370 } 519 371 520 372 for rows.Next() { 521 - var comment IssueComment 373 + var comment models.IssueComment 522 374 var created string 523 375 var rkey, edited, deleted, replyTo sql.Null[string] 524 376 err := rows.Scan( ··· 625 477 return err 626 478 } 627 479 628 - type IssueCount struct { 629 - Open int 630 - Closed int 631 - } 632 - 633 - func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 480 + func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 634 481 row := e.QueryRow(` 635 482 select 636 483 count(case when open = 1 then 1 end) as open_count, ··· 640 487 repoAt, 641 488 ) 642 489 643 - var count IssueCount 490 + var count models.IssueCount 644 491 if err := row.Scan(&count.Open, &count.Closed); err != nil { 645 - return IssueCount{0, 0}, err 492 + return models.IssueCount{}, err 646 493 } 647 494 648 495 return count, nil 649 496 } 497 + 498 + func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) { 499 + var conditions []string 500 + var args []any 501 + 502 + for _, filter := range filters { 503 + conditions = append(conditions, filter.Condition()) 504 + args = append(args, filter.Arg()...) 505 + } 506 + 507 + if text != "" { 508 + searchPattern := "%" + text + "%" 509 + conditions = append(conditions, "(title like ? or body like ?)") 510 + args = append(args, searchPattern, searchPattern) 511 + } 512 + 513 + whereClause := "" 514 + if len(conditions) > 0 { 515 + whereClause = " where " + strings.Join(conditions, " and ") 516 + } 517 + 518 + pLower := FilterGte("row_num", page.Offset+1) 519 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 520 + args = append(args, pLower.Arg()...) 521 + args = append(args, pUpper.Arg()...) 522 + paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition() 523 + 524 + query := fmt.Sprintf( 525 + ` 526 + select * from ( 527 + select 528 + id, 529 + did, 530 + rkey, 531 + repo_at, 532 + issue_id, 533 + title, 534 + body, 535 + open, 536 + created, 537 + edited, 538 + deleted, 539 + row_number() over (order by created desc) as row_num 540 + from 541 + issues 542 + %s 543 + ) ranked_issues 544 + %s 545 + `, 546 + whereClause, 547 + paginationClause, 548 + ) 549 + 550 + rows, err := e.Query(query, args...) 551 + if err != nil { 552 + return nil, fmt.Errorf("failed to query issues: %w", err) 553 + } 554 + defer rows.Close() 555 + 556 + issueMap := make(map[string]*models.Issue) 557 + for rows.Next() { 558 + var issue models.Issue 559 + var createdAt string 560 + var editedAt, deletedAt sql.Null[string] 561 + var rowNum int64 562 + 563 + err := rows.Scan( 564 + &issue.Id, 565 + &issue.Did, 566 + &issue.Rkey, 567 + &issue.RepoAt, 568 + &issue.IssueId, 569 + &issue.Title, 570 + &issue.Body, 571 + &issue.Open, 572 + &createdAt, 573 + &editedAt, 574 + &deletedAt, 575 + &rowNum, 576 + ) 577 + if err != nil { 578 + return nil, fmt.Errorf("failed to scan issue: %w", err) 579 + } 580 + 581 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 582 + issue.Created = t 583 + } 584 + if editedAt.Valid { 585 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 586 + issue.Edited = &t 587 + } 588 + } 589 + if deletedAt.Valid { 590 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 591 + issue.Deleted = &t 592 + } 593 + } 594 + 595 + atUri := issue.AtUri().String() 596 + issueMap[atUri] = &issue 597 + } 598 + 599 + repoAts := make([]string, 0, len(issueMap)) 600 + for _, issue := range issueMap { 601 + repoAts = append(repoAts, string(issue.RepoAt)) 602 + } 603 + 604 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 605 + if err != nil { 606 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 607 + } 608 + 609 + repoMap := make(map[string]*models.Repo) 610 + for i := range repos { 611 + repoMap[string(repos[i].RepoAt())] = &repos[i] 612 + } 613 + 614 + for issueAt, i := range issueMap { 615 + if r, ok := repoMap[string(i.RepoAt)]; ok { 616 + i.Repo = r 617 + } else { 618 + delete(issueMap, issueAt) 619 + } 620 + } 621 + 622 + issueAts := slices.Collect(maps.Keys(issueMap)) 623 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 624 + if err != nil { 625 + return nil, fmt.Errorf("failed to query comments: %w", err) 626 + } 627 + for i := range comments { 628 + issueAt := comments[i].IssueAt 629 + if issue, ok := issueMap[issueAt]; ok { 630 + issue.Comments = append(issue.Comments, comments[i]) 631 + } 632 + } 633 + 634 + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 635 + if err != nil { 636 + return nil, fmt.Errorf("failed to query labels: %w", err) 637 + } 638 + for issueAt, labels := range allLabels { 639 + if issue, ok := issueMap[issueAt.String()]; ok { 640 + issue.Labels = labels 641 + } 642 + } 643 + 644 + reactionCounts := make(map[string]int) 645 + if len(issueAts) > 0 { 646 + reactionArgs := make([]any, len(issueAts)) 647 + for i, v := range issueAts { 648 + reactionArgs[i] = v 649 + } 650 + rows, err := e.Query(` 651 + select thread_at, count(*) as total 652 + from reactions 653 + where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`) 654 + group by thread_at 655 + `, reactionArgs...) 656 + if err == nil { 657 + defer rows.Close() 658 + for rows.Next() { 659 + var threadAt string 660 + var count int 661 + if err := rows.Scan(&threadAt, &count); err == nil { 662 + reactionCounts[threadAt] = count 663 + } 664 + } 665 + } 666 + } 667 + 668 + if len(labels) > 0 { 669 + if len(issueMap) > 0 { 670 + var repoAt string 671 + for _, issue := range issueMap { 672 + repoAt = string(issue.RepoAt) 673 + break 674 + } 675 + 676 + repo, err := GetRepoByAtUri(e, repoAt) 677 + if err == nil && len(repo.Labels) > 0 { 678 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 679 + if err == nil { 680 + labelNameToUri := make(map[string]string) 681 + for _, def := range labelDefs { 682 + labelNameToUri[def.Name] = def.AtUri().String() 683 + } 684 + 685 + for issueAt, issue := range issueMap { 686 + hasAllLabels := true 687 + for _, labelName := range labels { 688 + labelUri, found := labelNameToUri[labelName] 689 + if !found { 690 + hasAllLabels = false 691 + break 692 + } 693 + if !issue.Labels.ContainsLabel(labelUri) { 694 + hasAllLabels = false 695 + break 696 + } 697 + } 698 + if !hasAllLabels { 699 + delete(issueMap, issueAt) 700 + } 701 + } 702 + } 703 + } 704 + } 705 + } 706 + 707 + var issues []models.Issue 708 + for _, i := range issueMap { 709 + i.ReactionCount = reactionCounts[i.AtUri().String()] 710 + issues = append(issues, *i) 711 + } 712 + 713 + sort.Slice(issues, func(i, j int) bool { 714 + var less bool 715 + 716 + switch sortBy { 717 + case "comments": 718 + if len(issues[i].Comments) == len(issues[j].Comments) { 719 + // Tiebreaker: use issue_id for stable sort 720 + less = issues[i].IssueId > issues[j].IssueId 721 + } else { 722 + less = len(issues[i].Comments) > len(issues[j].Comments) 723 + } 724 + case "reactions": 725 + iCount := reactionCounts[issues[i].AtUri().String()] 726 + jCount := reactionCounts[issues[j].AtUri().String()] 727 + if iCount == jCount { 728 + // Tiebreaker: use issue_id for stable sort 729 + less = issues[i].IssueId > issues[j].IssueId 730 + } else { 731 + less = iCount > jCount 732 + } 733 + case "created": 734 + fallthrough 735 + default: 736 + if issues[i].Created.Equal(issues[j].Created) { 737 + // Tiebreaker: use issue_id for stable sort 738 + less = issues[i].IssueId > issues[j].IssueId 739 + } else { 740 + less = issues[i].Created.After(issues[j].Created) 741 + } 742 + } 743 + 744 + if sortOrder == "asc" { 745 + return !less 746 + } 747 + return less 748 + }) 749 + 750 + return issues, nil 751 + }
+41 -409
appview/db/label.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/sha1" 5 4 "database/sql" 6 - "encoding/hex" 7 - "errors" 8 5 "fmt" 9 - "log" 10 6 "maps" 11 7 "slices" 12 8 "strings" 13 9 "time" 14 10 15 11 "github.com/bluesky-social/indigo/atproto/syntax" 16 - "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.org/core/appview/models" 17 13 ) 18 14 19 - type ConcreteType string 20 - 21 - const ( 22 - ConcreteTypeNull ConcreteType = "null" 23 - ConcreteTypeString ConcreteType = "string" 24 - ConcreteTypeInt ConcreteType = "integer" 25 - ConcreteTypeBool ConcreteType = "boolean" 26 - ) 27 - 28 - type ValueTypeFormat string 29 - 30 - const ( 31 - ValueTypeFormatAny ValueTypeFormat = "any" 32 - ValueTypeFormatDid ValueTypeFormat = "did" 33 - ) 34 - 35 - // ValueType represents an atproto lexicon type definition with constraints 36 - type ValueType struct { 37 - Type ConcreteType `json:"type"` 38 - Format ValueTypeFormat `json:"format,omitempty"` 39 - Enum []string `json:"enum,omitempty"` 40 - } 41 - 42 - func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 43 - return tangled.LabelDefinition_ValueType{ 44 - Type: string(vt.Type), 45 - Format: string(vt.Format), 46 - Enum: vt.Enum, 47 - } 48 - } 49 - 50 - func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 51 - return ValueType{ 52 - Type: ConcreteType(record.Type), 53 - Format: ValueTypeFormat(record.Format), 54 - Enum: record.Enum, 55 - } 56 - } 57 - 58 - func (vt ValueType) IsConcreteType() bool { 59 - return vt.Type == ConcreteTypeNull || 60 - vt.Type == ConcreteTypeString || 61 - vt.Type == ConcreteTypeInt || 62 - vt.Type == ConcreteTypeBool 63 - } 64 - 65 - func (vt ValueType) IsNull() bool { 66 - return vt.Type == ConcreteTypeNull 67 - } 68 - 69 - func (vt ValueType) IsString() bool { 70 - return vt.Type == ConcreteTypeString 71 - } 72 - 73 - func (vt ValueType) IsInt() bool { 74 - return vt.Type == ConcreteTypeInt 75 - } 76 - 77 - func (vt ValueType) IsBool() bool { 78 - return vt.Type == ConcreteTypeBool 79 - } 80 - 81 - func (vt ValueType) IsEnumType() bool { 82 - return len(vt.Enum) > 0 83 - } 84 - 85 - type LabelDefinition struct { 86 - Id int64 87 - Did string 88 - Rkey string 89 - 90 - Name string 91 - ValueType ValueType 92 - Scope syntax.NSID 93 - Color *string 94 - Multiple bool 95 - Created time.Time 96 - } 97 - 98 - func (l *LabelDefinition) AtUri() syntax.ATURI { 99 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 100 - } 101 - 102 - func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 103 - vt := l.ValueType.AsRecord() 104 - return tangled.LabelDefinition{ 105 - Name: l.Name, 106 - Color: l.Color, 107 - CreatedAt: l.Created.Format(time.RFC3339), 108 - Multiple: &l.Multiple, 109 - Scope: l.Scope.String(), 110 - ValueType: &vt, 111 - } 112 - } 113 - 114 - // random color for a given seed 115 - func randomColor(seed string) string { 116 - hash := sha1.Sum([]byte(seed)) 117 - hexStr := hex.EncodeToString(hash[:]) 118 - r := hexStr[0:2] 119 - g := hexStr[2:4] 120 - b := hexStr[4:6] 121 - 122 - return fmt.Sprintf("#%s%s%s", r, g, b) 123 - } 124 - 125 - func (ld LabelDefinition) GetColor() string { 126 - if ld.Color == nil { 127 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 128 - color := randomColor(seed) 129 - return color 130 - } 131 - 132 - return *ld.Color 133 - } 134 - 135 - func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) LabelDefinition { 136 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 137 - if err != nil { 138 - created = time.Now() 139 - } 140 - 141 - multiple := false 142 - if record.Multiple != nil { 143 - multiple = *record.Multiple 144 - } 145 - 146 - var vt ValueType 147 - if record.ValueType != nil { 148 - vt = ValueTypeFromRecord(*record.ValueType) 149 - } 150 - 151 - return LabelDefinition{ 152 - Did: did, 153 - Rkey: rkey, 154 - 155 - Name: record.Name, 156 - ValueType: vt, 157 - Scope: syntax.NSID(record.Scope), 158 - Color: record.Color, 159 - Multiple: multiple, 160 - Created: created, 161 - } 162 - } 163 - 164 - func DeleteLabelDefinition(e Execer, filters ...filter) error { 165 - var conditions []string 166 - var args []any 167 - for _, filter := range filters { 168 - conditions = append(conditions, filter.Condition()) 169 - args = append(args, filter.Arg()...) 170 - } 171 - whereClause := "" 172 - if conditions != nil { 173 - whereClause = " where " + strings.Join(conditions, " and ") 174 - } 175 - query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 176 - _, err := e.Exec(query, args...) 177 - return err 178 - } 179 - 180 - func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { 15 + // no updating type for now 16 + func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) { 181 17 result, err := e.Exec( 182 18 `insert into label_definitions ( 183 19 did, ··· 203 39 l.ValueType.Type, 204 40 l.ValueType.Format, 205 41 strings.Join(l.ValueType.Enum, ","), 206 - l.Scope.String(), 42 + strings.Join(l.Scope, ","), 207 43 l.Color, 208 44 l.Multiple, 209 45 l.Created.Format(time.RFC3339), ··· 223 59 return id, nil 224 60 } 225 61 226 - func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) { 227 - var labelDefinitions []LabelDefinition 62 + func DeleteLabelDefinition(e Execer, filters ...filter) error { 63 + var conditions []string 64 + var args []any 65 + for _, filter := range filters { 66 + conditions = append(conditions, filter.Condition()) 67 + args = append(args, filter.Arg()...) 68 + } 69 + whereClause := "" 70 + if conditions != nil { 71 + whereClause = " where " + strings.Join(conditions, " and ") 72 + } 73 + query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 74 + _, err := e.Exec(query, args...) 75 + return err 76 + } 77 + 78 + func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) { 79 + var labelDefinitions []models.LabelDefinition 228 80 var conditions []string 229 81 var args []any 230 82 ··· 266 118 defer rows.Close() 267 119 268 120 for rows.Next() { 269 - var labelDefinition LabelDefinition 270 - var createdAt, enumVariants string 121 + var labelDefinition models.LabelDefinition 122 + var createdAt, enumVariants, scopes string 271 123 var color sql.Null[string] 272 124 var multiple int 273 125 ··· 279 131 &labelDefinition.ValueType.Type, 280 132 &labelDefinition.ValueType.Format, 281 133 &enumVariants, 282 - &labelDefinition.Scope, 134 + &scopes, 283 135 &color, 284 136 &multiple, 285 137 &createdAt, ··· 304 156 labelDefinition.ValueType.Enum = strings.Split(enumVariants, ",") 305 157 } 306 158 159 + for s := range strings.SplitSeq(scopes, ",") { 160 + labelDefinition.Scope = append(labelDefinition.Scope, s) 161 + } 162 + 307 163 labelDefinitions = append(labelDefinitions, labelDefinition) 308 164 } 309 165 ··· 311 167 } 312 168 313 169 // helper to get exactly one label def 314 - func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { 170 + func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 315 171 labels, err := GetLabelDefinitions(e, filters...) 316 172 if err != nil { 317 173 return nil, err ··· 328 184 return &labels[0], nil 329 185 } 330 186 331 - type LabelOp struct { 332 - Id int64 333 - Did string 334 - Rkey string 335 - Subject syntax.ATURI 336 - Operation LabelOperation 337 - OperandKey string 338 - OperandValue string 339 - PerformedAt time.Time 340 - IndexedAt time.Time 341 - } 342 - 343 - func (l LabelOp) SortAt() time.Time { 344 - createdAt := l.PerformedAt 345 - indexedAt := l.IndexedAt 346 - 347 - // if we don't have an indexedat, fall back to now 348 - if indexedAt.IsZero() { 349 - indexedAt = time.Now() 350 - } 351 - 352 - // if createdat is invalid (before epoch), treat as null -> return zero time 353 - if createdAt.Before(time.UnixMicro(0)) { 354 - return time.Time{} 355 - } 356 - 357 - // if createdat is <= indexedat, use createdat 358 - if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 359 - return createdAt 360 - } 361 - 362 - // otherwise, createdat is in the future relative to indexedat -> use indexedat 363 - return indexedAt 364 - } 365 - 366 - type LabelOperation string 367 - 368 - const ( 369 - LabelOperationAdd LabelOperation = "add" 370 - LabelOperationDel LabelOperation = "del" 371 - ) 372 - 373 - // a record can create multiple label ops 374 - func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 375 - performed, err := time.Parse(time.RFC3339, record.PerformedAt) 376 - if err != nil { 377 - performed = time.Now() 378 - } 379 - 380 - mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 381 - return LabelOp{ 382 - Did: did, 383 - Rkey: rkey, 384 - Subject: syntax.ATURI(record.Subject), 385 - OperandKey: operand.Key, 386 - OperandValue: operand.Value, 387 - PerformedAt: performed, 388 - } 389 - } 390 - 391 - var ops []LabelOp 392 - for _, o := range record.Add { 393 - if o != nil { 394 - op := mkOp(o) 395 - op.Operation = LabelOperationAdd 396 - ops = append(ops, op) 397 - } 398 - } 399 - for _, o := range record.Delete { 400 - if o != nil { 401 - op := mkOp(o) 402 - op.Operation = LabelOperationDel 403 - ops = append(ops, op) 404 - } 405 - } 406 - 407 - return ops 408 - } 409 - 410 - func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 411 - if len(ops) == 0 { 412 - return tangled.LabelOp{} 413 - } 414 - 415 - // use the first operation to establish common fields 416 - first := ops[0] 417 - record := tangled.LabelOp{ 418 - Subject: string(first.Subject), 419 - PerformedAt: first.PerformedAt.Format(time.RFC3339), 420 - } 421 - 422 - var addOperands []*tangled.LabelOp_Operand 423 - var deleteOperands []*tangled.LabelOp_Operand 424 - 425 - for _, op := range ops { 426 - operand := &tangled.LabelOp_Operand{ 427 - Key: op.OperandKey, 428 - Value: op.OperandValue, 429 - } 430 - 431 - switch op.Operation { 432 - case LabelOperationAdd: 433 - addOperands = append(addOperands, operand) 434 - case LabelOperationDel: 435 - deleteOperands = append(deleteOperands, operand) 436 - default: 437 - return tangled.LabelOp{} 438 - } 439 - } 440 - 441 - record.Add = addOperands 442 - record.Delete = deleteOperands 443 - 444 - return record 445 - } 446 - 447 - func AddLabelOp(e Execer, l *LabelOp) (int64, error) { 187 + func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) { 448 188 now := time.Now() 449 189 result, err := e.Exec( 450 190 `insert into label_ops ( ··· 487 227 return id, nil 488 228 } 489 229 490 - func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { 491 - var labelOps []LabelOp 230 + func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + var labelOps []models.LabelOp 492 232 var conditions []string 493 233 var args []any 494 234 ··· 528 268 defer rows.Close() 529 269 530 270 for rows.Next() { 531 - var labelOp LabelOp 271 + var labelOp models.LabelOp 532 272 var performedAt, indexedAt string 533 273 534 274 if err := rows.Scan( ··· 562 302 } 563 303 564 304 // get labels for a given list of subject URIs 565 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) { 305 + func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 566 306 ops, err := GetLabelOps(e, filters...) 567 307 if err != nil { 568 308 return nil, err 569 309 } 570 310 571 311 // group ops by subject 572 - opsBySubject := make(map[syntax.ATURI][]LabelOp) 312 + opsBySubject := make(map[syntax.ATURI][]models.LabelOp) 573 313 for _, op := range ops { 574 314 subject := syntax.ATURI(op.Subject) 575 315 opsBySubject[subject] = append(opsBySubject[subject], op) ··· 588 328 } 589 329 590 330 // apply label ops for each subject and collect results 591 - results := make(map[syntax.ATURI]LabelState) 331 + results := make(map[syntax.ATURI]models.LabelState) 592 332 for subject, subjectOps := range opsBySubject { 593 - state := NewLabelState() 333 + state := models.NewLabelState() 594 334 actx.ApplyLabelOps(state, subjectOps) 595 335 results[subject] = state 596 336 } 597 - 598 - log.Println("results for get labels", "s", results) 599 337 600 338 return results, nil 601 339 } 602 340 603 - type set = map[string]struct{} 604 - 605 - type LabelState struct { 606 - inner map[string]set 607 - } 608 - 609 - func NewLabelState() LabelState { 610 - return LabelState{ 611 - inner: make(map[string]set), 612 - } 613 - } 614 - 615 - func (s LabelState) Inner() map[string]set { 616 - return s.inner 617 - } 618 - 619 - func (s LabelState) ContainsLabel(l string) bool { 620 - if valset, exists := s.inner[l]; exists { 621 - if valset != nil { 622 - return true 623 - } 624 - } 625 - 626 - return false 627 - } 628 - 629 - func (s *LabelState) GetValSet(l string) set { 630 - return s.inner[l] 631 - } 632 - 633 - type LabelApplicationCtx struct { 634 - defs map[string]*LabelDefinition // labelAt -> labelDef 635 - } 636 - 637 - var ( 638 - LabelNoOpError = errors.New("no-op") 639 - ) 640 - 641 - func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) { 341 + func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 642 342 labels, err := GetLabelDefinitions(e, filters...) 643 343 if err != nil { 644 344 return nil, err 645 345 } 646 346 647 - defs := make(map[string]*LabelDefinition) 347 + defs := make(map[string]*models.LabelDefinition) 648 348 for _, l := range labels { 649 349 defs[l.AtUri().String()] = &l 650 350 } 651 351 652 - return &LabelApplicationCtx{defs}, nil 653 - } 654 - 655 - func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 656 - def := c.defs[op.OperandKey] 657 - 658 - switch op.Operation { 659 - case LabelOperationAdd: 660 - // if valueset is empty, init it 661 - if state.inner[op.OperandKey] == nil { 662 - state.inner[op.OperandKey] = make(set) 663 - } 664 - 665 - // if valueset is populated & this val alr exists, this labelop is a noop 666 - if valueSet, exists := state.inner[op.OperandKey]; exists { 667 - if _, exists = valueSet[op.OperandValue]; exists { 668 - return LabelNoOpError 669 - } 670 - } 671 - 672 - if def.Multiple { 673 - // append to set 674 - state.inner[op.OperandKey][op.OperandValue] = struct{}{} 675 - } else { 676 - // reset to just this value 677 - state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 678 - } 679 - 680 - case LabelOperationDel: 681 - // if label DNE, then deletion is a no-op 682 - if valueSet, exists := state.inner[op.OperandKey]; !exists { 683 - return LabelNoOpError 684 - } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 685 - return LabelNoOpError 686 - } 687 - 688 - if def.Multiple { 689 - // remove from set 690 - delete(state.inner[op.OperandKey], op.OperandValue) 691 - } else { 692 - // reset the entire label 693 - delete(state.inner, op.OperandKey) 694 - } 695 - 696 - // if the map becomes empty, then set it to nil, this is just the inverse of add 697 - if len(state.inner[op.OperandKey]) == 0 { 698 - state.inner[op.OperandKey] = nil 699 - } 700 - 701 - } 702 - 703 - return nil 704 - } 705 - 706 - func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 707 - // sort label ops in sort order first 708 - slices.SortFunc(ops, func(a, b LabelOp) int { 709 - return a.SortAt().Compare(b.SortAt()) 710 - }) 711 - 712 - // apply ops in sequence 713 - for _, o := range ops { 714 - _ = c.ApplyLabelOp(state, o) 715 - } 716 - } 717 - 718 - type Label struct { 719 - def *LabelDefinition 720 - val set 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 721 353 }
+38 -13
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type RepoLanguage struct { 11 - Id int64 12 - RepoAt syntax.ATURI 13 - Ref string 14 - IsDefaultRef bool 15 - Language string 16 - Bytes int64 17 - } 18 - 19 - func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) { 12 + func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 20 13 var conditions []string 21 14 var args []any 22 15 for _, filter := range filters { ··· 39 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 40 33 } 41 34 42 - var langs []RepoLanguage 35 + var langs []models.RepoLanguage 43 36 for rows.Next() { 44 - var rl RepoLanguage 37 + var rl models.RepoLanguage 45 38 var isDefaultRef int 46 39 47 40 err := rows.Scan( ··· 69 62 return langs, nil 70 63 } 71 64 72 - func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 65 + func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error { 73 66 stmt, err := e.Prepare( 74 67 "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 68 ) ··· 91 84 92 85 return nil 93 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+450
appview/db/notifications.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + ) 14 + 15 + func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 + query := ` 17 + INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 + ` 20 + 21 + result, err := d.DB.ExecContext(ctx, query, 22 + notification.RecipientDid, 23 + notification.ActorDid, 24 + string(notification.Type), 25 + notification.EntityType, 26 + notification.EntityId, 27 + notification.Read, 28 + notification.RepoId, 29 + notification.IssueId, 30 + notification.PullId, 31 + ) 32 + if err != nil { 33 + return fmt.Errorf("failed to create notification: %w", err) 34 + } 35 + 36 + id, err := result.LastInsertId() 37 + if err != nil { 38 + return fmt.Errorf("failed to get notification ID: %w", err) 39 + } 40 + 41 + notification.ID = id 42 + return nil 43 + } 44 + 45 + // GetNotificationsPaginated retrieves notifications with filters and pagination 46 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 47 + var conditions []string 48 + var args []any 49 + 50 + for _, filter := range filters { 51 + conditions = append(conditions, filter.Condition()) 52 + args = append(args, filter.Arg()...) 53 + } 54 + 55 + whereClause := "" 56 + if len(conditions) > 0 { 57 + whereClause = "WHERE " + conditions[0] 58 + for _, condition := range conditions[1:] { 59 + whereClause += " AND " + condition 60 + } 61 + } 62 + 63 + query := fmt.Sprintf(` 64 + select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 + from notifications 66 + %s 67 + order by created desc 68 + limit ? offset ? 69 + `, whereClause) 70 + 71 + args = append(args, page.Limit, page.Offset) 72 + 73 + rows, err := e.QueryContext(context.Background(), query, args...) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to query notifications: %w", err) 76 + } 77 + defer rows.Close() 78 + 79 + var notifications []*models.Notification 80 + for rows.Next() { 81 + var n models.Notification 82 + var typeStr string 83 + var createdStr string 84 + err := rows.Scan( 85 + &n.ID, 86 + &n.RecipientDid, 87 + &n.ActorDid, 88 + &typeStr, 89 + &n.EntityType, 90 + &n.EntityId, 91 + &n.Read, 92 + &createdStr, 93 + &n.RepoId, 94 + &n.IssueId, 95 + &n.PullId, 96 + ) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to scan notification: %w", err) 99 + } 100 + n.Type = models.NotificationType(typeStr) 101 + n.Created, err = time.Parse(time.RFC3339, createdStr) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 104 + } 105 + notifications = append(notifications, &n) 106 + } 107 + 108 + return notifications, nil 109 + } 110 + 111 + // GetNotificationsWithEntities retrieves notifications with their related entities 112 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 113 + var conditions []string 114 + var args []any 115 + 116 + for _, filter := range filters { 117 + conditions = append(conditions, filter.Condition()) 118 + args = append(args, filter.Arg()...) 119 + } 120 + 121 + whereClause := "" 122 + if len(conditions) > 0 { 123 + whereClause = "WHERE " + conditions[0] 124 + for _, condition := range conditions[1:] { 125 + whereClause += " AND " + condition 126 + } 127 + } 128 + 129 + query := fmt.Sprintf(` 130 + select 131 + n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 132 + n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 133 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 134 + i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 135 + p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 136 + from notifications n 137 + left join repos r on n.repo_id = r.id 138 + left join issues i on n.issue_id = i.id 139 + left join pulls p on n.pull_id = p.id 140 + %s 141 + order by n.created desc 142 + limit ? offset ? 143 + `, whereClause) 144 + 145 + args = append(args, page.Limit, page.Offset) 146 + 147 + rows, err := e.QueryContext(context.Background(), query, args...) 148 + if err != nil { 149 + return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 150 + } 151 + defer rows.Close() 152 + 153 + var notifications []*models.NotificationWithEntity 154 + for rows.Next() { 155 + var n models.Notification 156 + var typeStr string 157 + var createdStr string 158 + var repo models.Repo 159 + var issue models.Issue 160 + var pull models.Pull 161 + var rId, iId, pId sql.NullInt64 162 + var rDid, rName, rDescription sql.NullString 163 + var iDid sql.NullString 164 + var iIssueId sql.NullInt64 165 + var iTitle sql.NullString 166 + var iOpen sql.NullBool 167 + var pOwnerDid sql.NullString 168 + var pPullId sql.NullInt64 169 + var pTitle sql.NullString 170 + var pState sql.NullInt64 171 + 172 + err := rows.Scan( 173 + &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 174 + &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 175 + &rId, &rDid, &rName, &rDescription, 176 + &iId, &iDid, &iIssueId, &iTitle, &iOpen, 177 + &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 178 + ) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 181 + } 182 + 183 + n.Type = models.NotificationType(typeStr) 184 + n.Created, err = time.Parse(time.RFC3339, createdStr) 185 + if err != nil { 186 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 187 + } 188 + 189 + nwe := &models.NotificationWithEntity{Notification: &n} 190 + 191 + // populate repo if present 192 + if rId.Valid { 193 + repo.Id = rId.Int64 194 + if rDid.Valid { 195 + repo.Did = rDid.String 196 + } 197 + if rName.Valid { 198 + repo.Name = rName.String 199 + } 200 + if rDescription.Valid { 201 + repo.Description = rDescription.String 202 + } 203 + nwe.Repo = &repo 204 + } 205 + 206 + // populate issue if present 207 + if iId.Valid { 208 + issue.Id = iId.Int64 209 + if iDid.Valid { 210 + issue.Did = iDid.String 211 + } 212 + if iIssueId.Valid { 213 + issue.IssueId = int(iIssueId.Int64) 214 + } 215 + if iTitle.Valid { 216 + issue.Title = iTitle.String 217 + } 218 + if iOpen.Valid { 219 + issue.Open = iOpen.Bool 220 + } 221 + nwe.Issue = &issue 222 + } 223 + 224 + // populate pull if present 225 + if pId.Valid { 226 + pull.ID = int(pId.Int64) 227 + if pOwnerDid.Valid { 228 + pull.OwnerDid = pOwnerDid.String 229 + } 230 + if pPullId.Valid { 231 + pull.PullId = int(pPullId.Int64) 232 + } 233 + if pTitle.Valid { 234 + pull.Title = pTitle.String 235 + } 236 + if pState.Valid { 237 + pull.State = models.PullState(pState.Int64) 238 + } 239 + nwe.Pull = &pull 240 + } 241 + 242 + notifications = append(notifications, nwe) 243 + } 244 + 245 + return notifications, nil 246 + } 247 + 248 + // GetNotifications retrieves notifications with filters 249 + func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 250 + return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 251 + } 252 + 253 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 254 + var conditions []string 255 + var args []any 256 + for _, filter := range filters { 257 + conditions = append(conditions, filter.Condition()) 258 + args = append(args, filter.Arg()...) 259 + } 260 + 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 265 + 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 269 + 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 272 + } 273 + 274 + return count, nil 275 + } 276 + 277 + func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 + idFilter := FilterEq("id", notificationID) 279 + recipientFilter := FilterEq("recipient_did", userDID) 280 + 281 + query := fmt.Sprintf(` 282 + UPDATE notifications 283 + SET read = 1 284 + WHERE %s AND %s 285 + `, idFilter.Condition(), recipientFilter.Condition()) 286 + 287 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 + 289 + result, err := d.DB.ExecContext(ctx, query, args...) 290 + if err != nil { 291 + return fmt.Errorf("failed to mark notification as read: %w", err) 292 + } 293 + 294 + rowsAffected, err := result.RowsAffected() 295 + if err != nil { 296 + return fmt.Errorf("failed to get rows affected: %w", err) 297 + } 298 + 299 + if rowsAffected == 0 { 300 + return fmt.Errorf("notification not found or access denied") 301 + } 302 + 303 + return nil 304 + } 305 + 306 + func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 + recipientFilter := FilterEq("recipient_did", userDID) 308 + readFilter := FilterEq("read", 0) 309 + 310 + query := fmt.Sprintf(` 311 + UPDATE notifications 312 + SET read = 1 313 + WHERE %s AND %s 314 + `, recipientFilter.Condition(), readFilter.Condition()) 315 + 316 + args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 + 318 + _, err := d.DB.ExecContext(ctx, query, args...) 319 + if err != nil { 320 + return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 + } 322 + 323 + return nil 324 + } 325 + 326 + func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 + idFilter := FilterEq("id", notificationID) 328 + recipientFilter := FilterEq("recipient_did", userDID) 329 + 330 + query := fmt.Sprintf(` 331 + DELETE FROM notifications 332 + WHERE %s AND %s 333 + `, idFilter.Condition(), recipientFilter.Condition()) 334 + 335 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 + 337 + result, err := d.DB.ExecContext(ctx, query, args...) 338 + if err != nil { 339 + return fmt.Errorf("failed to delete notification: %w", err) 340 + } 341 + 342 + rowsAffected, err := result.RowsAffected() 343 + if err != nil { 344 + return fmt.Errorf("failed to get rows affected: %w", err) 345 + } 346 + 347 + if rowsAffected == 0 { 348 + return fmt.Errorf("notification not found or access denied") 349 + } 350 + 351 + return nil 352 + } 353 + 354 + func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 + userFilter := FilterEq("user_did", userDID) 356 + 357 + query := fmt.Sprintf(` 358 + SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 + pull_commented, followed, pull_merged, issue_closed, email_notifications 360 + FROM notification_preferences 361 + WHERE %s 362 + `, userFilter.Condition()) 363 + 364 + var prefs models.NotificationPreferences 365 + err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 + &prefs.ID, 367 + &prefs.UserDid, 368 + &prefs.RepoStarred, 369 + &prefs.IssueCreated, 370 + &prefs.IssueCommented, 371 + &prefs.PullCreated, 372 + &prefs.PullCommented, 373 + &prefs.Followed, 374 + &prefs.PullMerged, 375 + &prefs.IssueClosed, 376 + &prefs.EmailNotifications, 377 + ) 378 + 379 + if err != nil { 380 + if err == sql.ErrNoRows { 381 + return &models.NotificationPreferences{ 382 + UserDid: userDID, 383 + RepoStarred: true, 384 + IssueCreated: true, 385 + IssueCommented: true, 386 + PullCreated: true, 387 + PullCommented: true, 388 + Followed: true, 389 + PullMerged: true, 390 + IssueClosed: true, 391 + EmailNotifications: false, 392 + }, nil 393 + } 394 + return nil, fmt.Errorf("failed to get notification preferences: %w", err) 395 + } 396 + 397 + return &prefs, nil 398 + } 399 + 400 + func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 401 + query := ` 402 + INSERT OR REPLACE INTO notification_preferences 403 + (user_did, repo_starred, issue_created, issue_commented, pull_created, 404 + pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 406 + ` 407 + 408 + result, err := d.DB.ExecContext(ctx, query, 409 + prefs.UserDid, 410 + prefs.RepoStarred, 411 + prefs.IssueCreated, 412 + prefs.IssueCommented, 413 + prefs.PullCreated, 414 + prefs.PullCommented, 415 + prefs.Followed, 416 + prefs.PullMerged, 417 + prefs.IssueClosed, 418 + prefs.EmailNotifications, 419 + ) 420 + if err != nil { 421 + return fmt.Errorf("failed to update notification preferences: %w", err) 422 + } 423 + 424 + if prefs.ID == 0 { 425 + id, err := result.LastInsertId() 426 + if err != nil { 427 + return fmt.Errorf("failed to get preferences ID: %w", err) 428 + } 429 + prefs.ID = id 430 + } 431 + 432 + return nil 433 + } 434 + 435 + func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 436 + cutoff := time.Now().Add(-olderThan) 437 + createdFilter := FilterLte("created", cutoff) 438 + 439 + query := fmt.Sprintf(` 440 + DELETE FROM notifications 441 + WHERE %s 442 + `, createdFilter.Condition()) 443 + 444 + _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 445 + if err != nil { 446 + return fmt.Errorf("failed to cleanup old notifications: %w", err) 447 + } 448 + 449 + return nil 450 + }
-173
appview/db/oauth.go
··· 1 - package db 2 - 3 - type OAuthRequest struct { 4 - ID uint 5 - AuthserverIss string 6 - Handle string 7 - State string 8 - Did string 9 - PdsUrl string 10 - PkceVerifier string 11 - DpopAuthserverNonce string 12 - DpopPrivateJwk string 13 - } 14 - 15 - func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error { 16 - _, err := e.Exec(` 17 - insert into oauth_requests ( 18 - auth_server_iss, 19 - state, 20 - handle, 21 - did, 22 - pds_url, 23 - pkce_verifier, 24 - dpop_auth_server_nonce, 25 - dpop_private_jwk 26 - ) values (?, ?, ?, ?, ?, ?, ?, ?)`, 27 - oauthRequest.AuthserverIss, 28 - oauthRequest.State, 29 - oauthRequest.Handle, 30 - oauthRequest.Did, 31 - oauthRequest.PdsUrl, 32 - oauthRequest.PkceVerifier, 33 - oauthRequest.DpopAuthserverNonce, 34 - oauthRequest.DpopPrivateJwk, 35 - ) 36 - return err 37 - } 38 - 39 - func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) { 40 - var req OAuthRequest 41 - err := e.QueryRow(` 42 - select 43 - id, 44 - auth_server_iss, 45 - handle, 46 - state, 47 - did, 48 - pds_url, 49 - pkce_verifier, 50 - dpop_auth_server_nonce, 51 - dpop_private_jwk 52 - from oauth_requests 53 - where state = ?`, state).Scan( 54 - &req.ID, 55 - &req.AuthserverIss, 56 - &req.Handle, 57 - &req.State, 58 - &req.Did, 59 - &req.PdsUrl, 60 - &req.PkceVerifier, 61 - &req.DpopAuthserverNonce, 62 - &req.DpopPrivateJwk, 63 - ) 64 - return req, err 65 - } 66 - 67 - func DeleteOAuthRequestByState(e Execer, state string) error { 68 - _, err := e.Exec(` 69 - delete from oauth_requests 70 - where state = ?`, state) 71 - return err 72 - } 73 - 74 - type OAuthSession struct { 75 - ID uint 76 - Handle string 77 - Did string 78 - PdsUrl string 79 - AccessJwt string 80 - RefreshJwt string 81 - AuthServerIss string 82 - DpopPdsNonce string 83 - DpopAuthserverNonce string 84 - DpopPrivateJwk string 85 - Expiry string 86 - } 87 - 88 - func SaveOAuthSession(e Execer, session OAuthSession) error { 89 - _, err := e.Exec(` 90 - insert into oauth_sessions ( 91 - did, 92 - handle, 93 - pds_url, 94 - access_jwt, 95 - refresh_jwt, 96 - auth_server_iss, 97 - dpop_auth_server_nonce, 98 - dpop_private_jwk, 99 - expiry 100 - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 101 - session.Did, 102 - session.Handle, 103 - session.PdsUrl, 104 - session.AccessJwt, 105 - session.RefreshJwt, 106 - session.AuthServerIss, 107 - session.DpopAuthserverNonce, 108 - session.DpopPrivateJwk, 109 - session.Expiry, 110 - ) 111 - return err 112 - } 113 - 114 - func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error { 115 - _, err := e.Exec(` 116 - update oauth_sessions 117 - set access_jwt = ?, refresh_jwt = ?, expiry = ? 118 - where did = ?`, 119 - accessJwt, 120 - refreshJwt, 121 - expiry, 122 - did, 123 - ) 124 - return err 125 - } 126 - 127 - func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) { 128 - var session OAuthSession 129 - err := e.QueryRow(` 130 - select 131 - id, 132 - did, 133 - handle, 134 - pds_url, 135 - access_jwt, 136 - refresh_jwt, 137 - auth_server_iss, 138 - dpop_auth_server_nonce, 139 - dpop_private_jwk, 140 - expiry 141 - from oauth_sessions 142 - where did = ?`, did).Scan( 143 - &session.ID, 144 - &session.Did, 145 - &session.Handle, 146 - &session.PdsUrl, 147 - &session.AccessJwt, 148 - &session.RefreshJwt, 149 - &session.AuthServerIss, 150 - &session.DpopAuthserverNonce, 151 - &session.DpopPrivateJwk, 152 - &session.Expiry, 153 - ) 154 - return &session, err 155 - } 156 - 157 - func DeleteOAuthSessionByDid(e Execer, did string) error { 158 - _, err := e.Exec(` 159 - delete from oauth_sessions 160 - where did = ?`, did) 161 - return err 162 - } 163 - 164 - func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error { 165 - _, err := e.Exec(` 166 - update oauth_sessions 167 - set dpop_pds_nonce = ? 168 - where did = ?`, 169 - dpopPdsNonce, 170 - did, 171 - ) 172 - return err 173 - }
+17 -139
appview/db/pipeline.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/go-git/go-git/v5/plumbing" 11 - spindle "tangled.org/core/spindle/models" 12 - "tangled.org/core/workflow" 9 + "tangled.org/core/appview/models" 13 10 ) 14 11 15 - type Pipeline struct { 16 - Id int 17 - Rkey string 18 - Knot string 19 - RepoOwner syntax.DID 20 - RepoName string 21 - TriggerId int 22 - Sha string 23 - Created time.Time 24 - 25 - // populate when querying for reverse mappings 26 - Trigger *Trigger 27 - Statuses map[string]WorkflowStatus 28 - } 29 - 30 - type WorkflowStatus struct { 31 - Data []PipelineStatus 32 - } 33 - 34 - func (w WorkflowStatus) Latest() PipelineStatus { 35 - return w.Data[len(w.Data)-1] 36 - } 37 - 38 - // time taken by this workflow to reach an "end state" 39 - func (w WorkflowStatus) TimeTaken() time.Duration { 40 - var start, end *time.Time 41 - for _, s := range w.Data { 42 - if s.Status.IsStart() { 43 - start = &s.Created 44 - } 45 - if s.Status.IsFinish() { 46 - end = &s.Created 47 - } 48 - } 49 - 50 - if start != nil && end != nil && end.After(*start) { 51 - return end.Sub(*start) 52 - } 53 - 54 - return 0 55 - } 56 - 57 - func (p Pipeline) Counts() map[string]int { 58 - m := make(map[string]int) 59 - for _, w := range p.Statuses { 60 - m[w.Latest().Status.String()] += 1 61 - } 62 - return m 63 - } 64 - 65 - func (p Pipeline) TimeTaken() time.Duration { 66 - var s time.Duration 67 - for _, w := range p.Statuses { 68 - s += w.TimeTaken() 69 - } 70 - return s 71 - } 72 - 73 - func (p Pipeline) Workflows() []string { 74 - var ws []string 75 - for v := range p.Statuses { 76 - ws = append(ws, v) 77 - } 78 - slices.Sort(ws) 79 - return ws 80 - } 81 - 82 - // if we know that a spindle has picked up this pipeline, then it is Responding 83 - func (p Pipeline) IsResponding() bool { 84 - return len(p.Statuses) != 0 85 - } 86 - 87 - type Trigger struct { 88 - Id int 89 - Kind workflow.TriggerKind 90 - 91 - // push trigger fields 92 - PushRef *string 93 - PushNewSha *string 94 - PushOldSha *string 95 - 96 - // pull request trigger fields 97 - PRSourceBranch *string 98 - PRTargetBranch *string 99 - PRSourceSha *string 100 - PRAction *string 101 - } 102 - 103 - func (t *Trigger) IsPush() bool { 104 - return t != nil && t.Kind == workflow.TriggerKindPush 105 - } 106 - 107 - func (t *Trigger) IsPullRequest() bool { 108 - return t != nil && t.Kind == workflow.TriggerKindPullRequest 109 - } 110 - 111 - func (t *Trigger) TargetRef() string { 112 - if t.IsPush() { 113 - return plumbing.ReferenceName(*t.PushRef).Short() 114 - } else if t.IsPullRequest() { 115 - return *t.PRTargetBranch 116 - } 117 - 118 - return "" 119 - } 120 - 121 - type PipelineStatus struct { 122 - ID int 123 - Spindle string 124 - Rkey string 125 - PipelineKnot string 126 - PipelineRkey string 127 - Created time.Time 128 - Workflow string 129 - Status spindle.StatusKind 130 - Error *string 131 - ExitCode int 132 - } 133 - 134 - func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) { 135 - var pipelines []Pipeline 12 + func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + var pipelines []models.Pipeline 136 14 137 15 var conditions []string 138 16 var args []any ··· 156 34 defer rows.Close() 157 35 158 36 for rows.Next() { 159 - var pipeline Pipeline 37 + var pipeline models.Pipeline 160 38 var createdAt string 161 39 err = rows.Scan( 162 40 &pipeline.Id, ··· 185 63 return pipelines, nil 186 64 } 187 65 188 - func AddPipeline(e Execer, pipeline Pipeline) error { 66 + func AddPipeline(e Execer, pipeline models.Pipeline) error { 189 67 args := []any{ 190 68 pipeline.Rkey, 191 69 pipeline.Knot, ··· 216 94 return err 217 95 } 218 96 219 - func AddTrigger(e Execer, trigger Trigger) (int64, error) { 97 + func AddTrigger(e Execer, trigger models.Trigger) (int64, error) { 220 98 args := []any{ 221 99 trigger.Kind, 222 100 trigger.PushRef, ··· 252 130 return res.LastInsertId() 253 131 } 254 132 255 - func AddPipelineStatus(e Execer, status PipelineStatus) error { 133 + func AddPipelineStatus(e Execer, status models.PipelineStatus) error { 256 134 args := []any{ 257 135 status.Spindle, 258 136 status.Rkey, ··· 290 168 291 169 // this is a mega query, but the most useful one: 292 170 // get N pipelines, for each one get the latest status of its N workflows 293 - func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) { 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 294 172 var conditions []string 295 173 var args []any 296 174 for _, filter := range filters { ··· 335 213 } 336 214 defer rows.Close() 337 215 338 - pipelines := make(map[string]Pipeline) 216 + pipelines := make(map[string]models.Pipeline) 339 217 for rows.Next() { 340 - var p Pipeline 341 - var t Trigger 218 + var p models.Pipeline 219 + var t models.Trigger 342 220 var created string 343 221 344 222 err := rows.Scan( ··· 370 248 371 249 t.Id = p.TriggerId 372 250 p.Trigger = &t 373 - p.Statuses = make(map[string]WorkflowStatus) 251 + p.Statuses = make(map[string]models.WorkflowStatus) 374 252 375 253 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 376 254 pipelines[k] = p ··· 409 287 defer rows.Close() 410 288 411 289 for rows.Next() { 412 - var ps PipelineStatus 290 + var ps models.PipelineStatus 413 291 var created string 414 292 415 293 err := rows.Scan( ··· 442 320 } 443 321 statuses, _ := pipeline.Statuses[ps.Workflow] 444 322 if !ok { 445 - pipeline.Statuses[ps.Workflow] = WorkflowStatus{} 323 + pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{} 446 324 } 447 325 448 326 // append ··· 453 331 pipelines[key] = pipeline 454 332 } 455 333 456 - var all []Pipeline 334 + var all []models.Pipeline 457 335 for _, p := range pipelines { 458 336 for _, s := range p.Statuses { 459 - slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 337 + slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int { 460 338 if a.Created.After(b.Created) { 461 339 return 1 462 340 } ··· 476 354 } 477 355 478 356 // sort pipelines by date 479 - slices.SortFunc(all, func(a, b Pipeline) int { 357 + slices.SortFunc(all, func(a, b models.Pipeline) int { 480 358 if a.Created.After(b.Created) { 481 359 return -1 482 360 }
+25 -194
appview/db/profile.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/models" 14 14 ) 15 15 16 - type RepoEvent struct { 17 - Repo *Repo 18 - Source *Repo 19 - } 20 - 21 - type ProfileTimeline struct { 22 - ByMonth []ByMonth 23 - } 24 - 25 - func (p *ProfileTimeline) IsEmpty() bool { 26 - if p == nil { 27 - return true 28 - } 29 - 30 - for _, m := range p.ByMonth { 31 - if !m.IsEmpty() { 32 - return false 33 - } 34 - } 35 - 36 - return true 37 - } 38 - 39 - type ByMonth struct { 40 - RepoEvents []RepoEvent 41 - IssueEvents IssueEvents 42 - PullEvents PullEvents 43 - } 44 - 45 - func (b ByMonth) IsEmpty() bool { 46 - return len(b.RepoEvents) == 0 && 47 - len(b.IssueEvents.Items) == 0 && 48 - len(b.PullEvents.Items) == 0 49 - } 50 - 51 - type IssueEvents struct { 52 - Items []*Issue 53 - } 54 - 55 - type IssueEventStats struct { 56 - Open int 57 - Closed int 58 - } 59 - 60 - func (i IssueEvents) Stats() IssueEventStats { 61 - var open, closed int 62 - for _, issue := range i.Items { 63 - if issue.Open { 64 - open += 1 65 - } else { 66 - closed += 1 67 - } 68 - } 69 - 70 - return IssueEventStats{ 71 - Open: open, 72 - Closed: closed, 73 - } 74 - } 75 - 76 - type PullEvents struct { 77 - Items []*Pull 78 - } 79 - 80 - func (p PullEvents) Stats() PullEventStats { 81 - var open, merged, closed int 82 - for _, pull := range p.Items { 83 - switch pull.State { 84 - case PullOpen: 85 - open += 1 86 - case PullMerged: 87 - merged += 1 88 - case PullClosed: 89 - closed += 1 90 - } 91 - } 92 - 93 - return PullEventStats{ 94 - Open: open, 95 - Merged: merged, 96 - Closed: closed, 97 - } 98 - } 99 - 100 - type PullEventStats struct { 101 - Closed int 102 - Open int 103 - Merged int 104 - } 105 - 106 16 const TimeframeMonths = 7 107 17 108 - func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { 109 - timeline := ProfileTimeline{ 110 - ByMonth: make([]ByMonth, TimeframeMonths), 18 + func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 19 + timeline := models.ProfileTimeline{ 20 + ByMonth: make([]models.ByMonth, TimeframeMonths), 111 21 } 112 22 currentMonth := time.Now().Month() 113 23 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) ··· 162 72 163 73 for _, repo := range repos { 164 74 // TODO: get this in the original query; requires COALESCE because nullable 165 - var sourceRepo *Repo 75 + var sourceRepo *models.Repo 166 76 if repo.Source != "" { 167 77 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 168 78 if err != nil { ··· 180 90 idx := currentMonth - repoMonth 181 91 182 92 items := &timeline.ByMonth[idx].RepoEvents 183 - *items = append(*items, RepoEvent{ 93 + *items = append(*items, models.RepoEvent{ 184 94 Repo: &repo, 185 95 Source: sourceRepo, 186 96 }) ··· 189 99 return &timeline, nil 190 100 } 191 101 192 - type Profile struct { 193 - // ids 194 - ID int 195 - Did string 196 - 197 - // data 198 - Description string 199 - IncludeBluesky bool 200 - Location string 201 - Links [5]string 202 - Stats [2]VanityStat 203 - PinnedRepos [6]syntax.ATURI 204 - } 205 - 206 - func (p Profile) IsLinksEmpty() bool { 207 - for _, l := range p.Links { 208 - if l != "" { 209 - return false 210 - } 211 - } 212 - return true 213 - } 214 - 215 - func (p Profile) IsStatsEmpty() bool { 216 - for _, s := range p.Stats { 217 - if s.Kind != "" { 218 - return false 219 - } 220 - } 221 - return true 222 - } 223 - 224 - func (p Profile) IsPinnedReposEmpty() bool { 225 - for _, r := range p.PinnedRepos { 226 - if r != "" { 227 - return false 228 - } 229 - } 230 - return true 231 - } 232 - 233 - type VanityStatKind string 234 - 235 - const ( 236 - VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 237 - VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 238 - VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 239 - VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 240 - VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 241 - VanityStatRepositoryCount VanityStatKind = "repository-count" 242 - ) 243 - 244 - func (v VanityStatKind) String() string { 245 - switch v { 246 - case VanityStatMergedPRCount: 247 - return "Merged PRs" 248 - case VanityStatClosedPRCount: 249 - return "Closed PRs" 250 - case VanityStatOpenPRCount: 251 - return "Open PRs" 252 - case VanityStatOpenIssueCount: 253 - return "Open Issues" 254 - case VanityStatClosedIssueCount: 255 - return "Closed Issues" 256 - case VanityStatRepositoryCount: 257 - return "Repositories" 258 - } 259 - return "" 260 - } 261 - 262 - type VanityStat struct { 263 - Kind VanityStatKind 264 - Value uint64 265 - } 266 - 267 - func (p *Profile) ProfileAt() syntax.ATURI { 268 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 269 - } 270 - 271 - func UpsertProfile(tx *sql.Tx, profile *Profile) error { 102 + func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 272 103 defer tx.Rollback() 273 104 274 105 // update links ··· 366 197 return tx.Commit() 367 198 } 368 199 369 - func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 200 + func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 370 201 var conditions []string 371 202 var args []any 372 203 for _, filter := range filters { ··· 396 227 return nil, err 397 228 } 398 229 399 - profileMap := make(map[string]*Profile) 230 + profileMap := make(map[string]*models.Profile) 400 231 for rows.Next() { 401 - var profile Profile 232 + var profile models.Profile 402 233 var includeBluesky int 403 234 404 235 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) ··· 469 300 return profileMap, nil 470 301 } 471 302 472 - func GetProfile(e Execer, did string) (*Profile, error) { 473 - var profile Profile 303 + func GetProfile(e Execer, did string) (*models.Profile, error) { 304 + var profile models.Profile 474 305 profile.Did = did 475 306 476 307 includeBluesky := 0 ··· 479 310 did, 480 311 ).Scan(&profile.Description, &includeBluesky, &profile.Location) 481 312 if err == sql.ErrNoRows { 482 - profile := Profile{} 313 + profile := models.Profile{} 483 314 profile.Did = did 484 315 return &profile, nil 485 316 } ··· 539 370 return &profile, nil 540 371 } 541 372 542 - func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 373 + func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 543 374 query := "" 544 375 var args []any 545 376 switch stat { 546 - case VanityStatMergedPRCount: 377 + case models.VanityStatMergedPRCount: 547 378 query = `select count(id) from pulls where owner_did = ? and state = ?` 548 - args = append(args, did, PullMerged) 549 - case VanityStatClosedPRCount: 379 + args = append(args, did, models.PullMerged) 380 + case models.VanityStatClosedPRCount: 550 381 query = `select count(id) from pulls where owner_did = ? and state = ?` 551 - args = append(args, did, PullClosed) 552 - case VanityStatOpenPRCount: 382 + args = append(args, did, models.PullClosed) 383 + case models.VanityStatOpenPRCount: 553 384 query = `select count(id) from pulls where owner_did = ? and state = ?` 554 - args = append(args, did, PullOpen) 555 - case VanityStatOpenIssueCount: 385 + args = append(args, did, models.PullOpen) 386 + case models.VanityStatOpenIssueCount: 556 387 query = `select count(id) from issues where did = ? and open = 1` 557 388 args = append(args, did) 558 - case VanityStatClosedIssueCount: 389 + case models.VanityStatClosedIssueCount: 559 390 query = `select count(id) from issues where did = ? and open = 0` 560 391 args = append(args, did) 561 - case VanityStatRepositoryCount: 392 + case models.VanityStatRepositoryCount: 562 393 query = `select count(id) from repos where did = ?` 563 394 args = append(args, did) 564 395 } ··· 572 403 return result, nil 573 404 } 574 405 575 - func ValidateProfile(e Execer, profile *Profile) error { 406 + func ValidateProfile(e Execer, profile *models.Profile) error { 576 407 // ensure description is not too long 577 408 if len(profile.Description) > 256 { 578 409 return fmt.Errorf("Entered bio is too long.") ··· 620 451 return nil 621 452 } 622 453 623 - func validateLinks(profile *Profile) error { 454 + func validateLinks(profile *models.Profile) error { 624 455 for i, link := range profile.Links { 625 456 if link == "" { 626 457 continue
+7 -26
appview/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 - "encoding/json" 4 + "tangled.org/core/appview/models" 5 5 "time" 6 6 ) 7 7 ··· 29 29 return err 30 30 } 31 31 32 - type PublicKey struct { 33 - Did string `json:"did"` 34 - Key string `json:"key"` 35 - Name string `json:"name"` 36 - Rkey string `json:"rkey"` 37 - Created *time.Time 38 - } 39 - 40 - func (p PublicKey) MarshalJSON() ([]byte, error) { 41 - type Alias PublicKey 42 - return json.Marshal(&struct { 43 - Created string `json:"created"` 44 - *Alias 45 - }{ 46 - Created: p.Created.Format(time.RFC3339), 47 - Alias: (*Alias)(&p), 48 - }) 49 - } 50 - 51 - func GetAllPublicKeys(e Execer) ([]PublicKey, error) { 52 - var keys []PublicKey 32 + func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) { 33 + var keys []models.PublicKey 53 34 54 35 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 55 36 if err != nil { ··· 58 39 defer rows.Close() 59 40 60 41 for rows.Next() { 61 - var publicKey PublicKey 42 + var publicKey models.PublicKey 62 43 var createdAt string 63 44 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 64 45 return nil, err ··· 75 56 return keys, nil 76 57 } 77 58 78 - func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) { 79 - var keys []PublicKey 59 + func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) { 60 + var keys []models.PublicKey 80 61 81 62 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 82 63 if err != nil { ··· 85 66 defer rows.Close() 86 67 87 68 for rows.Next() { 88 - var publicKey PublicKey 69 + var publicKey models.PublicKey 89 70 var createdAt string 90 71 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 91 72 return nil, err
+364 -547
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 7 9 "slices" 8 10 "sort" 9 11 "strings" 10 12 "time" 11 13 12 14 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/api/tangled" 14 - "tangled.org/core/patchutil" 15 - "tangled.org/core/types" 15 + "tangled.org/core/appview/models" 16 16 ) 17 17 18 - type PullState int 19 - 20 - const ( 21 - PullClosed PullState = iota 22 - PullOpen 23 - PullMerged 24 - PullDeleted 25 - ) 26 - 27 - func (p PullState) String() string { 28 - switch p { 29 - case PullOpen: 30 - return "open" 31 - case PullMerged: 32 - return "merged" 33 - case PullClosed: 34 - return "closed" 35 - case PullDeleted: 36 - return "deleted" 37 - default: 38 - return "closed" 39 - } 40 - } 41 - 42 - func (p PullState) IsOpen() bool { 43 - return p == PullOpen 44 - } 45 - func (p PullState) IsMerged() bool { 46 - return p == PullMerged 47 - } 48 - func (p PullState) IsClosed() bool { 49 - return p == PullClosed 50 - } 51 - func (p PullState) IsDeleted() bool { 52 - return p == PullDeleted 53 - } 54 - 55 - type Pull struct { 56 - // ids 57 - ID int 58 - PullId int 59 - 60 - // at ids 61 - RepoAt syntax.ATURI 62 - OwnerDid string 63 - Rkey string 64 - 65 - // content 66 - Title string 67 - Body string 68 - TargetBranch string 69 - State PullState 70 - Submissions []*PullSubmission 71 - 72 - // stacking 73 - StackId string // nullable string 74 - ChangeId string // nullable string 75 - ParentChangeId string // nullable string 76 - 77 - // meta 78 - Created time.Time 79 - PullSource *PullSource 80 - 81 - // optionally, populate this when querying for reverse mappings 82 - Repo *Repo 83 - } 84 - 85 - func (p Pull) AsRecord() tangled.RepoPull { 86 - var source *tangled.RepoPull_Source 87 - if p.PullSource != nil { 88 - s := p.PullSource.AsRecord() 89 - source = &s 90 - source.Sha = p.LatestSha() 91 - } 92 - 93 - record := tangled.RepoPull{ 94 - Title: p.Title, 95 - Body: &p.Body, 96 - CreatedAt: p.Created.Format(time.RFC3339), 97 - Target: &tangled.RepoPull_Target{ 98 - Repo: p.RepoAt.String(), 99 - Branch: p.TargetBranch, 100 - }, 101 - Patch: p.LatestPatch(), 102 - Source: source, 103 - } 104 - return record 105 - } 106 - 107 - type PullSource struct { 108 - Branch string 109 - RepoAt *syntax.ATURI 110 - 111 - // optionally populate this for reverse mappings 112 - Repo *Repo 113 - } 114 - 115 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 116 - var repoAt *string 117 - if p.RepoAt != nil { 118 - s := p.RepoAt.String() 119 - repoAt = &s 120 - } 121 - record := tangled.RepoPull_Source{ 122 - Branch: p.Branch, 123 - Repo: repoAt, 124 - } 125 - return record 126 - } 127 - 128 - type PullSubmission struct { 129 - // ids 130 - ID int 131 - PullId int 132 - 133 - // at ids 134 - RepoAt syntax.ATURI 135 - 136 - // content 137 - RoundNumber int 138 - Patch string 139 - Comments []PullComment 140 - SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 141 - 142 - // meta 143 - Created time.Time 144 - } 145 - 146 - type PullComment struct { 147 - // ids 148 - ID int 149 - PullId int 150 - SubmissionId int 151 - 152 - // at ids 153 - RepoAt string 154 - OwnerDid string 155 - CommentAt string 156 - 157 - // content 158 - Body string 159 - 160 - // meta 161 - Created time.Time 162 - } 163 - 164 - func (p *Pull) LatestPatch() string { 165 - latestSubmission := p.Submissions[p.LastRoundNumber()] 166 - return latestSubmission.Patch 167 - } 168 - 169 - func (p *Pull) LatestSha() string { 170 - latestSubmission := p.Submissions[p.LastRoundNumber()] 171 - return latestSubmission.SourceRev 172 - } 173 - 174 - func (p *Pull) PullAt() syntax.ATURI { 175 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 176 - } 177 - 178 - func (p *Pull) LastRoundNumber() int { 179 - return len(p.Submissions) - 1 180 - } 181 - 182 - func (p *Pull) IsPatchBased() bool { 183 - return p.PullSource == nil 184 - } 185 - 186 - func (p *Pull) IsBranchBased() bool { 187 - if p.PullSource != nil { 188 - if p.PullSource.RepoAt != nil { 189 - return p.PullSource.RepoAt == &p.RepoAt 190 - } else { 191 - // no repo specified 192 - return true 193 - } 194 - } 195 - return false 196 - } 197 - 198 - func (p *Pull) IsForkBased() bool { 199 - if p.PullSource != nil { 200 - if p.PullSource.RepoAt != nil { 201 - // make sure repos are different 202 - return p.PullSource.RepoAt != &p.RepoAt 203 - } 204 - } 205 - return false 206 - } 207 - 208 - func (p *Pull) IsStacked() bool { 209 - return p.StackId != "" 210 - } 211 - 212 - func (s PullSubmission) IsFormatPatch() bool { 213 - return patchutil.IsFormatPatch(s.Patch) 214 - } 215 - 216 - func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 217 - patches, err := patchutil.ExtractPatches(s.Patch) 218 - if err != nil { 219 - log.Println("error extracting patches from submission:", err) 220 - return []types.FormatPatch{} 221 - } 222 - 223 - return patches 224 - } 225 - 226 - func NewPull(tx *sql.Tx, pull *Pull) error { 18 + func NewPull(tx *sql.Tx, pull *models.Pull) error { 227 19 _, err := tx.Exec(` 228 20 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 229 21 values (?, 1) ··· 244 36 } 245 37 246 38 pull.PullId = nextId 247 - pull.State = PullOpen 39 + pull.State = models.PullOpen 248 40 249 41 var sourceBranch, sourceRepoAt *string 250 42 if pull.PullSource != nil { ··· 266 58 parentChangeId = &pull.ParentChangeId 267 59 } 268 60 269 - _, err = tx.Exec( 61 + result, err := tx.Exec( 270 62 ` 271 63 insert into pulls ( 272 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 290 82 return err 291 83 } 292 84 85 + // Set the database primary key ID 86 + id, err := result.LastInsertId() 87 + if err != nil { 88 + return err 89 + } 90 + pull.ID = int(id) 91 + 293 92 _, err = tx.Exec(` 294 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 295 - values (?, ?, ?, ?, ?) 296 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 297 96 return err 298 97 } 299 98 ··· 311 110 return pullId - 1, err 312 111 } 313 112 314 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 315 - pulls := make(map[int]*Pull) 113 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 316 115 317 116 var conditions []string 318 117 var args []any ··· 332 131 333 132 query := fmt.Sprintf(` 334 133 select 134 + id, 335 135 owner_did, 336 136 repo_at, 337 137 pull_id, ··· 361 161 defer rows.Close() 362 162 363 163 for rows.Next() { 364 - var pull Pull 164 + var pull models.Pull 365 165 var createdAt string 366 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 367 167 err := rows.Scan( 168 + &pull.ID, 368 169 &pull.OwnerDid, 369 170 &pull.RepoAt, 370 171 &pull.PullId, ··· 391 192 pull.Created = createdTime 392 193 393 194 if sourceBranch.Valid { 394 - pull.PullSource = &PullSource{ 195 + pull.PullSource = &models.PullSource{ 395 196 Branch: sourceBranch.String, 396 197 } 397 198 if sourceRepoAt.Valid { ··· 413 214 pull.ParentChangeId = parentChangeId.String 414 215 } 415 216 416 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 417 218 } 418 219 419 - // get latest round no. for each pull 420 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 421 - submissionsQuery := fmt.Sprintf(` 422 - select 423 - id, pull_id, round_number, patch, created, source_rev 424 - from 425 - pull_submissions 426 - where 427 - repo_at in (%s) and pull_id in (%s) 428 - `, inClause, inClause) 429 - 430 - args = make([]any, len(pulls)*2) 431 - idx := 0 220 + var pullAts []syntax.ATURI 432 221 for _, p := range pulls { 433 - args[idx] = p.RepoAt 434 - idx += 1 435 - } 436 - for _, p := range pulls { 437 - args[idx] = p.PullId 438 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 439 223 } 440 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 441 225 if err != nil { 442 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 443 227 } 444 - defer submissionsRows.Close() 445 228 446 - for submissionsRows.Next() { 447 - var s PullSubmission 448 - var sourceRev sql.NullString 449 - var createdAt string 450 - err := submissionsRows.Scan( 451 - &s.ID, 452 - &s.PullId, 453 - &s.RoundNumber, 454 - &s.Patch, 455 - &createdAt, 456 - &sourceRev, 457 - ) 458 - if err != nil { 459 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 460 232 } 233 + } 461 234 462 - createdTime, err := time.Parse(time.RFC3339, createdAt) 463 - if err != nil { 464 - return nil, err 465 - } 466 - s.Created = createdTime 467 - 468 - if sourceRev.Valid { 469 - s.SourceRev = sourceRev.String 470 - } 471 - 472 - if p, ok := pulls[s.PullId]; ok { 473 - p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 474 - p.Submissions[s.RoundNumber] = &s 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 475 243 } 476 244 } 477 - if err := rows.Err(); err != nil { 478 - return nil, err 479 - } 480 - 481 - // get comment count on latest submission on each pull 482 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 483 - commentsQuery := fmt.Sprintf(` 484 - select 485 - count(id), pull_id 486 - from 487 - pull_comments 488 - where 489 - submission_id in (%s) 490 - group by 491 - submission_id 492 - `, inClause) 493 245 494 - args = []any{} 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 495 248 for _, p := range pulls { 496 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 497 252 } 498 - commentsRows, err := e.Query(commentsQuery, args...) 499 - if err != nil { 500 - return nil, err 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 501 256 } 502 - defer commentsRows.Close() 503 - 504 - for commentsRows.Next() { 505 - var commentCount, pullId int 506 - err := commentsRows.Scan( 507 - &commentCount, 508 - &pullId, 509 - ) 510 - if err != nil { 511 - return nil, err 512 - } 513 - if p, ok := pulls[pullId]; ok { 514 - p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 + } 261 + for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 515 266 } 516 267 } 517 - if err := rows.Err(); err != nil { 518 - return nil, err 519 - } 520 268 521 - orderedByPullId := []*Pull{} 269 + orderedByPullId := []*models.Pull{} 522 270 for _, p := range pulls { 523 271 orderedByPullId = append(orderedByPullId, p) 524 272 } ··· 529 277 return orderedByPullId, nil 530 278 } 531 279 532 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 280 + func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 533 281 return GetPullsWithLimit(e, 0, filters...) 534 282 } 535 283 536 - func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { 537 - query := ` 538 - select 539 - owner_did, 540 - pull_id, 541 - created, 542 - title, 543 - state, 544 - target_branch, 545 - repo_at, 546 - body, 547 - rkey, 548 - source_branch, 549 - source_repo_at, 550 - stack_id, 551 - change_id, 552 - parent_change_id 553 - from 554 - pulls 555 - where 556 - repo_at = ? and pull_id = ? 557 - ` 558 - row := e.QueryRow(query, repoAt, pullId) 559 - 560 - var pull Pull 561 - var createdAt string 562 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 563 - err := row.Scan( 564 - &pull.OwnerDid, 565 - &pull.PullId, 566 - &createdAt, 567 - &pull.Title, 568 - &pull.State, 569 - &pull.TargetBranch, 570 - &pull.RepoAt, 571 - &pull.Body, 572 - &pull.Rkey, 573 - &sourceBranch, 574 - &sourceRepoAt, 575 - &stackId, 576 - &changeId, 577 - &parentChangeId, 578 - ) 284 + func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 579 286 if err != nil { 580 287 return nil, err 581 288 } 582 - 583 - createdTime, err := time.Parse(time.RFC3339, createdAt) 584 - if err != nil { 585 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 586 291 } 587 - pull.Created = createdTime 292 + 293 + return pulls[0], nil 294 + } 588 295 589 - // populate source 590 - if sourceBranch.Valid { 591 - pull.PullSource = &PullSource{ 592 - Branch: sourceBranch.String, 593 - } 594 - if sourceRepoAt.Valid { 595 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 596 - if err != nil { 597 - return nil, err 598 - } 599 - pull.PullSource.RepoAt = &sourceRepoAtParsed 600 - } 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 601 303 } 602 304 603 - if stackId.Valid { 604 - pull.StackId = stackId.String 605 - } 606 - if changeId.Valid { 607 - pull.ChangeId = changeId.String 608 - } 609 - if parentChangeId.Valid { 610 - pull.ParentChangeId = parentChangeId.String 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 611 308 } 612 309 613 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 614 311 select 615 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 616 318 from 617 319 pull_submissions 618 - where 619 - repo_at = ? and pull_id = ? 620 - ` 621 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 622 326 if err != nil { 623 327 return nil, err 624 328 } 625 - defer submissionsRows.Close() 329 + defer rows.Close() 626 330 627 - submissionsMap := make(map[int]*PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 628 332 629 - for submissionsRows.Next() { 630 - var submission PullSubmission 631 - var submissionCreatedStr string 632 - var submissionSourceRev sql.NullString 633 - err := submissionsRows.Scan( 333 + for rows.Next() { 334 + var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 634 338 &submission.ID, 635 - &submission.PullId, 636 - &submission.RepoAt, 339 + &submission.PullAt, 637 340 &submission.RoundNumber, 638 341 &submission.Patch, 639 - &submissionCreatedStr, 640 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 641 344 ) 642 345 if err != nil { 643 346 return nil, err 644 347 } 645 348 646 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 647 350 if err != nil { 648 351 return nil, err 649 352 } 650 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 651 354 652 - if submissionSourceRev.Valid { 653 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 654 357 } 655 358 656 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 657 360 } 658 - if err = submissionsRows.Close(); err != nil { 361 + 362 + if err := rows.Err(); err != nil { 659 363 return nil, err 660 364 } 661 - if len(submissionsMap) == 0 { 662 - return &pull, nil 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 370 + return nil, err 371 + } 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 663 376 } 664 377 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 + } 383 + 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 + } 390 + 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 665 396 var args []any 666 - for k := range submissionsMap { 667 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 400 + } 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 668 405 } 669 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 670 - commentsQuery := fmt.Sprintf(` 406 + 407 + query := fmt.Sprintf(` 671 408 select 672 409 id, 673 410 pull_id, ··· 679 416 created 680 417 from 681 418 pull_comments 682 - where 683 - submission_id IN (%s) 419 + %s 684 420 order by 685 421 created asc 686 - `, inClause) 687 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 688 425 if err != nil { 689 426 return nil, err 690 427 } 691 - defer commentsRows.Close() 428 + defer rows.Close() 692 429 693 - for commentsRows.Next() { 694 - var comment PullComment 695 - var commentCreatedStr string 696 - err := commentsRows.Scan( 430 + var comments []models.PullComment 431 + for rows.Next() { 432 + var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 697 435 &comment.ID, 698 436 &comment.PullId, 699 437 &comment.SubmissionId, ··· 701 439 &comment.OwnerDid, 702 440 &comment.CommentAt, 703 441 &comment.Body, 704 - &commentCreatedStr, 442 + &createdAt, 705 443 ) 706 444 if err != nil { 707 445 return nil, err 708 446 } 709 447 710 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 711 - if err != nil { 712 - return nil, err 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 713 450 } 714 - comment.Created = commentCreatedTime 715 451 716 - // Add the comment to its submission 717 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 718 - submission.Comments = append(submission.Comments, comment) 719 - } 452 + comments = append(comments, comment) 453 + } 720 454 721 - } 722 - if err = commentsRows.Err(); err != nil { 455 + if err := rows.Err(); err != nil { 723 456 return nil, err 724 457 } 725 458 726 - var pullSourceRepo *Repo 727 - if pull.PullSource != nil { 728 - if pull.PullSource.RepoAt != nil { 729 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 730 - if err != nil { 731 - log.Printf("failed to get repo by at uri: %v", err) 732 - } else { 733 - pull.PullSource.Repo = pullSourceRepo 734 - } 735 - } 736 - } 737 - 738 - pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 739 - for _, submission := range submissionsMap { 740 - pull.Submissions[submission.RoundNumber] = submission 741 - } 742 - 743 - return &pull, nil 459 + return comments, nil 744 460 } 745 461 746 462 // timeframe here is directly passed into the sql query filter, and any 747 463 // timeframe in the past should be negative; e.g.: "-3 months" 748 - func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) { 749 - var pulls []Pull 464 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 465 + var pulls []models.Pull 750 466 751 467 rows, err := e.Query(` 752 468 select ··· 775 491 defer rows.Close() 776 492 777 493 for rows.Next() { 778 - var pull Pull 779 - var repo Repo 494 + var pull models.Pull 495 + var repo models.Repo 780 496 var pullCreatedAt, repoCreatedAt string 781 497 err := rows.Scan( 782 498 &pull.OwnerDid, ··· 819 535 return pulls, nil 820 536 } 821 537 822 - func NewPullComment(e Execer, comment *PullComment) (int64, error) { 538 + func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 823 539 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 824 540 res, err := e.Exec( 825 541 query, ··· 842 558 return i, nil 843 559 } 844 560 845 - func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error { 561 + func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { 846 562 _, err := e.Exec( 847 563 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, 848 564 pullState, 849 565 repoAt, 850 566 pullId, 851 - PullDeleted, // only update state of non-deleted pulls 852 - PullMerged, // only update state of non-merged pulls 567 + models.PullDeleted, // only update state of non-deleted pulls 568 + models.PullMerged, // only update state of non-merged pulls 853 569 ) 854 570 return err 855 571 } 856 572 857 573 func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error { 858 - err := SetPullState(e, repoAt, pullId, PullClosed) 574 + err := SetPullState(e, repoAt, pullId, models.PullClosed) 859 575 return err 860 576 } 861 577 862 578 func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error { 863 - err := SetPullState(e, repoAt, pullId, PullOpen) 579 + err := SetPullState(e, repoAt, pullId, models.PullOpen) 864 580 return err 865 581 } 866 582 867 583 func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error { 868 - err := SetPullState(e, repoAt, pullId, PullMerged) 584 + err := SetPullState(e, repoAt, pullId, models.PullMerged) 869 585 return err 870 586 } 871 587 872 588 func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error { 873 - err := SetPullState(e, repoAt, pullId, PullDeleted) 589 + err := SetPullState(e, repoAt, pullId, models.PullDeleted) 874 590 return err 875 591 } 876 592 877 - func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 593 + func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 878 594 newRoundNumber := len(pull.Submissions) 879 595 _, err := e.Exec(` 880 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 881 - values (?, ?, ?, ?, ?) 882 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 883 599 884 600 return err 885 601 } ··· 931 647 return err 932 648 } 933 649 934 - type PullCount struct { 935 - Open int 936 - Merged int 937 - Closed int 938 - Deleted int 939 - } 940 - 941 - func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) { 650 + func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) { 942 651 row := e.QueryRow(` 943 652 select 944 653 count(case when state = ? then 1 end) as open_count, ··· 947 656 count(case when state = ? then 1 end) as deleted_count 948 657 from pulls 949 658 where repo_at = ?`, 950 - PullOpen, 951 - PullMerged, 952 - PullClosed, 953 - PullDeleted, 659 + models.PullOpen, 660 + models.PullMerged, 661 + models.PullClosed, 662 + models.PullDeleted, 954 663 repoAt, 955 664 ) 956 665 957 - var count PullCount 666 + var count models.PullCount 958 667 if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil { 959 - return PullCount{0, 0, 0, 0}, err 668 + return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err 960 669 } 961 670 962 671 return count, nil 963 672 } 964 - 965 - type Stack []*Pull 966 673 967 674 // change-id parent-change-id 968 675 // ··· 972 679 // 1 x <------' nil (BOT) 973 680 // 974 681 // `w` is parent of none, so it is the top of the stack 975 - func GetStack(e Execer, stackId string) (Stack, error) { 682 + func GetStack(e Execer, stackId string) (models.Stack, error) { 976 683 unorderedPulls, err := GetPulls( 977 684 e, 978 685 FilterEq("stack_id", stackId), 979 - FilterNotEq("state", PullDeleted), 686 + FilterNotEq("state", models.PullDeleted), 980 687 ) 981 688 if err != nil { 982 689 return nil, err 983 690 } 984 691 // map of parent-change-id to pull 985 - changeIdMap := make(map[string]*Pull, len(unorderedPulls)) 986 - parentMap := make(map[string]*Pull, len(unorderedPulls)) 692 + changeIdMap := make(map[string]*models.Pull, len(unorderedPulls)) 693 + parentMap := make(map[string]*models.Pull, len(unorderedPulls)) 987 694 for _, p := range unorderedPulls { 988 695 changeIdMap[p.ChangeId] = p 989 696 if p.ParentChangeId != "" { ··· 992 699 } 993 700 994 701 // the top of the stack is the pull that is not a parent of any pull 995 - var topPull *Pull 702 + var topPull *models.Pull 996 703 for _, maybeTop := range unorderedPulls { 997 704 if _, ok := parentMap[maybeTop.ChangeId]; !ok { 998 705 topPull = maybeTop ··· 1000 707 } 1001 708 } 1002 709 1003 - pulls := []*Pull{} 710 + pulls := []*models.Pull{} 1004 711 for { 1005 712 pulls = append(pulls, topPull) 1006 713 if topPull.ParentChangeId != "" { ··· 1017 724 return pulls, nil 1018 725 } 1019 726 1020 - func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) { 727 + func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 1021 728 pulls, err := GetPulls( 1022 729 e, 1023 730 FilterEq("stack_id", stackId), 1024 - FilterEq("state", PullDeleted), 731 + FilterEq("state", models.PullDeleted), 1025 732 ) 1026 733 if err != nil { 1027 734 return nil, err ··· 1030 737 return pulls, nil 1031 738 } 1032 739 1033 - // position of this pull in the stack 1034 - func (stack Stack) Position(pull *Pull) int { 1035 - return slices.IndexFunc(stack, func(p *Pull) bool { 1036 - return p.ChangeId == pull.ChangeId 1037 - }) 1038 - } 740 + func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) { 741 + var conditions []string 742 + var args []any 1039 743 1040 - // all pulls below this pull (including self) in this stack 1041 - // 1042 - // nil if this pull does not belong to this stack 1043 - func (stack Stack) Below(pull *Pull) Stack { 1044 - position := stack.Position(pull) 744 + for _, filter := range filters { 745 + conditions = append(conditions, filter.Condition()) 746 + args = append(args, filter.Arg()...) 747 + } 1045 748 1046 - if position < 0 { 1047 - return nil 749 + if text != "" { 750 + searchPattern := "%" + text + "%" 751 + conditions = append(conditions, "title like ?") 752 + args = append(args, searchPattern) 1048 753 } 1049 754 1050 - return stack[position:] 1051 - } 755 + whereClause := "" 756 + if len(conditions) > 0 { 757 + whereClause = " where " + strings.Join(conditions, " and ") 758 + } 1052 759 1053 - // all pulls below this pull (excluding self) in this stack 1054 - func (stack Stack) StrictlyBelow(pull *Pull) Stack { 1055 - below := stack.Below(pull) 760 + query := fmt.Sprintf(` 761 + select 762 + id, 763 + owner_did, 764 + pull_id, 765 + title, 766 + body, 767 + target_branch, 768 + repo_at, 769 + rkey, 770 + state, 771 + source_branch, 772 + source_repo_at, 773 + stack_id, 774 + change_id, 775 + parent_change_id, 776 + created 777 + from pulls 778 + %s 779 + order by created desc 780 + `, whereClause) 1056 781 1057 - if len(below) > 0 { 1058 - return below[1:] 782 + rows, err := e.Query(query, args...) 783 + if err != nil { 784 + return nil, fmt.Errorf("failed to query pulls: %w", err) 1059 785 } 786 + defer rows.Close() 1060 787 1061 - return nil 1062 - } 788 + pullMap := make(map[string]*models.Pull) 789 + for rows.Next() { 790 + var pull models.Pull 791 + var createdAt string 792 + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string] 1063 793 1064 - // all pulls above this pull (including self) in this stack 1065 - func (stack Stack) Above(pull *Pull) Stack { 1066 - position := stack.Position(pull) 794 + err := rows.Scan( 795 + &pull.ID, 796 + &pull.OwnerDid, 797 + &pull.PullId, 798 + &pull.Title, 799 + &pull.Body, 800 + &pull.TargetBranch, 801 + &pull.RepoAt, 802 + &pull.Rkey, 803 + &pull.State, 804 + &sourceBranch, 805 + &sourceRepoAt, 806 + &stackId, 807 + &changeId, 808 + &parentChangeId, 809 + &createdAt, 810 + ) 811 + if err != nil { 812 + return nil, fmt.Errorf("failed to scan pull: %w", err) 813 + } 814 + 815 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 816 + pull.Created = t 817 + } 818 + 819 + if sourceBranch.Valid || sourceRepoAt.Valid { 820 + pull.PullSource = &models.PullSource{} 821 + if sourceBranch.Valid { 822 + pull.PullSource.Branch = sourceBranch.V 823 + } 824 + if sourceRepoAt.Valid { 825 + uri := syntax.ATURI(sourceRepoAt.V) 826 + pull.PullSource.RepoAt = &uri 827 + } 828 + } 829 + 830 + if stackId.Valid { 831 + pull.StackId = stackId.V 832 + } 833 + if changeId.Valid { 834 + pull.ChangeId = changeId.V 835 + } 836 + if parentChangeId.Valid { 837 + pull.ParentChangeId = parentChangeId.V 838 + } 839 + 840 + pullAt := pull.PullAt().String() 841 + pullMap[pullAt] = &pull 842 + } 843 + 844 + // Load submissions and labels 845 + for _, pull := range pullMap { 846 + submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String())) 847 + if err != nil { 848 + return nil, fmt.Errorf("failed to query submissions: %w", err) 849 + } 850 + if subs, ok := submissionsMap[pull.PullAt()]; ok { 851 + pull.Submissions = subs 852 + } 853 + } 1067 854 1068 - if position < 0 { 1069 - return nil 855 + // Collect labels 856 + pullAts := slices.Collect(maps.Keys(pullMap)) 857 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 858 + if err != nil { 859 + return nil, fmt.Errorf("failed to query labels: %w", err) 860 + } 861 + for pullAt, labels := range allLabels { 862 + if pull, ok := pullMap[pullAt.String()]; ok { 863 + pull.Labels = labels 864 + } 1070 865 } 1071 866 1072 - return stack[:position+1] 1073 - } 867 + // Filter by labels if specified 868 + if len(labels) > 0 { 869 + if len(pullMap) > 0 { 870 + var repoAt string 871 + for _, pull := range pullMap { 872 + repoAt = string(pull.RepoAt) 873 + break 874 + } 1074 875 1075 - // all pulls below this pull (excluding self) in this stack 1076 - func (stack Stack) StrictlyAbove(pull *Pull) Stack { 1077 - above := stack.Above(pull) 876 + repo, err := GetRepoByAtUri(e, repoAt) 877 + if err == nil && len(repo.Labels) > 0 { 878 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 879 + if err == nil { 880 + labelNameToUri := make(map[string]string) 881 + for _, def := range labelDefs { 882 + labelNameToUri[def.Name] = def.AtUri().String() 883 + } 1078 884 1079 - if len(above) > 0 { 1080 - return above[:len(above)-1] 885 + for pullAt, pull := range pullMap { 886 + hasAllLabels := true 887 + for _, labelName := range labels { 888 + labelUri, found := labelNameToUri[labelName] 889 + if !found { 890 + hasAllLabels = false 891 + break 892 + } 893 + if !pull.Labels.ContainsLabel(labelUri) { 894 + hasAllLabels = false 895 + break 896 + } 897 + } 898 + if !hasAllLabels { 899 + delete(pullMap, pullAt) 900 + } 901 + } 902 + } 903 + } 904 + } 1081 905 } 1082 906 1083 - return nil 1084 - } 1085 - 1086 - // the combined format-patches of all the newest submissions in this stack 1087 - func (stack Stack) CombinedPatch() string { 1088 - // go in reverse order because the bottom of the stack is the last element in the slice 1089 - var combined strings.Builder 1090 - for idx := range stack { 1091 - pull := stack[len(stack)-1-idx] 1092 - combined.WriteString(pull.LatestPatch()) 1093 - combined.WriteString("\n") 907 + var pulls []*models.Pull 908 + for _, p := range pullMap { 909 + pulls = append(pulls, p) 1094 910 } 1095 - return combined.String() 1096 - } 1097 911 1098 - // filter out PRs that are "active" 1099 - // 1100 - // PRs that are still open are active 1101 - func (stack Stack) Mergeable() Stack { 1102 - var mergeable Stack 912 + sort.Slice(pulls, func(i, j int) bool { 913 + var less bool 1103 914 1104 - for _, p := range stack { 1105 - // stop at the first merged PR 1106 - if p.State == PullMerged || p.State == PullClosed { 1107 - break 915 + switch sortBy { 916 + case "created": 917 + fallthrough 918 + default: 919 + if pulls[i].Created.Equal(pulls[j].Created) { 920 + // Tiebreaker: use pull_id for stable sort 921 + less = pulls[i].PullId > pulls[j].PullId 922 + } else { 923 + less = pulls[i].Created.After(pulls[j].Created) 924 + } 1108 925 } 1109 926 1110 - // skip over deleted PRs 1111 - if p.State != PullDeleted { 1112 - mergeable = append(mergeable, p) 927 + if sortOrder == "asc" { 928 + return !less 1113 929 } 1114 - } 930 + return less 931 + }) 1115 932 1116 - return mergeable 933 + return pulls, nil 1117 934 }
+7 -16
appview/db/punchcard.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 + 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type Punch struct { 11 - Did string 12 - Date time.Time 13 - Count int 14 - } 15 - 16 12 // this adds to the existing count 17 - func AddPunch(e Execer, punch Punch) error { 13 + func AddPunch(e Execer, punch models.Punch) error { 18 14 _, err := e.Exec(` 19 15 insert into punchcard (did, date, count) 20 16 values (?, ?, ?) ··· 24 20 return err 25 21 } 26 22 27 - type Punchcard struct { 28 - Total int 29 - Punches []Punch 30 - } 31 - 32 - func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 - punchcard := &Punchcard{} 23 + func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + punchcard := &models.Punchcard{} 34 25 now := time.Now() 35 26 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 27 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) 37 28 for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) { 38 - punchcard.Punches = append(punchcard.Punches, Punch{ 29 + punchcard.Punches = append(punchcard.Punches, models.Punch{ 39 30 Date: d, 40 31 Count: 0, 41 32 }) ··· 68 59 defer rows.Close() 69 60 70 61 for rows.Next() { 71 - var punch Punch 62 + var punch models.Punch 72 63 var date string 73 64 var count sql.NullInt64 74 65 if err := rows.Scan(&date, &count); err != nil {
+45 -67
appview/db/reaction.go
··· 5 5 "time" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type ReactionKind string 11 - 12 - const ( 13 - Like ReactionKind = "👍" 14 - Unlike ReactionKind = "👎" 15 - Laugh ReactionKind = "😆" 16 - Celebration ReactionKind = "🎉" 17 - Confused ReactionKind = "🫤" 18 - Heart ReactionKind = "❤️" 19 - Rocket ReactionKind = "🚀" 20 - Eyes ReactionKind = "👀" 8 + "tangled.org/core/appview/models" 21 9 ) 22 10 23 - func (rk ReactionKind) String() string { 24 - return string(rk) 25 - } 26 - 27 - var OrderedReactionKinds = []ReactionKind{ 28 - Like, 29 - Unlike, 30 - Laugh, 31 - Celebration, 32 - Confused, 33 - Heart, 34 - Rocket, 35 - Eyes, 36 - } 37 - 38 - func ParseReactionKind(raw string) (ReactionKind, bool) { 39 - k, ok := (map[string]ReactionKind{ 40 - "👍": Like, 41 - "👎": Unlike, 42 - "😆": Laugh, 43 - "🎉": Celebration, 44 - "🫤": Confused, 45 - "❤️": Heart, 46 - "🚀": Rocket, 47 - "👀": Eyes, 48 - })[raw] 49 - return k, ok 50 - } 51 - 52 - type Reaction struct { 53 - ReactedByDid string 54 - ThreadAt syntax.ATURI 55 - Created time.Time 56 - Rkey string 57 - Kind ReactionKind 58 - } 59 - 60 - func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 11 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 61 12 query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 13 _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 14 return err 64 15 } 65 16 66 17 // Get a reaction record 67 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 18 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 68 19 query := ` 69 20 select reacted_by_did, thread_at, created, rkey 70 21 from reactions 71 22 where reacted_by_did = ? and thread_at = ? and kind = ?` 72 23 row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 24 74 - var reaction Reaction 25 + var reaction models.Reaction 75 26 var created string 76 27 err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 28 if err != nil { ··· 90 41 } 91 42 92 43 // Remove a reaction 93 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 44 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 94 45 _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 46 return err 96 47 } ··· 101 52 return err 102 53 } 103 54 104 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 55 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 105 56 count := 0 106 57 err := e.QueryRow( 107 58 `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) ··· 111 62 return count, nil 112 63 } 113 64 114 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 - countMap := map[ReactionKind]int{} 116 - for _, kind := range OrderedReactionKinds { 117 - count, err := GetReactionCount(e, threadAt, kind) 118 - if err != nil { 119 - return map[ReactionKind]int{}, nil 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 + for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 120 91 } 121 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 122 99 } 123 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 124 102 } 125 103 126 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 104 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 127 105 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 106 return false 129 107 } else { ··· 131 109 } 132 110 } 133 111 134 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 - statusMap := map[ReactionKind]bool{} 136 - for _, kind := range OrderedReactionKinds { 112 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 113 + statusMap := map[models.ReactionKind]bool{} 114 + for _, kind := range models.OrderedReactionKinds { 137 115 count := GetReactionStatus(e, userDid, threadAt, kind) 138 116 statusMap[kind] = count 139 117 }
+4 -43
appview/db/registration.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 - ) 9 - 10 - // Registration represents a knot registration. Knot would've been a better 11 - // name but we're stuck with this for historical reasons. 12 - type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - NeedsUpgrade bool 19 - } 20 8 21 - func (r *Registration) Status() Status { 22 - if r.NeedsUpgrade { 23 - return NeedsUpgrade 24 - } else if r.Registered != nil { 25 - return Registered 26 - } else { 27 - return Pending 28 - } 29 - } 30 - 31 - func (r *Registration) IsRegistered() bool { 32 - return r.Status() == Registered 33 - } 34 - 35 - func (r *Registration) IsNeedsUpgrade() bool { 36 - return r.Status() == NeedsUpgrade 37 - } 38 - 39 - func (r *Registration) IsPending() bool { 40 - return r.Status() == Pending 41 - } 42 - 43 - type Status uint32 44 - 45 - const ( 46 - Registered Status = iota 47 - Pending 48 - NeedsUpgrade 9 + "tangled.org/core/appview/models" 49 10 ) 50 11 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 - var registrations []Registration 12 + func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + var registrations []models.Registration 53 14 54 15 var conditions []string 55 16 var args []any ··· 81 42 var createdAt string 82 43 var registeredAt sql.Null[string] 83 44 var needsUpgrade int 84 - var reg Registration 45 + var reg models.Registration 85 46 86 47 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 48 if err != nil {
+117 -93
appview/db/repos.go
··· 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/models" 15 16 ) 16 17 17 18 type Repo struct { 19 + Id int64 18 20 Did string 19 21 Name string 20 22 Knot string ··· 24 26 Spindle string 25 27 26 28 // optionally, populate this when querying for reverse mappings 27 - RepoStats *RepoStats 29 + RepoStats *models.RepoStats 28 30 29 31 // optional 30 32 Source string 31 33 } 32 34 33 - func (r *Repo) AsRecord() tangled.Repo { 34 - return tangled.Repo{} 35 - } 36 - 37 35 func (r Repo) RepoAt() syntax.ATURI { 38 36 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 39 37 } ··· 43 41 return p 44 42 } 45 43 46 - func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 47 - repoMap := make(map[syntax.ATURI]*Repo) 44 + func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 + repoMap := make(map[syntax.ATURI]*models.Repo) 48 46 49 47 var conditions []string 50 48 var args []any ··· 65 63 66 64 repoQuery := fmt.Sprintf( 67 65 `select 66 + id, 68 67 did, 69 68 name, 70 69 knot, ··· 88 87 } 89 88 90 89 for rows.Next() { 91 - var repo Repo 90 + var repo models.Repo 92 91 var createdAt string 93 92 var description, source, spindle sql.NullString 94 93 95 94 err := rows.Scan( 95 + &repo.Id, 96 96 &repo.Did, 97 97 &repo.Name, 98 98 &repo.Knot, ··· 119 119 repo.Spindle = spindle.String 120 120 } 121 121 122 - repo.RepoStats = &RepoStats{} 122 + repo.RepoStats = &models.RepoStats{} 123 123 repoMap[repo.RepoAt()] = &repo 124 124 } 125 125 ··· 136 136 i++ 137 137 } 138 138 139 + // Get labels for all repos 140 + labelsQuery := fmt.Sprintf( 141 + `select repo_at, label_at from repo_labels where repo_at in (%s)`, 142 + inClause, 143 + ) 144 + rows, err = e.Query(labelsQuery, args...) 145 + if err != nil { 146 + return nil, fmt.Errorf("failed to execute labels query: %w ", err) 147 + } 148 + for rows.Next() { 149 + var repoat, labelat string 150 + if err := rows.Scan(&repoat, &labelat); err != nil { 151 + log.Println("err", "err", err) 152 + continue 153 + } 154 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 155 + r.Labels = append(r.Labels, labelat) 156 + } 157 + } 158 + if err = rows.Err(); err != nil { 159 + return nil, fmt.Errorf("failed to execute labels query: %w ", err) 160 + } 161 + 139 162 languageQuery := fmt.Sprintf( 140 163 ` 141 - select 142 - repo_at, language 143 - from 144 - repo_languages r1 145 - where 146 - repo_at IN (%s) 164 + select repo_at, language 165 + from ( 166 + select 167 + repo_at, 168 + language, 169 + row_number() over ( 170 + partition by repo_at 171 + order by bytes desc 172 + ) as rn 173 + from repo_languages 174 + where repo_at in (%s) 147 175 and is_default_ref = 1 148 - and id = ( 149 - select id 150 - from repo_languages r2 151 - where r2.repo_at = r1.repo_at 152 - and r2.is_default_ref = 1 153 - order by bytes desc 154 - limit 1 155 - ); 176 + ) 177 + where rn = 1 156 178 `, 157 179 inClause, 158 180 ) ··· 244 266 inClause, 245 267 ) 246 268 args = append([]any{ 247 - PullOpen, 248 - PullMerged, 249 - PullClosed, 250 - PullDeleted, 269 + models.PullOpen, 270 + models.PullMerged, 271 + models.PullClosed, 272 + models.PullDeleted, 251 273 }, args...) 252 274 rows, err = e.Query( 253 275 pullCountQuery, ··· 274 296 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 275 297 } 276 298 277 - var repos []Repo 299 + var repos []models.Repo 278 300 for _, r := range repoMap { 279 301 repos = append(repos, *r) 280 302 } 281 303 282 - slices.SortFunc(repos, func(a, b Repo) int { 304 + slices.SortFunc(repos, func(a, b models.Repo) int { 283 305 if a.Created.After(b.Created) { 284 306 return -1 285 307 } ··· 289 311 return repos, nil 290 312 } 291 313 314 + // helper to get exactly one repo 315 + func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 316 + repos, err := GetRepos(e, 0, filters...) 317 + if err != nil { 318 + return nil, err 319 + } 320 + 321 + if repos == nil { 322 + return nil, sql.ErrNoRows 323 + } 324 + 325 + if len(repos) != 1 { 326 + return nil, fmt.Errorf("too many rows returned") 327 + } 328 + 329 + return &repos[0], nil 330 + } 331 + 292 332 func CountRepos(e Execer, filters ...filter) (int64, error) { 293 333 var conditions []string 294 334 var args []any ··· 313 353 return count, nil 314 354 } 315 355 316 - func GetRepo(e Execer, did, name string) (*Repo, error) { 317 - var repo Repo 318 - var description, spindle sql.NullString 319 - 320 - row := e.QueryRow(` 321 - select did, name, knot, created, description, spindle, rkey 322 - from repos 323 - where did = ? and name = ? 324 - `, 325 - did, 326 - name, 327 - ) 328 - 329 - var createdAt string 330 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 331 - return nil, err 332 - } 333 - createdAtTime, _ := time.Parse(time.RFC3339, createdAt) 334 - repo.Created = createdAtTime 335 - 336 - if description.Valid { 337 - repo.Description = description.String 338 - } 339 - 340 - if spindle.Valid { 341 - repo.Spindle = spindle.String 342 - } 343 - 344 - return &repo, nil 345 - } 346 - 347 - func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { 348 - var repo Repo 356 + func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 + var repo models.Repo 349 358 var nullableDescription sql.NullString 350 359 351 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 352 361 353 362 var createdAt string 354 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 355 364 return nil, err 356 365 } 357 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 363 372 repo.Description = "" 364 373 } 365 374 375 + // Load labels for this repo 376 + rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri) 377 + if err != nil { 378 + return nil, fmt.Errorf("failed to load repo labels: %w", err) 379 + } 380 + defer rows.Close() 381 + 382 + for rows.Next() { 383 + var labelAt string 384 + if err := rows.Scan(&labelAt); err != nil { 385 + continue 386 + } 387 + repo.Labels = append(repo.Labels, labelAt) 388 + } 389 + 366 390 return &repo, nil 367 391 } 368 392 369 - func AddRepo(e Execer, repo *Repo) error { 370 - _, err := e.Exec( 393 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 394 + _, err := tx.Exec( 371 395 `insert into repos 372 396 (did, name, knot, rkey, at_uri, description, source) 373 397 values (?, ?, ?, ?, ?, ?, ?)`, 374 398 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 375 399 ) 376 - return err 400 + if err != nil { 401 + return fmt.Errorf("failed to insert repo: %w", err) 402 + } 403 + 404 + for _, dl := range repo.Labels { 405 + if err := SubscribeLabel(tx, &models.RepoLabel{ 406 + RepoAt: repo.RepoAt(), 407 + LabelAt: syntax.ATURI(dl), 408 + }); err != nil { 409 + return fmt.Errorf("failed to subscribe to label: %w", err) 410 + } 411 + } 412 + 413 + return nil 377 414 } 378 415 379 416 func RemoveRepo(e Execer, did, name string) error { ··· 390 427 return nullableSource.String, nil 391 428 } 392 429 393 - func GetForksByDid(e Execer, did string) ([]Repo, error) { 394 - var repos []Repo 430 + func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 431 + var repos []models.Repo 395 432 396 433 rows, err := e.Query( 397 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 434 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 398 435 from repos r 399 436 left join collaborators c on r.at_uri = c.repo_at 400 437 where (r.did = ? or c.subject_did = ?) ··· 409 446 defer rows.Close() 410 447 411 448 for rows.Next() { 412 - var repo Repo 449 + var repo models.Repo 413 450 var createdAt string 414 451 var nullableDescription sql.NullString 415 452 var nullableSource sql.NullString 416 453 417 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 454 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 418 455 if err != nil { 419 456 return nil, err 420 457 } ··· 444 481 return repos, nil 445 482 } 446 483 447 - func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 448 - var repo Repo 484 + func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { 485 + var repo models.Repo 449 486 var createdAt string 450 487 var nullableDescription sql.NullString 451 488 var nullableSource sql.NullString 452 489 453 490 row := e.QueryRow( 454 - `select did, name, knot, rkey, description, created, source 491 + `select id, did, name, knot, rkey, description, created, source 455 492 from repos 456 493 where did = ? and name = ? and source is not null and source != ''`, 457 494 did, name, 458 495 ) 459 496 460 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 497 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 461 498 if err != nil { 462 499 return nil, err 463 500 } ··· 492 529 return err 493 530 } 494 531 495 - type RepoStats struct { 496 - Language string 497 - StarCount int 498 - IssueCount IssueCount 499 - PullCount PullCount 500 - } 501 - 502 - type RepoLabel struct { 503 - Id int64 504 - RepoAt syntax.ATURI 505 - LabelAt syntax.ATURI 506 - } 507 - 508 - func SubscribeLabel(e Execer, rl *RepoLabel) error { 532 + func SubscribeLabel(e Execer, rl *models.RepoLabel) error { 509 533 query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 510 534 511 535 _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) ··· 530 554 return err 531 555 } 532 556 533 - func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) { 557 + func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 534 558 var conditions []string 535 559 var args []any 536 560 for _, filter := range filters { ··· 551 575 } 552 576 defer rows.Close() 553 577 554 - var labels []RepoLabel 578 + var labels []models.RepoLabel 555 579 for rows.Next() { 556 - var label RepoLabel 580 + var label models.RepoLabel 557 581 558 582 err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 559 583 if err != nil {
+4 -9
appview/db/signup.go
··· 1 1 package db 2 2 3 - import "time" 3 + import ( 4 + "tangled.org/core/appview/models" 5 + ) 4 6 5 - type InflightSignup struct { 6 - Id int64 7 - Email string 8 - InviteCode string 9 - Created time.Time 10 - } 11 - 12 - func AddInflightSignup(e Execer, signup InflightSignup) error { 7 + func AddInflightSignup(e Execer, signup models.InflightSignup) error { 13 8 query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 9 _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 10 return err
+9 -27
appview/db/spindle.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 10 10 ) 11 11 12 - type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 18 - NeedsUpgrade bool 19 - } 20 - 21 - type SpindleMember struct { 22 - Id int 23 - Did syntax.DID // owner of the record 24 - Rkey string // rkey of the record 25 - Instance string 26 - Subject syntax.DID // the member being added 27 - Created time.Time 28 - } 29 - 30 - func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) { 31 - var spindles []Spindle 12 + func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + var spindles []models.Spindle 32 14 33 15 var conditions []string 34 16 var args []any ··· 59 41 defer rows.Close() 60 42 61 43 for rows.Next() { 62 - var spindle Spindle 44 + var spindle models.Spindle 63 45 var createdAt string 64 46 var verified sql.NullString 65 47 var needsUpgrade int ··· 100 82 } 101 83 102 84 // if there is an existing spindle with the same instance, this returns an error 103 - func AddSpindle(e Execer, spindle Spindle) error { 85 + func AddSpindle(e Execer, spindle models.Spindle) error { 104 86 _, err := e.Exec( 105 87 `insert into spindles (owner, instance) values (?, ?)`, 106 88 spindle.Owner, ··· 151 133 return err 152 134 } 153 135 154 - func AddSpindleMember(e Execer, member SpindleMember) error { 136 + func AddSpindleMember(e Execer, member models.SpindleMember) error { 155 137 _, err := e.Exec( 156 138 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 157 139 member.Did, ··· 181 163 return err 182 164 } 183 165 184 - func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) { 185 - var members []SpindleMember 166 + func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + var members []models.SpindleMember 186 168 187 169 var conditions []string 188 170 var args []any ··· 213 195 defer rows.Close() 214 196 215 197 for rows.Next() { 216 - var member SpindleMember 198 + var member models.SpindleMember 217 199 var createdAt string 218 200 219 201 if err := rows.Scan(
+27 -39
appview/db/star.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "log" 8 + "slices" 8 9 "strings" 9 10 "time" 10 11 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 12 14 ) 13 15 14 - type Star struct { 15 - StarredByDid string 16 - RepoAt syntax.ATURI 17 - Created time.Time 18 - Rkey string 19 - 20 - // optionally, populate this when querying for reverse mappings 21 - Repo *Repo 22 - } 23 - 24 - func (star *Star) ResolveRepo(e Execer) error { 25 - if star.Repo != nil { 26 - return nil 27 - } 28 - 29 - repo, err := GetRepoByAtUri(e, star.RepoAt.String()) 30 - if err != nil { 31 - return err 32 - } 33 - 34 - star.Repo = repo 35 - return nil 36 - } 37 - 38 - func AddStar(e Execer, star *Star) error { 16 + func AddStar(e Execer, star *models.Star) error { 39 17 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 40 18 _, err := e.Exec( 41 19 query, ··· 47 25 } 48 26 49 27 // Get a star record 50 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 28 + func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 51 29 query := ` 52 30 select starred_by_did, repo_at, created, rkey 53 31 from stars 54 32 where starred_by_did = ? and repo_at = ?` 55 33 row := e.QueryRow(query, starredByDid, repoAt) 56 34 57 - var star Star 35 + var star models.Star 58 36 var created string 59 37 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 60 38 if err != nil { ··· 152 130 func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 153 131 return getStarStatuses(e, userDid, repoAts) 154 132 } 155 - func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 133 + func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 156 134 var conditions []string 157 135 var args []any 158 136 for _, filter := range filters { ··· 184 162 return nil, err 185 163 } 186 164 187 - starMap := make(map[string][]Star) 165 + starMap := make(map[string][]models.Star) 188 166 for rows.Next() { 189 - var star Star 167 + var star models.Star 190 168 var created string 191 169 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 192 170 if err != nil { ··· 227 205 } 228 206 } 229 207 230 - var stars []Star 208 + var stars []models.Star 231 209 for _, s := range starMap { 232 210 stars = append(stars, s...) 233 211 } 212 + 213 + slices.SortFunc(stars, func(a, b models.Star) int { 214 + if a.Created.After(b.Created) { 215 + return -1 216 + } 217 + if b.Created.After(a.Created) { 218 + return 1 219 + } 220 + return 0 221 + }) 234 222 235 223 return stars, nil 236 224 } ··· 259 247 return count, nil 260 248 } 261 249 262 - func GetAllStars(e Execer, limit int) ([]Star, error) { 263 - var stars []Star 250 + func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 + var stars []models.Star 264 252 265 253 rows, err := e.Query(` 266 254 select ··· 283 271 defer rows.Close() 284 272 285 273 for rows.Next() { 286 - var star Star 287 - var repo Repo 274 + var star models.Star 275 + var repo models.Repo 288 276 var starCreatedAt, repoCreatedAt string 289 277 290 278 if err := rows.Scan( ··· 322 310 } 323 311 324 312 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 325 - func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 313 + func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 326 314 // first, get the top repo URIs by star count from the last week 327 315 query := ` 328 316 with recent_starred_repos as ( ··· 366 354 } 367 355 368 356 if len(repoUris) == 0 { 369 - return []Repo{}, nil 357 + return []models.Repo{}, nil 370 358 } 371 359 372 360 // get full repo data ··· 376 364 } 377 365 378 366 // sort repos by the original trending order 379 - repoMap := make(map[string]Repo) 367 + repoMap := make(map[string]models.Repo) 380 368 for _, repo := range repos { 381 369 repoMap[repo.RepoAt().String()] = repo 382 370 } 383 371 384 - orderedRepos := make([]Repo, 0, len(repoUris)) 372 + orderedRepos := make([]models.Repo, 0, len(repoUris)) 385 373 for _, uri := range repoUris { 386 374 if repo, exists := repoMap[uri]; exists { 387 375 orderedRepos = append(orderedRepos, repo)
+5 -110
appview/db/strings.go
··· 1 1 package db 2 2 3 3 import ( 4 - "bytes" 5 4 "database/sql" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "strings" 10 8 "time" 11 - "unicode/utf8" 12 9 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/models" 15 11 ) 16 12 17 - type String struct { 18 - Did syntax.DID 19 - Rkey string 20 - 21 - Filename string 22 - Description string 23 - Contents string 24 - Created time.Time 25 - Edited *time.Time 26 - } 27 - 28 - func (s *String) StringAt() syntax.ATURI { 29 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 - } 31 - 32 - type StringStats struct { 33 - LineCount uint64 34 - ByteCount uint64 35 - } 36 - 37 - func (s String) Stats() StringStats { 38 - lineCount, err := countLines(strings.NewReader(s.Contents)) 39 - if err != nil { 40 - // non-fatal 41 - // TODO: log this? 42 - } 43 - 44 - return StringStats{ 45 - LineCount: uint64(lineCount), 46 - ByteCount: uint64(len(s.Contents)), 47 - } 48 - } 49 - 50 - func (s String) Validate() error { 51 - var err error 52 - 53 - if utf8.RuneCountInString(s.Filename) > 140 { 54 - err = errors.Join(err, fmt.Errorf("filename too long")) 55 - } 56 - 57 - if utf8.RuneCountInString(s.Description) > 280 { 58 - err = errors.Join(err, fmt.Errorf("description too long")) 59 - } 60 - 61 - if len(s.Contents) == 0 { 62 - err = errors.Join(err, fmt.Errorf("contents is empty")) 63 - } 64 - 65 - return err 66 - } 67 - 68 - func (s *String) AsRecord() tangled.String { 69 - return tangled.String{ 70 - Filename: s.Filename, 71 - Description: s.Description, 72 - Contents: s.Contents, 73 - CreatedAt: s.Created.Format(time.RFC3339), 74 - } 75 - } 76 - 77 - func StringFromRecord(did, rkey string, record tangled.String) String { 78 - created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 - if err != nil { 80 - created = time.Now() 81 - } 82 - return String{ 83 - Did: syntax.DID(did), 84 - Rkey: rkey, 85 - Filename: record.Filename, 86 - Description: record.Description, 87 - Contents: record.Contents, 88 - Created: created, 89 - } 90 - } 91 - 92 - func AddString(e Execer, s String) error { 13 + func AddString(e Execer, s models.String) error { 93 14 _, err := e.Exec( 94 15 `insert into strings ( 95 16 did, ··· 123 44 return err 124 45 } 125 46 126 - func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 - var all []String 47 + func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 + var all []models.String 128 49 129 50 var conditions []string 130 51 var args []any ··· 167 88 defer rows.Close() 168 89 169 90 for rows.Next() { 170 - var s String 91 + var s models.String 171 92 var createdAt string 172 93 var editedAt sql.NullString 173 94 ··· 248 169 _, err := e.Exec(query, args...) 249 170 return err 250 171 } 251 - 252 - func countLines(r io.Reader) (int, error) { 253 - buf := make([]byte, 32*1024) 254 - bufLen := 0 255 - count := 0 256 - nl := []byte{'\n'} 257 - 258 - for { 259 - c, err := r.Read(buf) 260 - if c > 0 { 261 - bufLen += c 262 - } 263 - count += bytes.Count(buf[:c], nl) 264 - 265 - switch { 266 - case err == io.EOF: 267 - /* handle last line not having a newline at the end */ 268 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 269 - count++ 270 - } 271 - return count, nil 272 - case err != nil: 273 - return 0, err 274 - } 275 - } 276 - }
+20 -40
appview/db/timeline.go
··· 2 2 3 3 import ( 4 4 "sort" 5 - "time" 6 5 7 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 8 8 ) 9 9 10 - type TimelineEvent struct { 11 - *Repo 12 - *Follow 13 - *Star 14 - 15 - EventAt time.Time 16 - 17 - // optional: populate only if Repo is a fork 18 - Source *Repo 19 - 20 - // optional: populate only if event is Follow 21 - *Profile 22 - *FollowStats 23 - *FollowStatus 24 - 25 - // optional: populate only if event is Repo 26 - IsStarred bool 27 - StarCount int64 28 - } 29 - 30 10 // TODO: this gathers heterogenous events from different sources and aggregates 31 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 32 - func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 33 - var events []TimelineEvent 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 + var events []models.TimelineEvent 34 14 35 15 repos, err := getTimelineRepos(e, limit, loggedInUserDid) 36 16 if err != nil { ··· 63 43 return events, nil 64 44 } 65 45 66 - func fetchStarStatuses(e Execer, loggedInUserDid string, repos []Repo) (map[string]bool, error) { 46 + func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) { 67 47 if loggedInUserDid == "" { 68 48 return nil, nil 69 49 } ··· 76 56 return GetStarStatuses(e, loggedInUserDid, repoAts) 77 57 } 78 58 79 - func getRepoStarInfo(repo *Repo, starStatuses map[string]bool) (bool, int64) { 59 + func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) { 80 60 var isStarred bool 81 61 if starStatuses != nil { 82 62 isStarred = starStatuses[repo.RepoAt().String()] ··· 90 70 return isStarred, starCount 91 71 } 92 72 93 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 73 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 94 74 repos, err := GetRepos(e, limit) 95 75 if err != nil { 96 76 return nil, err ··· 104 84 } 105 85 } 106 86 107 - var origRepos []Repo 87 + var origRepos []models.Repo 108 88 if args != nil { 109 89 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 110 90 } ··· 112 92 return nil, err 113 93 } 114 94 115 - uriToRepo := make(map[string]Repo) 95 + uriToRepo := make(map[string]models.Repo) 116 96 for _, r := range origRepos { 117 97 uriToRepo[r.RepoAt().String()] = r 118 98 } ··· 122 102 return nil, err 123 103 } 124 104 125 - var events []TimelineEvent 105 + var events []models.TimelineEvent 126 106 for _, r := range repos { 127 - var source *Repo 107 + var source *models.Repo 128 108 if r.Source != "" { 129 109 if origRepo, ok := uriToRepo[r.Source]; ok { 130 110 source = &origRepo ··· 133 113 134 114 isStarred, starCount := getRepoStarInfo(&r, starStatuses) 135 115 136 - events = append(events, TimelineEvent{ 116 + events = append(events, models.TimelineEvent{ 137 117 Repo: &r, 138 118 EventAt: r.Created, 139 119 Source: source, ··· 145 125 return events, nil 146 126 } 147 127 148 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 128 + func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 149 129 stars, err := GetStars(e, limit) 150 130 if err != nil { 151 131 return nil, err ··· 161 141 } 162 142 stars = stars[:n] 163 143 164 - var repos []Repo 144 + var repos []models.Repo 165 145 for _, s := range stars { 166 146 repos = append(repos, *s.Repo) 167 147 } ··· 171 151 return nil, err 172 152 } 173 153 174 - var events []TimelineEvent 154 + var events []models.TimelineEvent 175 155 for _, s := range stars { 176 156 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 177 157 178 - events = append(events, TimelineEvent{ 158 + events = append(events, models.TimelineEvent{ 179 159 Star: &s, 180 160 EventAt: s.Created, 181 161 IsStarred: isStarred, ··· 186 166 return events, nil 187 167 } 188 168 189 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 169 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 190 170 follows, err := GetFollows(e, limit) 191 171 if err != nil { 192 172 return nil, err ··· 211 191 return nil, err 212 192 } 213 193 214 - var followStatuses map[string]FollowStatus 194 + var followStatuses map[string]models.FollowStatus 215 195 if loggedInUserDid != "" { 216 196 followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects) 217 197 if err != nil { ··· 219 199 } 220 200 } 221 201 222 - var events []TimelineEvent 202 + var events []models.TimelineEvent 223 203 for _, f := range follows { 224 204 profile, _ := profiles[f.SubjectDid] 225 205 followStatMap, _ := followStatMap[f.SubjectDid] 226 206 227 - followStatus := IsNotFollowing 207 + followStatus := models.IsNotFollowing 228 208 if followStatuses != nil { 229 209 followStatus = followStatuses[f.SubjectDid] 230 210 } 231 211 232 - events = append(events, TimelineEvent{ 212 + events = append(events, models.TimelineEvent{ 233 213 Follow: &f, 234 214 Profile: profile, 235 215 FollowStats: &followStatMap,
+191 -54
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "maps" 9 + "slices" 8 10 9 11 "time" 10 12 11 13 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/jetstream/pkg/models" 14 + jmodels "github.com/bluesky-social/jetstream/pkg/models" 13 15 "github.com/go-git/go-git/v5/plumbing" 14 16 "github.com/ipfs/go-cid" 15 17 "tangled.org/core/api/tangled" 16 18 "tangled.org/core/appview/config" 17 19 "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 18 21 "tangled.org/core/appview/serververify" 19 22 "tangled.org/core/appview/validator" 20 23 "tangled.org/core/idresolver" ··· 30 33 Validator *validator.Validator 31 34 } 32 35 33 - type processFunc func(ctx context.Context, e *models.Event) error 36 + type processFunc func(ctx context.Context, e *jmodels.Event) error 34 37 35 38 func (i *Ingester) Ingest() processFunc { 36 - return func(ctx context.Context, e *models.Event) error { 39 + return func(ctx context.Context, e *jmodels.Event) error { 37 40 var err error 38 41 defer func() { 39 42 eventTime := e.TimeUS ··· 45 48 46 49 l := i.Logger.With("kind", e.Kind) 47 50 switch e.Kind { 48 - case models.EventKindAccount: 51 + case jmodels.EventKindAccount: 49 52 if !e.Account.Active && *e.Account.Status == "deactivated" { 50 53 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 51 54 } 52 - case models.EventKindIdentity: 55 + case jmodels.EventKindIdentity: 53 56 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 54 - case models.EventKindCommit: 57 + case jmodels.EventKindCommit: 55 58 switch e.Commit.Collection { 56 59 case tangled.GraphFollowNSID: 57 60 err = i.ingestFollow(e) ··· 77 80 err = i.ingestIssue(ctx, e) 78 81 case tangled.RepoIssueCommentNSID: 79 82 err = i.ingestIssueComment(e) 83 + case tangled.LabelDefinitionNSID: 84 + err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 80 87 } 81 88 l = i.Logger.With("nsid", e.Commit.Collection) 82 89 } ··· 89 96 } 90 97 } 91 98 92 - func (i *Ingester) ingestStar(e *models.Event) error { 99 + func (i *Ingester) ingestStar(e *jmodels.Event) error { 93 100 var err error 94 101 did := e.Did 95 102 ··· 97 104 l = l.With("nsid", e.Commit.Collection) 98 105 99 106 switch e.Commit.Operation { 100 - case models.CommitOperationCreate, models.CommitOperationUpdate: 107 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 101 108 var subjectUri syntax.ATURI 102 109 103 110 raw := json.RawMessage(e.Commit.Record) ··· 113 120 l.Error("invalid record", "err", err) 114 121 return err 115 122 } 116 - err = db.AddStar(i.Db, &db.Star{ 123 + err = db.AddStar(i.Db, &models.Star{ 117 124 StarredByDid: did, 118 125 RepoAt: subjectUri, 119 126 Rkey: e.Commit.RKey, 120 127 }) 121 - case models.CommitOperationDelete: 128 + case jmodels.CommitOperationDelete: 122 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 123 130 } 124 131 ··· 129 136 return nil 130 137 } 131 138 132 - func (i *Ingester) ingestFollow(e *models.Event) error { 139 + func (i *Ingester) ingestFollow(e *jmodels.Event) error { 133 140 var err error 134 141 did := e.Did 135 142 ··· 137 144 l = l.With("nsid", e.Commit.Collection) 138 145 139 146 switch e.Commit.Operation { 140 - case models.CommitOperationCreate, models.CommitOperationUpdate: 147 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 141 148 raw := json.RawMessage(e.Commit.Record) 142 149 record := tangled.GraphFollow{} 143 150 err = json.Unmarshal(raw, &record) ··· 146 153 return err 147 154 } 148 155 149 - err = db.AddFollow(i.Db, &db.Follow{ 156 + err = db.AddFollow(i.Db, &models.Follow{ 150 157 UserDid: did, 151 158 SubjectDid: record.Subject, 152 159 Rkey: e.Commit.RKey, 153 160 }) 154 - case models.CommitOperationDelete: 161 + case jmodels.CommitOperationDelete: 155 162 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 156 163 } 157 164 ··· 162 169 return nil 163 170 } 164 171 165 - func (i *Ingester) ingestPublicKey(e *models.Event) error { 172 + func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { 166 173 did := e.Did 167 174 var err error 168 175 ··· 170 177 l = l.With("nsid", e.Commit.Collection) 171 178 172 179 switch e.Commit.Operation { 173 - case models.CommitOperationCreate, models.CommitOperationUpdate: 180 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 174 181 l.Debug("processing add of pubkey") 175 182 raw := json.RawMessage(e.Commit.Record) 176 183 record := tangled.PublicKey{} ··· 183 190 name := record.Name 184 191 key := record.Key 185 192 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 186 - case models.CommitOperationDelete: 193 + case jmodels.CommitOperationDelete: 187 194 l.Debug("processing delete of pubkey") 188 195 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 189 196 } ··· 195 202 return nil 196 203 } 197 204 198 - func (i *Ingester) ingestArtifact(e *models.Event) error { 205 + func (i *Ingester) ingestArtifact(e *jmodels.Event) error { 199 206 did := e.Did 200 207 var err error 201 208 ··· 203 210 l = l.With("nsid", e.Commit.Collection) 204 211 205 212 switch e.Commit.Operation { 206 - case models.CommitOperationCreate, models.CommitOperationUpdate: 213 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 207 214 raw := json.RawMessage(e.Commit.Record) 208 215 record := tangled.RepoArtifact{} 209 216 err = json.Unmarshal(raw, &record) ··· 232 239 createdAt = time.Now() 233 240 } 234 241 235 - artifact := db.Artifact{ 242 + artifact := models.Artifact{ 236 243 Did: did, 237 244 Rkey: e.Commit.RKey, 238 245 RepoAt: repoAt, ··· 245 252 } 246 253 247 254 err = db.AddArtifact(i.Db, artifact) 248 - case models.CommitOperationDelete: 255 + case jmodels.CommitOperationDelete: 249 256 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 250 257 } 251 258 ··· 256 263 return nil 257 264 } 258 265 259 - func (i *Ingester) ingestProfile(e *models.Event) error { 266 + func (i *Ingester) ingestProfile(e *jmodels.Event) error { 260 267 did := e.Did 261 268 var err error 262 269 ··· 268 275 } 269 276 270 277 switch e.Commit.Operation { 271 - case models.CommitOperationCreate, models.CommitOperationUpdate: 278 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 272 279 raw := json.RawMessage(e.Commit.Record) 273 280 record := tangled.ActorProfile{} 274 281 err = json.Unmarshal(raw, &record) ··· 296 303 } 297 304 } 298 305 299 - var stats [2]db.VanityStat 306 + var stats [2]models.VanityStat 300 307 for i, s := range record.Stats { 301 308 if i < 2 { 302 - stats[i].Kind = db.VanityStatKind(s) 309 + stats[i].Kind = models.VanityStatKind(s) 303 310 } 304 311 } 305 312 ··· 310 317 } 311 318 } 312 319 313 - profile := db.Profile{ 320 + profile := models.Profile{ 314 321 Did: did, 315 322 Description: description, 316 323 IncludeBluesky: includeBluesky, ··· 336 343 } 337 344 338 345 err = db.UpsertProfile(tx, &profile) 339 - case models.CommitOperationDelete: 346 + case jmodels.CommitOperationDelete: 340 347 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 341 348 } 342 349 ··· 347 354 return nil 348 355 } 349 356 350 - func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 357 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error { 351 358 did := e.Did 352 359 var err error 353 360 ··· 355 362 l = l.With("nsid", e.Commit.Collection) 356 363 357 364 switch e.Commit.Operation { 358 - case models.CommitOperationCreate: 365 + case jmodels.CommitOperationCreate: 359 366 raw := json.RawMessage(e.Commit.Record) 360 367 record := tangled.SpindleMember{} 361 368 err = json.Unmarshal(raw, &record) ··· 384 391 return fmt.Errorf("failed to index profile record, invalid db cast") 385 392 } 386 393 387 - err = db.AddSpindleMember(ddb, db.SpindleMember{ 394 + err = db.AddSpindleMember(ddb, models.SpindleMember{ 388 395 Did: syntax.DID(did), 389 396 Rkey: e.Commit.RKey, 390 397 Instance: record.Instance, ··· 400 407 } 401 408 402 409 l.Info("added spindle member") 403 - case models.CommitOperationDelete: 410 + case jmodels.CommitOperationDelete: 404 411 rkey := e.Commit.RKey 405 412 406 413 ddb, ok := i.Db.Execer.(*db.DB) ··· 453 460 return nil 454 461 } 455 462 456 - func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 463 + func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error { 457 464 did := e.Did 458 465 var err error 459 466 ··· 461 468 l = l.With("nsid", e.Commit.Collection) 462 469 463 470 switch e.Commit.Operation { 464 - case models.CommitOperationCreate: 471 + case jmodels.CommitOperationCreate: 465 472 raw := json.RawMessage(e.Commit.Record) 466 473 record := tangled.Spindle{} 467 474 err = json.Unmarshal(raw, &record) ··· 477 484 return fmt.Errorf("failed to index profile record, invalid db cast") 478 485 } 479 486 480 - err := db.AddSpindle(ddb, db.Spindle{ 487 + err := db.AddSpindle(ddb, models.Spindle{ 481 488 Owner: syntax.DID(did), 482 489 Instance: instance, 483 490 }) ··· 499 506 500 507 return nil 501 508 502 - case models.CommitOperationDelete: 509 + case jmodels.CommitOperationDelete: 503 510 instance := e.Commit.RKey 504 511 505 512 ddb, ok := i.Db.Execer.(*db.DB) ··· 567 574 return nil 568 575 } 569 576 570 - func (i *Ingester) ingestString(e *models.Event) error { 577 + func (i *Ingester) ingestString(e *jmodels.Event) error { 571 578 did := e.Did 572 579 rkey := e.Commit.RKey 573 580 ··· 582 589 } 583 590 584 591 switch e.Commit.Operation { 585 - case models.CommitOperationCreate, models.CommitOperationUpdate: 592 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 586 593 raw := json.RawMessage(e.Commit.Record) 587 594 record := tangled.String{} 588 595 err = json.Unmarshal(raw, &record) ··· 591 598 return err 592 599 } 593 600 594 - string := db.StringFromRecord(did, rkey, record) 601 + string := models.StringFromRecord(did, rkey, record) 595 602 596 - if err = string.Validate(); err != nil { 603 + if err = i.Validator.ValidateString(&string); err != nil { 597 604 l.Error("invalid record", "err", err) 598 605 return err 599 606 } ··· 605 612 606 613 return nil 607 614 608 - case models.CommitOperationDelete: 615 + case jmodels.CommitOperationDelete: 609 616 if err := db.DeleteString( 610 617 ddb, 611 618 db.FilterEq("did", did), ··· 621 628 return nil 622 629 } 623 630 624 - func (i *Ingester) ingestKnotMember(e *models.Event) error { 631 + func (i *Ingester) ingestKnotMember(e *jmodels.Event) error { 625 632 did := e.Did 626 633 var err error 627 634 ··· 629 636 l = l.With("nsid", e.Commit.Collection) 630 637 631 638 switch e.Commit.Operation { 632 - case models.CommitOperationCreate: 639 + case jmodels.CommitOperationCreate: 633 640 raw := json.RawMessage(e.Commit.Record) 634 641 record := tangled.KnotMember{} 635 642 err = json.Unmarshal(raw, &record) ··· 659 666 } 660 667 661 668 l.Info("added knot member") 662 - case models.CommitOperationDelete: 669 + case jmodels.CommitOperationDelete: 663 670 // we don't store knot members in a table (like we do for spindle) 664 671 // and we can't remove this just yet. possibly fixed if we switch 665 672 // to either: ··· 673 680 return nil 674 681 } 675 682 676 - func (i *Ingester) ingestKnot(e *models.Event) error { 683 + func (i *Ingester) ingestKnot(e *jmodels.Event) error { 677 684 did := e.Did 678 685 var err error 679 686 ··· 681 688 l = l.With("nsid", e.Commit.Collection) 682 689 683 690 switch e.Commit.Operation { 684 - case models.CommitOperationCreate: 691 + case jmodels.CommitOperationCreate: 685 692 raw := json.RawMessage(e.Commit.Record) 686 693 record := tangled.Knot{} 687 694 err = json.Unmarshal(raw, &record) ··· 716 723 717 724 return nil 718 725 719 - case models.CommitOperationDelete: 726 + case jmodels.CommitOperationDelete: 720 727 domain := e.Commit.RKey 721 728 722 729 ddb, ok := i.Db.Execer.(*db.DB) ··· 776 783 777 784 return nil 778 785 } 779 - func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 786 + func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error { 780 787 did := e.Did 781 788 rkey := e.Commit.RKey 782 789 ··· 791 798 } 792 799 793 800 switch e.Commit.Operation { 794 - case models.CommitOperationCreate, models.CommitOperationUpdate: 801 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 795 802 raw := json.RawMessage(e.Commit.Record) 796 803 record := tangled.RepoIssue{} 797 804 err = json.Unmarshal(raw, &record) ··· 800 807 return err 801 808 } 802 809 803 - issue := db.IssueFromRecord(did, rkey, record) 810 + issue := models.IssueFromRecord(did, rkey, record) 804 811 805 812 if err := i.Validator.ValidateIssue(&issue); err != nil { 806 813 return fmt.Errorf("failed to validate issue: %w", err) ··· 827 834 828 835 return nil 829 836 830 - case models.CommitOperationDelete: 837 + case jmodels.CommitOperationDelete: 831 838 if err := db.DeleteIssues( 832 839 ddb, 833 840 db.FilterEq("did", did), ··· 843 850 return nil 844 851 } 845 852 846 - func (i *Ingester) ingestIssueComment(e *models.Event) error { 853 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 847 854 did := e.Did 848 855 rkey := e.Commit.RKey 849 856 ··· 858 865 } 859 866 860 867 switch e.Commit.Operation { 861 - case models.CommitOperationCreate, models.CommitOperationUpdate: 868 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 862 869 raw := json.RawMessage(e.Commit.Record) 863 870 record := tangled.RepoIssueComment{} 864 871 err = json.Unmarshal(raw, &record) ··· 866 873 return fmt.Errorf("invalid record: %w", err) 867 874 } 868 875 869 - comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 876 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 870 877 if err != nil { 871 878 return fmt.Errorf("failed to parse comment from record: %w", err) 872 879 } ··· 882 889 883 890 return nil 884 891 885 - case models.CommitOperationDelete: 892 + case jmodels.CommitOperationDelete: 886 893 if err := db.DeleteIssueComments( 887 894 ddb, 888 895 db.FilterEq("did", did), ··· 896 903 897 904 return nil 898 905 } 906 + 907 + func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { 908 + did := e.Did 909 + rkey := e.Commit.RKey 910 + 911 + var err error 912 + 913 + l := i.Logger.With("handler", "ingestLabelDefinition", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 914 + l.Info("ingesting record") 915 + 916 + ddb, ok := i.Db.Execer.(*db.DB) 917 + if !ok { 918 + return fmt.Errorf("failed to index label definition, invalid db cast") 919 + } 920 + 921 + switch e.Commit.Operation { 922 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 923 + raw := json.RawMessage(e.Commit.Record) 924 + record := tangled.LabelDefinition{} 925 + err = json.Unmarshal(raw, &record) 926 + if err != nil { 927 + return fmt.Errorf("invalid record: %w", err) 928 + } 929 + 930 + def, err := models.LabelDefinitionFromRecord(did, rkey, record) 931 + if err != nil { 932 + return fmt.Errorf("failed to parse labeldef from record: %w", err) 933 + } 934 + 935 + if err := i.Validator.ValidateLabelDefinition(def); err != nil { 936 + return fmt.Errorf("failed to validate labeldef: %w", err) 937 + } 938 + 939 + _, err = db.AddLabelDefinition(ddb, def) 940 + if err != nil { 941 + return fmt.Errorf("failed to create labeldef: %w", err) 942 + } 943 + 944 + return nil 945 + 946 + case jmodels.CommitOperationDelete: 947 + if err := db.DeleteLabelDefinition( 948 + ddb, 949 + db.FilterEq("did", did), 950 + db.FilterEq("rkey", rkey), 951 + ); err != nil { 952 + return fmt.Errorf("failed to delete labeldef record: %w", err) 953 + } 954 + 955 + return nil 956 + } 957 + 958 + return nil 959 + } 960 + 961 + func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { 962 + did := e.Did 963 + rkey := e.Commit.RKey 964 + 965 + var err error 966 + 967 + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 968 + l.Info("ingesting record") 969 + 970 + ddb, ok := i.Db.Execer.(*db.DB) 971 + if !ok { 972 + return fmt.Errorf("failed to index label op, invalid db cast") 973 + } 974 + 975 + switch e.Commit.Operation { 976 + case jmodels.CommitOperationCreate: 977 + raw := json.RawMessage(e.Commit.Record) 978 + record := tangled.LabelOp{} 979 + err = json.Unmarshal(raw, &record) 980 + if err != nil { 981 + return fmt.Errorf("invalid record: %w", err) 982 + } 983 + 984 + subject := syntax.ATURI(record.Subject) 985 + collection := subject.Collection() 986 + 987 + var repo *models.Repo 988 + switch collection { 989 + case tangled.RepoIssueNSID: 990 + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 991 + if err != nil || len(i) != 1 { 992 + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 + } 994 + repo = i[0].Repo 995 + default: 996 + return fmt.Errorf("unsupport label subject: %s", collection) 997 + } 998 + 999 + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1000 + if err != nil { 1001 + return fmt.Errorf("failed to build label application ctx: %w", err) 1002 + } 1003 + 1004 + ops := models.LabelOpsFromRecord(did, rkey, record) 1005 + 1006 + for _, o := range ops { 1007 + def, ok := actx.Defs[o.OperandKey] 1008 + if !ok { 1009 + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 + } 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 + return fmt.Errorf("failed to validate labelop: %w", err) 1013 + } 1014 + } 1015 + 1016 + tx, err := ddb.Begin() 1017 + if err != nil { 1018 + return err 1019 + } 1020 + defer tx.Rollback() 1021 + 1022 + for _, o := range ops { 1023 + _, err = db.AddLabelOp(tx, &o) 1024 + if err != nil { 1025 + return fmt.Errorf("failed to add labelop: %w", err) 1026 + } 1027 + } 1028 + 1029 + if err = tx.Commit(); err != nil { 1030 + return err 1031 + } 1032 + } 1033 + 1034 + return nil 1035 + }
+104 -29
appview/issues/issues.go
··· 12 12 "time" 13 13 14 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 lexutil "github.com/bluesky-social/indigo/lex/util" 17 18 "github.com/go-chi/chi/v5" ··· 19 20 "tangled.org/core/api/tangled" 20 21 "tangled.org/core/appview/config" 21 22 "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/models" 22 24 "tangled.org/core/appview/notify" 23 25 "tangled.org/core/appview/oauth" 24 26 "tangled.org/core/appview/pages" 25 27 "tangled.org/core/appview/pagination" 26 28 "tangled.org/core/appview/reporesolver" 29 + "tangled.org/core/appview/search" 27 30 "tangled.org/core/appview/validator" 28 - "tangled.org/core/appview/xrpcclient" 29 31 "tangled.org/core/idresolver" 30 32 tlog "tangled.org/core/log" 31 33 "tangled.org/core/tid" ··· 75 77 return 76 78 } 77 79 78 - issue, ok := r.Context().Value("issue").(*db.Issue) 80 + issue, ok := r.Context().Value("issue").(*models.Issue) 79 81 if !ok { 80 82 l.Error("failed to get issue") 81 83 rp.pages.Error404(w) 82 84 return 83 85 } 84 86 85 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 86 88 if err != nil { 87 89 l.Error("failed to get issue reactions", "err", err) 88 90 } 89 91 90 - userReactions := map[db.ReactionKind]bool{} 92 + userReactions := map[models.ReactionKind]bool{} 91 93 if user != nil { 92 94 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 95 } 94 96 97 + labelDefs, err := db.GetLabelDefinitions( 98 + rp.db, 99 + db.FilterIn("at_uri", f.Repo.Labels), 100 + db.FilterContains("scope", tangled.RepoIssueNSID), 101 + ) 102 + if err != nil { 103 + log.Println("failed to fetch labels", err) 104 + rp.pages.Error503(w) 105 + return 106 + } 107 + 108 + defs := make(map[string]*models.LabelDefinition) 109 + for _, l := range labelDefs { 110 + defs[l.AtUri().String()] = &l 111 + } 112 + 95 113 rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 114 LoggedInUser: user, 97 115 RepoInfo: f.RepoInfo(user), 98 116 Issue: issue, 99 117 CommentList: issue.CommentList(), 100 - OrderedReactionKinds: db.OrderedReactionKinds, 101 - Reactions: reactionCountMap, 118 + OrderedReactionKinds: models.OrderedReactionKinds, 119 + Reactions: reactionMap, 102 120 UserReacted: userReactions, 121 + LabelDefs: defs, 103 122 }) 104 123 } 105 124 ··· 112 131 return 113 132 } 114 133 115 - issue, ok := r.Context().Value("issue").(*db.Issue) 134 + issue, ok := r.Context().Value("issue").(*models.Issue) 116 135 if !ok { 117 136 l.Error("failed to get issue") 118 137 rp.pages.Error404(w) ··· 148 167 return 149 168 } 150 169 151 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 171 if err != nil { 153 172 l.Error("failed to get record", "err", err) 154 173 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 174 return 156 175 } 157 176 158 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 159 178 Collection: tangled.RepoIssueNSID, 160 179 Repo: user.Did, 161 180 Rkey: newIssue.Rkey, ··· 208 227 return 209 228 } 210 229 211 - issue, ok := r.Context().Value("issue").(*db.Issue) 230 + issue, ok := r.Context().Value("issue").(*models.Issue) 212 231 if !ok { 213 232 l.Error("failed to get issue") 214 233 rp.pages.Notice(w, noticeId, "Failed to delete issue.") ··· 223 242 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 224 243 return 225 244 } 226 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 227 246 Collection: tangled.RepoIssueNSID, 228 247 Repo: issue.Did, 229 248 Rkey: issue.Rkey, ··· 255 274 return 256 275 } 257 276 258 - issue, ok := r.Context().Value("issue").(*db.Issue) 277 + issue, ok := r.Context().Value("issue").(*models.Issue) 259 278 if !ok { 260 279 l.Error("failed to get issue") 261 280 rp.pages.Error404(w) ··· 283 302 return 284 303 } 285 304 305 + // notify about the issue closure 306 + rp.notifier.NewIssueClosed(r.Context(), issue) 307 + 286 308 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 287 309 return 288 310 } else { ··· 301 323 return 302 324 } 303 325 304 - issue, ok := r.Context().Value("issue").(*db.Issue) 326 + issue, ok := r.Context().Value("issue").(*models.Issue) 305 327 if !ok { 306 328 l.Error("failed to get issue") 307 329 rp.pages.Error404(w) ··· 345 367 return 346 368 } 347 369 348 - issue, ok := r.Context().Value("issue").(*db.Issue) 370 + issue, ok := r.Context().Value("issue").(*models.Issue) 349 371 if !ok { 350 372 l.Error("failed to get issue") 351 373 rp.pages.Error404(w) ··· 364 386 replyTo = &replyToUri 365 387 } 366 388 367 - comment := db.IssueComment{ 389 + comment := models.IssueComment{ 368 390 Did: user.Did, 369 391 Rkey: tid.TID(), 370 392 IssueAt: issue.AtUri().String(), ··· 387 409 } 388 410 389 411 // create a record first 390 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 412 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 391 413 Collection: tangled.RepoIssueCommentNSID, 392 414 Repo: comment.Did, 393 415 Rkey: comment.Rkey, ··· 416 438 417 439 // reset atUri to make rollback a no-op 418 440 atUri = "" 441 + 442 + // notify about the new comment 443 + comment.Id = commentId 444 + rp.notifier.NewIssueComment(r.Context(), &comment) 445 + 419 446 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 420 447 } 421 448 ··· 428 455 return 429 456 } 430 457 431 - issue, ok := r.Context().Value("issue").(*db.Issue) 458 + issue, ok := r.Context().Value("issue").(*models.Issue) 432 459 if !ok { 433 460 l.Error("failed to get issue") 434 461 rp.pages.Error404(w) ··· 469 496 return 470 497 } 471 498 472 - issue, ok := r.Context().Value("issue").(*db.Issue) 499 + issue, ok := r.Context().Value("issue").(*models.Issue) 473 500 if !ok { 474 501 l.Error("failed to get issue") 475 502 rp.pages.Error404(w) ··· 533 560 // rkey is optional, it was introduced later 534 561 if newComment.Rkey != "" { 535 562 // update the record on pds 536 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 537 564 if err != nil { 538 565 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 539 566 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 540 567 return 541 568 } 542 569 543 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 570 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 544 571 Collection: tangled.RepoIssueCommentNSID, 545 572 Repo: user.Did, 546 573 Rkey: newComment.Rkey, ··· 573 600 return 574 601 } 575 602 576 - issue, ok := r.Context().Value("issue").(*db.Issue) 603 + issue, ok := r.Context().Value("issue").(*models.Issue) 577 604 if !ok { 578 605 l.Error("failed to get issue") 579 606 rp.pages.Error404(w) ··· 614 641 return 615 642 } 616 643 617 - issue, ok := r.Context().Value("issue").(*db.Issue) 644 + issue, ok := r.Context().Value("issue").(*models.Issue) 618 645 if !ok { 619 646 l.Error("failed to get issue") 620 647 rp.pages.Error404(w) ··· 655 682 return 656 683 } 657 684 658 - issue, ok := r.Context().Value("issue").(*db.Issue) 685 + issue, ok := r.Context().Value("issue").(*models.Issue) 659 686 if !ok { 660 687 l.Error("failed to get issue") 661 688 rp.pages.Error404(w) ··· 707 734 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 708 735 return 709 736 } 710 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 737 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 711 738 Collection: tangled.RepoIssueCommentNSID, 712 739 Repo: user.Did, 713 740 Rkey: comment.Rkey, ··· 733 760 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 734 761 params := r.URL.Query() 735 762 state := params.Get("state") 763 + searchQuery := params.Get("q") 764 + sortBy := params.Get("sort_by") 765 + sortOrder := params.Get("sort_order") 766 + 767 + // Use for template (preserve empty values) 768 + templateSortBy := sortBy 769 + templateSortOrder := sortOrder 770 + 771 + // Default sort values for queries 772 + if sortBy == "" { 773 + sortBy = "created" 774 + } 775 + if sortOrder == "" { 776 + sortOrder = "desc" 777 + } 778 + 736 779 isOpen := true 737 780 switch state { 738 781 case "open": ··· 760 803 if isOpen { 761 804 openVal = 1 762 805 } 763 - issues, err := db.GetIssuesPaginated( 806 + 807 + var issues []models.Issue 808 + 809 + // Parse the search query (even if empty, to handle label filters) 810 + query := search.Parse(searchQuery) 811 + 812 + // Always use search function to handle sorting 813 + issues, err = db.SearchIssues( 764 814 rp.db, 765 815 page, 816 + query.Text, 817 + query.Labels, 818 + sortBy, 819 + sortOrder, 766 820 db.FilterEq("repo_at", f.RepoAt()), 767 821 db.FilterEq("open", openVal), 768 822 ) 823 + 769 824 if err != nil { 770 825 log.Println("failed to get issues", err) 771 826 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 772 827 return 773 828 } 774 829 830 + labelDefs, err := db.GetLabelDefinitions( 831 + rp.db, 832 + db.FilterIn("at_uri", f.Repo.Labels), 833 + db.FilterContains("scope", tangled.RepoIssueNSID), 834 + ) 835 + if err != nil { 836 + log.Println("failed to fetch labels", err) 837 + rp.pages.Error503(w) 838 + return 839 + } 840 + 841 + defs := make(map[string]*models.LabelDefinition) 842 + for _, l := range labelDefs { 843 + defs[l.AtUri().String()] = &l 844 + } 845 + 775 846 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 776 847 LoggedInUser: rp.oauth.GetUser(r), 777 848 RepoInfo: f.RepoInfo(user), 778 849 Issues: issues, 850 + LabelDefs: defs, 779 851 FilteringByOpen: isOpen, 780 852 Page: page, 853 + SearchQuery: searchQuery, 854 + SortBy: templateSortBy, 855 + SortOrder: templateSortOrder, 781 856 }) 782 857 } 783 858 ··· 798 873 RepoInfo: f.RepoInfo(user), 799 874 }) 800 875 case http.MethodPost: 801 - issue := &db.Issue{ 876 + issue := &models.Issue{ 802 877 RepoAt: f.RepoAt(), 803 878 Rkey: tid.TID(), 804 879 Title: r.FormValue("title"), ··· 822 897 rp.pages.Notice(w, "issues", "Failed to create issue.") 823 898 return 824 899 } 825 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 900 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 826 901 Collection: tangled.RepoIssueNSID, 827 902 Repo: user.Did, 828 903 Rkey: issue.Rkey, ··· 880 955 // this is used to rollback changes made to the PDS 881 956 // 882 957 // it is a no-op if the provided ATURI is empty 883 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 958 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 884 959 if aturi == "" { 885 960 return nil 886 961 } ··· 891 966 repo := parsed.Authority().String() 892 967 rkey := parsed.RecordKey().String() 893 968 894 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 969 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 895 970 Collection: collection, 896 971 Repo: repo, 897 972 Rkey: rkey,
+1 -1
appview/issues/router.go
··· 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 15 16 16 r.Route("/{issue}", func(r chi.Router) { 17 - r.Use(mw.ResolveIssue()) 17 + r.Use(mw.ResolveIssue) 18 18 r.Get("/", i.RepoSingleIssue) 19 19 20 20 // authenticated routes
+8 -7
appview/knots/knots.go
··· 13 13 "tangled.org/core/appview/config" 14 14 "tangled.org/core/appview/db" 15 15 "tangled.org/core/appview/middleware" 16 + "tangled.org/core/appview/models" 16 17 "tangled.org/core/appview/oauth" 17 18 "tangled.org/core/appview/pages" 18 19 "tangled.org/core/appview/serververify" ··· 119 120 } 120 121 121 122 // organize repos by did 122 - repoMap := make(map[string][]db.Repo) 123 + repoMap := make(map[string][]models.Repo) 123 124 for _, r := range repos { 124 125 repoMap[r.Did] = append(repoMap[r.Did], r) 125 126 } ··· 184 185 return 185 186 } 186 187 187 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 188 189 var exCid *string 189 190 if ex != nil { 190 191 exCid = ex.Cid 191 192 } 192 193 193 194 // re-announce by registering under same rkey 194 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 195 196 Collection: tangled.KnotNSID, 196 197 Repo: user.Did, 197 198 Rkey: domain, ··· 322 323 return 323 324 } 324 325 325 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 326 327 Collection: tangled.KnotNSID, 327 328 Repo: user.Did, 328 329 Rkey: domain, ··· 430 431 return 431 432 } 432 433 433 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 434 435 var exCid *string 435 436 if ex != nil { 436 437 exCid = ex.Cid 437 438 } 438 439 439 440 // ignore the error here 440 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 441 442 Collection: tangled.KnotNSID, 442 443 Repo: user.Did, 443 444 Rkey: domain, ··· 554 555 555 556 rkey := tid.TID() 556 557 557 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 558 559 Collection: tangled.KnotMemberNSID, 559 560 Repo: user.Did, 560 561 Rkey: rkey,
+272
appview/labels/labels.go
··· 1 + package labels 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "log/slog" 9 + "net/http" 10 + "time" 11 + 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/middleware" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/validator" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + "tangled.org/core/tid" 22 + 23 + comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + "github.com/go-chi/chi/v5" 28 + ) 29 + 30 + type Labels struct { 31 + oauth *oauth.OAuth 32 + pages *pages.Pages 33 + db *db.DB 34 + logger *slog.Logger 35 + validator *validator.Validator 36 + enforcer *rbac.Enforcer 37 + } 38 + 39 + func New( 40 + oauth *oauth.OAuth, 41 + pages *pages.Pages, 42 + db *db.DB, 43 + validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 45 + ) *Labels { 46 + logger := log.New("labels") 47 + 48 + return &Labels{ 49 + oauth: oauth, 50 + pages: pages, 51 + db: db, 52 + logger: logger, 53 + validator: validator, 54 + enforcer: enforcer, 55 + } 56 + } 57 + 58 + func (l *Labels) Router(mw *middleware.Middleware) http.Handler { 59 + r := chi.NewRouter() 60 + 61 + r.Use(middleware.AuthMiddleware(l.oauth)) 62 + r.Put("/perform", l.PerformLabelOp) 63 + 64 + return r 65 + } 66 + 67 + // this is a tricky handler implementation: 68 + // - the user selects the new state of all the labels in the label panel and hits save 69 + // - this handler should calculate the diff in order to create the labelop record 70 + // - we need the diff in order to maintain a "history" of operations performed by users 71 + func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { 72 + user := l.oauth.GetUser(r) 73 + 74 + noticeId := "add-label-error" 75 + 76 + fail := func(msg string, err error) { 77 + l.logger.Error("failed to add label", "err", err) 78 + l.pages.Notice(w, noticeId, msg) 79 + } 80 + 81 + if err := r.ParseForm(); err != nil { 82 + fail("Invalid form.", err) 83 + return 84 + } 85 + 86 + did := user.Did 87 + rkey := tid.TID() 88 + performedAt := time.Now() 89 + indexedAt := time.Now() 90 + repoAt := r.Form.Get("repo") 91 + subjectUri := r.Form.Get("subject") 92 + 93 + repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 94 + if err != nil { 95 + fail("Failed to get repository.", err) 96 + return 97 + } 98 + 99 + // find all the labels that this repo subscribes to 100 + repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 101 + if err != nil { 102 + fail("Failed to get labels for this repository.", err) 103 + return 104 + } 105 + 106 + var labelAts []string 107 + for _, rl := range repoLabels { 108 + labelAts = append(labelAts, rl.LabelAt.String()) 109 + } 110 + 111 + actx, err := db.NewLabelApplicationCtx(l.db, db.FilterIn("at_uri", labelAts)) 112 + if err != nil { 113 + fail("Invalid form data.", err) 114 + return 115 + } 116 + 117 + // calculate the start state by applying already known labels 118 + existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 119 + if err != nil { 120 + fail("Invalid form data.", err) 121 + return 122 + } 123 + 124 + labelState := models.NewLabelState() 125 + actx.ApplyLabelOps(labelState, existingOps) 126 + 127 + var labelOps []models.LabelOp 128 + 129 + // first delete all existing state 130 + for key, vals := range labelState.Inner() { 131 + for val := range vals { 132 + labelOps = append(labelOps, models.LabelOp{ 133 + Did: did, 134 + Rkey: rkey, 135 + Subject: syntax.ATURI(subjectUri), 136 + Operation: models.LabelOperationDel, 137 + OperandKey: key, 138 + OperandValue: val, 139 + PerformedAt: performedAt, 140 + IndexedAt: indexedAt, 141 + }) 142 + } 143 + } 144 + 145 + // add all the new state the user specified 146 + for key, vals := range r.Form { 147 + if _, ok := actx.Defs[key]; !ok { 148 + continue 149 + } 150 + 151 + for _, val := range vals { 152 + labelOps = append(labelOps, models.LabelOp{ 153 + Did: did, 154 + Rkey: rkey, 155 + Subject: syntax.ATURI(subjectUri), 156 + Operation: models.LabelOperationAdd, 157 + OperandKey: key, 158 + OperandValue: val, 159 + PerformedAt: performedAt, 160 + IndexedAt: indexedAt, 161 + }) 162 + } 163 + } 164 + 165 + for i := range labelOps { 166 + def := actx.Defs[labelOps[i].OperandKey] 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 168 + fail(fmt.Sprintf("Invalid form data: %s", err), err) 169 + return 170 + } 171 + } 172 + 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 + 176 + // next, apply all ops introduced in this request and filter out ones that are no-ops 177 + validLabelOps := labelOps[:0] 178 + for _, op := range labelOps { 179 + if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { 180 + validLabelOps = append(validLabelOps, op) 181 + } 182 + } 183 + 184 + // nothing to do 185 + if len(validLabelOps) == 0 { 186 + l.pages.HxRefresh(w) 187 + return 188 + } 189 + 190 + // create an atproto record of valid ops 191 + record := models.LabelOpsAsRecord(validLabelOps) 192 + 193 + client, err := l.oauth.AuthorizedClient(r) 194 + if err != nil { 195 + fail("Failed to authorize user.", err) 196 + return 197 + } 198 + 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 + Collection: tangled.LabelOpNSID, 201 + Repo: did, 202 + Rkey: rkey, 203 + Record: &lexutil.LexiconTypeDecoder{ 204 + Val: &record, 205 + }, 206 + }) 207 + if err != nil { 208 + fail("Failed to create record on PDS for user.", err) 209 + return 210 + } 211 + atUri := resp.Uri 212 + 213 + tx, err := l.db.BeginTx(r.Context(), nil) 214 + if err != nil { 215 + fail("Failed to update labels. Try again later.", err) 216 + return 217 + } 218 + 219 + rollback := func() { 220 + err1 := tx.Rollback() 221 + err2 := rollbackRecord(context.Background(), atUri, client) 222 + 223 + // ignore txn complete errors, this is okay 224 + if errors.Is(err1, sql.ErrTxDone) { 225 + err1 = nil 226 + } 227 + 228 + if errs := errors.Join(err1, err2); errs != nil { 229 + return 230 + } 231 + } 232 + defer rollback() 233 + 234 + for _, o := range validLabelOps { 235 + if _, err := db.AddLabelOp(l.db, &o); err != nil { 236 + fail("Failed to update labels. Try again later.", err) 237 + return 238 + } 239 + } 240 + 241 + err = tx.Commit() 242 + if err != nil { 243 + return 244 + } 245 + 246 + // clear aturi when everything is successful 247 + atUri = "" 248 + 249 + l.pages.HxRefresh(w) 250 + } 251 + 252 + // this is used to rollback changes made to the PDS 253 + // 254 + // it is a no-op if the provided ATURI is empty 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 + if aturi == "" { 257 + return nil 258 + } 259 + 260 + parsed := syntax.ATURI(aturi) 261 + 262 + collection := parsed.Collection().String() 263 + repo := parsed.Authority().String() 264 + rkey := parsed.RecordKey().String() 265 + 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 + Collection: collection, 268 + Repo: repo, 269 + Rkey: rkey, 270 + }) 271 + return err 272 + }
+43 -42
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 49 returnURL := "/" ··· 63 63 } 64 64 } 65 65 66 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 67 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 69 redirectFunc(w, r) 70 70 return 71 71 } 72 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 75 redirectFunc(w, r) 76 76 return 77 77 } ··· 213 213 return 214 214 } 215 215 216 - repo, err := db.GetRepo(mw.db, id.DID.String(), repoName) 216 + repo, err := db.GetRepo( 217 + mw.db, 218 + db.FilterEq("did", id.DID.String()), 219 + db.FilterEq("name", repoName), 220 + ) 217 221 if err != nil { 218 - // invalid did or handle 219 - log.Println("failed to resolve repo") 222 + log.Println("failed to resolve repo", "err", err) 220 223 mw.pages.ErrorKnot404(w) 221 224 return 222 225 } ··· 276 279 } 277 280 278 281 // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 - func (mw Middleware) ResolveIssue() middlewareFunc { 280 - return func(next http.Handler) http.Handler { 281 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 - f, err := mw.repoResolver.Resolve(r) 283 - if err != nil { 284 - log.Println("failed to fully resolve repo", err) 285 - mw.pages.ErrorKnot404(w) 286 - return 287 - } 282 + func (mw Middleware) ResolveIssue(next http.Handler) http.Handler { 283 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 284 + f, err := mw.repoResolver.Resolve(r) 285 + if err != nil { 286 + log.Println("failed to fully resolve repo", err) 287 + mw.pages.ErrorKnot404(w) 288 + return 289 + } 288 290 289 - issueIdStr := chi.URLParam(r, "issue") 290 - issueId, err := strconv.Atoi(issueIdStr) 291 - if err != nil { 292 - log.Println("failed to fully resolve issue ID", err) 293 - mw.pages.ErrorKnot404(w) 294 - return 295 - } 291 + issueIdStr := chi.URLParam(r, "issue") 292 + issueId, err := strconv.Atoi(issueIdStr) 293 + if err != nil { 294 + log.Println("failed to fully resolve issue ID", err) 295 + mw.pages.ErrorKnot404(w) 296 + return 297 + } 296 298 297 - issues, err := db.GetIssues( 298 - mw.db, 299 - db.FilterEq("repo_at", f.RepoAt()), 300 - db.FilterEq("issue_id", issueId), 301 - ) 302 - if err != nil { 303 - log.Println("failed to get issues", "err", err) 304 - return 305 - } 306 - if len(issues) != 1 { 307 - log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 - return 309 - } 310 - issue := issues[0] 299 + issues, err := db.GetIssues( 300 + mw.db, 301 + db.FilterEq("repo_at", f.RepoAt()), 302 + db.FilterEq("issue_id", issueId), 303 + ) 304 + if err != nil { 305 + log.Println("failed to get issues", "err", err) 306 + return 307 + } 308 + if len(issues) != 1 { 309 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 310 + return 311 + } 312 + issue := issues[0] 311 313 312 - ctx := context.WithValue(r.Context(), "issue", &issue) 313 - next.ServeHTTP(w, r.WithContext(ctx)) 314 - }) 315 - } 314 + ctx := context.WithValue(r.Context(), "issue", &issue) 315 + next.ServeHTTP(w, r.WithContext(ctx)) 316 + }) 316 317 } 317 318 318 319 // this should serve the go-import meta tag even if the path is technically
+30
appview/models/artifact.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/ipfs/go-cid" 10 + "tangled.org/core/api/tangled" 11 + ) 12 + 13 + type Artifact struct { 14 + Id uint64 15 + Did string 16 + Rkey string 17 + 18 + RepoAt syntax.ATURI 19 + Tag plumbing.Hash 20 + CreatedAt time.Time 21 + 22 + BlobCid cid.Cid 23 + Name string 24 + Size uint64 25 + MimeType string 26 + } 27 + 28 + func (a *Artifact) ArtifactAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 30 + }
+21
appview/models/collaborator.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Collaborator struct { 10 + // identifiers for the record 11 + Id int64 12 + Did syntax.DID 13 + Rkey string 14 + 15 + // content 16 + SubjectDid syntax.DID 17 + RepoAt syntax.ATURI 18 + 19 + // meta 20 + Created time.Time 21 + }
+16
appview/models/email.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+38
appview/models/follow.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Follow struct { 8 + UserDid string 9 + SubjectDid string 10 + FollowedAt time.Time 11 + Rkey string 12 + } 13 + 14 + type FollowStats struct { 15 + Followers int64 16 + Following int64 17 + } 18 + 19 + type FollowStatus int 20 + 21 + const ( 22 + IsNotFollowing FollowStatus = iota 23 + IsFollowing 24 + IsSelf 25 + ) 26 + 27 + func (s FollowStatus) String() string { 28 + switch s { 29 + case IsNotFollowing: 30 + return "IsNotFollowing" 31 + case IsFollowing: 32 + return "IsFollowing" 33 + case IsSelf: 34 + return "IsSelf" 35 + default: 36 + return "IsNotFollowing" 37 + } 38 + }
+195
appview/models/issue.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Issue struct { 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + 25 + // optionally, populate this when querying for reverse mappings 26 + // like comment counts, parent repo etc. 27 + Comments []IssueComment 28 + ReactionCount int 29 + Labels LabelState 30 + Repo *Repo 31 + } 32 + 33 + func (i *Issue) AtUri() syntax.ATURI { 34 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 35 + } 36 + 37 + func (i *Issue) AsRecord() tangled.RepoIssue { 38 + return tangled.RepoIssue{ 39 + Repo: i.RepoAt.String(), 40 + Title: i.Title, 41 + Body: &i.Body, 42 + CreatedAt: i.Created.Format(time.RFC3339), 43 + } 44 + } 45 + 46 + func (i *Issue) State() string { 47 + if i.Open { 48 + return "open" 49 + } 50 + return "closed" 51 + } 52 + 53 + type CommentListItem struct { 54 + Self *IssueComment 55 + Replies []*IssueComment 56 + } 57 + 58 + func (i *Issue) CommentList() []CommentListItem { 59 + // Create a map to quickly find comments by their aturi 60 + toplevel := make(map[string]*CommentListItem) 61 + var replies []*IssueComment 62 + 63 + // collect top level comments into the map 64 + for _, comment := range i.Comments { 65 + if comment.IsTopLevel() { 66 + toplevel[comment.AtUri().String()] = &CommentListItem{ 67 + Self: &comment, 68 + } 69 + } else { 70 + replies = append(replies, &comment) 71 + } 72 + } 73 + 74 + for _, r := range replies { 75 + parentAt := *r.ReplyTo 76 + if parent, exists := toplevel[parentAt]; exists { 77 + parent.Replies = append(parent.Replies, r) 78 + } 79 + } 80 + 81 + var listing []CommentListItem 82 + for _, v := range toplevel { 83 + listing = append(listing, *v) 84 + } 85 + 86 + // sort everything 87 + sortFunc := func(a, b *IssueComment) bool { 88 + return a.Created.Before(b.Created) 89 + } 90 + sort.Slice(listing, func(i, j int) bool { 91 + return sortFunc(listing[i].Self, listing[j].Self) 92 + }) 93 + for _, r := range listing { 94 + sort.Slice(r.Replies, func(i, j int) bool { 95 + return sortFunc(r.Replies[i], r.Replies[j]) 96 + }) 97 + } 98 + 99 + return listing 100 + } 101 + 102 + func (i *Issue) Participants() []string { 103 + participantSet := make(map[string]struct{}) 104 + participants := []string{} 105 + 106 + addParticipant := func(did string) { 107 + if _, exists := participantSet[did]; !exists { 108 + participantSet[did] = struct{}{} 109 + participants = append(participants, did) 110 + } 111 + } 112 + 113 + addParticipant(i.Did) 114 + 115 + for _, c := range i.Comments { 116 + addParticipant(c.Did) 117 + } 118 + 119 + return participants 120 + } 121 + 122 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 123 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 124 + if err != nil { 125 + created = time.Now() 126 + } 127 + 128 + body := "" 129 + if record.Body != nil { 130 + body = *record.Body 131 + } 132 + 133 + return Issue{ 134 + RepoAt: syntax.ATURI(record.Repo), 135 + Did: did, 136 + Rkey: rkey, 137 + Created: created, 138 + Title: record.Title, 139 + Body: body, 140 + Open: true, // new issues are open by default 141 + } 142 + } 143 + 144 + type IssueComment struct { 145 + Id int64 146 + Did string 147 + Rkey string 148 + IssueAt string 149 + ReplyTo *string 150 + Body string 151 + Created time.Time 152 + Edited *time.Time 153 + Deleted *time.Time 154 + } 155 + 156 + func (i *IssueComment) AtUri() syntax.ATURI { 157 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 158 + } 159 + 160 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 161 + return tangled.RepoIssueComment{ 162 + Body: i.Body, 163 + Issue: i.IssueAt, 164 + CreatedAt: i.Created.Format(time.RFC3339), 165 + ReplyTo: i.ReplyTo, 166 + } 167 + } 168 + 169 + func (i *IssueComment) IsTopLevel() bool { 170 + return i.ReplyTo == nil 171 + } 172 + 173 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 174 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 175 + if err != nil { 176 + created = time.Now() 177 + } 178 + 179 + ownerDid := did 180 + 181 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 182 + return nil, err 183 + } 184 + 185 + comment := IssueComment{ 186 + Did: ownerDid, 187 + Rkey: rkey, 188 + Body: record.Body, 189 + IssueAt: record.Issue, 190 + ReplyTo: record.ReplyTo, 191 + Created: created, 192 + } 193 + 194 + return &comment, nil 195 + }
+542
appview/models/label.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "crypto/sha1" 6 + "encoding/hex" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "slices" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 19 + ) 20 + 21 + type ConcreteType string 22 + 23 + const ( 24 + ConcreteTypeNull ConcreteType = "null" 25 + ConcreteTypeString ConcreteType = "string" 26 + ConcreteTypeInt ConcreteType = "integer" 27 + ConcreteTypeBool ConcreteType = "boolean" 28 + ) 29 + 30 + type ValueTypeFormat string 31 + 32 + const ( 33 + ValueTypeFormatAny ValueTypeFormat = "any" 34 + ValueTypeFormatDid ValueTypeFormat = "did" 35 + ) 36 + 37 + // ValueType represents an atproto lexicon type definition with constraints 38 + type ValueType struct { 39 + Type ConcreteType `json:"type"` 40 + Format ValueTypeFormat `json:"format,omitempty"` 41 + Enum []string `json:"enum,omitempty"` 42 + } 43 + 44 + func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 45 + return tangled.LabelDefinition_ValueType{ 46 + Type: string(vt.Type), 47 + Format: string(vt.Format), 48 + Enum: vt.Enum, 49 + } 50 + } 51 + 52 + func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 53 + return ValueType{ 54 + Type: ConcreteType(record.Type), 55 + Format: ValueTypeFormat(record.Format), 56 + Enum: record.Enum, 57 + } 58 + } 59 + 60 + func (vt ValueType) IsConcreteType() bool { 61 + return vt.Type == ConcreteTypeNull || 62 + vt.Type == ConcreteTypeString || 63 + vt.Type == ConcreteTypeInt || 64 + vt.Type == ConcreteTypeBool 65 + } 66 + 67 + func (vt ValueType) IsNull() bool { 68 + return vt.Type == ConcreteTypeNull 69 + } 70 + 71 + func (vt ValueType) IsString() bool { 72 + return vt.Type == ConcreteTypeString 73 + } 74 + 75 + func (vt ValueType) IsInt() bool { 76 + return vt.Type == ConcreteTypeInt 77 + } 78 + 79 + func (vt ValueType) IsBool() bool { 80 + return vt.Type == ConcreteTypeBool 81 + } 82 + 83 + func (vt ValueType) IsEnum() bool { 84 + return len(vt.Enum) > 0 85 + } 86 + 87 + func (vt ValueType) IsDidFormat() bool { 88 + return vt.Format == ValueTypeFormatDid 89 + } 90 + 91 + func (vt ValueType) IsAnyFormat() bool { 92 + return vt.Format == ValueTypeFormatAny 93 + } 94 + 95 + type LabelDefinition struct { 96 + Id int64 97 + Did string 98 + Rkey string 99 + 100 + Name string 101 + ValueType ValueType 102 + Scope []string 103 + Color *string 104 + Multiple bool 105 + Created time.Time 106 + } 107 + 108 + func (l *LabelDefinition) AtUri() syntax.ATURI { 109 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 110 + } 111 + 112 + func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 113 + vt := l.ValueType.AsRecord() 114 + return tangled.LabelDefinition{ 115 + Name: l.Name, 116 + Color: l.Color, 117 + CreatedAt: l.Created.Format(time.RFC3339), 118 + Multiple: &l.Multiple, 119 + Scope: l.Scope, 120 + ValueType: &vt, 121 + } 122 + } 123 + 124 + // random color for a given seed 125 + func randomColor(seed string) string { 126 + hash := sha1.Sum([]byte(seed)) 127 + hexStr := hex.EncodeToString(hash[:]) 128 + r := hexStr[0:2] 129 + g := hexStr[2:4] 130 + b := hexStr[4:6] 131 + 132 + return fmt.Sprintf("#%s%s%s", r, g, b) 133 + } 134 + 135 + func (ld LabelDefinition) GetColor() string { 136 + if ld.Color == nil { 137 + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 138 + color := randomColor(seed) 139 + return color 140 + } 141 + 142 + return *ld.Color 143 + } 144 + 145 + func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 146 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 147 + if err != nil { 148 + created = time.Now() 149 + } 150 + 151 + multiple := false 152 + if record.Multiple != nil { 153 + multiple = *record.Multiple 154 + } 155 + 156 + var vt ValueType 157 + if record.ValueType != nil { 158 + vt = ValueTypeFromRecord(*record.ValueType) 159 + } 160 + 161 + return &LabelDefinition{ 162 + Did: did, 163 + Rkey: rkey, 164 + 165 + Name: record.Name, 166 + ValueType: vt, 167 + Scope: record.Scope, 168 + Color: record.Color, 169 + Multiple: multiple, 170 + Created: created, 171 + }, nil 172 + } 173 + 174 + type LabelOp struct { 175 + Id int64 176 + Did string 177 + Rkey string 178 + Subject syntax.ATURI 179 + Operation LabelOperation 180 + OperandKey string 181 + OperandValue string 182 + PerformedAt time.Time 183 + IndexedAt time.Time 184 + } 185 + 186 + func (l LabelOp) SortAt() time.Time { 187 + createdAt := l.PerformedAt 188 + indexedAt := l.IndexedAt 189 + 190 + // if we don't have an indexedat, fall back to now 191 + if indexedAt.IsZero() { 192 + indexedAt = time.Now() 193 + } 194 + 195 + // if createdat is invalid (before epoch), treat as null -> return zero time 196 + if createdAt.Before(time.UnixMicro(0)) { 197 + return time.Time{} 198 + } 199 + 200 + // if createdat is <= indexedat, use createdat 201 + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 202 + return createdAt 203 + } 204 + 205 + // otherwise, createdat is in the future relative to indexedat -> use indexedat 206 + return indexedAt 207 + } 208 + 209 + type LabelOperation string 210 + 211 + const ( 212 + LabelOperationAdd LabelOperation = "add" 213 + LabelOperationDel LabelOperation = "del" 214 + ) 215 + 216 + // a record can create multiple label ops 217 + func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 218 + performed, err := time.Parse(time.RFC3339, record.PerformedAt) 219 + if err != nil { 220 + performed = time.Now() 221 + } 222 + 223 + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 224 + return LabelOp{ 225 + Did: did, 226 + Rkey: rkey, 227 + Subject: syntax.ATURI(record.Subject), 228 + OperandKey: operand.Key, 229 + OperandValue: operand.Value, 230 + PerformedAt: performed, 231 + } 232 + } 233 + 234 + var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 + if o != nil { 238 + op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 + ops = append(ops, op) 241 + } 242 + } 243 + for _, o := range record.Add { 244 + if o != nil { 245 + op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 + ops = append(ops, op) 248 + } 249 + } 250 + 251 + return ops 252 + } 253 + 254 + func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 255 + if len(ops) == 0 { 256 + return tangled.LabelOp{} 257 + } 258 + 259 + // use the first operation to establish common fields 260 + first := ops[0] 261 + record := tangled.LabelOp{ 262 + Subject: string(first.Subject), 263 + PerformedAt: first.PerformedAt.Format(time.RFC3339), 264 + } 265 + 266 + var addOperands []*tangled.LabelOp_Operand 267 + var deleteOperands []*tangled.LabelOp_Operand 268 + 269 + for _, op := range ops { 270 + operand := &tangled.LabelOp_Operand{ 271 + Key: op.OperandKey, 272 + Value: op.OperandValue, 273 + } 274 + 275 + switch op.Operation { 276 + case LabelOperationAdd: 277 + addOperands = append(addOperands, operand) 278 + case LabelOperationDel: 279 + deleteOperands = append(deleteOperands, operand) 280 + default: 281 + return tangled.LabelOp{} 282 + } 283 + } 284 + 285 + record.Add = addOperands 286 + record.Delete = deleteOperands 287 + 288 + return record 289 + } 290 + 291 + type set = map[string]struct{} 292 + 293 + type LabelState struct { 294 + inner map[string]set 295 + } 296 + 297 + func NewLabelState() LabelState { 298 + return LabelState{ 299 + inner: make(map[string]set), 300 + } 301 + } 302 + 303 + func (s LabelState) Inner() map[string]set { 304 + return s.inner 305 + } 306 + 307 + func (s LabelState) ContainsLabel(l string) bool { 308 + if valset, exists := s.inner[l]; exists { 309 + if valset != nil { 310 + return true 311 + } 312 + } 313 + 314 + return false 315 + } 316 + 317 + // go maps behavior in templates make this necessary, 318 + // indexing a map and getting `set` in return is apparently truthy 319 + func (s LabelState) ContainsLabelAndVal(l, v string) bool { 320 + if valset, exists := s.inner[l]; exists { 321 + if _, exists := valset[v]; exists { 322 + return true 323 + } 324 + } 325 + 326 + return false 327 + } 328 + 329 + func (s LabelState) GetValSet(l string) set { 330 + if valset, exists := s.inner[l]; exists { 331 + return valset 332 + } else { 333 + return make(set) 334 + } 335 + } 336 + 337 + type LabelApplicationCtx struct { 338 + Defs map[string]*LabelDefinition // labelAt -> labelDef 339 + } 340 + 341 + var ( 342 + LabelNoOpError = errors.New("no-op") 343 + ) 344 + 345 + func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 346 + def, ok := c.Defs[op.OperandKey] 347 + if !ok { 348 + // this def was deleted, but an op exists, so we just skip over the op 349 + return nil 350 + } 351 + 352 + switch op.Operation { 353 + case LabelOperationAdd: 354 + // if valueset is empty, init it 355 + if state.inner[op.OperandKey] == nil { 356 + state.inner[op.OperandKey] = make(set) 357 + } 358 + 359 + // if valueset is populated & this val alr exists, this labelop is a noop 360 + if valueSet, exists := state.inner[op.OperandKey]; exists { 361 + if _, exists = valueSet[op.OperandValue]; exists { 362 + return LabelNoOpError 363 + } 364 + } 365 + 366 + if def.Multiple { 367 + // append to set 368 + state.inner[op.OperandKey][op.OperandValue] = struct{}{} 369 + } else { 370 + // reset to just this value 371 + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 372 + } 373 + 374 + case LabelOperationDel: 375 + // if label DNE, then deletion is a no-op 376 + if valueSet, exists := state.inner[op.OperandKey]; !exists { 377 + return LabelNoOpError 378 + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 379 + return LabelNoOpError 380 + } 381 + 382 + if def.Multiple { 383 + // remove from set 384 + delete(state.inner[op.OperandKey], op.OperandValue) 385 + } else { 386 + // reset the entire label 387 + delete(state.inner, op.OperandKey) 388 + } 389 + 390 + // if the map becomes empty, then set it to nil, this is just the inverse of add 391 + if len(state.inner[op.OperandKey]) == 0 { 392 + state.inner[op.OperandKey] = nil 393 + } 394 + 395 + } 396 + 397 + return nil 398 + } 399 + 400 + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 401 + // sort label ops in sort order first 402 + slices.SortFunc(ops, func(a, b LabelOp) int { 403 + return a.SortAt().Compare(b.SortAt()) 404 + }) 405 + 406 + // apply ops in sequence 407 + for _, o := range ops { 408 + _ = c.ApplyLabelOp(state, o) 409 + } 410 + } 411 + 412 + // IsInverse checks if one label operation is the inverse of another 413 + // returns true if one is an add and the other is a delete with the same key and value 414 + func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 415 + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 416 + return false 417 + } 418 + 419 + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 420 + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 421 + } 422 + 423 + // removes pairs of label operations that are inverses of each other 424 + // from the given slice. the function preserves the order of remaining operations. 425 + func ReduceLabelOps(ops []LabelOp) []LabelOp { 426 + if len(ops) <= 1 { 427 + return ops 428 + } 429 + 430 + keep := make([]bool, len(ops)) 431 + for i := range keep { 432 + keep[i] = true 433 + } 434 + 435 + for i := range ops { 436 + if !keep[i] { 437 + continue 438 + } 439 + 440 + for j := i + 1; j < len(ops); j++ { 441 + if !keep[j] { 442 + continue 443 + } 444 + 445 + if ops[i].IsInverse(ops[j]) { 446 + keep[i] = false 447 + keep[j] = false 448 + break // move to next i since this one is now eliminated 449 + } 450 + } 451 + } 452 + 453 + // build result slice with only kept operations 454 + var result []LabelOp 455 + for i, op := range ops { 456 + if keep[i] { 457 + result = append(result, op) 458 + } 459 + } 460 + 461 + return result 462 + } 463 + 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 + func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 + } 480 + } 481 + 482 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 + if err != nil { 485 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 + } 487 + pdsEndpoint := resolved.PDSEndpoint() 488 + if pdsEndpoint == "" { 489 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 + } 491 + client := &xrpc.Client{ 492 + Host: pdsEndpoint, 493 + } 494 + 495 + var labelDefs []LabelDefinition 496 + 497 + for _, dl := range DefaultLabelDefs() { 498 + atUri := syntax.ATURI(dl) 499 + parsedUri, err := syntax.ParseATURI(string(atUri)) 500 + if err != nil { 501 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 + } 503 + record, err := atproto.RepoGetRecord( 504 + context.Background(), 505 + client, 506 + "", 507 + parsedUri.Collection().String(), 508 + parsedUri.Authority().String(), 509 + parsedUri.RecordKey().String(), 510 + ) 511 + if err != nil { 512 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 513 + } 514 + 515 + if record != nil { 516 + bytes, err := record.Value.MarshalJSON() 517 + if err != nil { 518 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 519 + } 520 + 521 + raw := json.RawMessage(bytes) 522 + labelRecord := tangled.LabelDefinition{} 523 + err = json.Unmarshal(raw, &labelRecord) 524 + if err != nil { 525 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 526 + } 527 + 528 + labelDef, err := LabelDefinitionFromRecord( 529 + parsedUri.Authority().String(), 530 + parsedUri.RecordKey().String(), 531 + labelRecord, 532 + ) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 535 + } 536 + 537 + labelDefs = append(labelDefs, *labelDef) 538 + } 539 + } 540 + 541 + return labelDefs, nil 542 + }
+14
appview/models/language.go
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
+82
appview/models/notifications.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type NotificationType string 8 + 9 + const ( 10 + NotificationTypeRepoStarred NotificationType = "repo_starred" 11 + NotificationTypeIssueCreated NotificationType = "issue_created" 12 + NotificationTypeIssueCommented NotificationType = "issue_commented" 13 + NotificationTypePullCreated NotificationType = "pull_created" 14 + NotificationTypePullCommented NotificationType = "pull_commented" 15 + NotificationTypeFollowed NotificationType = "followed" 16 + NotificationTypePullMerged NotificationType = "pull_merged" 17 + NotificationTypeIssueClosed NotificationType = "issue_closed" 18 + NotificationTypePullClosed NotificationType = "pull_closed" 19 + ) 20 + 21 + type Notification struct { 22 + ID int64 23 + RecipientDid string 24 + ActorDid string 25 + Type NotificationType 26 + EntityType string 27 + EntityId string 28 + Read bool 29 + Created time.Time 30 + 31 + // foreign key references 32 + RepoId *int64 33 + IssueId *int64 34 + PullId *int64 35 + } 36 + 37 + // lucide icon that represents this notification 38 + func (n *Notification) Icon() string { 39 + switch n.Type { 40 + case NotificationTypeRepoStarred: 41 + return "star" 42 + case NotificationTypeIssueCreated: 43 + return "circle-dot" 44 + case NotificationTypeIssueCommented: 45 + return "message-square" 46 + case NotificationTypeIssueClosed: 47 + return "ban" 48 + case NotificationTypePullCreated: 49 + return "git-pull-request-create" 50 + case NotificationTypePullCommented: 51 + return "message-square" 52 + case NotificationTypePullMerged: 53 + return "git-merge" 54 + case NotificationTypePullClosed: 55 + return "git-pull-request-closed" 56 + case NotificationTypeFollowed: 57 + return "user-plus" 58 + default: 59 + return "" 60 + } 61 + } 62 + 63 + type NotificationWithEntity struct { 64 + *Notification 65 + Repo *Repo 66 + Issue *Issue 67 + Pull *Pull 68 + } 69 + 70 + type NotificationPreferences struct { 71 + ID int64 72 + UserDid string 73 + RepoStarred bool 74 + IssueCreated bool 75 + IssueCommented bool 76 + PullCreated bool 77 + PullCommented bool 78 + Followed bool 79 + PullMerged bool 80 + IssueClosed bool 81 + EmailNotifications bool 82 + }
+130
appview/models/pipeline.go
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + spindle "tangled.org/core/spindle/models" 10 + "tangled.org/core/workflow" 11 + ) 12 + 13 + type Pipeline struct { 14 + Id int 15 + Rkey string 16 + Knot string 17 + RepoOwner syntax.DID 18 + RepoName string 19 + TriggerId int 20 + Sha string 21 + Created time.Time 22 + 23 + // populate when querying for reverse mappings 24 + Trigger *Trigger 25 + Statuses map[string]WorkflowStatus 26 + } 27 + 28 + type WorkflowStatus struct { 29 + Data []PipelineStatus 30 + } 31 + 32 + func (w WorkflowStatus) Latest() PipelineStatus { 33 + return w.Data[len(w.Data)-1] 34 + } 35 + 36 + // time taken by this workflow to reach an "end state" 37 + func (w WorkflowStatus) TimeTaken() time.Duration { 38 + var start, end *time.Time 39 + for _, s := range w.Data { 40 + if s.Status.IsStart() { 41 + start = &s.Created 42 + } 43 + if s.Status.IsFinish() { 44 + end = &s.Created 45 + } 46 + } 47 + 48 + if start != nil && end != nil && end.After(*start) { 49 + return end.Sub(*start) 50 + } 51 + 52 + return 0 53 + } 54 + 55 + func (p Pipeline) Counts() map[string]int { 56 + m := make(map[string]int) 57 + for _, w := range p.Statuses { 58 + m[w.Latest().Status.String()] += 1 59 + } 60 + return m 61 + } 62 + 63 + func (p Pipeline) TimeTaken() time.Duration { 64 + var s time.Duration 65 + for _, w := range p.Statuses { 66 + s += w.TimeTaken() 67 + } 68 + return s 69 + } 70 + 71 + func (p Pipeline) Workflows() []string { 72 + var ws []string 73 + for v := range p.Statuses { 74 + ws = append(ws, v) 75 + } 76 + slices.Sort(ws) 77 + return ws 78 + } 79 + 80 + // if we know that a spindle has picked up this pipeline, then it is Responding 81 + func (p Pipeline) IsResponding() bool { 82 + return len(p.Statuses) != 0 83 + } 84 + 85 + type Trigger struct { 86 + Id int 87 + Kind workflow.TriggerKind 88 + 89 + // push trigger fields 90 + PushRef *string 91 + PushNewSha *string 92 + PushOldSha *string 93 + 94 + // pull request trigger fields 95 + PRSourceBranch *string 96 + PRTargetBranch *string 97 + PRSourceSha *string 98 + PRAction *string 99 + } 100 + 101 + func (t *Trigger) IsPush() bool { 102 + return t != nil && t.Kind == workflow.TriggerKindPush 103 + } 104 + 105 + func (t *Trigger) IsPullRequest() bool { 106 + return t != nil && t.Kind == workflow.TriggerKindPullRequest 107 + } 108 + 109 + func (t *Trigger) TargetRef() string { 110 + if t.IsPush() { 111 + return plumbing.ReferenceName(*t.PushRef).Short() 112 + } else if t.IsPullRequest() { 113 + return *t.PRTargetBranch 114 + } 115 + 116 + return "" 117 + } 118 + 119 + type PipelineStatus struct { 120 + ID int 121 + Spindle string 122 + Rkey string 123 + PipelineKnot string 124 + PipelineRkey string 125 + Created time.Time 126 + Workflow string 127 + Status spindle.StatusKind 128 + Error *string 129 + ExitCode int 130 + }
+177
appview/models/profile.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + type Profile struct { 11 + // ids 12 + ID int 13 + Did string 14 + 15 + // data 16 + Description string 17 + IncludeBluesky bool 18 + Location string 19 + Links [5]string 20 + Stats [2]VanityStat 21 + PinnedRepos [6]syntax.ATURI 22 + } 23 + 24 + func (p Profile) IsLinksEmpty() bool { 25 + for _, l := range p.Links { 26 + if l != "" { 27 + return false 28 + } 29 + } 30 + return true 31 + } 32 + 33 + func (p Profile) IsStatsEmpty() bool { 34 + for _, s := range p.Stats { 35 + if s.Kind != "" { 36 + return false 37 + } 38 + } 39 + return true 40 + } 41 + 42 + func (p Profile) IsPinnedReposEmpty() bool { 43 + for _, r := range p.PinnedRepos { 44 + if r != "" { 45 + return false 46 + } 47 + } 48 + return true 49 + } 50 + 51 + type VanityStatKind string 52 + 53 + const ( 54 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 55 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 56 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 57 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 58 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 59 + VanityStatRepositoryCount VanityStatKind = "repository-count" 60 + ) 61 + 62 + func (v VanityStatKind) String() string { 63 + switch v { 64 + case VanityStatMergedPRCount: 65 + return "Merged PRs" 66 + case VanityStatClosedPRCount: 67 + return "Closed PRs" 68 + case VanityStatOpenPRCount: 69 + return "Open PRs" 70 + case VanityStatOpenIssueCount: 71 + return "Open Issues" 72 + case VanityStatClosedIssueCount: 73 + return "Closed Issues" 74 + case VanityStatRepositoryCount: 75 + return "Repositories" 76 + } 77 + return "" 78 + } 79 + 80 + type VanityStat struct { 81 + Kind VanityStatKind 82 + Value uint64 83 + } 84 + 85 + func (p *Profile) ProfileAt() syntax.ATURI { 86 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 87 + } 88 + 89 + type RepoEvent struct { 90 + Repo *Repo 91 + Source *Repo 92 + } 93 + 94 + type ProfileTimeline struct { 95 + ByMonth []ByMonth 96 + } 97 + 98 + func (p *ProfileTimeline) IsEmpty() bool { 99 + if p == nil { 100 + return true 101 + } 102 + 103 + for _, m := range p.ByMonth { 104 + if !m.IsEmpty() { 105 + return false 106 + } 107 + } 108 + 109 + return true 110 + } 111 + 112 + type ByMonth struct { 113 + RepoEvents []RepoEvent 114 + IssueEvents IssueEvents 115 + PullEvents PullEvents 116 + } 117 + 118 + func (b ByMonth) IsEmpty() bool { 119 + return len(b.RepoEvents) == 0 && 120 + len(b.IssueEvents.Items) == 0 && 121 + len(b.PullEvents.Items) == 0 122 + } 123 + 124 + type IssueEvents struct { 125 + Items []*Issue 126 + } 127 + 128 + type IssueEventStats struct { 129 + Open int 130 + Closed int 131 + } 132 + 133 + func (i IssueEvents) Stats() IssueEventStats { 134 + var open, closed int 135 + for _, issue := range i.Items { 136 + if issue.Open { 137 + open += 1 138 + } else { 139 + closed += 1 140 + } 141 + } 142 + 143 + return IssueEventStats{ 144 + Open: open, 145 + Closed: closed, 146 + } 147 + } 148 + 149 + type PullEvents struct { 150 + Items []*Pull 151 + } 152 + 153 + func (p PullEvents) Stats() PullEventStats { 154 + var open, merged, closed int 155 + for _, pull := range p.Items { 156 + switch pull.State { 157 + case PullOpen: 158 + open += 1 159 + case PullMerged: 160 + merged += 1 161 + case PullClosed: 162 + closed += 1 163 + } 164 + } 165 + 166 + return PullEventStats{ 167 + Open: open, 168 + Merged: merged, 169 + Closed: closed, 170 + } 171 + } 172 + 173 + type PullEventStats struct { 174 + Closed int 175 + Open int 176 + Merged int 177 + }
+25
appview/models/pubkey.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + type PublicKey struct { 9 + Did string `json:"did"` 10 + Key string `json:"key"` 11 + Name string `json:"name"` 12 + Rkey string `json:"rkey"` 13 + Created *time.Time 14 + } 15 + 16 + func (p PublicKey) MarshalJSON() ([]byte, error) { 17 + type Alias PublicKey 18 + return json.Marshal(&struct { 19 + Created string `json:"created"` 20 + *Alias 21 + }{ 22 + Created: p.Created.Format(time.RFC3339), 23 + Alias: (*Alias)(&p), 24 + }) 25 + }
+352
appview/models/pull.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "slices" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/patchutil" 13 + "tangled.org/core/types" 14 + ) 15 + 16 + type PullState int 17 + 18 + const ( 19 + PullClosed PullState = iota 20 + PullOpen 21 + PullMerged 22 + PullDeleted 23 + ) 24 + 25 + func (p PullState) String() string { 26 + switch p { 27 + case PullOpen: 28 + return "open" 29 + case PullMerged: 30 + return "merged" 31 + case PullClosed: 32 + return "closed" 33 + case PullDeleted: 34 + return "deleted" 35 + default: 36 + return "closed" 37 + } 38 + } 39 + 40 + func (p PullState) IsOpen() bool { 41 + return p == PullOpen 42 + } 43 + func (p PullState) IsMerged() bool { 44 + return p == PullMerged 45 + } 46 + func (p PullState) IsClosed() bool { 47 + return p == PullClosed 48 + } 49 + func (p PullState) IsDeleted() bool { 50 + return p == PullDeleted 51 + } 52 + 53 + type Pull struct { 54 + // ids 55 + ID int 56 + PullId int 57 + 58 + // at ids 59 + RepoAt syntax.ATURI 60 + OwnerDid string 61 + Rkey string 62 + 63 + // content 64 + Title string 65 + Body string 66 + TargetBranch string 67 + State PullState 68 + Submissions []*PullSubmission 69 + 70 + // stacking 71 + StackId string // nullable string 72 + ChangeId string // nullable string 73 + ParentChangeId string // nullable string 74 + 75 + // meta 76 + Created time.Time 77 + PullSource *PullSource 78 + 79 + // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 + } 83 + 84 + func (p Pull) AsRecord() tangled.RepoPull { 85 + var source *tangled.RepoPull_Source 86 + if p.PullSource != nil { 87 + s := p.PullSource.AsRecord() 88 + source = &s 89 + source.Sha = p.LatestSha() 90 + } 91 + 92 + record := tangled.RepoPull{ 93 + Title: p.Title, 94 + Body: &p.Body, 95 + CreatedAt: p.Created.Format(time.RFC3339), 96 + Target: &tangled.RepoPull_Target{ 97 + Repo: p.RepoAt.String(), 98 + Branch: p.TargetBranch, 99 + }, 100 + Patch: p.LatestPatch(), 101 + Source: source, 102 + } 103 + return record 104 + } 105 + 106 + type PullSource struct { 107 + Branch string 108 + RepoAt *syntax.ATURI 109 + 110 + // optionally populate this for reverse mappings 111 + Repo *Repo 112 + } 113 + 114 + func (p PullSource) AsRecord() tangled.RepoPull_Source { 115 + var repoAt *string 116 + if p.RepoAt != nil { 117 + s := p.RepoAt.String() 118 + repoAt = &s 119 + } 120 + record := tangled.RepoPull_Source{ 121 + Branch: p.Branch, 122 + Repo: repoAt, 123 + } 124 + return record 125 + } 126 + 127 + type PullSubmission struct { 128 + // ids 129 + ID int 130 + 131 + // at ids 132 + PullAt syntax.ATURI 133 + 134 + // content 135 + RoundNumber int 136 + Patch string 137 + Comments []PullComment 138 + SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 + 140 + // meta 141 + Created time.Time 142 + } 143 + 144 + type PullComment struct { 145 + // ids 146 + ID int 147 + PullId int 148 + SubmissionId int 149 + 150 + // at ids 151 + RepoAt string 152 + OwnerDid string 153 + CommentAt string 154 + 155 + // content 156 + Body string 157 + 158 + // meta 159 + Created time.Time 160 + } 161 + 162 + func (p *Pull) LatestPatch() string { 163 + latestSubmission := p.Submissions[p.LastRoundNumber()] 164 + return latestSubmission.Patch 165 + } 166 + 167 + func (p *Pull) LatestSha() string { 168 + latestSubmission := p.Submissions[p.LastRoundNumber()] 169 + return latestSubmission.SourceRev 170 + } 171 + 172 + func (p *Pull) PullAt() syntax.ATURI { 173 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 + } 175 + 176 + func (p *Pull) LastRoundNumber() int { 177 + return len(p.Submissions) - 1 178 + } 179 + 180 + func (p *Pull) IsPatchBased() bool { 181 + return p.PullSource == nil 182 + } 183 + 184 + func (p *Pull) IsBranchBased() bool { 185 + if p.PullSource != nil { 186 + if p.PullSource.RepoAt != nil { 187 + return p.PullSource.RepoAt == &p.RepoAt 188 + } else { 189 + // no repo specified 190 + return true 191 + } 192 + } 193 + return false 194 + } 195 + 196 + func (p *Pull) IsForkBased() bool { 197 + if p.PullSource != nil { 198 + if p.PullSource.RepoAt != nil { 199 + // make sure repos are different 200 + return p.PullSource.RepoAt != &p.RepoAt 201 + } 202 + } 203 + return false 204 + } 205 + 206 + func (p *Pull) IsStacked() bool { 207 + return p.StackId != "" 208 + } 209 + 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 + func (s PullSubmission) IsFormatPatch() bool { 233 + return patchutil.IsFormatPatch(s.Patch) 234 + } 235 + 236 + func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 237 + patches, err := patchutil.ExtractPatches(s.Patch) 238 + if err != nil { 239 + log.Println("error extracting patches from submission:", err) 240 + return []types.FormatPatch{} 241 + } 242 + 243 + return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 + } 265 + 266 + type Stack []*Pull 267 + 268 + // position of this pull in the stack 269 + func (stack Stack) Position(pull *Pull) int { 270 + return slices.IndexFunc(stack, func(p *Pull) bool { 271 + return p.ChangeId == pull.ChangeId 272 + }) 273 + } 274 + 275 + // all pulls below this pull (including self) in this stack 276 + // 277 + // nil if this pull does not belong to this stack 278 + func (stack Stack) Below(pull *Pull) Stack { 279 + position := stack.Position(pull) 280 + 281 + if position < 0 { 282 + return nil 283 + } 284 + 285 + return stack[position:] 286 + } 287 + 288 + // all pulls below this pull (excluding self) in this stack 289 + func (stack Stack) StrictlyBelow(pull *Pull) Stack { 290 + below := stack.Below(pull) 291 + 292 + if len(below) > 0 { 293 + return below[1:] 294 + } 295 + 296 + return nil 297 + } 298 + 299 + // all pulls above this pull (including self) in this stack 300 + func (stack Stack) Above(pull *Pull) Stack { 301 + position := stack.Position(pull) 302 + 303 + if position < 0 { 304 + return nil 305 + } 306 + 307 + return stack[:position+1] 308 + } 309 + 310 + // all pulls below this pull (excluding self) in this stack 311 + func (stack Stack) StrictlyAbove(pull *Pull) Stack { 312 + above := stack.Above(pull) 313 + 314 + if len(above) > 0 { 315 + return above[:len(above)-1] 316 + } 317 + 318 + return nil 319 + } 320 + 321 + // the combined format-patches of all the newest submissions in this stack 322 + func (stack Stack) CombinedPatch() string { 323 + // go in reverse order because the bottom of the stack is the last element in the slice 324 + var combined strings.Builder 325 + for idx := range stack { 326 + pull := stack[len(stack)-1-idx] 327 + combined.WriteString(pull.LatestPatch()) 328 + combined.WriteString("\n") 329 + } 330 + return combined.String() 331 + } 332 + 333 + // filter out PRs that are "active" 334 + // 335 + // PRs that are still open are active 336 + func (stack Stack) Mergeable() Stack { 337 + var mergeable Stack 338 + 339 + for _, p := range stack { 340 + // stop at the first merged PR 341 + if p.State == PullMerged || p.State == PullClosed { 342 + break 343 + } 344 + 345 + // skip over deleted PRs 346 + if p.State != PullDeleted { 347 + mergeable = append(mergeable, p) 348 + } 349 + } 350 + 351 + return mergeable 352 + }
+14
appview/models/punchcard.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+62
appview/models/reaction.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type ReactionKind string 10 + 11 + const ( 12 + Like ReactionKind = "👍" 13 + Unlike ReactionKind = "👎" 14 + Laugh ReactionKind = "😆" 15 + Celebration ReactionKind = "🎉" 16 + Confused ReactionKind = "🫤" 17 + Heart ReactionKind = "❤️" 18 + Rocket ReactionKind = "🚀" 19 + Eyes ReactionKind = "👀" 20 + ) 21 + 22 + func (rk ReactionKind) String() string { 23 + return string(rk) 24 + } 25 + 26 + var OrderedReactionKinds = []ReactionKind{ 27 + Like, 28 + Unlike, 29 + Laugh, 30 + Celebration, 31 + Confused, 32 + Heart, 33 + Rocket, 34 + Eyes, 35 + } 36 + 37 + func ParseReactionKind(raw string) (ReactionKind, bool) { 38 + k, ok := (map[string]ReactionKind{ 39 + "👍": Like, 40 + "👎": Unlike, 41 + "😆": Laugh, 42 + "🎉": Celebration, 43 + "🫤": Confused, 44 + "❤️": Heart, 45 + "🚀": Rocket, 46 + "👀": Eyes, 47 + })[raw] 48 + return k, ok 49 + } 50 + 51 + type Reaction struct { 52 + ReactedByDid string 53 + ThreadAt syntax.ATURI 54 + Created time.Time 55 + Rkey string 56 + Kind ReactionKind 57 + } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+44
appview/models/registration.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Registration represents a knot registration. Knot would've been a better 6 + // name but we're stuck with this for historical reasons. 7 + type Registration struct { 8 + Id int64 9 + Domain string 10 + ByDid string 11 + Created *time.Time 12 + Registered *time.Time 13 + NeedsUpgrade bool 14 + } 15 + 16 + func (r *Registration) Status() Status { 17 + if r.NeedsUpgrade { 18 + return NeedsUpgrade 19 + } else if r.Registered != nil { 20 + return Registered 21 + } else { 22 + return Pending 23 + } 24 + } 25 + 26 + func (r *Registration) IsRegistered() bool { 27 + return r.Status() == Registered 28 + } 29 + 30 + func (r *Registration) IsNeedsUpgrade() bool { 31 + return r.Status() == NeedsUpgrade 32 + } 33 + 34 + func (r *Registration) IsPending() bool { 35 + return r.Status() == Pending 36 + } 37 + 38 + type Status uint32 39 + 40 + const ( 41 + Registered Status = iota 42 + Pending 43 + NeedsUpgrade 44 + )
+93
appview/models/repo.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + securejoin "github.com/cyphar/filepath-securejoin" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Repo struct { 13 + Id int64 14 + Did string 15 + Name string 16 + Knot string 17 + Rkey string 18 + Created time.Time 19 + Description string 20 + Spindle string 21 + Labels []string 22 + 23 + // optionally, populate this when querying for reverse mappings 24 + RepoStats *RepoStats 25 + 26 + // optional 27 + Source string 28 + } 29 + 30 + func (r *Repo) AsRecord() tangled.Repo { 31 + var source, spindle, description *string 32 + 33 + if r.Source != "" { 34 + source = &r.Source 35 + } 36 + 37 + if r.Spindle != "" { 38 + spindle = &r.Spindle 39 + } 40 + 41 + if r.Description != "" { 42 + description = &r.Description 43 + } 44 + 45 + return tangled.Repo{ 46 + Knot: r.Knot, 47 + Name: r.Name, 48 + Description: description, 49 + CreatedAt: r.Created.Format(time.RFC3339), 50 + Source: source, 51 + Spindle: spindle, 52 + Labels: r.Labels, 53 + } 54 + } 55 + 56 + func (r Repo) RepoAt() syntax.ATURI { 57 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 58 + } 59 + 60 + func (r Repo) DidSlashRepo() string { 61 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 + return p 63 + } 64 + 65 + type RepoStats struct { 66 + Language string 67 + StarCount int 68 + IssueCount IssueCount 69 + PullCount PullCount 70 + } 71 + 72 + type IssueCount struct { 73 + Open int 74 + Closed int 75 + } 76 + 77 + type PullCount struct { 78 + Open int 79 + Merged int 80 + Closed int 81 + Deleted int 82 + } 83 + 84 + type RepoLabel struct { 85 + Id int64 86 + RepoAt syntax.ATURI 87 + LabelAt syntax.ATURI 88 + } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+10
appview/models/signup.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+25
appview/models/spindle.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Spindle struct { 10 + Id int 11 + Owner syntax.DID 12 + Instance string 13 + Verified *time.Time 14 + Created time.Time 15 + NeedsUpgrade bool 16 + } 17 + 18 + type SpindleMember struct { 19 + Id int 20 + Did syntax.DID // owner of the record 21 + Rkey string // rkey of the record 22 + Instance string 23 + Subject syntax.DID // the member being added 24 + Created time.Time 25 + }
+17
appview/models/star.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Star struct { 10 + StarredByDid string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + 15 + // optionally, populate this when querying for reverse mappings 16 + Repo *Repo 17 + }
+95
appview/models/string.go
··· 1 + package models 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + type String struct { 15 + Did syntax.DID 16 + Rkey string 17 + 18 + Filename string 19 + Description string 20 + Contents string 21 + Created time.Time 22 + Edited *time.Time 23 + } 24 + 25 + func (s *String) StringAt() syntax.ATURI { 26 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 + } 28 + 29 + func (s *String) AsRecord() tangled.String { 30 + return tangled.String{ 31 + Filename: s.Filename, 32 + Description: s.Description, 33 + Contents: s.Contents, 34 + CreatedAt: s.Created.Format(time.RFC3339), 35 + } 36 + } 37 + 38 + func StringFromRecord(did, rkey string, record tangled.String) String { 39 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 40 + if err != nil { 41 + created = time.Now() 42 + } 43 + return String{ 44 + Did: syntax.DID(did), 45 + Rkey: rkey, 46 + Filename: record.Filename, 47 + Description: record.Description, 48 + Contents: record.Contents, 49 + Created: created, 50 + } 51 + } 52 + 53 + type StringStats struct { 54 + LineCount uint64 55 + ByteCount uint64 56 + } 57 + 58 + func (s String) Stats() StringStats { 59 + lineCount, err := countLines(strings.NewReader(s.Contents)) 60 + if err != nil { 61 + // non-fatal 62 + // TODO: log this? 63 + } 64 + 65 + return StringStats{ 66 + LineCount: uint64(lineCount), 67 + ByteCount: uint64(len(s.Contents)), 68 + } 69 + } 70 + 71 + func countLines(r io.Reader) (int, error) { 72 + buf := make([]byte, 32*1024) 73 + bufLen := 0 74 + count := 0 75 + nl := []byte{'\n'} 76 + 77 + for { 78 + c, err := r.Read(buf) 79 + if c > 0 { 80 + bufLen += c 81 + } 82 + count += bytes.Count(buf[:c], nl) 83 + 84 + switch { 85 + case err == io.EOF: 86 + /* handle last line not having a newline at the end */ 87 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 88 + count++ 89 + } 90 + return count, nil 91 + case err != nil: 92 + return 0, err 93 + } 94 + } 95 + }
+23
appview/models/timeline.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type TimelineEvent struct { 6 + *Repo 7 + *Follow 8 + *Star 9 + 10 + EventAt time.Time 11 + 12 + // optional: populate only if Repo is a fork 13 + Source *Repo 14 + 15 + // optional: populate only if event is Follow 16 + *Profile 17 + *FollowStats 18 + *FollowStatus 19 + 20 + // optional: populate only if event is Repo 21 + IsStarred bool 22 + StarCount int64 23 + }
+166
appview/notifications/notifications.go
··· 1 + package notifications 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/middleware" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/appview/pagination" 14 + ) 15 + 16 + type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + } 21 + 22 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 + return &Notifications{ 24 + db: database, 25 + oauth: oauthHandler, 26 + pages: pagesHandler, 27 + } 28 + } 29 + 30 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 31 + r := chi.NewRouter() 32 + 33 + r.Get("/count", n.getUnreadCount) 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(middleware.AuthMiddleware(n.oauth)) 37 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 38 + r.Post("/{id}/read", n.markRead) 39 + r.Post("/read-all", n.markAllRead) 40 + r.Delete("/{id}", n.deleteNotification) 41 + }) 42 + 43 + return r 44 + } 45 + 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + user := n.oauth.GetUser(r) 48 + 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 54 + 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", user.Did), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 63 + } 64 + 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", user.Did), 69 + ) 70 + if err != nil { 71 + log.Println("failed to get notifications:", err) 72 + n.pages.Error500(w) 73 + return 74 + } 75 + 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 + } 80 + 81 + unreadCount := 0 82 + 83 + n.pages.Notifications(w, pages.NotificationsParams{ 84 + LoggedInUser: user, 85 + Notifications: notifications, 86 + UnreadCount: unreadCount, 87 + Page: page, 88 + Total: total, 89 + }) 90 + } 91 + 92 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 + user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 98 + count, err := db.CountNotifications( 99 + n.db, 100 + db.FilterEq("recipient_did", user.Did), 101 + db.FilterEq("read", 0), 102 + ) 103 + if err != nil { 104 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 105 + return 106 + } 107 + 108 + params := pages.NotificationCountParams{ 109 + Count: count, 110 + } 111 + err = n.pages.NotificationCount(w, params) 112 + if err != nil { 113 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 114 + return 115 + } 116 + } 117 + 118 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 119 + userDid := n.oauth.GetDid(r) 120 + 121 + idStr := chi.URLParam(r, "id") 122 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 123 + if err != nil { 124 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 125 + return 126 + } 127 + 128 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 129 + if err != nil { 130 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + w.WriteHeader(http.StatusNoContent) 135 + } 136 + 137 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 138 + userDid := n.oauth.GetDid(r) 139 + 140 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 141 + if err != nil { 142 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 147 + } 148 + 149 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 150 + userDid := n.oauth.GetDid(r) 151 + 152 + idStr := chi.URLParam(r, "id") 153 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 154 + if err != nil { 155 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 156 + return 157 + } 158 + 159 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 160 + if err != nil { 161 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + w.WriteHeader(http.StatusOK) 166 + }
+429
appview/notify/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + "tangled.org/core/idresolver" 11 + ) 12 + 13 + type databaseNotifier struct { 14 + db *db.DB 15 + res *idresolver.Resolver 16 + } 17 + 18 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 19 + return &databaseNotifier{ 20 + db: database, 21 + res: resolver, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &databaseNotifier{} 26 + 27 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + // no-op for now 29 + } 30 + 31 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 + var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 + if err != nil { 35 + log.Printf("NewStar: failed to get repos: %v", err) 36 + return 37 + } 38 + 39 + // don't notify yourself 40 + if repo.Did == star.StarredByDid { 41 + return 42 + } 43 + 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 67 + } 68 + 69 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 + // no-op 71 + } 72 + 73 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 + if err != nil { 76 + log.Printf("NewIssue: failed to get repos: %v", err) 77 + return 78 + } 79 + 80 + if repo.Did == issue.Did { 81 + return 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 + if err != nil { 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 + return 88 + } 89 + if !prefs.IssueCreated { 90 + return 91 + } 92 + 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 102 + 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 108 + } 109 + 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 + if err != nil { 113 + log.Printf("NewIssueComment: failed to get issues: %v", err) 114 + return 115 + } 116 + if len(issues) == 0 { 117 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 118 + return 119 + } 120 + issue := issues[0] 121 + 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 + if err != nil { 124 + log.Printf("NewIssueComment: failed to get repos: %v", err) 125 + return 126 + } 127 + 128 + recipients := make(map[string]bool) 129 + 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 + } 138 + } 139 + 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 149 + 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 161 + 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 167 + } 168 + 169 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 186 + 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 192 + } 193 + 194 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 195 + // no-op 196 + } 197 + 198 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 + if err != nil { 201 + log.Printf("NewPull: failed to get repos: %v", err) 202 + return 203 + } 204 + 205 + if repo.Did == pull.OwnerDid { 206 + return 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 + if err != nil { 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 + return 213 + } 214 + if !prefs.PullCreated { 215 + return 216 + } 217 + 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 227 + 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 233 + } 234 + 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 239 + if err != nil { 240 + log.Printf("NewPullComment: failed to get pulls: %v", err) 241 + return 242 + } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + return 246 + } 247 + pull := pulls[0] 248 + 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 + if err != nil { 251 + log.Printf("NewPullComment: failed to get repos: %v", err) 252 + return 253 + } 254 + 255 + recipients := make(map[string]bool) 256 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 265 + } 266 + 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 287 + 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 293 + } 294 + 295 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 296 + // no-op 297 + } 298 + 299 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 300 + // no-op 301 + } 302 + 303 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 304 + // no-op 305 + } 306 + 307 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 308 + // no-op 309 + } 310 + 311 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 + // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 + if err != nil { 315 + log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 + return 317 + } 318 + 319 + // Don't notify yourself 320 + if repo.Did == issue.Did { 321 + return 322 + } 323 + 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 333 + 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 342 + } 343 + 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 349 + } 350 + 351 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 + // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 + if err != nil { 355 + log.Printf("NewPullMerged: failed to get repos: %v", err) 356 + return 357 + } 358 + 359 + // Don't notify yourself 360 + if repo.Did == pull.OwnerDid { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 + if err != nil { 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 + return 369 + } 370 + if !prefs.PullMerged { 371 + return 372 + } 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 + } 383 + 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + return 388 + } 389 + } 390 + 391 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 + // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 + if err != nil { 395 + log.Printf("NewPullClosed: failed to get repos: %v", err) 396 + return 397 + } 398 + 399 + // Don't notify yourself 400 + if repo.Did == pull.OwnerDid { 401 + return 402 + } 403 + 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 + if err != nil { 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 + return 409 + } 410 + if !prefs.PullMerged { 411 + return 412 + } 413 + 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 + } 423 + 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 427 + return 428 + } 429 + }
+35 -12
appview/notify/merged_notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "tangled.org/core/appview/db" 6 + "tangled.org/core/appview/models" 7 7 ) 8 8 9 9 type mergedNotifier struct { ··· 16 16 17 17 var _ Notifier = &mergedNotifier{} 18 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 20 for _, notifier := range m.notifiers { 21 21 notifier.NewRepo(ctx, repo) 22 22 } 23 23 } 24 24 25 - func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 26 for _, notifier := range m.notifiers { 27 27 notifier.NewStar(ctx, star) 28 28 } 29 29 } 30 - func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 31 for _, notifier := range m.notifiers { 32 32 notifier.DeleteStar(ctx, star) 33 33 } 34 34 } 35 35 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 37 for _, notifier := range m.notifiers { 38 38 notifier.NewIssue(ctx, issue) 39 39 } 40 40 } 41 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 + for _, notifier := range m.notifiers { 43 + notifier.NewIssueComment(ctx, comment) 44 + } 45 + } 41 46 42 - func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 47 + func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 + for _, notifier := range m.notifiers { 49 + notifier.NewIssueClosed(ctx, issue) 50 + } 51 + } 52 + 53 + func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 54 for _, notifier := range m.notifiers { 44 55 notifier.NewFollow(ctx, follow) 45 56 } 46 57 } 47 - func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 58 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 48 59 for _, notifier := range m.notifiers { 49 60 notifier.DeleteFollow(ctx, follow) 50 61 } 51 62 } 52 63 53 - func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 64 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 54 65 for _, notifier := range m.notifiers { 55 66 notifier.NewPull(ctx, pull) 56 67 } 57 68 } 58 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 69 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 70 for _, notifier := range m.notifiers { 60 71 notifier.NewPullComment(ctx, comment) 61 72 } 62 73 } 63 74 64 - func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 75 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 + for _, notifier := range m.notifiers { 77 + notifier.NewPullMerged(ctx, pull) 78 + } 79 + } 80 + 81 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 + for _, notifier := range m.notifiers { 83 + notifier.NewPullClosed(ctx, pull) 84 + } 85 + } 86 + 87 + func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 65 88 for _, notifier := range m.notifiers { 66 89 notifier.UpdateProfile(ctx, profile) 67 90 } 68 91 } 69 92 70 - func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) { 93 + func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 71 94 for _, notifier := range m.notifiers { 72 95 notifier.NewString(ctx, string) 73 96 } 74 97 } 75 98 76 - func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) { 99 + func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 77 100 for _, notifier := range m.notifiers { 78 101 notifier.EditString(ctx, string) 79 102 }
+31 -23
appview/notify/notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "tangled.org/core/appview/db" 6 + "tangled.org/core/appview/models" 7 7 ) 8 8 9 9 type Notifier interface { 10 - NewRepo(ctx context.Context, repo *db.Repo) 10 + NewRepo(ctx context.Context, repo *models.Repo) 11 11 12 - NewStar(ctx context.Context, star *db.Star) 13 - DeleteStar(ctx context.Context, star *db.Star) 12 + NewStar(ctx context.Context, star *models.Star) 13 + DeleteStar(ctx context.Context, star *models.Star) 14 14 15 - NewIssue(ctx context.Context, issue *db.Issue) 15 + NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 16 18 17 - NewFollow(ctx context.Context, follow *db.Follow) 18 - DeleteFollow(ctx context.Context, follow *db.Follow) 19 + NewFollow(ctx context.Context, follow *models.Follow) 20 + DeleteFollow(ctx context.Context, follow *models.Follow) 19 21 20 - NewPull(ctx context.Context, pull *db.Pull) 21 - NewPullComment(ctx context.Context, comment *db.PullComment) 22 + NewPull(ctx context.Context, pull *models.Pull) 23 + NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 22 26 23 - UpdateProfile(ctx context.Context, profile *db.Profile) 27 + UpdateProfile(ctx context.Context, profile *models.Profile) 24 28 25 - NewString(ctx context.Context, s *db.String) 26 - EditString(ctx context.Context, s *db.String) 29 + NewString(ctx context.Context, s *models.String) 30 + EditString(ctx context.Context, s *models.String) 27 31 DeleteString(ctx context.Context, did, rkey string) 28 32 } 29 33 ··· 32 36 33 37 var _ Notifier = &BaseNotifier{} 34 38 35 - func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 39 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {} 36 40 37 - func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 38 - func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 41 + func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 43 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 41 47 42 - func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 43 - func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 48 + func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 + func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 50 45 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 46 - func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 51 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 52 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 + func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 47 55 48 - func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 56 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49 57 50 - func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {} 51 - func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {} 58 + func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {} 59 + func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {} 52 60 func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
··· 1 + package posthog 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 + err := n.client.Enqueue(posthog.Capture{ 117 + DistinctId: follow.UserDid, 118 + Event: "follow", 119 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 120 + }) 121 + if err != nil { 122 + log.Println("failed to enqueue posthog event:", err) 123 + } 124 + } 125 + 126 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 127 + err := n.client.Enqueue(posthog.Capture{ 128 + DistinctId: follow.UserDid, 129 + Event: "unfollow", 130 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 131 + }) 132 + if err != nil { 133 + log.Println("failed to enqueue posthog event:", err) 134 + } 135 + } 136 + 137 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 138 + err := n.client.Enqueue(posthog.Capture{ 139 + DistinctId: profile.Did, 140 + Event: "edit_profile", 141 + }) 142 + if err != nil { 143 + log.Println("failed to enqueue posthog event:", err) 144 + } 145 + } 146 + 147 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 148 + err := n.client.Enqueue(posthog.Capture{ 149 + DistinctId: did, 150 + Event: "delete_string", 151 + Properties: posthog.Properties{"rkey": rkey}, 152 + }) 153 + if err != nil { 154 + log.Println("failed to enqueue posthog event:", err) 155 + } 156 + } 157 + 158 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 159 + err := n.client.Enqueue(posthog.Capture{ 160 + DistinctId: string.Did.String(), 161 + Event: "edit_string", 162 + Properties: posthog.Properties{"rkey": string.Rkey}, 163 + }) 164 + if err != nil { 165 + log.Println("failed to enqueue posthog event:", err) 166 + } 167 + } 168 + 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 + err := n.client.Enqueue(posthog.Capture{ 171 + DistinctId: string.Did.String(), 172 + Event: "new_string", 173 + Properties: posthog.Properties{"rkey": string.Rkey}, 174 + }) 175 + if err != nil { 176 + log.Println("failed to enqueue posthog event:", err) 177 + } 178 + } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
+2 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
-545
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - tangled "tangled.org/core/api/tangled" 20 - sessioncache "tangled.org/core/appview/cache/session" 21 - "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 23 - "tangled.org/core/appview/middleware" 24 - "tangled.org/core/appview/oauth" 25 - "tangled.org/core/appview/oauth/client" 26 - "tangled.org/core/appview/pages" 27 - "tangled.org/core/idresolver" 28 - "tangled.org/core/rbac" 29 - "tangled.org/core/tid" 30 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 31 - ) 32 - 33 - const ( 34 - oauthScope = "atproto transition:generic" 35 - ) 36 - 37 - type OAuthHandler struct { 38 - config *config.Config 39 - pages *pages.Pages 40 - idResolver *idresolver.Resolver 41 - sess *sessioncache.SessionStore 42 - db *db.DB 43 - store *sessions.CookieStore 44 - oauth *oauth.OAuth 45 - enforcer *rbac.Enforcer 46 - posthog posthog.Client 47 - } 48 - 49 - func New( 50 - config *config.Config, 51 - pages *pages.Pages, 52 - idResolver *idresolver.Resolver, 53 - db *db.DB, 54 - sess *sessioncache.SessionStore, 55 - store *sessions.CookieStore, 56 - oauth *oauth.OAuth, 57 - enforcer *rbac.Enforcer, 58 - posthog posthog.Client, 59 - ) *OAuthHandler { 60 - return &OAuthHandler{ 61 - config: config, 62 - pages: pages, 63 - idResolver: idResolver, 64 - db: db, 65 - sess: sess, 66 - store: store, 67 - oauth: oauth, 68 - enforcer: enforcer, 69 - posthog: posthog, 70 - } 71 - } 72 - 73 - func (o *OAuthHandler) Router() http.Handler { 74 - r := chi.NewRouter() 75 - 76 - r.Get("/login", o.login) 77 - r.Post("/login", o.login) 78 - 79 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 80 - 81 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 82 - r.Get("/oauth/jwks.json", o.jwks) 83 - r.Get("/oauth/callback", o.callback) 84 - return r 85 - } 86 - 87 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 88 - w.Header().Set("Content-Type", "application/json") 89 - w.WriteHeader(http.StatusOK) 90 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 91 - } 92 - 93 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 94 - jwks := o.config.OAuth.Jwks 95 - pubKey, err := pubKeyFromJwk(jwks) 96 - if err != nil { 97 - log.Printf("error parsing public key: %v", err) 98 - http.Error(w, err.Error(), http.StatusInternalServerError) 99 - return 100 - } 101 - 102 - response := helpers.CreateJwksResponseObject(pubKey) 103 - 104 - w.Header().Set("Content-Type", "application/json") 105 - w.WriteHeader(http.StatusOK) 106 - json.NewEncoder(w).Encode(response) 107 - } 108 - 109 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 110 - switch r.Method { 111 - case http.MethodGet: 112 - returnURL := r.URL.Query().Get("return_url") 113 - o.pages.Login(w, pages.LoginParams{ 114 - ReturnUrl: returnURL, 115 - }) 116 - case http.MethodPost: 117 - handle := r.FormValue("handle") 118 - 119 - // when users copy their handle from bsky.app, it tends to have these characters around it: 120 - // 121 - // @nelind.dk: 122 - // \u202a ensures that the handle is always rendered left to right and 123 - // \u202c reverts that so the rest of the page renders however it should 124 - handle = strings.TrimPrefix(handle, "\u202a") 125 - handle = strings.TrimSuffix(handle, "\u202c") 126 - 127 - // `@` is harmless 128 - handle = strings.TrimPrefix(handle, "@") 129 - 130 - // basic handle validation 131 - if !strings.Contains(handle, ".") { 132 - log.Println("invalid handle format", "raw", handle) 133 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 134 - return 135 - } 136 - 137 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 138 - if err != nil { 139 - log.Println("failed to resolve handle:", err) 140 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 141 - return 142 - } 143 - self := o.oauth.ClientMetadata() 144 - oauthClient, err := client.NewClient( 145 - self.ClientID, 146 - o.config.OAuth.Jwks, 147 - self.RedirectURIs[0], 148 - ) 149 - 150 - if err != nil { 151 - log.Println("failed to create oauth client:", err) 152 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 153 - return 154 - } 155 - 156 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 157 - if err != nil { 158 - log.Println("failed to resolve auth server:", err) 159 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 160 - return 161 - } 162 - 163 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 164 - if err != nil { 165 - log.Println("failed to fetch auth server metadata:", err) 166 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 167 - return 168 - } 169 - 170 - dpopKey, err := helpers.GenerateKey(nil) 171 - if err != nil { 172 - log.Println("failed to generate dpop key:", err) 173 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 174 - return 175 - } 176 - 177 - dpopKeyJson, err := json.Marshal(dpopKey) 178 - if err != nil { 179 - log.Println("failed to marshal dpop key:", err) 180 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 181 - return 182 - } 183 - 184 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 185 - if err != nil { 186 - log.Println("failed to send par auth request:", err) 187 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 188 - return 189 - } 190 - 191 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 192 - Did: resolved.DID.String(), 193 - PdsUrl: resolved.PDSEndpoint(), 194 - Handle: handle, 195 - AuthserverIss: authMeta.Issuer, 196 - PkceVerifier: parResp.PkceVerifier, 197 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 198 - DpopPrivateJwk: string(dpopKeyJson), 199 - State: parResp.State, 200 - ReturnUrl: r.FormValue("return_url"), 201 - }) 202 - if err != nil { 203 - log.Println("failed to save oauth request:", err) 204 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 205 - return 206 - } 207 - 208 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 209 - query := url.Values{} 210 - query.Add("client_id", self.ClientID) 211 - query.Add("request_uri", parResp.RequestUri) 212 - u.RawQuery = query.Encode() 213 - o.pages.HxRedirect(w, u.String()) 214 - } 215 - } 216 - 217 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 218 - state := r.FormValue("state") 219 - 220 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 221 - if err != nil { 222 - log.Println("failed to get oauth request:", err) 223 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 224 - return 225 - } 226 - 227 - defer func() { 228 - err := o.sess.DeleteRequestByState(r.Context(), state) 229 - if err != nil { 230 - log.Println("failed to delete oauth request for state:", state, err) 231 - } 232 - }() 233 - 234 - error := r.FormValue("error") 235 - errorDescription := r.FormValue("error_description") 236 - if error != "" || errorDescription != "" { 237 - log.Printf("error: %s, %s", error, errorDescription) 238 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 239 - return 240 - } 241 - 242 - code := r.FormValue("code") 243 - if code == "" { 244 - log.Println("missing code for state: ", state) 245 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 246 - return 247 - } 248 - 249 - iss := r.FormValue("iss") 250 - if iss == "" { 251 - log.Println("missing iss for state: ", state) 252 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 253 - return 254 - } 255 - 256 - if iss != oauthRequest.AuthserverIss { 257 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 258 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 259 - return 260 - } 261 - 262 - self := o.oauth.ClientMetadata() 263 - 264 - oauthClient, err := client.NewClient( 265 - self.ClientID, 266 - o.config.OAuth.Jwks, 267 - self.RedirectURIs[0], 268 - ) 269 - 270 - if err != nil { 271 - log.Println("failed to create oauth client:", err) 272 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 273 - return 274 - } 275 - 276 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 277 - if err != nil { 278 - log.Println("failed to parse jwk:", err) 279 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 280 - return 281 - } 282 - 283 - tokenResp, err := oauthClient.InitialTokenRequest( 284 - r.Context(), 285 - code, 286 - oauthRequest.AuthserverIss, 287 - oauthRequest.PkceVerifier, 288 - oauthRequest.DpopAuthserverNonce, 289 - jwk, 290 - ) 291 - if err != nil { 292 - log.Println("failed to get token:", err) 293 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 294 - return 295 - } 296 - 297 - if tokenResp.Scope != oauthScope { 298 - log.Println("scope doesn't match:", tokenResp.Scope) 299 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 300 - return 301 - } 302 - 303 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 304 - if err != nil { 305 - log.Println("failed to save session:", err) 306 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 307 - return 308 - } 309 - 310 - log.Println("session saved successfully") 311 - go o.addToDefaultKnot(oauthRequest.Did) 312 - go o.addToDefaultSpindle(oauthRequest.Did) 313 - 314 - if !o.config.Core.Dev { 315 - err = o.posthog.Enqueue(posthog.Capture{ 316 - DistinctId: oauthRequest.Did, 317 - Event: "signin", 318 - }) 319 - if err != nil { 320 - log.Println("failed to enqueue posthog event:", err) 321 - } 322 - } 323 - 324 - returnUrl := oauthRequest.ReturnUrl 325 - if returnUrl == "" { 326 - returnUrl = "/" 327 - } 328 - 329 - http.Redirect(w, r, returnUrl, http.StatusFound) 330 - } 331 - 332 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 333 - err := o.oauth.ClearSession(r, w) 334 - if err != nil { 335 - log.Println("failed to clear session:", err) 336 - http.Redirect(w, r, "/", http.StatusFound) 337 - return 338 - } 339 - 340 - log.Println("session cleared successfully") 341 - o.pages.HxRedirect(w, "/login") 342 - } 343 - 344 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 345 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 346 - if err != nil { 347 - return nil, err 348 - } 349 - pubKey, err := k.PublicKey() 350 - if err != nil { 351 - return nil, err 352 - } 353 - return pubKey, nil 354 - } 355 - 356 - var ( 357 - tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 - icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 359 - 360 - defaultSpindle = "spindle.tangled.sh" 361 - defaultKnot = "knot1.tangled.sh" 362 - ) 363 - 364 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 365 - // use the tangled.sh app password to get an accessJwt 366 - // and create an sh.tangled.spindle.member record with that 367 - spindleMembers, err := db.GetSpindleMembers( 368 - o.db, 369 - db.FilterEq("instance", "spindle.tangled.sh"), 370 - db.FilterEq("subject", did), 371 - ) 372 - if err != nil { 373 - log.Printf("failed to get spindle members for did %s: %v", did, err) 374 - return 375 - } 376 - 377 - if len(spindleMembers) != 0 { 378 - log.Printf("did %s is already a member of the default spindle", did) 379 - return 380 - } 381 - 382 - log.Printf("adding %s to default spindle", did) 383 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 384 - if err != nil { 385 - log.Printf("failed to create session: %s", err) 386 - return 387 - } 388 - 389 - record := tangled.SpindleMember{ 390 - LexiconTypeID: "sh.tangled.spindle.member", 391 - Subject: did, 392 - Instance: defaultSpindle, 393 - CreatedAt: time.Now().Format(time.RFC3339), 394 - } 395 - 396 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 397 - log.Printf("failed to add member to default spindle: %s", err) 398 - return 399 - } 400 - 401 - log.Printf("successfully added %s to default spindle", did) 402 - } 403 - 404 - func (o *OAuthHandler) addToDefaultKnot(did string) { 405 - // use the tangled.sh app password to get an accessJwt 406 - // and create an sh.tangled.spindle.member record with that 407 - 408 - allKnots, err := o.enforcer.GetKnotsForUser(did) 409 - if err != nil { 410 - log.Printf("failed to get knot members for did %s: %v", did, err) 411 - return 412 - } 413 - 414 - if slices.Contains(allKnots, defaultKnot) { 415 - log.Printf("did %s is already a member of the default knot", did) 416 - return 417 - } 418 - 419 - log.Printf("adding %s to default knot", did) 420 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 421 - if err != nil { 422 - log.Printf("failed to create session: %s", err) 423 - return 424 - } 425 - 426 - record := tangled.KnotMember{ 427 - LexiconTypeID: "sh.tangled.knot.member", 428 - Subject: did, 429 - Domain: defaultKnot, 430 - CreatedAt: time.Now().Format(time.RFC3339), 431 - } 432 - 433 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 434 - log.Printf("failed to add member to default knot: %s", err) 435 - return 436 - } 437 - 438 - if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil { 439 - log.Printf("failed to set up enforcer rules: %s", err) 440 - return 441 - } 442 - 443 - log.Printf("successfully added %s to default Knot", did) 444 - } 445 - 446 - // create a session using apppasswords 447 - type session struct { 448 - AccessJwt string `json:"accessJwt"` 449 - PdsEndpoint string 450 - Did string 451 - } 452 - 453 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 454 - if appPassword == "" { 455 - return nil, fmt.Errorf("no app password configured, skipping member addition") 456 - } 457 - 458 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 459 - if err != nil { 460 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 461 - } 462 - 463 - pdsEndpoint := resolved.PDSEndpoint() 464 - if pdsEndpoint == "" { 465 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 466 - } 467 - 468 - sessionPayload := map[string]string{ 469 - "identifier": did, 470 - "password": appPassword, 471 - } 472 - sessionBytes, err := json.Marshal(sessionPayload) 473 - if err != nil { 474 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 475 - } 476 - 477 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 478 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session request: %v", err) 481 - } 482 - sessionReq.Header.Set("Content-Type", "application/json") 483 - 484 - client := &http.Client{Timeout: 30 * time.Second} 485 - sessionResp, err := client.Do(sessionReq) 486 - if err != nil { 487 - return nil, fmt.Errorf("failed to create session: %v", err) 488 - } 489 - defer sessionResp.Body.Close() 490 - 491 - if sessionResp.StatusCode != http.StatusOK { 492 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 493 - } 494 - 495 - var session session 496 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 497 - return nil, fmt.Errorf("failed to decode session response: %v", err) 498 - } 499 - 500 - session.PdsEndpoint = pdsEndpoint 501 - session.Did = did 502 - 503 - return &session, nil 504 - } 505 - 506 - func (s *session) putRecord(record any, collection string) error { 507 - recordBytes, err := json.Marshal(record) 508 - if err != nil { 509 - return fmt.Errorf("failed to marshal knot member record: %w", err) 510 - } 511 - 512 - payload := map[string]any{ 513 - "repo": s.Did, 514 - "collection": collection, 515 - "rkey": tid.TID(), 516 - "record": json.RawMessage(recordBytes), 517 - } 518 - 519 - payloadBytes, err := json.Marshal(payload) 520 - if err != nil { 521 - return fmt.Errorf("failed to marshal request payload: %w", err) 522 - } 523 - 524 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 525 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 526 - if err != nil { 527 - return fmt.Errorf("failed to create HTTP request: %w", err) 528 - } 529 - 530 - req.Header.Set("Content-Type", "application/json") 531 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 532 - 533 - client := &http.Client{Timeout: 30 * time.Second} 534 - resp, err := client.Do(req) 535 - if err != nil { 536 - return fmt.Errorf("failed to add user to default service: %w", err) 537 - } 538 - defer resp.Body.Close() 539 - 540 - if resp.StatusCode != http.StatusOK { 541 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 542 - } 543 - 544 - return nil 545 - }
+65
appview/oauth/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
+107 -202
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 6 "net/http" 7 - "net/url" 8 7 "time" 9 8 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 14 "github.com/gorilla/sessions" 12 - sessioncache "tangled.org/core/appview/cache/session" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 13 16 "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/oauth/client" 15 - xrpc "tangled.org/core/appview/xrpcclient" 16 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 17 ) 19 18 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 25 23 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 31 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 32 50 } 33 51 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 36 57 } 37 58 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 60 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 61 + userSession, err := o.SessStore.Get(r, SessionName) 41 62 if err != nil { 42 63 return err 43 64 } 44 65 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 48 69 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 50 75 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 76 + return nil, fmt.Errorf("error getting user session: %w", err) 52 77 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 65 80 } 66 81 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 74 86 } 75 87 76 - did := userSession.Values[SessionDid].(string) 88 + sessId := userSession.Values[SessionId].(string) 77 89 78 - err = o.sess.DeleteSession(r.Context(), did) 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 91 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 93 } 82 94 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 95 + return clientSess, nil 86 96 } 87 97 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 100 + if err != nil { 101 + return fmt.Errorf("error getting user session: %w", err) 92 102 } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 - if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 100 105 } 101 106 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 103 109 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 111 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 112 - self := o.ClientMetadata() 113 + sessId := userSession.Values[SessionId].(string) 113 114 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 117 120 - if err != nil { 121 - return nil, false, err 122 - } 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 123 121 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 122 + return errors.Join(err1, err2) 123 + } 128 124 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 127 + if err != nil { 128 + return nil, err 129 + } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 140 133 } 141 - 142 - return session, auth, nil 134 + return pubKey, nil 143 135 } 144 136 145 137 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 138 + Did string 139 + Pds string 149 140 } 150 141 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 153 144 154 - if err != nil || clientSession.IsNew { 145 + if err != nil || sess.IsNew { 155 146 return nil 156 147 } 157 148 158 149 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 162 152 } 163 153 } 164 154 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 170 158 } 171 159 172 - return clientSession.Values[SessionDid].(string) 160 + return "" 173 161 } 174 162 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 177 165 if err != nil { 178 166 return nil, fmt.Errorf("error getting session: %w", err) 179 167 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 168 + return session.APIClient(), nil 208 169 } 209 170 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 171 // this is a higher level abstraction on ServerGetServiceAuth 213 172 type ServiceClientOpts struct { 214 173 service string ··· 259 218 return scheme + s.service 260 219 } 261 220 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 222 opts := ServiceClientOpts{} 264 223 for _, o := range os { 265 224 o(&opts) 266 225 } 267 226 268 - authorizedClient, err := o.AuthorizedClient(r) 227 + client, err := o.AuthorizedClient(r) 269 228 if err != nil { 270 229 return nil, err 271 230 } ··· 276 235 opts.exp = sixty 277 236 } 278 237 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 239 if err != nil { 281 240 return nil, err 282 241 } 283 242 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 286 245 AccessJwt: resp.Token, 287 246 }, 288 247 Host: opts.Host(), ··· 291 250 }, 292 251 }, nil 293 252 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+29 -15
appview/pages/funcmap.go
··· 29 29 "split": func(s string) []string { 30 30 return strings.Split(s, "\n") 31 31 }, 32 + "trimPrefix": func(s, prefix string) string { 33 + return strings.TrimPrefix(s, prefix) 34 + }, 35 + "join": func(elems []string, sep string) string { 36 + return strings.Join(elems, sep) 37 + }, 32 38 "contains": func(s string, target string) bool { 33 39 return strings.Contains(s, target) 40 + }, 41 + "mapContains": func(m any, key any) bool { 42 + mapValue := reflect.ValueOf(m) 43 + if mapValue.Kind() != reflect.Map { 44 + return false 45 + } 46 + keyValue := reflect.ValueOf(key) 47 + return mapValue.MapIndex(keyValue).IsValid() 34 48 }, 35 49 "resolve": func(s string) string { 36 50 identity, err := p.resolver.ResolveIdent(context.Background(), s) ··· 127 141 "relTimeFmt": humanize.Time, 128 142 "shortRelTimeFmt": func(t time.Time) string { 129 143 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 130 - {time.Second, "now", time.Second}, 131 - {2 * time.Second, "1s %s", 1}, 132 - {time.Minute, "%ds %s", time.Second}, 133 - {2 * time.Minute, "1min %s", 1}, 134 - {time.Hour, "%dmin %s", time.Minute}, 135 - {2 * time.Hour, "1hr %s", 1}, 136 - {humanize.Day, "%dhrs %s", time.Hour}, 137 - {2 * humanize.Day, "1d %s", 1}, 138 - {20 * humanize.Day, "%dd %s", humanize.Day}, 139 - {8 * humanize.Week, "%dw %s", humanize.Week}, 140 - {humanize.Year, "%dmo %s", humanize.Month}, 141 - {18 * humanize.Month, "1y %s", 1}, 142 - {2 * humanize.Year, "2y %s", 1}, 143 - {humanize.LongTime, "%dy %s", humanize.Year}, 144 - {math.MaxInt64, "a long while %s", 1}, 144 + {D: time.Second, Format: "now", DivBy: time.Second}, 145 + {D: 2 * time.Second, Format: "1s %s", DivBy: 1}, 146 + {D: time.Minute, Format: "%ds %s", DivBy: time.Second}, 147 + {D: 2 * time.Minute, Format: "1min %s", DivBy: 1}, 148 + {D: time.Hour, Format: "%dmin %s", DivBy: time.Minute}, 149 + {D: 2 * time.Hour, Format: "1hr %s", DivBy: 1}, 150 + {D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour}, 151 + {D: 2 * humanize.Day, Format: "1d %s", DivBy: 1}, 152 + {D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day}, 153 + {D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week}, 154 + {D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month}, 155 + {D: 18 * humanize.Month, Format: "1y %s", DivBy: 1}, 156 + {D: 2 * humanize.Year, Format: "2y %s", DivBy: 1}, 157 + {D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year}, 158 + {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, 145 159 }) 146 160 }, 147 161 "longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "html/template" 5 + "tangled.org/core/appview/config" 6 + "tangled.org/core/idresolver" 7 + "testing" 8 + ) 9 + 10 + func TestPages_funcMap(t *testing.T) { 11 + tests := []struct { 12 + name string // description of this test case 13 + // Named input parameters for receiver constructor. 14 + config *config.Config 15 + res *idresolver.Resolver 16 + want template.FuncMap 17 + }{ 18 + // TODO: Add test cases. 19 + } 20 + for _, tt := range tests { 21 + t.Run(tt.name, func(t *testing.T) { 22 + p := NewPages(tt.config, tt.res) 23 + got := p.funcMap() 24 + // TODO: update the condition below to compare got with tt.want. 25 + if true { 26 + t.Errorf("funcMap() = %v, want %v", got, tt.want) 27 + } 28 + }) 29 + } 30 + }
+156
appview/pages/legal/privacy.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+15 -17
appview/pages/markup/format.go
··· 1 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 16 } 15 17 16 - // ReadmeFilenames contains the list of common README filenames to search for, 17 - // in order of preference. Only includes well-supported formats. 18 - var ReadmeFilenames = []string{ 19 - "README.md", "readme.md", 20 - "README", 21 - "readme", 22 - "README.markdown", 23 - "readme.markdown", 24 - "README.txt", 25 - "readme.txt", 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 26 26 } 27 27 28 28 func GetFormat(filename string) Format { 29 - for format, extensions := range FileTypes { 30 - for _, extension := range extensions { 31 - if strings.HasSuffix(filename, extension) { 32 - return format 33 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 34 32 } 35 33 } 36 34 // default format
+230 -115
appview/pages/pages.go
··· 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/commitverify" 21 21 "tangled.org/core/appview/config" 22 - "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/models" 23 23 "tangled.org/core/appview/oauth" 24 24 "tangled.org/core/appview/pages/markup" 25 25 "tangled.org/core/appview/pages/repoinfo" ··· 38 38 "github.com/go-git/go-git/v5/plumbing/object" 39 39 ) 40 40 41 - //go:embed templates/* static 41 + //go:embed templates/* static legal 42 42 var Files embed.FS 43 43 44 44 type Pages struct { ··· 81 81 } 82 82 83 83 return p 84 - } 85 - 86 - func (p *Pages) pathToName(s string) string { 87 - return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 84 } 89 85 90 86 // reverse of pathToName ··· 230 226 return p.executePlain("user/login", w, params) 231 227 } 232 228 233 - func (p *Pages) Signup(w io.Writer) error { 234 - return p.executePlain("user/signup", w, nil) 229 + type SignupParams struct { 230 + CloudflareSiteKey string 231 + } 232 + 233 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 234 + return p.executePlain("user/signup", w, params) 235 235 } 236 236 237 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 246 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 247 filename := "terms.md" 248 248 filePath := filepath.Join("legal", filename) 249 - markdownBytes, err := os.ReadFile(filePath) 249 + 250 + file, err := p.embedFS.Open(filePath) 251 + if err != nil { 252 + return fmt.Errorf("failed to read %s: %w", filename, err) 253 + } 254 + defer file.Close() 255 + 256 + markdownBytes, err := io.ReadAll(file) 250 257 if err != nil { 251 258 return fmt.Errorf("failed to read %s: %w", filename, err) 252 259 } ··· 267 274 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 275 filename := "privacy.md" 269 276 filePath := filepath.Join("legal", filename) 270 - markdownBytes, err := os.ReadFile(filePath) 277 + 278 + file, err := p.embedFS.Open(filePath) 279 + if err != nil { 280 + return fmt.Errorf("failed to read %s: %w", filename, err) 281 + } 282 + defer file.Close() 283 + 284 + markdownBytes, err := io.ReadAll(file) 271 285 if err != nil { 272 286 return fmt.Errorf("failed to read %s: %w", filename, err) 273 287 } ··· 280 294 return p.execute("legal/privacy", w, params) 281 295 } 282 296 297 + type BrandParams struct { 298 + LoggedInUser *oauth.User 299 + } 300 + 301 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 302 + return p.execute("brand/brand", w, params) 303 + } 304 + 283 305 type TimelineParams struct { 284 306 LoggedInUser *oauth.User 285 - Timeline []db.TimelineEvent 286 - Repos []db.Repo 307 + Timeline []models.TimelineEvent 308 + Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 287 310 } 288 311 289 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 290 313 return p.execute("timeline/timeline", w, params) 291 314 } 292 315 316 + type GoodFirstIssuesParams struct { 317 + LoggedInUser *oauth.User 318 + Issues []models.Issue 319 + RepoGroups []*models.RepoGroup 320 + LabelDefs map[string]*models.LabelDefinition 321 + GfiLabel *models.LabelDefinition 322 + Page pagination.Page 323 + } 324 + 325 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 326 + return p.execute("goodfirstissues/index", w, params) 327 + } 328 + 293 329 type UserProfileSettingsParams struct { 294 330 LoggedInUser *oauth.User 295 331 Tabs []map[string]any ··· 300 336 return p.execute("user/settings/profile", w, params) 301 337 } 302 338 339 + type NotificationsParams struct { 340 + LoggedInUser *oauth.User 341 + Notifications []*models.NotificationWithEntity 342 + UnreadCount int 343 + Page pagination.Page 344 + Total int64 345 + } 346 + 347 + func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 348 + return p.execute("notifications/list", w, params) 349 + } 350 + 351 + type NotificationItemParams struct { 352 + Notification *models.Notification 353 + } 354 + 355 + func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 356 + return p.executePlain("notifications/fragments/item", w, params) 357 + } 358 + 359 + type NotificationCountParams struct { 360 + Count int64 361 + } 362 + 363 + func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 364 + return p.executePlain("notifications/fragments/count", w, params) 365 + } 366 + 303 367 type UserKeysSettingsParams struct { 304 368 LoggedInUser *oauth.User 305 - PubKeys []db.PublicKey 369 + PubKeys []models.PublicKey 306 370 Tabs []map[string]any 307 371 Tab string 308 372 } ··· 313 377 314 378 type UserEmailsSettingsParams struct { 315 379 LoggedInUser *oauth.User 316 - Emails []db.Email 380 + Emails []models.Email 317 381 Tabs []map[string]any 318 382 Tab string 319 383 } ··· 322 386 return p.execute("user/settings/emails", w, params) 323 387 } 324 388 389 + type UserNotificationSettingsParams struct { 390 + LoggedInUser *oauth.User 391 + Preferences *models.NotificationPreferences 392 + Tabs []map[string]any 393 + Tab string 394 + } 395 + 396 + func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 397 + return p.execute("user/settings/notifications", w, params) 398 + } 399 + 325 400 type UpgradeBannerParams struct { 326 - Registrations []db.Registration 327 - Spindles []db.Spindle 401 + Registrations []models.Registration 402 + Spindles []models.Spindle 328 403 } 329 404 330 405 func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { ··· 333 408 334 409 type KnotsParams struct { 335 410 LoggedInUser *oauth.User 336 - Registrations []db.Registration 411 + Registrations []models.Registration 337 412 } 338 413 339 414 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 342 417 343 418 type KnotParams struct { 344 419 LoggedInUser *oauth.User 345 - Registration *db.Registration 420 + Registration *models.Registration 346 421 Members []string 347 - Repos map[string][]db.Repo 422 + Repos map[string][]models.Repo 348 423 IsOwner bool 349 424 } 350 425 ··· 353 428 } 354 429 355 430 type KnotListingParams struct { 356 - *db.Registration 431 + *models.Registration 357 432 } 358 433 359 434 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { ··· 362 437 363 438 type SpindlesParams struct { 364 439 LoggedInUser *oauth.User 365 - Spindles []db.Spindle 440 + Spindles []models.Spindle 366 441 } 367 442 368 443 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 370 445 } 371 446 372 447 type SpindleListingParams struct { 373 - db.Spindle 448 + models.Spindle 374 449 } 375 450 376 451 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 379 454 380 455 type SpindleDashboardParams struct { 381 456 LoggedInUser *oauth.User 382 - Spindle db.Spindle 457 + Spindle models.Spindle 383 458 Members []string 384 - Repos map[string][]db.Repo 459 + Repos map[string][]models.Repo 385 460 } 386 461 387 462 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 410 485 type ProfileCard struct { 411 486 UserDid string 412 487 UserHandle string 413 - FollowStatus db.FollowStatus 414 - Punchcard *db.Punchcard 415 - Profile *db.Profile 488 + FollowStatus models.FollowStatus 489 + Punchcard *models.Punchcard 490 + Profile *models.Profile 416 491 Stats ProfileStats 417 492 Active string 418 493 } ··· 438 513 439 514 type ProfileOverviewParams struct { 440 515 LoggedInUser *oauth.User 441 - Repos []db.Repo 442 - CollaboratingRepos []db.Repo 443 - ProfileTimeline *db.ProfileTimeline 516 + Repos []models.Repo 517 + CollaboratingRepos []models.Repo 518 + ProfileTimeline *models.ProfileTimeline 444 519 Card *ProfileCard 445 520 Active string 446 521 } ··· 452 527 453 528 type ProfileReposParams struct { 454 529 LoggedInUser *oauth.User 455 - Repos []db.Repo 530 + Repos []models.Repo 456 531 Card *ProfileCard 457 532 Active string 458 533 } ··· 464 539 465 540 type ProfileStarredParams struct { 466 541 LoggedInUser *oauth.User 467 - Repos []db.Repo 542 + Repos []models.Repo 468 543 Card *ProfileCard 469 544 Active string 470 545 } ··· 476 551 477 552 type ProfileStringsParams struct { 478 553 LoggedInUser *oauth.User 479 - Strings []db.String 554 + Strings []models.String 480 555 Card *ProfileCard 481 556 Active string 482 557 } ··· 488 563 489 564 type FollowCard struct { 490 565 UserDid string 491 - FollowStatus db.FollowStatus 566 + LoggedInUser *oauth.User 567 + FollowStatus models.FollowStatus 492 568 FollowersCount int64 493 569 FollowingCount int64 494 - Profile *db.Profile 570 + Profile *models.Profile 495 571 } 496 572 497 573 type ProfileFollowersParams struct { ··· 520 596 521 597 type FollowFragmentParams struct { 522 598 UserDid string 523 - FollowStatus db.FollowStatus 599 + FollowStatus models.FollowStatus 524 600 } 525 601 526 602 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { ··· 529 605 530 606 type EditBioParams struct { 531 607 LoggedInUser *oauth.User 532 - Profile *db.Profile 608 + Profile *models.Profile 533 609 } 534 610 535 611 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { ··· 538 614 539 615 type EditPinsParams struct { 540 616 LoggedInUser *oauth.User 541 - Profile *db.Profile 617 + Profile *models.Profile 542 618 AllRepos []PinnedRepo 543 619 } 544 620 545 621 type PinnedRepo struct { 546 622 IsPinned bool 547 - db.Repo 623 + models.Repo 548 624 } 549 625 550 626 func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { ··· 554 630 type RepoStarFragmentParams struct { 555 631 IsStarred bool 556 632 RepoAt syntax.ATURI 557 - Stats db.RepoStats 633 + Stats models.RepoStats 558 634 } 559 635 560 636 func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { ··· 587 663 EmailToDidOrHandle map[string]string 588 664 VerifiedCommits commitverify.VerifiedCommits 589 665 Languages []types.RepoLanguageDetails 590 - Pipelines map[string]db.Pipeline 666 + Pipelines map[string]models.Pipeline 591 667 NeedsKnotUpgrade bool 592 668 types.RepoIndexResponse 593 669 } ··· 630 706 Active string 631 707 EmailToDidOrHandle map[string]string 632 708 VerifiedCommits commitverify.VerifiedCommits 633 - Pipelines map[string]db.Pipeline 709 + Pipelines map[string]models.Pipeline 634 710 } 635 711 636 712 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 643 719 RepoInfo repoinfo.RepoInfo 644 720 Active string 645 721 EmailToDidOrHandle map[string]string 646 - Pipeline *db.Pipeline 722 + Pipeline *models.Pipeline 647 723 DiffOpts types.DiffOpts 648 724 649 725 // singular because it's always going to be just one ··· 658 734 } 659 735 660 736 type RepoTreeParams struct { 661 - LoggedInUser *oauth.User 662 - RepoInfo repoinfo.RepoInfo 663 - Active string 664 - BreadCrumbs [][]string 665 - TreePath string 666 - Readme string 667 - ReadmeFileName string 668 - HTMLReadme template.HTML 669 - Raw bool 737 + LoggedInUser *oauth.User 738 + RepoInfo repoinfo.RepoInfo 739 + Active string 740 + BreadCrumbs [][]string 741 + TreePath string 742 + Raw bool 743 + HTMLReadme template.HTML 670 744 types.RepoTreeResponse 671 745 } 672 746 ··· 693 767 694 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 695 769 params.Active = "overview" 770 + 771 + p.rctx.RepoInfo = params.RepoInfo 772 + p.rctx.RepoInfo.Ref = params.Ref 773 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 696 774 697 775 if params.ReadmeFileName != "" { 698 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 699 - 700 776 ext := filepath.Ext(params.ReadmeFileName) 701 777 switch ext { 702 778 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 729 805 RepoInfo repoinfo.RepoInfo 730 806 Active string 731 807 types.RepoTagsResponse 732 - ArtifactMap map[plumbing.Hash][]db.Artifact 733 - DanglingArtifacts []db.Artifact 808 + ArtifactMap map[plumbing.Hash][]models.Artifact 809 + DanglingArtifacts []models.Artifact 734 810 } 735 811 736 812 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { ··· 741 817 type RepoArtifactParams struct { 742 818 LoggedInUser *oauth.User 743 819 RepoInfo repoinfo.RepoInfo 744 - Artifact db.Artifact 820 + Artifact models.Artifact 745 821 } 746 822 747 823 func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { ··· 838 914 } 839 915 840 916 type RepoGeneralSettingsParams struct { 841 - LoggedInUser *oauth.User 842 - RepoInfo repoinfo.RepoInfo 843 - Active string 844 - Tabs []map[string]any 845 - Tab string 846 - Branches []types.Branch 917 + LoggedInUser *oauth.User 918 + RepoInfo repoinfo.RepoInfo 919 + Labels []models.LabelDefinition 920 + DefaultLabels []models.LabelDefinition 921 + SubscribedLabels map[string]struct{} 922 + ShouldSubscribeAll bool 923 + Active string 924 + Tabs []map[string]any 925 + Tab string 926 + Branches []types.Branch 847 927 } 848 928 849 929 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 885 965 LoggedInUser *oauth.User 886 966 RepoInfo repoinfo.RepoInfo 887 967 Active string 888 - Issues []db.Issue 968 + Issues []models.Issue 969 + LabelDefs map[string]*models.LabelDefinition 889 970 Page pagination.Page 890 971 FilteringByOpen bool 972 + SearchQuery string 973 + SortBy string 974 + SortOrder string 891 975 } 892 976 893 977 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 896 980 } 897 981 898 982 type RepoSingleIssueParams struct { 899 - LoggedInUser *oauth.User 900 - RepoInfo repoinfo.RepoInfo 901 - Active string 902 - Issue *db.Issue 903 - CommentList []db.CommentListItem 904 - IssueOwnerHandle string 983 + LoggedInUser *oauth.User 984 + RepoInfo repoinfo.RepoInfo 985 + Active string 986 + Issue *models.Issue 987 + CommentList []models.CommentListItem 988 + LabelDefs map[string]*models.LabelDefinition 905 989 906 - OrderedReactionKinds []db.ReactionKind 907 - Reactions map[db.ReactionKind]int 908 - UserReacted map[db.ReactionKind]bool 990 + OrderedReactionKinds []models.ReactionKind 991 + Reactions map[models.ReactionKind]models.ReactionDisplayData 992 + UserReacted map[models.ReactionKind]bool 909 993 } 910 994 911 995 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 916 1000 type EditIssueParams struct { 917 1001 LoggedInUser *oauth.User 918 1002 RepoInfo repoinfo.RepoInfo 919 - Issue *db.Issue 1003 + Issue *models.Issue 920 1004 Action string 921 1005 } 922 1006 ··· 927 1011 928 1012 type ThreadReactionFragmentParams struct { 929 1013 ThreadAt syntax.ATURI 930 - Kind db.ReactionKind 1014 + Kind models.ReactionKind 931 1015 Count int 1016 + Users []string 932 1017 IsReacted bool 933 1018 } 934 1019 ··· 939 1024 type RepoNewIssueParams struct { 940 1025 LoggedInUser *oauth.User 941 1026 RepoInfo repoinfo.RepoInfo 942 - Issue *db.Issue // existing issue if any -- passed when editing 1027 + Issue *models.Issue // existing issue if any -- passed when editing 943 1028 Active string 944 1029 Action string 945 1030 } ··· 953 1038 type EditIssueCommentParams struct { 954 1039 LoggedInUser *oauth.User 955 1040 RepoInfo repoinfo.RepoInfo 956 - Issue *db.Issue 957 - Comment *db.IssueComment 1041 + Issue *models.Issue 1042 + Comment *models.IssueComment 958 1043 } 959 1044 960 1045 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 964 1049 type ReplyIssueCommentPlaceholderParams struct { 965 1050 LoggedInUser *oauth.User 966 1051 RepoInfo repoinfo.RepoInfo 967 - Issue *db.Issue 968 - Comment *db.IssueComment 1052 + Issue *models.Issue 1053 + Comment *models.IssueComment 969 1054 } 970 1055 971 1056 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 975 1060 type ReplyIssueCommentParams struct { 976 1061 LoggedInUser *oauth.User 977 1062 RepoInfo repoinfo.RepoInfo 978 - Issue *db.Issue 979 - Comment *db.IssueComment 1063 + Issue *models.Issue 1064 + Comment *models.IssueComment 980 1065 } 981 1066 982 1067 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 986 1071 type IssueCommentBodyParams struct { 987 1072 LoggedInUser *oauth.User 988 1073 RepoInfo repoinfo.RepoInfo 989 - Issue *db.Issue 990 - Comment *db.IssueComment 1074 + Issue *models.Issue 1075 + Comment *models.IssueComment 991 1076 } 992 1077 993 1078 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1014 1099 type RepoPullsParams struct { 1015 1100 LoggedInUser *oauth.User 1016 1101 RepoInfo repoinfo.RepoInfo 1017 - Pulls []*db.Pull 1102 + Pulls []*models.Pull 1018 1103 Active string 1019 - FilteringBy db.PullState 1020 - Stacks map[string]db.Stack 1021 - Pipelines map[string]db.Pipeline 1104 + FilteringBy models.PullState 1105 + Stacks map[string]models.Stack 1106 + Pipelines map[string]models.Pipeline 1107 + LabelDefs map[string]*models.LabelDefinition 1108 + SearchQuery string 1109 + SortBy string 1110 + SortOrder string 1022 1111 } 1023 1112 1024 1113 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1048 1137 LoggedInUser *oauth.User 1049 1138 RepoInfo repoinfo.RepoInfo 1050 1139 Active string 1051 - Pull *db.Pull 1052 - Stack db.Stack 1053 - AbandonedPulls []*db.Pull 1140 + Pull *models.Pull 1141 + Stack models.Stack 1142 + AbandonedPulls []*models.Pull 1054 1143 MergeCheck types.MergeCheckResponse 1055 1144 ResubmitCheck ResubmitResult 1056 - Pipelines map[string]db.Pipeline 1145 + Pipelines map[string]models.Pipeline 1057 1146 1058 - OrderedReactionKinds []db.ReactionKind 1059 - Reactions map[db.ReactionKind]int 1060 - UserReacted map[db.ReactionKind]bool 1147 + OrderedReactionKinds []models.ReactionKind 1148 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1149 + UserReacted map[models.ReactionKind]bool 1150 + 1151 + LabelDefs map[string]*models.LabelDefinition 1061 1152 } 1062 1153 1063 1154 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1068 1159 type RepoPullPatchParams struct { 1069 1160 LoggedInUser *oauth.User 1070 1161 RepoInfo repoinfo.RepoInfo 1071 - Pull *db.Pull 1072 - Stack db.Stack 1162 + Pull *models.Pull 1163 + Stack models.Stack 1073 1164 Diff *types.NiceDiff 1074 1165 Round int 1075 - Submission *db.PullSubmission 1076 - OrderedReactionKinds []db.ReactionKind 1166 + Submission *models.PullSubmission 1167 + OrderedReactionKinds []models.ReactionKind 1077 1168 DiffOpts types.DiffOpts 1078 1169 } 1079 1170 ··· 1085 1176 type RepoPullInterdiffParams struct { 1086 1177 LoggedInUser *oauth.User 1087 1178 RepoInfo repoinfo.RepoInfo 1088 - Pull *db.Pull 1179 + Pull *models.Pull 1089 1180 Round int 1090 1181 Interdiff *patchutil.InterdiffResult 1091 - OrderedReactionKinds []db.ReactionKind 1182 + OrderedReactionKinds []models.ReactionKind 1092 1183 DiffOpts types.DiffOpts 1093 1184 } 1094 1185 ··· 1117 1208 1118 1209 type PullCompareForkParams struct { 1119 1210 RepoInfo repoinfo.RepoInfo 1120 - Forks []db.Repo 1211 + Forks []models.Repo 1121 1212 Selected string 1122 1213 } 1123 1214 ··· 1138 1229 type PullResubmitParams struct { 1139 1230 LoggedInUser *oauth.User 1140 1231 RepoInfo repoinfo.RepoInfo 1141 - Pull *db.Pull 1232 + Pull *models.Pull 1142 1233 SubmissionId int 1143 1234 } 1144 1235 ··· 1149 1240 type PullActionsParams struct { 1150 1241 LoggedInUser *oauth.User 1151 1242 RepoInfo repoinfo.RepoInfo 1152 - Pull *db.Pull 1243 + Pull *models.Pull 1153 1244 RoundNumber int 1154 1245 MergeCheck types.MergeCheckResponse 1155 1246 ResubmitCheck ResubmitResult 1156 - Stack db.Stack 1247 + Stack models.Stack 1157 1248 } 1158 1249 1159 1250 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1163 1254 type PullNewCommentParams struct { 1164 1255 LoggedInUser *oauth.User 1165 1256 RepoInfo repoinfo.RepoInfo 1166 - Pull *db.Pull 1257 + Pull *models.Pull 1167 1258 RoundNumber int 1168 1259 } 1169 1260 ··· 1174 1265 type RepoCompareParams struct { 1175 1266 LoggedInUser *oauth.User 1176 1267 RepoInfo repoinfo.RepoInfo 1177 - Forks []db.Repo 1268 + Forks []models.Repo 1178 1269 Branches []types.Branch 1179 1270 Tags []*types.TagReference 1180 1271 Base string ··· 1193 1284 type RepoCompareNewParams struct { 1194 1285 LoggedInUser *oauth.User 1195 1286 RepoInfo repoinfo.RepoInfo 1196 - Forks []db.Repo 1287 + Forks []models.Repo 1197 1288 Branches []types.Branch 1198 1289 Tags []*types.TagReference 1199 1290 Base string ··· 1228 1319 return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 1229 1320 } 1230 1321 1322 + type LabelPanelParams struct { 1323 + LoggedInUser *oauth.User 1324 + RepoInfo repoinfo.RepoInfo 1325 + Defs map[string]*models.LabelDefinition 1326 + Subject string 1327 + State models.LabelState 1328 + } 1329 + 1330 + func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { 1331 + return p.executePlain("repo/fragments/labelPanel", w, params) 1332 + } 1333 + 1334 + type EditLabelPanelParams struct { 1335 + LoggedInUser *oauth.User 1336 + RepoInfo repoinfo.RepoInfo 1337 + Defs map[string]*models.LabelDefinition 1338 + Subject string 1339 + State models.LabelState 1340 + } 1341 + 1342 + func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { 1343 + return p.executePlain("repo/fragments/editLabelPanel", w, params) 1344 + } 1345 + 1231 1346 type PipelinesParams struct { 1232 1347 LoggedInUser *oauth.User 1233 1348 RepoInfo repoinfo.RepoInfo 1234 - Pipelines []db.Pipeline 1349 + Pipelines []models.Pipeline 1235 1350 Active string 1236 1351 } 1237 1352 ··· 1263 1378 type WorkflowParams struct { 1264 1379 LoggedInUser *oauth.User 1265 1380 RepoInfo repoinfo.RepoInfo 1266 - Pipeline db.Pipeline 1381 + Pipeline models.Pipeline 1267 1382 Workflow string 1268 1383 LogUrl string 1269 1384 Active string ··· 1279 1394 Action string 1280 1395 1281 1396 // this is supplied in the case of editing an existing string 1282 - String db.String 1397 + String models.String 1283 1398 } 1284 1399 1285 1400 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1289 1404 type StringsDashboardParams struct { 1290 1405 LoggedInUser *oauth.User 1291 1406 Card ProfileCard 1292 - Strings []db.String 1407 + Strings []models.String 1293 1408 } 1294 1409 1295 1410 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1298 1413 1299 1414 type StringTimelineParams struct { 1300 1415 LoggedInUser *oauth.User 1301 - Strings []db.String 1416 + Strings []models.String 1302 1417 } 1303 1418 1304 1419 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1310 1425 ShowRendered bool 1311 1426 RenderToggle bool 1312 1427 RenderedContents template.HTML 1313 - String db.String 1314 - Stats db.StringStats 1428 + String models.String 1429 + Stats models.StringStats 1315 1430 Owner identity.Identity 1316 1431 } 1317 1432
+4 -3
appview/pages/repoinfo/repoinfo.go
··· 7 7 "strings" 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 11 "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 ··· 52 52 53 53 type RepoInfo struct { 54 54 Name string 55 + Rkey string 55 56 OwnerDid string 56 57 OwnerHandle string 57 58 Description string ··· 59 60 Spindle string 60 61 RepoAt syntax.ATURI 61 62 IsStarred bool 62 - Stats db.RepoStats 63 + Stats models.RepoStats 63 64 Roles RolesInRepo 64 - Source *db.Repo 65 + Source *models.Repo 65 66 SourceHandle string 66 67 Ref string 67 68 DisableFork bool
+224
appview/pages/templates/brand/brand.html
··· 1 + {{ define "title" }}brand{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 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"> 8 + Assets and guidelines for using Tangled's logo and brand elements. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="space-y-16"> 14 + 15 + <!-- Introduction Section --> 16 + <section> 17 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 + Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 + follow the below guidelines when using Dolly and the logotype. 20 + </p> 21 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 + All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 + </p> 24 + </section> 25 + 26 + <!-- Black Logotype Section --> 27 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 28 + <div class="order-2 lg:order-1"> 29 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 30 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 31 + alt="Tangled logo - black version" 32 + class="w-full max-w-sm mx-auto" /> 33 + </div> 34 + </div> 35 + <div class="order-1 lg:order-2"> 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> 38 + <p class="text-gray-700 dark:text-gray-300"> 39 + This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 + backgrounds and designs. 41 + </p> 42 + </div> 43 + </section> 44 + 45 + <!-- White Logotype Section --> 46 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 47 + <div class="order-2 lg:order-1"> 48 + <div class="bg-black p-8 sm:p-16 rounded"> 49 + <img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg" 50 + alt="Tangled logo - white version" 51 + class="w-full max-w-sm mx-auto" /> 52 + </div> 53 + </div> 54 + <div class="order-1 lg:order-2"> 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> 57 + <p class="text-gray-700 dark:text-gray-300"> 58 + This version features white text and elements, ideal for dark backgrounds 59 + and inverted designs. 60 + </p> 61 + </div> 62 + </section> 63 + 64 + <!-- Mark Only Section --> 65 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 66 + <div class="order-2 lg:order-1"> 67 + <div class="grid grid-cols-2 gap-2"> 68 + <!-- Black mark on light background --> 69 + <div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded"> 70 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 71 + alt="Dolly face - black version" 72 + class="w-full max-w-16 mx-auto" /> 73 + </div> 74 + <!-- White mark on dark background --> 75 + <div class="bg-black p-8 sm:p-12 rounded"> 76 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 77 + alt="Dolly face - white version" 78 + class="w-full max-w-16 mx-auto" /> 79 + </div> 80 + </div> 81 + </div> 82 + <div class="order-1 lg:order-2"> 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"> 85 + When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 + </p> 87 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 88 + <strong class="font-semibold">Note</strong>: for situations where the background 89 + is unknown, use the black version for ideal contrast in most environments. 90 + </p> 91 + </div> 92 + </section> 93 + 94 + <!-- Colored Backgrounds Section --> 95 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 96 + <div class="order-2 lg:order-1"> 97 + <div class="grid grid-cols-2 gap-2"> 98 + <!-- Pastel Green background --> 99 + <div class="bg-green-500 p-8 sm:p-12 rounded"> 100 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 101 + alt="Tangled logo on pastel green background" 102 + class="w-full max-w-16 mx-auto" /> 103 + </div> 104 + <!-- Pastel Blue background --> 105 + <div class="bg-blue-500 p-8 sm:p-12 rounded"> 106 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 107 + alt="Tangled logo on pastel blue background" 108 + class="w-full max-w-16 mx-auto" /> 109 + </div> 110 + <!-- Pastel Yellow background --> 111 + <div class="bg-yellow-500 p-8 sm:p-12 rounded"> 112 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 113 + alt="Tangled logo on pastel yellow background" 114 + class="w-full max-w-16 mx-auto" /> 115 + </div> 116 + <!-- Pastel Red background --> 117 + <div class="bg-red-500 p-8 sm:p-12 rounded"> 118 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 119 + alt="Tangled logo on pastel red background" 120 + class="w-full max-w-16 mx-auto" /> 121 + </div> 122 + </div> 123 + </div> 124 + <div class="order-1 lg:order-2"> 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"> 127 + White logo mark on colored backgrounds. 128 + </p> 129 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 130 + The white logo mark provides contrast on colored backgrounds. 131 + Perfect for more fun design contexts. 132 + </p> 133 + </div> 134 + </section> 135 + 136 + <!-- Black Logo on Pastel Backgrounds Section --> 137 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 138 + <div class="order-2 lg:order-1"> 139 + <div class="grid grid-cols-2 gap-2"> 140 + <!-- Pastel Green background --> 141 + <div class="bg-green-200 p-8 sm:p-12 rounded"> 142 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 143 + alt="Tangled logo on pastel green background" 144 + class="w-full max-w-16 mx-auto" /> 145 + </div> 146 + <!-- Pastel Blue background --> 147 + <div class="bg-blue-200 p-8 sm:p-12 rounded"> 148 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 149 + alt="Tangled logo on pastel blue background" 150 + class="w-full max-w-16 mx-auto" /> 151 + </div> 152 + <!-- Pastel Yellow background --> 153 + <div class="bg-yellow-200 p-8 sm:p-12 rounded"> 154 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 155 + alt="Tangled logo on pastel yellow background" 156 + class="w-full max-w-16 mx-auto" /> 157 + </div> 158 + <!-- Pastel Pink background --> 159 + <div class="bg-pink-200 p-8 sm:p-12 rounded"> 160 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 161 + alt="Tangled logo on pastel pink background" 162 + class="w-full max-w-16 mx-auto" /> 163 + </div> 164 + </div> 165 + </div> 166 + <div class="order-1 lg:order-2"> 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"> 169 + Dark logo mark on lighter, pastel backgrounds. 170 + </p> 171 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 172 + The dark logo mark works beautifully on pastel backgrounds, 173 + providing crisp contrast. 174 + </p> 175 + </div> 176 + </section> 177 + 178 + <!-- Recoloring Section --> 179 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 180 + <div class="order-2 lg:order-1"> 181 + <div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded"> 182 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 183 + alt="Recolored Tangled logotype in gray/sand color" 184 + class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" /> 185 + </div> 186 + </div> 187 + <div class="order-1 lg:order-2"> 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"> 190 + Custom coloring of the logotype is permitted. 191 + </p> 192 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 193 + Recoloring the logotype is allowed as long as readability is maintained. 194 + </p> 195 + <p class="text-gray-700 dark:text-gray-300 text-sm"> 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 + </p> 218 + </div> 219 + </section> 220 + 221 + </div> 222 + </main> 223 + </div> 224 + {{ end }}
+4 -11
appview/pages/templates/errors/500.html
··· 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 28 </div>
+167
appview/pages/templates/goodfirstissues/index.html
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+39
appview/pages/templates/labels/fragments/label.html
··· 1 + {{ define "labels/fragments/label" }} 2 + {{ $d := .def }} 3 + {{ $v := .val }} 4 + {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 + {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 + 8 + {{ $lhs := printf "%s" $d.Name }} 9 + {{ $rhs := "" }} 10 + 11 + {{ if not $d.ValueType.IsNull }} 12 + {{ if $d.ValueType.IsDidFormat }} 13 + {{ $v = resolve $v }} 14 + {{ end }} 15 + 16 + {{ if not $withPrefix }} 17 + {{ $lhs = "" }} 18 + {{ else }} 19 + {{ $lhs = printf "%s/" $d.Name }} 20 + {{ end }} 21 + 22 + {{ $rhs = printf "%s" $v }} 23 + {{ end }} 24 + 25 + {{ printf "%s%s" $lhs $rhs }} 26 + </span> 27 + {{ end }} 28 + 29 + 30 + {{ define "labelVal" }} 31 + {{ $d := .def }} 32 + {{ $v := .val }} 33 + 34 + {{ if $d.ValueType.IsDidFormat }} 35 + {{ resolve $v }} 36 + {{ else }} 37 + {{ $v }} 38 + {{ end }} 39 + {{ end }}
+6
appview/pages/templates/labels/fragments/labelDef.html
··· 1 + {{ define "labels/fragments/labelDef" }} 2 + <span class="flex items-center gap-2 font-normal normal-case"> 3 + {{ template "repo/fragments/colorBall" (dict "color" .GetColor) }} 4 + {{ .Name }} 5 + </span> 6 + {{ end }}
+16 -11
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 25 28 {{ block "topbarLayout" . }} 26 - <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 29 + <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 27 30 28 31 {{ if .LoggedInUser }} 29 32 <div id="upgrade-banner" ··· 37 40 {{ end }} 38 41 39 42 {{ block "mainLayout" . }} 40 - <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 41 - {{ block "contentLayout" . }} 42 - <main class="col-span-1 md:col-span-8"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 43 47 {{ block "content" . }}{{ end }} 44 48 </main> 45 - {{ end }} 46 - 47 - {{ block "contentAfterLayout" . }} 48 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 49 53 {{ block "contentAfter" . }}{{ end }} 50 54 </main> 51 - {{ end }} 55 + {{ end }} 56 + </div> 52 57 </div> 53 58 {{ end }} 54 59 55 60 {{ block "footerLayout" . }} 56 - <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 57 62 {{ template "layouts/fragments/footer" . }} 58 63 </footer> 59 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 1 {{ define "layouts/fragments/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 2 + <div class="w-full p-8"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 10 13 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 19 46 </div> 20 47 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 26 51 </div> 52 + </div> 27 53 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 33 64 </div> 34 65 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 38 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 39 93 </div> 40 - </div> 41 94 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 44 98 </div> 45 99 </div> 46 100 </div>
+18 -8
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 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> 7 11 </a> 8 12 </div> 9 13 10 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 11 15 {{ with .LoggedInUser }} 12 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 13 18 {{ block "dropDown" . }} {{ end }} 14 19 {{ else }} 15 20 <a href="/login">login</a> ··· 26 31 {{ define "newButton" }} 27 32 <details class="relative inline-block text-left nav-dropdown"> 28 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 30 35 </summary> 31 36 <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 49 {{ define "dropDown" }} 45 50 <details class="relative inline-block text-left nav-dropdown"> 46 51 <summary 47 - class="cursor-pointer list-none flex items-center" 52 + class="cursor-pointer list-none flex items-center gap-1" 48 53 > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 54 + {{ $user := .Did }} 55 + <img 56 + src="{{ tinyAvatar $user }}" 57 + alt="" 58 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 59 + /> 60 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 51 61 </summary> 52 62 <div 53 63 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+6 -8
appview/pages/templates/layouts/repobase.html
··· 41 41 {{ template "repo/fragments/repoDescription" . }} 42 42 </section> 43 43 44 - <section 45 - class="w-full flex flex-col" 46 - > 44 + <section class="w-full flex flex-col" > 47 45 <nav class="w-full pl-4 overflow-auto"> 48 46 <div class="flex z-60"> 49 47 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 80 78 {{ end }} 81 79 </div> 82 80 </nav> 83 - <section 84 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 85 - > 81 + {{ block "repoContentLayout" . }} 82 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 86 83 {{ block "repoContent" . }}{{ end }} 87 - </section> 88 - {{ block "repoAfter" . }}{{ end }} 84 + </section> 85 + {{ block "repoAfter" . }}{{ end }} 86 + {{ end }} 89 87 </section> 90 88 {{ end }}
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }}privacy policy{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
··· 1 + {{define "notifications/fragments/bell"}} 2 + <div class="relative" 3 + hx-get="/notifications/count" 4 + hx-target="#notification-count" 5 + hx-trigger="load, every 30s"> 6 + <a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group"> 7 + {{ i "bell" "w-5 h-5" }} 8 + <span id="notification-count"></span> 9 + </a> 10 + </div> 11 + {{end}}
+7
appview/pages/templates/notifications/fragments/count.html
··· 1 + {{define "notifications/fragments/count"}} 2 + {{if and .Count (gt .Count 0)}} 3 + <span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"> 4 + {{if gt .Count 99}}99+{{else}}{{.Count}}{{end}} 5 + </span> 6 + {{end}} 7 + {{end}}
+81
appview/pages/templates/notifications/fragments/item.html
··· 1 + {{define "notifications/fragments/item"}} 2 + <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 + <div 4 + class=" 5 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 + {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 + flex gap-2 items-center 8 + "> 9 + {{ template "notificationIcon" . }} 10 + <div class="flex-1 w-full flex flex-col gap-1"> 11 + <span>{{ template "notificationHeader" . }}</span> 12 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 + </div> 14 + 15 + </div> 16 + </a> 17 + {{end}} 18 + 19 + {{ define "notificationIcon" }} 20 + <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 23 + {{ i .Icon "size-3 text-black dark:text-white" }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "notificationHeader" }} 29 + {{ $actor := resolve .ActorDid }} 30 + 31 + <span class="text-black dark:text-white w-fit">{{ $actor }}</span> 32 + {{ if eq .Type "repo_starred" }} 33 + starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span> 34 + {{ else if eq .Type "issue_created" }} 35 + opened an issue 36 + {{ else if eq .Type "issue_commented" }} 37 + commented on an issue 38 + {{ else if eq .Type "issue_closed" }} 39 + closed an issue 40 + {{ else if eq .Type "pull_created" }} 41 + created a pull request 42 + {{ else if eq .Type "pull_commented" }} 43 + commented on a pull request 44 + {{ else if eq .Type "pull_merged" }} 45 + merged a pull request 46 + {{ else if eq .Type "pull_closed" }} 47 + closed a pull request 48 + {{ else if eq .Type "followed" }} 49 + followed you 50 + {{ else }} 51 + {{ end }} 52 + {{ end }} 53 + 54 + {{ define "notificationSummary" }} 55 + {{ if eq .Type "repo_starred" }} 56 + <!-- no summary --> 57 + {{ else if .Issue }} 58 + #{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 59 + {{ else if .Pull }} 60 + #{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 61 + {{ else if eq .Type "followed" }} 62 + <!-- no summary --> 63 + {{ else }} 64 + {{ end }} 65 + {{ end }} 66 + 67 + {{ define "notificationUrl" }} 68 + {{ $url := "" }} 69 + {{ if eq .Type "repo_starred" }} 70 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 71 + {{ else if .Issue }} 72 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 73 + {{ else if .Pull }} 74 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 75 + {{ else if eq .Type "followed" }} 76 + {{$url = printf "/%s" (resolve .ActorDid)}} 77 + {{ else }} 78 + {{ end }} 79 + 80 + {{ $url }} 81 + {{ end }}
+65
appview/pages/templates/notifications/list.html
··· 1 + {{ define "title" }}notifications{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex items-center justify-between"> 6 + <p class="text-xl font-bold dark:text-white">Notifications</p> 7 + <a href="/settings/notifications" class="flex items-center gap-2"> 8 + {{ i "settings" "w-4 h-4" }} 9 + preferences 10 + </a> 11 + </div> 12 + </div> 13 + 14 + {{if .Notifications}} 15 + <div class="flex flex-col gap-2" id="notifications-list"> 16 + {{range .Notifications}} 17 + {{template "notifications/fragments/item" .}} 18 + {{end}} 19 + </div> 20 + 21 + {{else}} 22 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 + <div class="text-center py-12"> 24 + <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 + {{ i "bell-off" "w-16 h-16" }} 26 + </div> 27 + <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 + <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 29 + </div> 30 + </div> 31 + {{end}} 32 + 33 + {{ template "pagination" . }} 34 + {{ end }} 35 + 36 + {{ define "pagination" }} 37 + <div class="flex justify-end mt-4 gap-2"> 38 + {{ if gt .Page.Offset 0 }} 39 + {{ $prev := .Page.Previous }} 40 + <a 41 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 + hx-boost="true" 43 + href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 + > 45 + {{ i "chevron-left" "w-4 h-4" }} 46 + previous 47 + </a> 48 + {{ else }} 49 + <div></div> 50 + {{ end }} 51 + 52 + {{ $next := .Page.Next }} 53 + {{ if lt $next.Offset .Total }} 54 + {{ $next := .Page.Next }} 55 + <a 56 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 + hx-boost="true" 58 + href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 + > 60 + next 61 + {{ i "chevron-right" "w-4 h-4" }} 62 + </a> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+7
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+6
appview/pages/templates/repo/fragments/colorBall.html
··· 1 + {{ define "repo/fragments/colorBall" }} 2 + <div 3 + class="size-2 rounded-full {{ .classes }}" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ .color }} 70%, white), {{ .color }} 30%, color-mix(in srgb, {{ .color }} 85%, black));" 5 + ></div> 6 + {{ end }}
+208
appview/pages/templates/repo/fragments/editLabelPanel.html
··· 1 + {{ define "repo/fragments/editLabelPanel" }} 2 + <form 3 + id="edit-label-panel" 4 + hx-put="/{{ .RepoInfo.FullName }}/labels/perform" 5 + hx-indicator="#spinner" 6 + hx-disabled-elt="#save-btn,#cancel-btn" 7 + hx-swap="none" 8 + class="flex flex-col gap-6" 9 + > 10 + <input type="hidden" name="repo" value="{{ .RepoInfo.RepoAt }}"> 11 + <input type="hidden" name="subject" value="{{ .Subject }}"> 12 + {{ template "editBasicLabels" . }} 13 + {{ template "editKvLabels" . }} 14 + {{ template "editLabelPanelActions" . }} 15 + <div id="add-label-error" class="text-red-500 dark:text-red-400"></div> 16 + </form> 17 + {{ end }} 18 + 19 + {{ define "editBasicLabels" }} 20 + {{ $defs := .Defs }} 21 + {{ $subject := .Subject }} 22 + {{ $state := .State }} 23 + {{ $labelStyle := "flex items-center gap-2 rounded py-1 px-2 border border-gray-200 dark:border-gray-700 text-sm bg-white dark:bg-gray-800 text-black dark:text-white" }} 24 + <div> 25 + {{ template "repo/fragments/labelSectionHeaderText" "Labels" }} 26 + 27 + <div class="flex gap-1 items-center flex-wrap"> 28 + {{ range $k, $d := $defs }} 29 + {{ $isChecked := $state.ContainsLabel $k }} 30 + {{ if $d.ValueType.IsNull }} 31 + {{ $fieldName := $d.AtUri }} 32 + <label class="{{$labelStyle}}"> 33 + <input type="checkbox" id="{{ $fieldName }}" name="{{ $fieldName }}" value="null" {{if $isChecked}}checked{{end}}> 34 + {{ template "labels/fragments/labelDef" $d }} 35 + </label> 36 + {{ end }} 37 + {{ else }} 38 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1"> 39 + No labels defined yet. You can choose default labels or define custom 40 + labels in <a class="underline" href="/{{ $.RepoInfo.FullName }}/settings">settings</a>. 41 + </p> 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "editKvLabels" }} 48 + {{ $defs := .Defs }} 49 + {{ $subject := .Subject }} 50 + {{ $state := .State }} 51 + {{ $labelStyle := "font-normal normal-case flex items-center gap-2 p-0" }} 52 + 53 + {{ range $k, $d := $defs }} 54 + {{ if (not $d.ValueType.IsNull) }} 55 + {{ $fieldName := $d.AtUri }} 56 + {{ $valset := $state.GetValSet $k }} 57 + <div id="label-{{$d.Id}}" class="flex flex-col gap-1"> 58 + {{ template "repo/fragments/labelSectionHeaderText" $d.Name }} 59 + {{ if (and $d.Multiple $d.ValueType.IsEnum) }} 60 + <!-- checkbox --> 61 + {{ range $variant := $d.ValueType.Enum }} 62 + <label class="{{$labelStyle}}"> 63 + <input type="checkbox" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}> 64 + {{ $variant }} 65 + </label> 66 + {{ end }} 67 + {{ else if $d.Multiple }} 68 + <!-- dynamically growing input fields --> 69 + {{ range $v, $s := $valset }} 70 + {{ template "multipleInputField" (dict "def" $d "value" $v "key" $k) }} 71 + {{ else }} 72 + {{ template "multipleInputField" (dict "def" $d "value" "" "key" $k) }} 73 + {{ end }} 74 + {{ template "addFieldButton" $d }} 75 + {{ else if $d.ValueType.IsEnum }} 76 + <!-- radio buttons --> 77 + {{ $isUsed := $state.ContainsLabel $k }} 78 + {{ range $variant := $d.ValueType.Enum }} 79 + <label class="{{$labelStyle}}"> 80 + <input type="radio" name="{{ $fieldName }}" value="{{$variant}}" {{if $state.ContainsLabelAndVal $k $variant}}checked{{end}}> 81 + {{ $variant }} 82 + </label> 83 + {{ end }} 84 + <label class="{{$labelStyle}}"> 85 + <input type="radio" name="{{ $fieldName }}" value="" {{ if not $isUsed }}checked{{ end }}> 86 + None 87 + </label> 88 + {{ else }} 89 + <!-- single input field based on value type --> 90 + {{ range $v, $s := $valset }} 91 + {{ template "valueTypeInput" (dict "def" $d "value" $v "key" $k) }} 92 + {{ else }} 93 + {{ template "valueTypeInput" (dict "def" $d "value" "" "key" $k) }} 94 + {{ end }} 95 + {{ end }} 96 + </div> 97 + {{ end }} 98 + {{ end }} 99 + {{ end }} 100 + 101 + {{ define "multipleInputField" }} 102 + <div class="flex gap-1 items-stretch"> 103 + {{ template "valueTypeInput" . }} 104 + {{ template "removeFieldButton" }} 105 + </div> 106 + {{ end }} 107 + 108 + {{ define "addFieldButton" }} 109 + <div style="display:none" id="tpl-{{ .Id }}"> 110 + {{ template "multipleInputField" (dict "def" . "value" "" "key" .AtUri.String) }} 111 + </div> 112 + <button type="button" onClick="this.insertAdjacentHTML('beforebegin', document.getElementById('tpl-{{ .Id }}').innerHTML)" class="w-full btn flex items-center gap-2"> 113 + {{ i "plus" "size-4" }} add 114 + </button> 115 + {{ end }} 116 + 117 + {{ define "removeFieldButton" }} 118 + <button type="button" onClick="this.parentElement.remove()" class="btn flex items-center gap-2 text-red-400 dark:text-red-500"> 119 + {{ i "trash-2" "size-4" }} 120 + </button> 121 + {{ end }} 122 + 123 + {{ define "valueTypeInput" }} 124 + {{ $def := .def }} 125 + {{ $valueType := $def.ValueType }} 126 + {{ $value := .value }} 127 + {{ $key := .key }} 128 + 129 + {{ if $valueType.IsBool }} 130 + {{ template "boolTypeInput" $ }} 131 + {{ else if $valueType.IsInt }} 132 + {{ template "intTypeInput" $ }} 133 + {{ else if $valueType.IsString }} 134 + {{ template "stringTypeInput" $ }} 135 + {{ else if $valueType.IsNull }} 136 + {{ template "nullTypeInput" $ }} 137 + {{ end }} 138 + {{ end }} 139 + 140 + {{ define "boolTypeInput" }} 141 + {{ $def := .def }} 142 + {{ $fieldName := $def.AtUri }} 143 + {{ $value := .value }} 144 + {{ $labelStyle = "font-normal normal-case flex items-center gap-2" }} 145 + <div class="flex flex-col gap-1"> 146 + <label class="{{$labelStyle}}"> 147 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 148 + None 149 + </label> 150 + <label class="{{$labelStyle}}"> 151 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 152 + None 153 + </label> 154 + <label class="{{$labelStyle}}"> 155 + <input type="radio" name="{{ $fieldName }}" value="true" {{ if not $value }}checked{{ end }}> 156 + None 157 + </label> 158 + </div> 159 + {{ end }} 160 + 161 + {{ define "intTypeInput" }} 162 + {{ $def := .def }} 163 + {{ $fieldName := $def.AtUri }} 164 + {{ $value := .value }} 165 + <input class="p-1 w-full" type="number" name="{{$fieldName}}" value="{{$value}}"> 166 + {{ end }} 167 + 168 + {{ define "stringTypeInput" }} 169 + {{ $def := .def }} 170 + {{ $fieldName := $def.AtUri }} 171 + {{ $valueType := $def.ValueType }} 172 + {{ $value := .value }} 173 + {{ if $valueType.IsDidFormat }} 174 + {{ $value = trimPrefix (resolve .value) "@" }} 175 + {{ end }} 176 + <input class="p-1 w-full" type="text" name="{{$fieldName}}" value="{{$value}}"> 177 + {{ end }} 178 + 179 + {{ define "nullTypeInput" }} 180 + {{ $def := .def }} 181 + {{ $fieldName := $def.AtUri }} 182 + <input class="p-1" type="hidden" name="{{$fieldName}}" value="null"> 183 + {{ end }} 184 + 185 + {{ define "editLabelPanelActions" }} 186 + <div class="flex gap-2 pt-2"> 187 + <button 188 + id="cancel-btn" 189 + type="button" 190 + hx-get="/{{ .RepoInfo.FullName }}/label" 191 + hx-vals='{"subject": "{{.Subject}}"}' 192 + hx-swap="outerHTML" 193 + hx-target="#edit-label-panel" 194 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 group"> 195 + {{ i "x" "size-4" }} cancel 196 + </button> 197 + 198 + <button 199 + id="save-btn" 200 + type="submit" 201 + class="btn w-1/2 flex items-center"> 202 + <span class="inline-flex gap-2 items-center">{{ i "check" "size-4" }} save</span> 203 + <span id="spinner" class="group"> 204 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 205 + </span> 206 + </button> 207 + </div> 208 + {{ end }}
+43
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 + {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 + {{ template "basicLabels" . }} 4 + {{ template "kvLabels" . }} 5 + </div> 6 + {{ end }} 7 + 8 + {{ define "basicLabels" }} 9 + <div> 10 + {{ template "repo/fragments/labelSectionHeader" (dict "Name" "Labels" "RepoInfo" .RepoInfo "Subject" .Subject) }} 11 + 12 + {{ $hasLabel := false }} 13 + <div class="flex gap-1 items-center flex-wrap"> 14 + {{ range $k, $d := .Defs }} 15 + {{ if (and $d.ValueType.IsNull ($.State.ContainsLabel $k)) }} 16 + {{ $hasLabel = true }} 17 + {{ template "labels/fragments/label" (dict "def" $d "val" "") }} 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ if not $hasLabel }} 22 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "kvLabels" }} 29 + {{ range $k, $d := .Defs }} 30 + {{ if (not $d.ValueType.IsNull) }} 31 + <div id="label-{{$d.Id}}"> 32 + {{ template "repo/fragments/labelSectionHeader" (dict "Name" $d.Name "RepoInfo" $.RepoInfo "Subject" $.Subject) }} 33 + <div class="flex gap-1 items-center flex-wrap"> 34 + {{ range $v, $s := $.State.GetValSet $d.AtUri.String }} 35 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" false) }} 36 + {{ else }} 37 + <p class="text-gray-500 dark:text-gray-400 text-sm py-1">None yet.</p> 38 + {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }}
+16
appview/pages/templates/repo/fragments/labelSectionHeader.html
··· 1 + {{ define "repo/fragments/labelSectionHeader" }} 2 + 3 + <div class="flex justify-between items-center gap-2"> 4 + {{ template "repo/fragments/labelSectionHeaderText" .Name }} 5 + {{ if (or .RepoInfo.Roles.IsOwner .RepoInfo.Roles.IsCollaborator) }} 6 + <a 7 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 8 + hx-get="/{{ .RepoInfo.FullName }}/label/edit" 9 + hx-vals='{"subject": "{{.Subject}}"}' 10 + hx-swap="outerHTML" 11 + hx-target="#label-panel"> 12 + {{ i "pencil" "size-3" }} 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+3
appview/pages/templates/repo/fragments/labelSectionHeaderText.html
··· 1 + {{ define "repo/fragments/labelSectionHeaderText" }} 2 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400 capitalize">{{ . }}</span> 3 + {{ end }}
-6
appview/pages/templates/repo/fragments/languageBall.html
··· 1 - {{ define "repo/fragments/languageBall" }} 2 - <div 3 - class="size-2 rounded-full" 4 - style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));" 5 - ></div> 6 - {{ end }}
+26
appview/pages/templates/repo/fragments/participants.html
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-2 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 1 {{ define "repo/fragments/readme" }} 2 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 7 </div> 8 8 {{- end -}} 9 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+185
appview/pages/templates/repo/fragments/searchBar.html
··· 1 + {{ define "repo/fragments/searchBar" }} 2 + <div class="flex gap-2 items-center w-full"> 3 + <form class="flex-grow flex gap-2" method="get" action=""> 4 + <div class="flex-grow flex items-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"> 5 + <input 6 + type="text" 7 + name="q" 8 + value="{{ .SearchQuery }}" 9 + placeholder="Search {{ .Placeholder }}... (e.g., 'has:enhancement fix bug')" 10 + class="flex-grow px-4 py-2 bg-transparent dark:text-white focus:outline-none" 11 + /> 12 + <button type="submit" class="px-3 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"> 13 + {{ i "search" "w-5 h-5" }} 14 + </button> 15 + </div> 16 + 17 + <!-- Keep state filter in search --> 18 + {{ if .State }} 19 + <input type="hidden" name="state" value="{{ .State }}" /> 20 + {{ end }} 21 + 22 + <!-- Sort options --> 23 + {{ $sortBy := .SortBy }} 24 + {{ $sortOrder := .SortOrder }} 25 + {{ $defaultSortBy := "created" }} 26 + {{ $defaultSortOrder := "desc" }} 27 + {{ if not $sortBy }} 28 + {{ $sortBy = $defaultSortBy }} 29 + {{ end }} 30 + {{ if not $sortOrder }} 31 + {{ $sortOrder = $defaultSortOrder }} 32 + {{ end }} 33 + <input type="hidden" name="sort_by" value="{{ $sortBy }}" id="sortByInput" /> 34 + <input type="hidden" name="sort_order" value="{{ $sortOrder }}" id="sortOrderInput" /> 35 + 36 + <details class="relative dropdown-menu" id="sortDropdown"> 37 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 38 + {{ i "arrow-down-up" "w-4 h-4" }} 39 + <span> 40 + {{ if .SortBy }} 41 + {{ if eq $sortBy "created" }}Created{{ else if eq $sortBy "comments" }}Comments{{ else if eq $sortBy "reactions" }}Reactions{{ end }} 42 + {{ else }} 43 + Sort 44 + {{ end }} 45 + </span> 46 + {{ i "chevron-down" "w-4 h-4" }} 47 + </summary> 48 + <div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10"> 49 + <div class="p-3"> 50 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Sort by</div> 51 + <div class="space-y-1 mb-3"> 52 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="created"> 53 + {{ if eq $sortBy "created" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 54 + <span class="text-sm dark:text-gray-200">Created</span> 55 + </div> 56 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="comments"> 57 + {{ if eq $sortBy "comments" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 58 + <span class="text-sm dark:text-gray-200">Comments</span> 59 + </div> 60 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="reactions"> 61 + {{ if eq $sortBy "reactions" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 62 + <span class="text-sm dark:text-gray-200">Reactions</span> 63 + </div> 64 + </div> 65 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300 pt-2 border-t border-gray-200 dark:border-gray-600">Order</div> 66 + <div class="space-y-1"> 67 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="desc"> 68 + {{ if eq $sortOrder "desc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 69 + <span class="text-sm dark:text-gray-200">Descending</span> 70 + </div> 71 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="asc"> 72 + {{ if eq $sortOrder "asc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 73 + <span class="text-sm dark:text-gray-200">Ascending</span> 74 + </div> 75 + </div> 76 + </div> 77 + </div> 78 + </details> 79 + 80 + <!-- Label filter dropdown --> 81 + <details class="relative dropdown-menu" id="labelDropdown"> 82 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 83 + {{ i "tag" "w-4 h-4" }} 84 + <span>label</span> 85 + {{ i "chevron-down" "w-4 h-4" }} 86 + </summary> 87 + <div class="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10 max-h-96 overflow-y-auto"> 88 + <div class="p-3"> 89 + <div class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Filter by label</div> 90 + <div class="space-y-2"> 91 + {{ range $uri, $def := .LabelDefs }} 92 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded label-option" data-label-name="{{ $def.Name }}"> 93 + <span class="label-checkbox-icon w-4 h-4"></span> 94 + <span class="flex-grow text-sm dark:text-gray-200"> 95 + {{ template "labels/fragments/label" (dict "def" $def "val" "" "withPrefix" false) }} 96 + </span> 97 + </div> 98 + {{ end }} 99 + </div> 100 + </div> 101 + </div> 102 + </details> 103 + </form> 104 + </div> 105 + 106 + <script> 107 + (function() { 108 + // Handle label filter changes 109 + const labelOptions = document.querySelectorAll('.label-option'); 110 + const searchInput = document.querySelector('input[name="q"]'); 111 + 112 + // Initialize checkmarks based on current query 113 + const currentQuery = searchInput.value; 114 + labelOptions.forEach(option => { 115 + const labelName = option.getAttribute('data-label-name'); 116 + const hasFilter = 'has:' + labelName; 117 + const iconSpan = option.querySelector('.label-checkbox-icon'); 118 + 119 + if (currentQuery.includes(hasFilter)) { 120 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 121 + } 122 + }); 123 + 124 + labelOptions.forEach(option => { 125 + option.addEventListener('click', function() { 126 + const labelName = this.getAttribute('data-label-name'); 127 + let currentQuery = searchInput.value; 128 + const hasFilter = 'has:' + labelName; 129 + const iconSpan = this.querySelector('.label-checkbox-icon'); 130 + const isChecked = currentQuery.includes(hasFilter); 131 + 132 + if (isChecked) { 133 + // Remove has: filter 134 + currentQuery = currentQuery.replace(hasFilter, '').replace(/\s+/g, ' '); 135 + searchInput.value = currentQuery.trim(); 136 + iconSpan.innerHTML = ''; 137 + } else { 138 + // Add has: filter if not already present 139 + currentQuery = currentQuery.trim() + ' ' + hasFilter; 140 + searchInput.value = currentQuery.trim(); 141 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 142 + } 143 + 144 + form.submit(); 145 + }); 146 + }); 147 + 148 + // Handle sort option changes 149 + const sortByOptions = document.querySelectorAll('.sort-by-option'); 150 + const sortOrderOptions = document.querySelectorAll('.sort-order-option'); 151 + const sortByInput = document.getElementById('sortByInput'); 152 + const sortOrderInput = document.getElementById('sortOrderInput'); 153 + const form = searchInput.closest('form'); 154 + 155 + sortByOptions.forEach(option => { 156 + option.addEventListener('click', function() { 157 + sortByInput.value = this.getAttribute('data-value'); 158 + form.submit(); 159 + }); 160 + }); 161 + 162 + sortOrderOptions.forEach(option => { 163 + option.addEventListener('click', function() { 164 + sortOrderInput.value = this.getAttribute('data-value'); 165 + form.submit(); 166 + }); 167 + }); 168 + 169 + // Make dropdowns mutually exclusive - close others when one opens 170 + const dropdowns = document.querySelectorAll('.dropdown-menu'); 171 + dropdowns.forEach(dropdown => { 172 + dropdown.addEventListener('toggle', function(e) { 173 + if (this.open) { 174 + // Close all other dropdowns 175 + dropdowns.forEach(other => { 176 + if (other !== this && other.open) { 177 + other.open = false; 178 + } 179 + }); 180 + } 181 + }); 182 + }); 183 + })(); 184 + </script> 185 + {{ end }}
+1 -1
appview/pages/templates/repo/index.html
··· 49 49 <div 50 50 class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 51 51 > 52 - {{ template "repo/fragments/languageBall" $value.Name }} 52 + {{ template "repo/fragments/colorBall" (dict "color" (langColor $value.Name)) }} 53 53 <div>{{ or $value.Name "Other" }} 54 54 <span class="text-gray-500 dark:text-gray-400"> 55 55 {{ if lt $value.Percentage 0.05 }}
+4 -4
appview/pages/templates/repo/issues/fragments/commentList.html
··· 3 3 {{ range $item := .CommentList }} 4 4 {{ template "commentListing" (list $ .) }} 5 5 {{ end }} 6 - <div> 6 + </div> 7 7 {{ end }} 8 8 9 9 {{ define "commentListing" }} ··· 16 16 "Issue" $root.Issue 17 17 "Comment" $comment.Self) }} 18 18 19 - <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 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 border-gray-300 dark:border-gray-700"> 22 + <div class="relative ml-4 border-l-2 border-gray-200 dark:border-gray-700"> 23 23 {{ range $index, $reply := $comment.Replies }} 24 24 <div class="relative "> 25 25 <!-- Horizontal connector --> 26 - <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 26 + <div class="absolute left-0 top-6 w-4 h-1 bg-gray-200 dark:bg-gray-700"></div> 27 27 28 28 <div class="pl-2"> 29 29 {{
+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 }}
+65
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 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"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 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> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ if gt .ReactionCount 0 }} 46 + <span class="before:content-['·']"> 47 + {{ $s := "s" }} 48 + {{ if eq .ReactionCount 1 }} 49 + {{ $s = "" }} 50 + {{ end }} 51 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .ReactionCount }} reaction{{$s}}</a> 52 + </span> 53 + {{ end }} 54 + 55 + {{ $state := .Labels }} 56 + {{ range $k, $d := $.LabelDefs }} 57 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 58 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 59 + {{ end }} 60 + {{ end }} 61 + </div> 62 + </div> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+29 -5
appview/pages/templates/repo/issues/issue.html
··· 8 8 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 9 9 {{ end }} 10 10 11 + {{ define "repoContentLayout" }} 12 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 13 + <div class="col-span-1 md:col-span-8"> 14 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 15 + {{ block "repoContent" . }}{{ end }} 16 + </section> 17 + {{ block "repoAfter" . }}{{ end }} 18 + </div> 19 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 20 + {{ template "repo/fragments/labelPanel" 21 + (dict "RepoInfo" $.RepoInfo 22 + "Defs" $.LabelDefs 23 + "Subject" $.Issue.AtUri 24 + "State" $.Issue.Labels) }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 11 30 {{ define "repoContent" }} 12 31 <section id="issue-{{ .Issue.IssueId }}"> 13 32 {{ template "issueHeader" .Issue }} ··· 15 34 {{ if .Issue.Body }} 16 35 <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 36 {{ end }} 18 - {{ template "issueReactions" . }} 37 + <div class="flex flex-wrap gap-2 items-stretch mt-4"> 38 + {{ template "issueReactions" . }} 39 + </div> 19 40 </section> 20 41 {{ end }} 21 42 ··· 86 107 {{ end }} 87 108 88 109 {{ define "issueReactions" }} 89 - <div class="flex items-center gap-2 mt-2"> 110 + <div class="flex items-center gap-2"> 90 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 92 114 {{ 93 115 template "repo/fragments/reaction" 94 116 (dict 95 117 "Kind" $kind 96 - "Count" (index $.Reactions $kind) 118 + "Count" $reactionData.Count 97 119 "IsReacted" (index $.UserReacted $kind) 98 - "ThreadAt" $.Issue.AtUri) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 99 122 }} 100 123 {{ end }} 101 124 </div> 102 125 {{ end }} 126 + 103 127 104 128 {{ define "repoAfter" }} 105 129 <div class="flex flex-col gap-4 mt-4"> ··· 113 137 }} 114 138 115 139 {{ template "repo/issues/fragments/newComment" . }} 116 - <div> 140 + </div> 117 141 {{ end }}
+32 -48
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 11 + <div class="flex justify-between items-center gap-4 mb-4"> 12 12 <div class="flex gap-4"> 13 13 <a 14 14 href="?state=open" ··· 33 33 <span>new</span> 34 34 </a> 35 35 </div> 36 + 37 + {{ $state := "open" }} 38 + {{ if not .FilteringByOpen }} 39 + {{ $state = "closed" }} 40 + {{ end }} 41 + 42 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 36 43 <div class="error" id="issues"></div> 37 44 {{ end }} 38 45 39 46 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - </p> 83 - </div> 84 - {{ end }} 47 + <div class="mt-2"> 48 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 85 49 </div> 86 50 {{ block "pagination" . }} {{ end }} 87 51 {{ end }} ··· 95 59 96 60 {{ if gt .Page.Offset 0 }} 97 61 {{ $prev := .Page.Previous }} 62 + {{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }} 63 + {{ if .SearchQuery }} 64 + {{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }} 65 + {{ end }} 66 + {{ if .SortBy }} 67 + {{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }} 68 + {{ end }} 69 + {{ if .SortOrder }} 70 + {{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }} 71 + {{ end }} 98 72 <a 99 73 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 100 74 hx-boost="true" 101 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 75 + href = "{{ $prevUrl }}" 102 76 > 103 77 {{ i "chevron-left" "w-4 h-4" }} 104 78 previous ··· 109 83 110 84 {{ if eq (len .Issues) .Page.Limit }} 111 85 {{ $next := .Page.Next }} 86 + {{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }} 87 + {{ if .SearchQuery }} 88 + {{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }} 89 + {{ end }} 90 + {{ if .SortBy }} 91 + {{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }} 92 + {{ end }} 93 + {{ if .SortOrder }} 94 + {{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }} 95 + {{ end }} 112 96 <a 113 97 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 114 98 hx-boost="true" 115 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 99 + href = "{{ $nextUrl }}" 116 100 > 117 101 next 118 102 {{ i "chevron-right" "w-4 h-4" }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 6 13 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 14 + {{ end }} 19 15 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 29 21 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 41 52 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 71 64 </div> 72 - </form> 73 - </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 74 176 {{ end }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 66 <div class="flex items-center gap-2 mt-2"> 67 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 69 70 {{ 70 71 template "repo/fragments/reaction" 71 72 (dict 72 73 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 + "Count" $reactionData.Count 74 75 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 76 78 }} 77 79 {{ end }} 78 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+37 -15
appview/pages/templates/repo/pulls/pull.html
··· 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 - <span id="interdiff-error-{{.RoundNumber}}"></span> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 84 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }} ··· 171 189 {{ if $.LoggedInUser }} 172 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 173 191 {{ else }} 174 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 175 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 176 - <a href="/login" class="underline">login</a> to join the discussion 192 + <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 w-fit"> 193 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 194 + sign up 195 + </a> 196 + <span class="text-gray-500 dark:text-gray-400">or</span> 197 + <a href="/login" class="underline">login</a> 198 + to add to the discussion 177 199 </div> 178 200 {{ end }} 179 201 </div>
+17 -1
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 11 + <div class="flex justify-between items-center mb-4"> 12 12 <div class="flex gap-4"> 13 13 <a 14 14 href="?state=open" ··· 40 40 <span>new</span> 41 41 </a> 42 42 </div> 43 + 44 + {{ $state := "open" }} 45 + {{ if .FilteringBy.IsMerged }} 46 + {{ $state = "merged" }} 47 + {{ else if .FilteringBy.IsClosed }} 48 + {{ $state = "closed" }} 49 + {{ end }} 50 + 51 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 43 52 <div class="error" id="pulls"></div> 44 53 {{ end }} 45 54 ··· 107 116 {{ if and $pipeline $pipeline.Id }} 108 117 <span class="before:content-['·']"></span> 109 118 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 119 + {{ end }} 120 + 121 + {{ $state := .Labels }} 122 + {{ range $k, $d := $.LabelDefs }} 123 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 124 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 125 + {{ end }} 110 126 {{ end }} 111 127 </div> 112 128 </div>
+165
appview/pages/templates/repo/settings/fragments/addLabelDefModal.html
··· 1 + {{ define "repo/settings/fragments/addLabelDefModal" }} 2 + <div class="grid grid-cols-2"> 3 + <input type="radio" name="tab" id="basic-tab" value="basic" class="hidden peer/basic" checked> 4 + <input type="radio" name="tab" id="kv-tab" value="kv" class="hidden peer/kv"> 5 + 6 + <!-- Labels as direct siblings --> 7 + {{ $base := "py-2 text-sm font-normal normal-case block hover:no-underline text-center cursor-pointer bg-gray-100 dark:bg-gray-800 shadow-inner border border-gray-200 dark:border-gray-700" }} 8 + <label for="basic-tab" class="{{$base}} peer-checked/basic:bg-white peer-checked/basic:dark:bg-gray-700 peer-checked/basic:shadow-sm rounded-l"> 9 + Basic Labels 10 + </label> 11 + <label for="kv-tab" class="{{$base}} peer-checked/kv:bg-white peer-checked/kv:dark:bg-gray-700 peer-checked/kv:shadow-sm rounded-r"> 12 + Key-value Labels 13 + </label> 14 + 15 + <!-- Basic Labels Content - direct sibling --> 16 + <div class="mt-4 hidden peer-checked/basic:block col-span-full"> 17 + {{ template "basicLabelDef" . }} 18 + </div> 19 + 20 + <!-- Key-value Labels Content - direct sibling --> 21 + <div class="mt-4 hidden peer-checked/kv:block col-span-full"> 22 + {{ template "kvLabelDef" . }} 23 + </div> 24 + 25 + <div id="add-label-error" class="text-red-500 dark:text-red-400 col-span-full"></div> 26 + </div> 27 + {{ end }} 28 + 29 + {{ define "basicLabelDef" }} 30 + <form 31 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 32 + hx-indicator="#spinner" 33 + hx-swap="none" 34 + hx-on::after-request="if(event.detail.successful) this.reset()" 35 + class="flex flex-col space-y-4"> 36 + 37 + <p class="text-gray-500 dark:text-gray-400">These labels can have a name and a color.</p> 38 + 39 + {{ template "nameInput" . }} 40 + {{ template "scopeInput" . }} 41 + {{ template "colorInput" . }} 42 + 43 + <div class="flex gap-2 pt-2"> 44 + {{ template "cancelButton" . }} 45 + {{ template "submitButton" . }} 46 + </div> 47 + </form> 48 + {{ end }} 49 + 50 + {{ define "kvLabelDef" }} 51 + <form 52 + hx-put="/{{ $.RepoInfo.FullName }}/settings/label" 53 + hx-indicator="#spinner" 54 + hx-swap="none" 55 + hx-on::after-request="if(event.detail.successful) this.reset()" 56 + class="flex flex-col space-y-4"> 57 + 58 + <p class="text-gray-500 dark:text-gray-400"> 59 + These labels are more detailed, they can have a key and an associated 60 + value. You may define additional constraints on label values. 61 + </p> 62 + 63 + {{ template "nameInput" . }} 64 + {{ template "valueInput" . }} 65 + {{ template "multipleInput" . }} 66 + {{ template "scopeInput" . }} 67 + {{ template "colorInput" . }} 68 + 69 + <div class="flex gap-2 pt-2"> 70 + {{ template "cancelButton" . }} 71 + {{ template "submitButton" . }} 72 + </div> 73 + </form> 74 + {{ end }} 75 + 76 + {{ define "nameInput" }} 77 + <div class="w-full"> 78 + <label for="name">Name</label> 79 + <input class="w-full" type="text" id="label-name" name="name" required placeholder="improvement"/> 80 + </div> 81 + {{ end }} 82 + 83 + {{ define "colorInput" }} 84 + <div class="w-full"> 85 + <label for="color">Color</label> 86 + <div class="grid grid-cols-4 grid-rows-2 place-items-center"> 87 + {{ $colors := list "#ef4444" "#3b82f6" "#10b981" "#f59e0b" "#8b5cf6" "#ec4899" "#06b6d4" "#64748b" }} 88 + {{ range $i, $color := $colors }} 89 + <label class="relative"> 90 + <input type="radio" name="color" value="{{ $color }}" class="sr-only peer" {{ if eq $i 0 }} checked {{ end }}> 91 + {{ template "repo/fragments/colorBall" (dict "color" $color "classes" "size-4 peer-checked:size-8 transition-all") }} 92 + </label> 93 + {{ end }} 94 + </div> 95 + </div> 96 + {{ end }} 97 + 98 + {{ define "scopeInput" }} 99 + <div class="w-full"> 100 + <label>Scope</label> 101 + <label class="font-normal normal-case flex items-center gap-2 p-0"> 102 + <input type="checkbox" id="issue-scope" name="scope" value="sh.tangled.repo.issue" checked /> 103 + Issues 104 + </label> 105 + <label class="font-normal normal-case flex items-center gap-2 p-0"> 106 + <input type="checkbox" id="pulls-scope" name="scope" value="sh.tangled.repo.pull" checked /> 107 + Pull Requests 108 + </label> 109 + </div> 110 + {{ end }} 111 + 112 + {{ define "valueInput" }} 113 + <div class="w-full"> 114 + <label for="valueType">Value Type</label> 115 + <select id="value-type" name="valueType" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 116 + <option value="string">String</option> 117 + <option value="integer">Integer</option> 118 + </select> 119 + </div> 120 + 121 + <div class="w-full"> 122 + <label for="enumValues">Permitted values</label> 123 + <input type="text" id="enumValues" name="enumValues" placeholder="value1, value2, value3" class="w-full"/> 124 + <p class="text-sm text-gray-400 dark:text-gray-500 mt-1"> 125 + Enter comma-separated list of permitted values, or leave empty to allow any value. 126 + </p> 127 + </div> 128 + 129 + <div class="w-full"> 130 + <label for="valueFormat">String format</label> 131 + <select id="valueFormat" name="valueFormat" class="w-full p-3 rounded border border-gray-300 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-600"> 132 + <option value="any" selected>Any</option> 133 + <option value="did">DID</option> 134 + </select> 135 + </div> 136 + {{ end }} 137 + 138 + {{ define "multipleInput" }} 139 + <div class="w-full flex flex-wrap gap-2"> 140 + <input type="checkbox" id="multiple" name="multiple" value="true" /> 141 + <span>Allow multiple values</span> 142 + </div> 143 + {{ end }} 144 + 145 + {{ define "cancelButton" }} 146 + <button 147 + type="button" 148 + popovertarget="add-labeldef-modal" 149 + popovertargetaction="hide" 150 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 151 + > 152 + {{ i "x" "size-4" }} cancel 153 + </button> 154 + {{ end }} 155 + 156 + {{ define "submitButton" }} 157 + <button type="submit" class="btn-create w-1/2 flex items-center"> 158 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 159 + <span id="spinner" class="group"> 160 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 161 + </span> 162 + </button> 163 + {{ end }} 164 + 165 +
+32
appview/pages/templates/repo/settings/fragments/labelListing.html
··· 1 + {{ define "repo/settings/fragments/labelListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $label := index . 1 }} 4 + <div class="flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 5 + {{ template "labels/fragments/labelDef" $label }} 6 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 7 + {{ if $label.ValueType.IsNull }} 8 + basic 9 + {{ else }} 10 + {{ $label.ValueType.Type }} type 11 + {{ end }} 12 + 13 + {{ if $label.ValueType.IsEnum }} 14 + <span class="before:content-['·'] before:select-none"></span> 15 + {{ join $label.ValueType.Enum ", " }} 16 + {{ end }} 17 + 18 + {{ if $label.ValueType.IsDidFormat }} 19 + <span class="before:content-['·'] before:select-none"></span> 20 + DID format 21 + {{ end }} 22 + 23 + {{ if $label.Multiple }} 24 + <span class="before:content-['·'] before:select-none"></span> 25 + multiple 26 + {{ end }} 27 + 28 + <span class="before:content-['·'] before:select-none"></span> 29 + {{ join $label.Scope ", " }} 30 + </div> 31 + </div> 32 + {{ end }}
+126
appview/pages/templates/repo/settings/general.html
··· 7 7 </div> 8 8 <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 9 {{ template "branchSettings" . }} 10 + {{ template "defaultLabelSettings" . }} 11 + {{ template "customLabelSettings" . }} 10 12 {{ template "deleteRepo" . }} 11 13 <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 14 </div> ··· 42 44 </div> 43 45 {{ end }} 44 46 47 + {{ define "defaultLabelSettings" }} 48 + <div class="flex flex-col gap-2"> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 85 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 86 + {{ range .DefaultLabels }} 87 + <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4"> 88 + {{ template "repo/settings/fragments/labelListing" (list $ .) }} 89 + {{ $action := "subscribe" }} 90 + {{ $icon := "plus" }} 91 + {{ if mapContains $.SubscribedLabels .AtUri.String }} 92 + {{ $action = "unsubscribe" }} 93 + {{ $icon = "minus" }} 94 + {{ end }} 95 + <button 96 + class="btn gap-2 group" 97 + title="{{$action}} from label" 98 + {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }} 99 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 100 + hx-swap="none" 101 + hx-vals='{"label": "{{ .AtUri.String }}"}'> 102 + {{ i $icon "size-4" }} 103 + <span class="hidden md:inline">{{$action}}</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + </div> 107 + {{ else }} 108 + <div class="flex items-center justify-center p-2 text-gray-500"> 109 + no labels added yet 110 + </div> 111 + {{ end }} 112 + </div> 113 + <div id="default-label-operation" class="error"></div> 114 + </div> 115 + {{ end }} 116 + 117 + {{ define "customLabelSettings" }} 118 + <div class="flex flex-col gap-2"> 119 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 120 + <div class="col-span-1 md:col-span-2"> 121 + <h2 class="text-sm pb-2 uppercase font-bold">Custom Labels</h2> 122 + </div> 123 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 124 + <button 125 + title="Add custom label" 126 + class="btn flex items-center gap-2" 127 + popovertarget="add-labeldef-modal" 128 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }} 129 + popovertargetaction="toggle"> 130 + {{ i "plus" "size-4" }} 131 + add label 132 + </button> 133 + <div 134 + id="add-labeldef-modal" 135 + popover 136 + class="bg-white w-full sm:w-[30rem] dark:bg-gray-800 p-6 max-h-dvh overflow-y-auto rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 137 + {{ template "repo/settings/fragments/addLabelDefModal" . }} 138 + </div> 139 + </div> 140 + </div> 141 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 142 + {{ range .Labels }} 143 + <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4"> 144 + {{ template "repo/settings/fragments/labelListing" (list $ .) }} 145 + {{ if $.RepoInfo.Roles.IsOwner }} 146 + <button 147 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 148 + title="Delete label" 149 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/label" 150 + hx-swap="none" 151 + hx-vals='{"label-id": "{{ .Id }}"}' 152 + hx-confirm="Are you sure you want to delete the label `{{ .Name }}`?" 153 + > 154 + {{ i "trash-2" "w-5 h-5" }} 155 + <span class="hidden md:inline">delete</span> 156 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 157 + </button> 158 + {{ end }} 159 + </div> 160 + {{ else }} 161 + <div class="flex items-center justify-center p-2 text-gray-500"> 162 + no labels added yet 163 + </div> 164 + {{ end }} 165 + </div> 166 + <div id="label-operation" class="error"></div> 167 + </div> 168 + {{ end }} 169 + 45 170 {{ define "deleteRepo" }} 46 171 {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 47 172 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> ··· 68 193 </div> 69 194 {{ end }} 70 195 {{ end }} 196 +
+1 -1
appview/pages/templates/repo/settings/pipelines.html
··· 109 109 hx-swap="none" 110 110 class="flex flex-col gap-2" 111 111 > 112 - <p class="uppercase p-0">ADD SECRET</p> 112 + <p class="uppercase p-0 font-bold">ADD SECRET</p> 113 113 <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 114 <input 115 115 type="text"
+1 -1
appview/pages/templates/repo/tree.html
··· 91 91 92 92 {{ define "repoAfter" }} 93 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 94 + {{ template "repo/fragments/readme" . }} 95 95 {{- end -}} 96 96 {{ end }}
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 5 {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 - <p class="">Store and share code snippets with ease.</p> 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 26 {{ end }} 27 27 28 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 29 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 - <div class="font-medium dark:text-white flex gap-2 items-center"> 31 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 32 35 </div> 33 36 {{ with .Description }} 34 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 45 43 46 {{ define "stringCardInfo" }} 44 47 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 - <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 - {{ template "user/fragments/picHandle" $resolved }} 49 - </a> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 50 <span class="select-none [&:before]:content-['·']"></span> 53 51 {{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 82 {{ $event := index . 1 }} 83 83 {{ $follow := $event.Follow }} 84 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 86 87 87 88 {{ $userHandle := resolve $follow.UserDid }} 88 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 95 </div> 95 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4"> 96 - <div class="flex items-center gap-4 flex-1"> 97 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 98 - <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 99 - </div> 100 - 101 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 102 - <a href="/{{ $subjectHandle }}"> 103 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 104 - </a> 105 - {{ with $profile }} 106 - {{ with .Description }} 107 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 108 - {{ end }} 109 - {{ end }} 110 - {{ with $stat }} 111 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 112 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 113 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 114 - <span class="select-none after:content-['·']"></span> 115 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 116 - </div> 117 - {{ end }} 118 - </div> 119 - </div> 120 - 121 - {{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }} 122 - <div class="flex-shrink-0 w-fit ml-auto"> 123 - {{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }} 124 - </div> 125 - {{ end }} 126 - </div> 96 + {{ template "user/fragments/followCard" 97 + (dict 98 + "LoggedInUser" $root.LoggedInUser 99 + "UserDid" $follow.SubjectDid 100 + "Profile" $profile 101 + "FollowStatus" $followStatus 102 + "FollowersCount" $followStats.Followers 103 + "FollowingCount" $followStats.Following) }} 127 104 {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }} 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 + {{ i "user-round-plus" "w-4 h-4" }} follow 17 + {{ else }} 18 + {{ i "user-round-minus" "w-4 h-4" }} unfollow 19 + {{ end }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['·']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 + <a href="/{{ $userIdent }}"> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 + </a> 14 + {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 + {{ end }} 17 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span class="select-none after:content-['·']"></span> 21 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 + </div> 19 23 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 + {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 25 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 24 26 {{ template "user/fragments/follow" . }} 25 27 </div> 26 - {{ end }} 28 + {{ end }} 29 + </div> 27 30 </div> 28 31 </div> 29 - {{ end }} 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+11 -11
appview/pages/templates/user/fragments/repoCard.html
··· 14 14 {{ with $repo }} 15 15 <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 16 16 <div class="font-medium dark:text-white flex items-center justify-between"> 17 - <div class="flex items-center"> 18 - {{ if .Source }} 19 - {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 - {{ else }} 21 - {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 - {{ end }} 23 - 17 + <div class="flex items-center min-w-0 flex-1 mr-2"> 18 + {{ if .Source }} 19 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 + {{ else }} 21 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 + {{ end }} 24 23 {{ $repoOwner := resolve .Did }} 25 24 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 27 26 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 29 28 {{- end -}} 30 29 </div> 31 - 32 30 {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 33 32 {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 34 34 {{ end }} 35 35 </div> 36 36 {{ with .Description }} ··· 50 50 <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 51 51 {{ with .Language }} 52 52 <div class="flex gap-2 items-center text-sm"> 53 - {{ template "repo/fragments/languageBall" . }} 53 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 54 54 <span>{{ . }}</span> 55 55 </div> 56 56 {{ end }}
+2 -1
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> ··· 36 37 placeholder="akshay.tngl.sh" 37 38 /> 38 39 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 40 41 handle to log in. If you're unsure, this is likely 41 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 43 </span>
+1 -1
appview/pages/templates/user/overview.html
··· 73 73 {{ with .Repo.RepoStats }} 74 74 {{ with .Language }} 75 75 <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 - {{ template "repo/fragments/languageBall" . }} 76 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 77 77 <span>{{ . }}</span> 78 78 </div> 79 79 {{end }}
+173
appview/pages/templates/user/settings/notifications.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 146 + <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+2 -1
appview/pipelines/pipelines.go
··· 48 48 ) *Pipelines { 49 49 logger := log.New("pipelines") 50 50 51 - return &Pipelines{oauth: oauth, 51 + return &Pipelines{ 52 + oauth: oauth, 52 53 repoResolver: repoResolver, 53 54 pages: pages, 54 55 idResolver: idResolver,
-164
appview/posthog/notifier.go
··· 1 - package posthog_service 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - "github.com/posthog/posthog-go" 8 - "tangled.org/core/appview/db" 9 - "tangled.org/core/appview/notify" 10 - ) 11 - 12 - type posthogNotifier struct { 13 - client posthog.Client 14 - notify.BaseNotifier 15 - } 16 - 17 - func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 - return &posthogNotifier{ 19 - client, 20 - notify.BaseNotifier{}, 21 - } 22 - } 23 - 24 - var _ notify.Notifier = &posthogNotifier{} 25 - 26 - func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 - err := n.client.Enqueue(posthog.Capture{ 28 - DistinctId: repo.Did, 29 - Event: "new_repo", 30 - Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 - }) 32 - if err != nil { 33 - log.Println("failed to enqueue posthog event:", err) 34 - } 35 - } 36 - 37 - func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 - err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 - Event: "star", 41 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 - }) 43 - if err != nil { 44 - log.Println("failed to enqueue posthog event:", err) 45 - } 46 - } 47 - 48 - func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 - err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 - Event: "unstar", 52 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 - }) 54 - if err != nil { 55 - log.Println("failed to enqueue posthog event:", err) 56 - } 57 - } 58 - 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 - err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.Did, 62 - Event: "new_issue", 63 - Properties: posthog.Properties{ 64 - "repo_at": issue.RepoAt.String(), 65 - "issue_id": issue.IssueId, 66 - }, 67 - }) 68 - if err != nil { 69 - log.Println("failed to enqueue posthog event:", err) 70 - } 71 - } 72 - 73 - func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 - err := n.client.Enqueue(posthog.Capture{ 75 - DistinctId: pull.OwnerDid, 76 - Event: "new_pull", 77 - Properties: posthog.Properties{ 78 - "repo_at": pull.RepoAt, 79 - "pull_id": pull.PullId, 80 - }, 81 - }) 82 - if err != nil { 83 - log.Println("failed to enqueue posthog event:", err) 84 - } 85 - } 86 - 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 - err := n.client.Enqueue(posthog.Capture{ 89 - DistinctId: comment.OwnerDid, 90 - Event: "new_pull_comment", 91 - Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 - }, 95 - }) 96 - if err != nil { 97 - log.Println("failed to enqueue posthog event:", err) 98 - } 99 - } 100 - 101 - func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 - err := n.client.Enqueue(posthog.Capture{ 103 - DistinctId: follow.UserDid, 104 - Event: "follow", 105 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 - }) 107 - if err != nil { 108 - log.Println("failed to enqueue posthog event:", err) 109 - } 110 - } 111 - 112 - func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 - err := n.client.Enqueue(posthog.Capture{ 114 - DistinctId: follow.UserDid, 115 - Event: "unfollow", 116 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 - }) 118 - if err != nil { 119 - log.Println("failed to enqueue posthog event:", err) 120 - } 121 - } 122 - 123 - func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 - err := n.client.Enqueue(posthog.Capture{ 125 - DistinctId: profile.Did, 126 - Event: "edit_profile", 127 - }) 128 - if err != nil { 129 - log.Println("failed to enqueue posthog event:", err) 130 - } 131 - } 132 - 133 - func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 - err := n.client.Enqueue(posthog.Capture{ 135 - DistinctId: did, 136 - Event: "delete_string", 137 - Properties: posthog.Properties{"rkey": rkey}, 138 - }) 139 - if err != nil { 140 - log.Println("failed to enqueue posthog event:", err) 141 - } 142 - } 143 - 144 - func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) { 145 - err := n.client.Enqueue(posthog.Capture{ 146 - DistinctId: string.Did.String(), 147 - Event: "edit_string", 148 - Properties: posthog.Properties{"rkey": string.Rkey}, 149 - }) 150 - if err != nil { 151 - log.Println("failed to enqueue posthog event:", err) 152 - } 153 - } 154 - 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) { 156 - err := n.client.Enqueue(posthog.Capture{ 157 - DistinctId: string.Did.String(), 158 - Event: "create_string", 159 - Properties: posthog.Properties{"rkey": string.Rkey}, 160 - }) 161 - if err != nil { 162 - log.Println("failed to enqueue posthog event:", err) 163 - } 164 - }
+152 -73
appview/pulls/pulls.go
··· 15 15 "tangled.org/core/api/tangled" 16 16 "tangled.org/core/appview/config" 17 17 "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/models" 18 19 "tangled.org/core/appview/notify" 19 20 "tangled.org/core/appview/oauth" 20 21 "tangled.org/core/appview/pages" 21 22 "tangled.org/core/appview/pages/markup" 22 23 "tangled.org/core/appview/reporesolver" 24 + "tangled.org/core/appview/search" 23 25 "tangled.org/core/appview/xrpcclient" 24 26 "tangled.org/core/idresolver" 25 27 "tangled.org/core/patchutil" ··· 75 77 return 76 78 } 77 79 78 - pull, ok := r.Context().Value("pull").(*db.Pull) 80 + pull, ok := r.Context().Value("pull").(*models.Pull) 79 81 if !ok { 80 82 log.Println("failed to get pull") 81 83 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 83 85 } 84 86 85 87 // can be nil if this pull is not stacked 86 - stack, _ := r.Context().Value("stack").(db.Stack) 88 + stack, _ := r.Context().Value("stack").(models.Stack) 87 89 88 90 roundNumberStr := chi.URLParam(r, "round") 89 91 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 123 125 return 124 126 } 125 127 126 - pull, ok := r.Context().Value("pull").(*db.Pull) 128 + pull, ok := r.Context().Value("pull").(*models.Pull) 127 129 if !ok { 128 130 log.Println("failed to get pull") 129 131 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 131 133 } 132 134 133 135 // can be nil if this pull is not stacked 134 - stack, _ := r.Context().Value("stack").(db.Stack) 135 - abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull) 136 + stack, _ := r.Context().Value("stack").(models.Stack) 137 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 136 138 137 139 totalIdents := 1 138 140 for _, submission := range pull.Submissions { ··· 159 161 160 162 repoInfo := f.RepoInfo(user) 161 163 162 - m := make(map[string]db.Pipeline) 164 + m := make(map[string]models.Pipeline) 163 165 164 166 var shas []string 165 167 for _, s := range pull.Submissions { ··· 188 190 m[p.Sha] = p 189 191 } 190 192 191 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 192 194 if err != nil { 193 195 log.Println("failed to get pull reactions") 194 196 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 197 } 196 198 197 - userReactions := map[db.ReactionKind]bool{} 199 + userReactions := map[models.ReactionKind]bool{} 198 200 if user != nil { 199 201 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 202 } 201 203 204 + labelDefs, err := db.GetLabelDefinitions( 205 + s.db, 206 + db.FilterIn("at_uri", f.Repo.Labels), 207 + db.FilterContains("scope", tangled.RepoPullNSID), 208 + ) 209 + if err != nil { 210 + log.Println("failed to fetch labels", err) 211 + s.pages.Error503(w) 212 + return 213 + } 214 + 215 + defs := make(map[string]*models.LabelDefinition) 216 + for _, l := range labelDefs { 217 + defs[l.AtUri().String()] = &l 218 + } 219 + 202 220 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 221 LoggedInUser: user, 204 222 RepoInfo: repoInfo, ··· 209 227 ResubmitCheck: resubmitResult, 210 228 Pipelines: m, 211 229 212 - OrderedReactionKinds: db.OrderedReactionKinds, 213 - Reactions: reactionCountMap, 230 + OrderedReactionKinds: models.OrderedReactionKinds, 231 + Reactions: reactionMap, 214 232 UserReacted: userReactions, 233 + 234 + LabelDefs: defs, 215 235 }) 216 236 } 217 237 218 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 - if pull.State == db.PullMerged { 238 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 239 + if pull.State == models.PullMerged { 220 240 return types.MergeCheckResponse{} 221 241 } 222 242 ··· 282 302 return result 283 303 } 284 304 285 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 - if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 305 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 306 + if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 287 307 return pages.Unknown 288 308 } 289 309 ··· 356 376 diffOpts.Split = true 357 377 } 358 378 359 - pull, ok := r.Context().Value("pull").(*db.Pull) 379 + pull, ok := r.Context().Value("pull").(*models.Pull) 360 380 if !ok { 361 381 log.Println("failed to get pull") 362 382 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 363 383 return 364 384 } 365 385 366 - stack, _ := r.Context().Value("stack").(db.Stack) 386 + stack, _ := r.Context().Value("stack").(models.Stack) 367 387 368 388 roundId := chi.URLParam(r, "round") 369 389 roundIdInt, err := strconv.Atoi(roundId) ··· 403 423 diffOpts.Split = true 404 424 } 405 425 406 - pull, ok := r.Context().Value("pull").(*db.Pull) 426 + pull, ok := r.Context().Value("pull").(*models.Pull) 407 427 if !ok { 408 428 log.Println("failed to get pull") 409 429 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 451 471 } 452 472 453 473 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 454 - pull, ok := r.Context().Value("pull").(*db.Pull) 474 + pull, ok := r.Context().Value("pull").(*models.Pull) 455 475 if !ok { 456 476 log.Println("failed to get pull") 457 477 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 473 493 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 474 494 user := s.oauth.GetUser(r) 475 495 params := r.URL.Query() 496 + searchQuery := params.Get("q") 497 + sortBy := params.Get("sort_by") 498 + sortOrder := params.Get("sort_order") 476 499 477 - state := db.PullOpen 500 + templateSortBy := sortBy 501 + templateSortOrder := sortOrder 502 + 503 + if sortBy == "" { 504 + sortBy = "created" 505 + } 506 + if sortOrder == "" { 507 + sortOrder = "desc" 508 + } 509 + 510 + state := models.PullOpen 478 511 switch params.Get("state") { 479 512 case "closed": 480 - state = db.PullClosed 513 + state = models.PullClosed 481 514 case "merged": 482 - state = db.PullMerged 515 + state = models.PullMerged 483 516 } 484 517 485 518 f, err := s.repoResolver.Resolve(r) ··· 488 521 return 489 522 } 490 523 491 - pulls, err := db.GetPulls( 524 + var pulls []*models.Pull 525 + 526 + query := search.Parse(searchQuery) 527 + 528 + pulls, err = db.SearchPulls( 492 529 s.db, 530 + query.Text, 531 + query.Labels, 532 + sortBy, 533 + sortOrder, 493 534 db.FilterEq("repo_at", f.RepoAt()), 494 535 db.FilterEq("state", state), 495 536 ) 537 + 496 538 if err != nil { 497 539 log.Println("failed to get pulls", err) 498 540 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") ··· 500 542 } 501 543 502 544 for _, p := range pulls { 503 - var pullSourceRepo *db.Repo 545 + var pullSourceRepo *models.Repo 504 546 if p.PullSource != nil { 505 547 if p.PullSource.RepoAt != nil { 506 548 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 515 557 } 516 558 517 559 // we want to group all stacked PRs into just one list 518 - stacks := make(map[string]db.Stack) 560 + stacks := make(map[string]models.Stack) 519 561 var shas []string 520 562 n := 0 521 563 for _, p := range pulls { ··· 551 593 log.Printf("failed to fetch pipeline statuses: %s", err) 552 594 // non-fatal 553 595 } 554 - m := make(map[string]db.Pipeline) 596 + m := make(map[string]models.Pipeline) 555 597 for _, p := range ps { 556 598 m[p.Sha] = p 557 599 } 558 600 601 + labelDefs, err := db.GetLabelDefinitions( 602 + s.db, 603 + db.FilterIn("at_uri", f.Repo.Labels), 604 + db.FilterContains("scope", tangled.RepoPullNSID), 605 + ) 606 + if err != nil { 607 + log.Println("failed to fetch labels", err) 608 + s.pages.Error503(w) 609 + return 610 + } 611 + 612 + defs := make(map[string]*models.LabelDefinition) 613 + for _, l := range labelDefs { 614 + defs[l.AtUri().String()] = &l 615 + } 616 + 559 617 s.pages.RepoPulls(w, pages.RepoPullsParams{ 560 618 LoggedInUser: s.oauth.GetUser(r), 561 619 RepoInfo: f.RepoInfo(user), 562 620 Pulls: pulls, 621 + LabelDefs: defs, 563 622 FilteringBy: state, 564 623 Stacks: stacks, 565 624 Pipelines: m, 625 + SearchQuery: searchQuery, 626 + SortBy: templateSortBy, 627 + SortOrder: templateSortOrder, 566 628 }) 567 629 } 568 630 ··· 574 636 return 575 637 } 576 638 577 - pull, ok := r.Context().Value("pull").(*db.Pull) 639 + pull, ok := r.Context().Value("pull").(*models.Pull) 578 640 if !ok { 579 641 log.Println("failed to get pull") 580 642 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 629 691 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 630 692 return 631 693 } 632 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 694 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 633 695 Collection: tangled.RepoPullCommentNSID, 634 696 Repo: user.Did, 635 697 Rkey: tid.TID(), ··· 647 709 return 648 710 } 649 711 650 - comment := &db.PullComment{ 712 + comment := &models.PullComment{ 651 713 OwnerDid: user.Did, 652 714 RepoAt: f.RepoAt().String(), 653 715 PullId: pull.PullId, ··· 890 952 return 891 953 } 892 954 893 - pullSource := &db.PullSource{ 955 + pullSource := &models.PullSource{ 894 956 Branch: sourceBranch, 895 957 } 896 958 recordPullSource := &tangled.RepoPull_Source{ ··· 1000 1062 forkAtUri := fork.RepoAt() 1001 1063 forkAtUriStr := forkAtUri.String() 1002 1064 1003 - pullSource := &db.PullSource{ 1065 + pullSource := &models.PullSource{ 1004 1066 Branch: sourceBranch, 1005 1067 RepoAt: &forkAtUri, 1006 1068 } ··· 1021 1083 title, body, targetBranch string, 1022 1084 patch string, 1023 1085 sourceRev string, 1024 - pullSource *db.PullSource, 1086 + pullSource *models.PullSource, 1025 1087 recordPullSource *tangled.RepoPull_Source, 1026 1088 isStacked bool, 1027 1089 ) { ··· 1057 1119 1058 1120 // We've already checked earlier if it's diff-based and title is empty, 1059 1121 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1060 - if title == "" { 1122 + if title == "" || body == "" { 1061 1123 formatPatches, err := patchutil.ExtractPatches(patch) 1062 1124 if err != nil { 1063 1125 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1068 1130 return 1069 1131 } 1070 1132 1071 - title = formatPatches[0].Title 1072 - body = formatPatches[0].Body 1133 + if title == "" { 1134 + title = formatPatches[0].Title 1135 + } 1136 + if body == "" { 1137 + body = formatPatches[0].Body 1138 + } 1073 1139 } 1074 1140 1075 1141 rkey := tid.TID() 1076 - initialSubmission := db.PullSubmission{ 1142 + initialSubmission := models.PullSubmission{ 1077 1143 Patch: patch, 1078 1144 SourceRev: sourceRev, 1079 1145 } 1080 - pull := &db.Pull{ 1146 + pull := &models.Pull{ 1081 1147 Title: title, 1082 1148 Body: body, 1083 1149 TargetBranch: targetBranch, 1084 1150 OwnerDid: user.Did, 1085 1151 RepoAt: f.RepoAt(), 1086 1152 Rkey: rkey, 1087 - Submissions: []*db.PullSubmission{ 1153 + Submissions: []*models.PullSubmission{ 1088 1154 &initialSubmission, 1089 1155 }, 1090 1156 PullSource: pullSource, ··· 1102 1168 return 1103 1169 } 1104 1170 1105 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1171 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1106 1172 Collection: tangled.RepoPullNSID, 1107 1173 Repo: user.Did, 1108 1174 Rkey: rkey, ··· 1143 1209 targetBranch string, 1144 1210 patch string, 1145 1211 sourceRev string, 1146 - pullSource *db.PullSource, 1212 + pullSource *models.PullSource, 1147 1213 ) { 1148 1214 // run some necessary checks for stacked-prs first 1149 1215 ··· 1199 1265 } 1200 1266 writes = append(writes, &write) 1201 1267 } 1202 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1268 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1203 1269 Repo: user.Did, 1204 1270 Writes: writes, 1205 1271 }) ··· 1364 1430 forkOwnerDid := repoString[0] 1365 1431 forkName := repoString[1] 1366 1432 // fork repo 1367 - repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1433 + repo, err := db.GetRepo( 1434 + s.db, 1435 + db.FilterEq("did", forkOwnerDid), 1436 + db.FilterEq("name", forkName), 1437 + ) 1368 1438 if err != nil { 1369 - log.Println("failed to get repo", user.Did, forkVal) 1439 + log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1370 1440 return 1371 1441 } 1372 1442 ··· 1447 1517 return 1448 1518 } 1449 1519 1450 - pull, ok := r.Context().Value("pull").(*db.Pull) 1520 + pull, ok := r.Context().Value("pull").(*models.Pull) 1451 1521 if !ok { 1452 1522 log.Println("failed to get pull") 1453 1523 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1478 1548 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1479 1549 user := s.oauth.GetUser(r) 1480 1550 1481 - pull, ok := r.Context().Value("pull").(*db.Pull) 1551 + pull, ok := r.Context().Value("pull").(*models.Pull) 1482 1552 if !ok { 1483 1553 log.Println("failed to get pull") 1484 1554 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1505 1575 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1506 1576 user := s.oauth.GetUser(r) 1507 1577 1508 - pull, ok := r.Context().Value("pull").(*db.Pull) 1578 + pull, ok := r.Context().Value("pull").(*models.Pull) 1509 1579 if !ok { 1510 1580 log.Println("failed to get pull") 1511 1581 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1568 1638 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1569 1639 user := s.oauth.GetUser(r) 1570 1640 1571 - pull, ok := r.Context().Value("pull").(*db.Pull) 1641 + pull, ok := r.Context().Value("pull").(*models.Pull) 1572 1642 if !ok { 1573 1643 log.Println("failed to get pull") 1574 1644 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1661 1731 } 1662 1732 1663 1733 // validate a resubmission against a pull request 1664 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1734 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1665 1735 if patch == "" { 1666 1736 return fmt.Errorf("Patch is empty.") 1667 1737 } ··· 1682 1752 r *http.Request, 1683 1753 f *reporesolver.ResolvedRepo, 1684 1754 user *oauth.User, 1685 - pull *db.Pull, 1755 + pull *models.Pull, 1686 1756 patch string, 1687 1757 sourceRev string, 1688 1758 ) { ··· 1726 1796 return 1727 1797 } 1728 1798 1729 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1799 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1730 1800 if err != nil { 1731 1801 // failed to get record 1732 1802 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1749 1819 } 1750 1820 } 1751 1821 1752 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1822 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1753 1823 Collection: tangled.RepoPullNSID, 1754 1824 Repo: user.Did, 1755 1825 Rkey: pull.Rkey, ··· 1786 1856 r *http.Request, 1787 1857 f *reporesolver.ResolvedRepo, 1788 1858 user *oauth.User, 1789 - pull *db.Pull, 1859 + pull *models.Pull, 1790 1860 patch string, 1791 1861 stackId string, 1792 1862 ) { 1793 1863 targetBranch := pull.TargetBranch 1794 1864 1795 - origStack, _ := r.Context().Value("stack").(db.Stack) 1865 + origStack, _ := r.Context().Value("stack").(models.Stack) 1796 1866 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1797 1867 if err != nil { 1798 1868 log.Println("failed to create resubmitted stack", err) ··· 1801 1871 } 1802 1872 1803 1873 // find the diff between the stacks, first, map them by changeId 1804 - origById := make(map[string]*db.Pull) 1805 - newById := make(map[string]*db.Pull) 1874 + origById := make(map[string]*models.Pull) 1875 + newById := make(map[string]*models.Pull) 1806 1876 for _, p := range origStack { 1807 1877 origById[p.ChangeId] = p 1808 1878 } ··· 1815 1885 // commits that got updated: corresponding pull is resubmitted & new round begins 1816 1886 // 1817 1887 // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1818 - additions := make(map[string]*db.Pull) 1819 - deletions := make(map[string]*db.Pull) 1888 + additions := make(map[string]*models.Pull) 1889 + deletions := make(map[string]*models.Pull) 1820 1890 unchanged := make(map[string]struct{}) 1821 1891 updated := make(map[string]struct{}) 1822 1892 ··· 1876 1946 // deleted pulls are marked as deleted in the DB 1877 1947 for _, p := range deletions { 1878 1948 // do not do delete already merged PRs 1879 - if p.State == db.PullMerged { 1949 + if p.State == models.PullMerged { 1880 1950 continue 1881 1951 } 1882 1952 ··· 1921 1991 np, _ := newById[id] 1922 1992 1923 1993 // do not update already merged PRs 1924 - if op.State == db.PullMerged { 1994 + if op.State == models.PullMerged { 1925 1995 continue 1926 1996 } 1927 1997 ··· 2021 2091 return 2022 2092 } 2023 2093 2024 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2094 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2025 2095 Repo: user.Did, 2026 2096 Writes: writes, 2027 2097 }) ··· 2042 2112 return 2043 2113 } 2044 2114 2045 - pull, ok := r.Context().Value("pull").(*db.Pull) 2115 + pull, ok := r.Context().Value("pull").(*models.Pull) 2046 2116 if !ok { 2047 2117 log.Println("failed to get pull") 2048 2118 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2049 2119 return 2050 2120 } 2051 2121 2052 - var pullsToMerge db.Stack 2122 + var pullsToMerge models.Stack 2053 2123 pullsToMerge = append(pullsToMerge, pull) 2054 2124 if pull.IsStacked() { 2055 - stack, ok := r.Context().Value("stack").(db.Stack) 2125 + stack, ok := r.Context().Value("stack").(models.Stack) 2056 2126 if !ok { 2057 2127 log.Println("failed to get stack") 2058 2128 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 2142 2212 return 2143 2213 } 2144 2214 2215 + // notify about the pull merge 2216 + for _, p := range pullsToMerge { 2217 + s.notifier.NewPullMerged(r.Context(), p) 2218 + } 2219 + 2145 2220 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2146 2221 } 2147 2222 ··· 2154 2229 return 2155 2230 } 2156 2231 2157 - pull, ok := r.Context().Value("pull").(*db.Pull) 2232 + pull, ok := r.Context().Value("pull").(*models.Pull) 2158 2233 if !ok { 2159 2234 log.Println("failed to get pull") 2160 2235 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2182 2257 } 2183 2258 defer tx.Rollback() 2184 2259 2185 - var pullsToClose []*db.Pull 2260 + var pullsToClose []*models.Pull 2186 2261 pullsToClose = append(pullsToClose, pull) 2187 2262 2188 2263 // if this PR is stacked, then we want to close all PRs below this one on the stack 2189 2264 if pull.IsStacked() { 2190 - stack := r.Context().Value("stack").(db.Stack) 2265 + stack := r.Context().Value("stack").(models.Stack) 2191 2266 subStack := stack.StrictlyBelow(pull) 2192 2267 pullsToClose = append(pullsToClose, subStack...) 2193 2268 } ··· 2209 2284 return 2210 2285 } 2211 2286 2287 + for _, p := range pullsToClose { 2288 + s.notifier.NewPullClosed(r.Context(), p) 2289 + } 2290 + 2212 2291 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2213 2292 } 2214 2293 ··· 2222 2301 return 2223 2302 } 2224 2303 2225 - pull, ok := r.Context().Value("pull").(*db.Pull) 2304 + pull, ok := r.Context().Value("pull").(*models.Pull) 2226 2305 if !ok { 2227 2306 log.Println("failed to get pull") 2228 2307 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2250 2329 } 2251 2330 defer tx.Rollback() 2252 2331 2253 - var pullsToReopen []*db.Pull 2332 + var pullsToReopen []*models.Pull 2254 2333 pullsToReopen = append(pullsToReopen, pull) 2255 2334 2256 2335 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2257 2336 if pull.IsStacked() { 2258 - stack := r.Context().Value("stack").(db.Stack) 2337 + stack := r.Context().Value("stack").(models.Stack) 2259 2338 subStack := stack.StrictlyAbove(pull) 2260 2339 pullsToReopen = append(pullsToReopen, subStack...) 2261 2340 } ··· 2280 2359 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2281 2360 } 2282 2361 2283 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2362 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2284 2363 formatPatches, err := patchutil.ExtractPatches(patch) 2285 2364 if err != nil { 2286 2365 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2292 2371 } 2293 2372 2294 2373 // the stack is identified by a UUID 2295 - var stack db.Stack 2374 + var stack models.Stack 2296 2375 parentChangeId := "" 2297 2376 for _, fp := range formatPatches { 2298 2377 // all patches must have a jj change-id ··· 2305 2384 body := fp.Body 2306 2385 rkey := tid.TID() 2307 2386 2308 - initialSubmission := db.PullSubmission{ 2387 + initialSubmission := models.PullSubmission{ 2309 2388 Patch: fp.Raw, 2310 2389 SourceRev: fp.SHA, 2311 2390 } 2312 - pull := db.Pull{ 2391 + pull := models.Pull{ 2313 2392 Title: title, 2314 2393 Body: body, 2315 2394 TargetBranch: targetBranch, 2316 2395 OwnerDid: user.Did, 2317 2396 RepoAt: f.RepoAt(), 2318 2397 Rkey: rkey, 2319 - Submissions: []*db.PullSubmission{ 2398 + Submissions: []*models.PullSubmission{ 2320 2399 &initialSubmission, 2321 2400 }, 2322 2401 PullSource: pullSource,
+53 -25
appview/repo/artifact.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "io" 7 8 "log" 8 9 "net/http" 9 10 "net/url" 10 11 "time" 11 12 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/reporesolver" 18 + "tangled.org/core/appview/xrpcclient" 19 + "tangled.org/core/tid" 20 + "tangled.org/core/types" 21 + 12 22 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 23 lexutil "github.com/bluesky-social/indigo/lex/util" 14 24 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 16 26 "github.com/go-chi/chi/v5" 17 27 "github.com/go-git/go-git/v5/plumbing" 18 28 "github.com/ipfs/go-cid" 19 - "tangled.org/core/api/tangled" 20 - "tangled.org/core/appview/db" 21 - "tangled.org/core/appview/pages" 22 - "tangled.org/core/appview/reporesolver" 23 - "tangled.org/core/appview/xrpcclient" 24 - "tangled.org/core/tid" 25 - "tangled.org/core/types" 26 29 ) 27 30 28 31 // TODO: proper statuses here on early exit ··· 58 61 return 59 62 } 60 63 61 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 62 65 if err != nil { 63 66 log.Println("failed to upload blob", err) 64 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 70 73 rkey := tid.TID() 71 74 createdAt := time.Now() 72 75 73 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 74 77 Collection: tangled.RepoArtifactNSID, 75 78 Repo: user.Did, 76 79 Rkey: rkey, ··· 100 103 } 101 104 defer tx.Rollback() 102 105 103 - artifact := db.Artifact{ 106 + artifact := models.Artifact{ 104 107 Did: user.Did, 105 108 Rkey: rkey, 106 109 RepoAt: f.RepoAt(), ··· 133 136 }) 134 137 } 135 138 136 - // TODO: proper statuses here on early exit 137 139 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 138 - tagParam := chi.URLParam(r, "tag") 139 - filename := chi.URLParam(r, "file") 140 140 f, err := rp.repoResolver.Resolve(r) 141 141 if err != nil { 142 142 log.Println("failed to get repo and knot", err) 143 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 143 144 return 144 145 } 146 + 147 + tagParam := chi.URLParam(r, "tag") 148 + filename := chi.URLParam(r, "file") 145 149 146 150 tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 151 if err != nil { ··· 150 154 return 151 155 } 152 156 153 - client, err := rp.oauth.AuthorizedClient(r) 154 - if err != nil { 155 - log.Println("failed to get authorized client", err) 156 - return 157 - } 158 - 159 157 artifacts, err := db.GetArtifact( 160 158 rp.db, 161 159 db.FilterEq("repo_at", f.RepoAt()), ··· 164 162 ) 165 163 if err != nil { 166 164 log.Println("failed to get artifacts", err) 165 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 167 166 return 168 167 } 168 + 169 169 if len(artifacts) != 1 { 170 - log.Printf("too many or too little artifacts found") 170 + log.Printf("too many or too few artifacts found") 171 + http.Error(w, "artifact not found", http.StatusNotFound) 171 172 return 172 173 } 173 174 174 175 artifact := artifacts[0] 175 176 176 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 177 + ownerPds := f.OwnerId.PDSEndpoint() 178 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 + q := url.Query() 180 + q.Set("cid", artifact.BlobCid.String()) 181 + q.Set("did", artifact.Did) 182 + url.RawQuery = q.Encode() 183 + 184 + req, err := http.NewRequest(http.MethodGet, url.String(), nil) 177 185 if err != nil { 178 - log.Println("failed to get blob from pds", err) 186 + log.Println("failed to create request", err) 187 + http.Error(w, "failed to create request", http.StatusInternalServerError) 188 + return 189 + } 190 + req.Header.Set("Content-Type", "application/json") 191 + 192 + resp, err := http.DefaultClient.Do(req) 193 + if err != nil { 194 + log.Println("failed to make request", err) 195 + http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 179 196 return 180 197 } 198 + defer resp.Body.Close() 181 199 182 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 183 - w.Write(getBlobResp) 200 + // copy status code and relevant headers from upstream response 201 + w.WriteHeader(resp.StatusCode) 202 + for key, values := range resp.Header { 203 + for _, v := range values { 204 + w.Header().Add(key, v) 205 + } 206 + } 207 + 208 + // stream the body directly to the client 209 + if _, err := io.Copy(w, resp.Body); err != nil { 210 + log.Println("error streaming response to client:", err) 211 + } 184 212 } 185 213 186 214 // TODO: proper statuses here on early exit ··· 222 250 return 223 251 } 224 252 225 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 226 254 Collection: tangled.RepoArtifactNSID, 227 255 Repo: user.Did, 228 256 Rkey: artifact.Rkey,
+7 -6
appview/repo/feed.go
··· 9 9 "time" 10 10 11 11 "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/pagination" 13 14 "tangled.org/core/appview/reporesolver" 14 15 ··· 70 71 return feed, nil 71 72 } 72 73 73 - func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 75 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 75 76 if err != nil { 76 77 return nil, err ··· 108 109 return items, nil 109 110 } 110 111 111 - func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 113 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 114 if err != nil { 114 115 return nil, err ··· 128 129 }, nil 129 130 } 130 131 131 - func (rp *Repo) getPullState(pull *db.Pull) string { 132 - if pull.State == db.PullOpen { 132 + func (rp *Repo) getPullState(pull *models.Pull) string { 133 + if pull.State == models.PullOpen { 133 134 return "opened" 134 135 } 135 136 return pull.State.String() 136 137 } 137 138 138 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 139 140 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 140 141 141 - if pull.State == db.PullMerged { 142 + if pull.State == models.PullMerged { 142 143 return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 143 144 } 144 145
+19 -23
appview/repo/index.go
··· 20 20 "tangled.org/core/api/tangled" 21 21 "tangled.org/core/appview/commitverify" 22 22 "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/models" 23 24 "tangled.org/core/appview/pages" 24 - "tangled.org/core/appview/pages/markup" 25 25 "tangled.org/core/appview/reporesolver" 26 26 "tangled.org/core/appview/xrpcclient" 27 27 "tangled.org/core/types" ··· 191 191 } 192 192 193 193 for _, lang := range ls.Languages { 194 - langs = append(langs, db.RepoLanguage{ 194 + langs = append(langs, models.RepoLanguage{ 195 195 RepoAt: f.RepoAt(), 196 196 Ref: currentRef, 197 197 IsDefaultRef: isDefaultRef, ··· 199 199 Bytes: lang.Size, 200 200 }) 201 201 } 202 + 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 202 208 203 209 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 205 211 if err != nil { 206 212 // non-fatal 207 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 208 219 } 209 220 } 210 221 ··· 327 338 } 328 339 }() 329 340 330 - // readme content 331 - wg.Add(1) 332 - go func() { 333 - defer wg.Done() 334 - for _, filename := range markup.ReadmeFilenames { 335 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 336 - if err != nil { 337 - continue 338 - } 339 - 340 - if blobResp == nil { 341 - continue 342 - } 343 - 344 - readmeContent = blobResp.Content 345 - readmeFileName = filename 346 - break 347 - } 348 - }() 349 - 350 341 wg.Wait() 351 342 352 343 if errs != nil { ··· 373 364 } 374 365 files = append(files, niceFile) 375 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 376 372 } 377 373 378 374 result := &types.RepoIndexResponse{
+651 -104
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 20 "tangled.org/core/api/tangled" 24 21 "tangled.org/core/appview/commitverify" 25 22 "tangled.org/core/appview/config" 26 23 "tangled.org/core/appview/db" 24 + "tangled.org/core/appview/models" 27 25 "tangled.org/core/appview/notify" 28 26 "tangled.org/core/appview/oauth" 29 27 "tangled.org/core/appview/pages" 30 28 "tangled.org/core/appview/pages/markup" 31 29 "tangled.org/core/appview/reporesolver" 30 + "tangled.org/core/appview/validator" 32 31 xrpcclient "tangled.org/core/appview/xrpcclient" 33 32 "tangled.org/core/eventconsumer" 34 33 "tangled.org/core/idresolver" ··· 38 37 "tangled.org/core/types" 39 38 "tangled.org/core/xrpc/serviceauth" 40 39 40 + comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 + lexutil "github.com/bluesky-social/indigo/lex/util" 44 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 41 45 securejoin "github.com/cyphar/filepath-securejoin" 42 46 "github.com/go-chi/chi/v5" 43 47 "github.com/go-git/go-git/v5/plumbing" 44 - 45 - "github.com/bluesky-social/indigo/atproto/syntax" 46 48 ) 47 49 48 50 type Repo struct { ··· 57 59 notifier notify.Notifier 58 60 logger *slog.Logger 59 61 serviceAuth *serviceauth.ServiceAuth 62 + validator *validator.Validator 60 63 } 61 64 62 65 func New( ··· 70 73 notifier notify.Notifier, 71 74 enforcer *rbac.Enforcer, 72 75 logger *slog.Logger, 76 + validator *validator.Validator, 73 77 ) *Repo { 74 78 return &Repo{oauth: oauth, 75 79 repoResolver: repoResolver, ··· 81 85 notifier: notifier, 82 86 enforcer: enforcer, 83 87 logger: logger, 88 + validator: validator, 84 89 } 85 90 } 86 91 ··· 295 300 return 296 301 } 297 302 303 + newRepo := f.Repo 304 + newRepo.Description = newDescription 305 + record := newRepo.AsRecord() 306 + 298 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 299 308 // 300 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 301 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 302 311 if err != nil { 303 312 // failed to get record 304 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 305 314 return 306 315 } 307 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 308 317 Collection: tangled.RepoNSID, 309 - Repo: user.Did, 310 - Rkey: rkey, 318 + Repo: newRepo.Did, 319 + Rkey: newRepo.Rkey, 311 320 SwapRecord: ex.Cid, 312 321 Record: &lexutil.LexiconTypeDecoder{ 313 - Val: &tangled.Repo{ 314 - Knot: f.Knot, 315 - Name: f.Name, 316 - Owner: user.Did, 317 - CreatedAt: f.Created.Format(time.RFC3339), 318 - Description: &newDescription, 319 - Spindle: &f.Spindle, 320 - }, 322 + Val: &record, 321 323 }, 322 324 }) 323 325 ··· 398 400 log.Println(err) 399 401 // non-fatal 400 402 } 401 - var pipeline *db.Pipeline 403 + var pipeline *models.Pipeline 402 404 if p, ok := pipelines[result.Diff.Commit.This]; ok { 403 405 pipeline = &p 404 406 } ··· 447 449 return 448 450 } 449 451 450 - // readme content 451 - var ( 452 - readmeContent string 453 - readmeFileName string 454 - ) 455 - 456 - for _, filename := range markup.ReadmeFilenames { 457 - path := fmt.Sprintf("%s/%s", treePath, filename) 458 - blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo) 459 - if err != nil { 460 - continue 461 - } 462 - 463 - if blobResp == nil { 464 - continue 465 - } 466 - 467 - readmeContent = blobResp.Content 468 - readmeFileName = path 469 - break 470 - } 471 - 472 452 // Convert XRPC response to internal types.RepoTreeResponse 473 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 474 454 for i, xrpcFile := range xrpcResp.Files { ··· 504 484 if xrpcResp.Dotdot != nil { 505 485 result.DotDot = *xrpcResp.Dotdot 506 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 507 491 508 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 509 493 // so we can safely redirect to the "parent" (which is the same file). ··· 530 514 BreadCrumbs: breadcrumbs, 531 515 TreePath: treePath, 532 516 RepoInfo: f.RepoInfo(user), 533 - Readme: readmeContent, 534 - ReadmeFileName: readmeFileName, 535 517 RepoTreeResponse: result, 536 518 }) 537 519 } ··· 574 556 } 575 557 576 558 // convert artifacts to map for easy UI building 577 - artifactMap := make(map[plumbing.Hash][]db.Artifact) 559 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 578 560 for _, a := range artifacts { 579 561 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 580 562 } 581 563 582 - var danglingArtifacts []db.Artifact 564 + var danglingArtifacts []models.Artifact 583 565 for _, a := range artifacts { 584 566 found := false 585 567 for _, t := range result.Tags { ··· 881 863 user := rp.oauth.GetUser(r) 882 864 l := rp.logger.With("handler", "EditSpindle") 883 865 l = l.With("did", user.Did) 884 - l = l.With("handle", user.Handle) 885 866 886 867 errorId := "operation-error" 887 868 fail := func(msg string, err error) { ··· 891 872 892 873 f, err := rp.repoResolver.Resolve(r) 893 874 if err != nil { 894 - fail("Failed to resolve repo. Try again later", err) 895 - return 896 - } 897 - 898 - repoAt := f.RepoAt() 899 - rkey := repoAt.RecordKey().String() 900 - if rkey == "" { 901 875 fail("Failed to resolve repo. Try again later", err) 902 876 return 903 877 } ··· 924 898 } 925 899 } 926 900 901 + newRepo := f.Repo 902 + newRepo.Spindle = newSpindle 903 + record := newRepo.AsRecord() 904 + 927 905 spindlePtr := &newSpindle 928 906 if removingSpindle { 929 907 spindlePtr = nil 908 + newRepo.Spindle = "" 930 909 } 931 910 932 911 // optimistic update 933 - err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 912 + err = db.UpdateSpindle(rp.db, newRepo.RepoAt().String(), spindlePtr) 934 913 if err != nil { 935 914 fail("Failed to update spindle. Try again later.", err) 936 915 return 937 916 } 938 917 939 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 940 919 if err != nil { 941 920 fail("Failed to update spindle, no record found on PDS.", err) 942 921 return 943 922 } 944 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 945 924 Collection: tangled.RepoNSID, 946 - Repo: user.Did, 947 - Rkey: rkey, 925 + Repo: newRepo.Did, 926 + Rkey: newRepo.Rkey, 948 927 SwapRecord: ex.Cid, 949 928 Record: &lexutil.LexiconTypeDecoder{ 950 - Val: &tangled.Repo{ 951 - Knot: f.Knot, 952 - Name: f.Name, 953 - Owner: user.Did, 954 - CreatedAt: f.Created.Format(time.RFC3339), 955 - Description: &f.Description, 956 - Spindle: spindlePtr, 957 - }, 929 + Val: &record, 958 930 }, 959 931 }) 960 932 ··· 974 946 rp.pages.HxRefresh(w) 975 947 } 976 948 949 + func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { 950 + user := rp.oauth.GetUser(r) 951 + l := rp.logger.With("handler", "AddLabel") 952 + l = l.With("did", user.Did) 953 + 954 + f, err := rp.repoResolver.Resolve(r) 955 + if err != nil { 956 + l.Error("failed to get repo and knot", "err", err) 957 + return 958 + } 959 + 960 + errorId := "add-label-error" 961 + fail := func(msg string, err error) { 962 + l.Error(msg, "err", err) 963 + rp.pages.Notice(w, errorId, msg) 964 + } 965 + 966 + // get form values for label definition 967 + name := r.FormValue("name") 968 + concreteType := r.FormValue("valueType") 969 + valueFormat := r.FormValue("valueFormat") 970 + enumValues := r.FormValue("enumValues") 971 + scope := r.Form["scope"] 972 + color := r.FormValue("color") 973 + multiple := r.FormValue("multiple") == "true" 974 + 975 + var variants []string 976 + for part := range strings.SplitSeq(enumValues, ",") { 977 + if part = strings.TrimSpace(part); part != "" { 978 + variants = append(variants, part) 979 + } 980 + } 981 + 982 + if concreteType == "" { 983 + concreteType = "null" 984 + } 985 + 986 + format := models.ValueTypeFormatAny 987 + if valueFormat == "did" { 988 + format = models.ValueTypeFormatDid 989 + } 990 + 991 + valueType := models.ValueType{ 992 + Type: models.ConcreteType(concreteType), 993 + Format: format, 994 + Enum: variants, 995 + } 996 + 997 + label := models.LabelDefinition{ 998 + Did: user.Did, 999 + Rkey: tid.TID(), 1000 + Name: name, 1001 + ValueType: valueType, 1002 + Scope: scope, 1003 + Color: &color, 1004 + Multiple: multiple, 1005 + Created: time.Now(), 1006 + } 1007 + if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 1008 + fail(err.Error(), err) 1009 + return 1010 + } 1011 + 1012 + // announce this relation into the firehose, store into owners' pds 1013 + client, err := rp.oauth.AuthorizedClient(r) 1014 + if err != nil { 1015 + fail(err.Error(), err) 1016 + return 1017 + } 1018 + 1019 + // emit a labelRecord 1020 + labelRecord := label.AsRecord() 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1022 + Collection: tangled.LabelDefinitionNSID, 1023 + Repo: label.Did, 1024 + Rkey: label.Rkey, 1025 + Record: &lexutil.LexiconTypeDecoder{ 1026 + Val: &labelRecord, 1027 + }, 1028 + }) 1029 + // invalid record 1030 + if err != nil { 1031 + fail("Failed to write record to PDS.", err) 1032 + return 1033 + } 1034 + 1035 + aturi := resp.Uri 1036 + l = l.With("at-uri", aturi) 1037 + l.Info("wrote label record to PDS") 1038 + 1039 + // update the repo to subscribe to this label 1040 + newRepo := f.Repo 1041 + newRepo.Labels = append(newRepo.Labels, aturi) 1042 + repoRecord := newRepo.AsRecord() 1043 + 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1045 + if err != nil { 1046 + fail("Failed to update labels, no record found on PDS.", err) 1047 + return 1048 + } 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1050 + Collection: tangled.RepoNSID, 1051 + Repo: newRepo.Did, 1052 + Rkey: newRepo.Rkey, 1053 + SwapRecord: ex.Cid, 1054 + Record: &lexutil.LexiconTypeDecoder{ 1055 + Val: &repoRecord, 1056 + }, 1057 + }) 1058 + if err != nil { 1059 + fail("Failed to update labels for repo.", err) 1060 + return 1061 + } 1062 + 1063 + tx, err := rp.db.BeginTx(r.Context(), nil) 1064 + if err != nil { 1065 + fail("Failed to add label.", err) 1066 + return 1067 + } 1068 + 1069 + rollback := func() { 1070 + err1 := tx.Rollback() 1071 + err2 := rollbackRecord(context.Background(), aturi, client) 1072 + 1073 + // ignore txn complete errors, this is okay 1074 + if errors.Is(err1, sql.ErrTxDone) { 1075 + err1 = nil 1076 + } 1077 + 1078 + if errs := errors.Join(err1, err2); errs != nil { 1079 + l.Error("failed to rollback changes", "errs", errs) 1080 + return 1081 + } 1082 + } 1083 + defer rollback() 1084 + 1085 + _, err = db.AddLabelDefinition(tx, &label) 1086 + if err != nil { 1087 + fail("Failed to add label.", err) 1088 + return 1089 + } 1090 + 1091 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1092 + RepoAt: f.RepoAt(), 1093 + LabelAt: label.AtUri(), 1094 + }) 1095 + 1096 + err = tx.Commit() 1097 + if err != nil { 1098 + fail("Failed to add label.", err) 1099 + return 1100 + } 1101 + 1102 + // clear aturi when everything is successful 1103 + aturi = "" 1104 + 1105 + rp.pages.HxRefresh(w) 1106 + } 1107 + 1108 + func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { 1109 + user := rp.oauth.GetUser(r) 1110 + l := rp.logger.With("handler", "DeleteLabel") 1111 + l = l.With("did", user.Did) 1112 + 1113 + f, err := rp.repoResolver.Resolve(r) 1114 + if err != nil { 1115 + l.Error("failed to get repo and knot", "err", err) 1116 + return 1117 + } 1118 + 1119 + errorId := "label-operation" 1120 + fail := func(msg string, err error) { 1121 + l.Error(msg, "err", err) 1122 + rp.pages.Notice(w, errorId, msg) 1123 + } 1124 + 1125 + // get form values 1126 + labelId := r.FormValue("label-id") 1127 + 1128 + label, err := db.GetLabelDefinition(rp.db, db.FilterEq("id", labelId)) 1129 + if err != nil { 1130 + fail("Failed to find label definition.", err) 1131 + return 1132 + } 1133 + 1134 + client, err := rp.oauth.AuthorizedClient(r) 1135 + if err != nil { 1136 + fail(err.Error(), err) 1137 + return 1138 + } 1139 + 1140 + // delete label record from PDS 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1142 + Collection: tangled.LabelDefinitionNSID, 1143 + Repo: label.Did, 1144 + Rkey: label.Rkey, 1145 + }) 1146 + if err != nil { 1147 + fail("Failed to delete label record from PDS.", err) 1148 + return 1149 + } 1150 + 1151 + // update repo record to remove the label reference 1152 + newRepo := f.Repo 1153 + var updated []string 1154 + removedAt := label.AtUri().String() 1155 + for _, l := range newRepo.Labels { 1156 + if l != removedAt { 1157 + updated = append(updated, l) 1158 + } 1159 + } 1160 + newRepo.Labels = updated 1161 + repoRecord := newRepo.AsRecord() 1162 + 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1164 + if err != nil { 1165 + fail("Failed to update labels, no record found on PDS.", err) 1166 + return 1167 + } 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1169 + Collection: tangled.RepoNSID, 1170 + Repo: newRepo.Did, 1171 + Rkey: newRepo.Rkey, 1172 + SwapRecord: ex.Cid, 1173 + Record: &lexutil.LexiconTypeDecoder{ 1174 + Val: &repoRecord, 1175 + }, 1176 + }) 1177 + if err != nil { 1178 + fail("Failed to update repo record.", err) 1179 + return 1180 + } 1181 + 1182 + // transaction for DB changes 1183 + tx, err := rp.db.BeginTx(r.Context(), nil) 1184 + if err != nil { 1185 + fail("Failed to delete label.", err) 1186 + return 1187 + } 1188 + defer tx.Rollback() 1189 + 1190 + err = db.UnsubscribeLabel( 1191 + tx, 1192 + db.FilterEq("repo_at", f.RepoAt()), 1193 + db.FilterEq("label_at", removedAt), 1194 + ) 1195 + if err != nil { 1196 + fail("Failed to unsubscribe label.", err) 1197 + return 1198 + } 1199 + 1200 + err = db.DeleteLabelDefinition(tx, db.FilterEq("id", label.Id)) 1201 + if err != nil { 1202 + fail("Failed to delete label definition.", err) 1203 + return 1204 + } 1205 + 1206 + err = tx.Commit() 1207 + if err != nil { 1208 + fail("Failed to delete label.", err) 1209 + return 1210 + } 1211 + 1212 + // everything succeeded 1213 + rp.pages.HxRefresh(w) 1214 + } 1215 + 1216 + func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { 1217 + user := rp.oauth.GetUser(r) 1218 + l := rp.logger.With("handler", "SubscribeLabel") 1219 + l = l.With("did", user.Did) 1220 + 1221 + f, err := rp.repoResolver.Resolve(r) 1222 + if err != nil { 1223 + l.Error("failed to get repo and knot", "err", err) 1224 + return 1225 + } 1226 + 1227 + if err := r.ParseForm(); err != nil { 1228 + l.Error("invalid form", "err", err) 1229 + return 1230 + } 1231 + 1232 + errorId := "default-label-operation" 1233 + fail := func(msg string, err error) { 1234 + l.Error(msg, "err", err) 1235 + rp.pages.Notice(w, errorId, msg) 1236 + } 1237 + 1238 + labelAts := r.Form["label"] 1239 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1240 + if err != nil { 1241 + fail("Failed to subscribe to label.", err) 1242 + return 1243 + } 1244 + 1245 + newRepo := f.Repo 1246 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1247 + 1248 + // dedup 1249 + slices.Sort(newRepo.Labels) 1250 + newRepo.Labels = slices.Compact(newRepo.Labels) 1251 + 1252 + repoRecord := newRepo.AsRecord() 1253 + 1254 + client, err := rp.oauth.AuthorizedClient(r) 1255 + if err != nil { 1256 + fail(err.Error(), err) 1257 + return 1258 + } 1259 + 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1261 + if err != nil { 1262 + fail("Failed to update labels, no record found on PDS.", err) 1263 + return 1264 + } 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1266 + Collection: tangled.RepoNSID, 1267 + Repo: newRepo.Did, 1268 + Rkey: newRepo.Rkey, 1269 + SwapRecord: ex.Cid, 1270 + Record: &lexutil.LexiconTypeDecoder{ 1271 + Val: &repoRecord, 1272 + }, 1273 + }) 1274 + 1275 + tx, err := rp.db.Begin() 1276 + if err != nil { 1277 + fail("Failed to subscribe to label.", err) 1278 + return 1279 + } 1280 + defer tx.Rollback() 1281 + 1282 + for _, l := range labelAts { 1283 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1284 + RepoAt: f.RepoAt(), 1285 + LabelAt: syntax.ATURI(l), 1286 + }) 1287 + if err != nil { 1288 + fail("Failed to subscribe to label.", err) 1289 + return 1290 + } 1291 + } 1292 + 1293 + if err := tx.Commit(); err != nil { 1294 + fail("Failed to subscribe to label.", err) 1295 + return 1296 + } 1297 + 1298 + // everything succeeded 1299 + rp.pages.HxRefresh(w) 1300 + } 1301 + 1302 + func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { 1303 + user := rp.oauth.GetUser(r) 1304 + l := rp.logger.With("handler", "UnsubscribeLabel") 1305 + l = l.With("did", user.Did) 1306 + 1307 + f, err := rp.repoResolver.Resolve(r) 1308 + if err != nil { 1309 + l.Error("failed to get repo and knot", "err", err) 1310 + return 1311 + } 1312 + 1313 + if err := r.ParseForm(); err != nil { 1314 + l.Error("invalid form", "err", err) 1315 + return 1316 + } 1317 + 1318 + errorId := "default-label-operation" 1319 + fail := func(msg string, err error) { 1320 + l.Error(msg, "err", err) 1321 + rp.pages.Notice(w, errorId, msg) 1322 + } 1323 + 1324 + labelAts := r.Form["label"] 1325 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1326 + if err != nil { 1327 + fail("Failed to unsubscribe to label.", err) 1328 + return 1329 + } 1330 + 1331 + // update repo record to remove the label reference 1332 + newRepo := f.Repo 1333 + var updated []string 1334 + for _, l := range newRepo.Labels { 1335 + if !slices.Contains(labelAts, l) { 1336 + updated = append(updated, l) 1337 + } 1338 + } 1339 + newRepo.Labels = updated 1340 + repoRecord := newRepo.AsRecord() 1341 + 1342 + client, err := rp.oauth.AuthorizedClient(r) 1343 + if err != nil { 1344 + fail(err.Error(), err) 1345 + return 1346 + } 1347 + 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1349 + if err != nil { 1350 + fail("Failed to update labels, no record found on PDS.", err) 1351 + return 1352 + } 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1354 + Collection: tangled.RepoNSID, 1355 + Repo: newRepo.Did, 1356 + Rkey: newRepo.Rkey, 1357 + SwapRecord: ex.Cid, 1358 + Record: &lexutil.LexiconTypeDecoder{ 1359 + Val: &repoRecord, 1360 + }, 1361 + }) 1362 + 1363 + err = db.UnsubscribeLabel( 1364 + rp.db, 1365 + db.FilterEq("repo_at", f.RepoAt()), 1366 + db.FilterIn("label_at", labelAts), 1367 + ) 1368 + if err != nil { 1369 + fail("Failed to unsubscribe label.", err) 1370 + return 1371 + } 1372 + 1373 + // everything succeeded 1374 + rp.pages.HxRefresh(w) 1375 + } 1376 + 1377 + func (rp *Repo) LabelPanel(w http.ResponseWriter, r *http.Request) { 1378 + l := rp.logger.With("handler", "LabelPanel") 1379 + 1380 + f, err := rp.repoResolver.Resolve(r) 1381 + if err != nil { 1382 + l.Error("failed to get repo and knot", "err", err) 1383 + return 1384 + } 1385 + 1386 + subjectStr := r.FormValue("subject") 1387 + subject, err := syntax.ParseATURI(subjectStr) 1388 + if err != nil { 1389 + l.Error("failed to get repo and knot", "err", err) 1390 + return 1391 + } 1392 + 1393 + labelDefs, err := db.GetLabelDefinitions( 1394 + rp.db, 1395 + db.FilterIn("at_uri", f.Repo.Labels), 1396 + db.FilterContains("scope", subject.Collection().String()), 1397 + ) 1398 + if err != nil { 1399 + log.Println("failed to fetch label defs", err) 1400 + return 1401 + } 1402 + 1403 + defs := make(map[string]*models.LabelDefinition) 1404 + for _, l := range labelDefs { 1405 + defs[l.AtUri().String()] = &l 1406 + } 1407 + 1408 + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1409 + if err != nil { 1410 + log.Println("failed to build label state", err) 1411 + return 1412 + } 1413 + state := states[subject] 1414 + 1415 + user := rp.oauth.GetUser(r) 1416 + rp.pages.LabelPanel(w, pages.LabelPanelParams{ 1417 + LoggedInUser: user, 1418 + RepoInfo: f.RepoInfo(user), 1419 + Defs: defs, 1420 + Subject: subject.String(), 1421 + State: state, 1422 + }) 1423 + } 1424 + 1425 + func (rp *Repo) EditLabelPanel(w http.ResponseWriter, r *http.Request) { 1426 + l := rp.logger.With("handler", "EditLabelPanel") 1427 + 1428 + f, err := rp.repoResolver.Resolve(r) 1429 + if err != nil { 1430 + l.Error("failed to get repo and knot", "err", err) 1431 + return 1432 + } 1433 + 1434 + subjectStr := r.FormValue("subject") 1435 + subject, err := syntax.ParseATURI(subjectStr) 1436 + if err != nil { 1437 + l.Error("failed to get repo and knot", "err", err) 1438 + return 1439 + } 1440 + 1441 + labelDefs, err := db.GetLabelDefinitions( 1442 + rp.db, 1443 + db.FilterIn("at_uri", f.Repo.Labels), 1444 + db.FilterContains("scope", subject.Collection().String()), 1445 + ) 1446 + if err != nil { 1447 + log.Println("failed to fetch labels", err) 1448 + return 1449 + } 1450 + 1451 + defs := make(map[string]*models.LabelDefinition) 1452 + for _, l := range labelDefs { 1453 + defs[l.AtUri().String()] = &l 1454 + } 1455 + 1456 + states, err := db.GetLabels(rp.db, db.FilterEq("subject", subject)) 1457 + if err != nil { 1458 + log.Println("failed to build label state", err) 1459 + return 1460 + } 1461 + state := states[subject] 1462 + 1463 + user := rp.oauth.GetUser(r) 1464 + rp.pages.EditLabelPanel(w, pages.EditLabelPanelParams{ 1465 + LoggedInUser: user, 1466 + RepoInfo: f.RepoInfo(user), 1467 + Defs: defs, 1468 + Subject: subject.String(), 1469 + State: state, 1470 + }) 1471 + } 1472 + 977 1473 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 978 1474 user := rp.oauth.GetUser(r) 979 1475 l := rp.logger.With("handler", "AddCollaborator") 980 1476 l = l.With("did", user.Did) 981 - l = l.With("handle", user.Handle) 982 1477 983 1478 f, err := rp.repoResolver.Resolve(r) 984 1479 if err != nil { ··· 1025 1520 currentUser := rp.oauth.GetUser(r) 1026 1521 rkey := tid.TID() 1027 1522 createdAt := time.Now() 1028 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1029 1524 Collection: tangled.RepoCollaboratorNSID, 1030 1525 Repo: currentUser.Did, 1031 1526 Rkey: rkey, ··· 1075 1570 return 1076 1571 } 1077 1572 1078 - err = db.AddCollaborator(rp.db, db.Collaborator{ 1573 + err = db.AddCollaborator(tx, models.Collaborator{ 1079 1574 Did: syntax.DID(currentUser.Did), 1080 1575 Rkey: rkey, 1081 1576 SubjectDid: collaboratorIdent.DID, ··· 1116 1611 } 1117 1612 1118 1613 // remove record from pds 1119 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1120 1615 if err != nil { 1121 1616 log.Println("failed to get authorized client", err) 1122 1617 return 1123 1618 } 1124 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1125 1620 Collection: tangled.RepoNSID, 1126 1621 Repo: user.Did, 1127 1622 Rkey: f.Rkey, ··· 1263 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1264 1759 user := rp.oauth.GetUser(r) 1265 1760 l := rp.logger.With("handler", "Secrets") 1266 - l = l.With("handle", user.Handle) 1267 1761 l = l.With("did", user.Did) 1268 1762 1269 1763 f, err := rp.repoResolver.Resolve(r) ··· 1403 1897 return 1404 1898 } 1405 1899 1900 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1901 + if err != nil { 1902 + log.Println("failed to fetch labels", err) 1903 + rp.pages.Error503(w) 1904 + return 1905 + } 1906 + 1907 + labels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 1908 + if err != nil { 1909 + log.Println("failed to fetch labels", err) 1910 + rp.pages.Error503(w) 1911 + return 1912 + } 1913 + // remove default labels from the labels list, if present 1914 + defaultLabelMap := make(map[string]bool) 1915 + for _, dl := range defaultLabels { 1916 + defaultLabelMap[dl.AtUri().String()] = true 1917 + } 1918 + n := 0 1919 + for _, l := range labels { 1920 + if !defaultLabelMap[l.AtUri().String()] { 1921 + labels[n] = l 1922 + n++ 1923 + } 1924 + } 1925 + labels = labels[:n] 1926 + 1927 + subscribedLabels := make(map[string]struct{}) 1928 + for _, l := range f.Repo.Labels { 1929 + subscribedLabels[l] = struct{}{} 1930 + } 1931 + 1932 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1933 + // if all default labels are subbed, show the "unsubscribe all" button 1934 + shouldSubscribeAll := false 1935 + for _, dl := range defaultLabels { 1936 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1937 + // one of the default labels is not subscribed to 1938 + shouldSubscribeAll = true 1939 + break 1940 + } 1941 + } 1942 + 1406 1943 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1407 - LoggedInUser: user, 1408 - RepoInfo: f.RepoInfo(user), 1409 - Branches: result.Branches, 1410 - Tabs: settingsTabs, 1411 - Tab: "general", 1944 + LoggedInUser: user, 1945 + RepoInfo: f.RepoInfo(user), 1946 + Branches: result.Branches, 1947 + Labels: labels, 1948 + DefaultLabels: defaultLabels, 1949 + SubscribedLabels: subscribedLabels, 1950 + ShouldSubscribeAll: shouldSubscribeAll, 1951 + Tabs: settingsTabs, 1952 + Tab: "general", 1412 1953 }) 1413 1954 } 1414 1955 ··· 1581 2122 } 1582 2123 1583 2124 // choose a name for a fork 1584 - forkName := f.Name 2125 + forkName := r.FormValue("repo_name") 2126 + if forkName == "" { 2127 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2128 + return 2129 + } 2130 + 1585 2131 // this check is *only* to see if the forked repo name already exists 1586 2132 // in the user's account. 1587 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 2133 + existingRepo, err := db.GetRepo( 2134 + rp.db, 2135 + db.FilterEq("did", user.Did), 2136 + db.FilterEq("name", forkName), 2137 + ) 1588 2138 if err != nil { 1589 - if errors.Is(err, sql.ErrNoRows) { 1590 - // no existing repo with this name found, we can use the name as is 1591 - } else { 1592 - log.Println("error fetching existing repo from db", err) 2139 + if !errors.Is(err, sql.ErrNoRows) { 2140 + log.Println("error fetching existing repo from db", "err", err) 1593 2141 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1594 2142 return 1595 2143 } 1596 2144 } else if existingRepo != nil { 1597 - // repo with this name already exists, append random string 1598 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2145 + // repo with this name already exists 2146 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2147 + return 1599 2148 } 1600 2149 l = l.With("forkName", forkName) 1601 2150 ··· 1611 2160 1612 2161 // create an atproto record for this fork 1613 2162 rkey := tid.TID() 1614 - repo := &db.Repo{ 1615 - Did: user.Did, 1616 - Name: forkName, 1617 - Knot: targetKnot, 1618 - Rkey: rkey, 1619 - Source: sourceAt, 2163 + repo := &models.Repo{ 2164 + Did: user.Did, 2165 + Name: forkName, 2166 + Knot: targetKnot, 2167 + Rkey: rkey, 2168 + Source: sourceAt, 2169 + Description: f.Repo.Description, 2170 + Created: time.Now(), 2171 + Labels: models.DefaultLabelDefs(), 1620 2172 } 2173 + record := repo.AsRecord() 1621 2174 1622 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 1623 2176 if err != nil { 1624 2177 l.Error("failed to create xrpcclient", "err", err) 1625 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 1626 2179 return 1627 2180 } 1628 2181 1629 - createdAt := time.Now().Format(time.RFC3339) 1630 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 1631 2183 Collection: tangled.RepoNSID, 1632 2184 Repo: user.Did, 1633 2185 Rkey: rkey, 1634 2186 Record: &lexutil.LexiconTypeDecoder{ 1635 - Val: &tangled.Repo{ 1636 - Knot: repo.Knot, 1637 - Name: repo.Name, 1638 - CreatedAt: createdAt, 1639 - Owner: user.Did, 1640 - Source: &sourceAt, 1641 - }}, 2187 + Val: &record, 2188 + }, 1642 2189 }) 1643 2190 if err != nil { 1644 2191 l.Error("failed to write to PDS", "err", err) ··· 1664 2211 rollback := func() { 1665 2212 err1 := tx.Rollback() 1666 2213 err2 := rp.enforcer.E.LoadPolicy() 1667 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 1668 2215 1669 2216 // ignore txn complete errors, this is okay 1670 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 1737 2284 aturi = "" 1738 2285 1739 2286 rp.notifier.NewRepo(r.Context(), repo) 1740 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 1741 2288 } 1742 2289 } 1743 2290 1744 2291 // this is used to rollback changes made to the PDS 1745 2292 // 1746 2293 // it is a no-op if the provided ATURI is empty 1747 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1748 2295 if aturi == "" { 1749 2296 return nil 1750 2297 } ··· 1755 2302 repo := parsed.Authority().String() 1756 2303 rkey := parsed.RecordKey().String() 1757 2304 1758 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 1759 2306 Collection: collection, 1760 2307 Repo: repo, 1761 2308 Rkey: rkey,
+3 -2
appview/repo/repo_util.go
··· 10 10 "strings" 11 11 12 12 "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 13 14 "tangled.org/core/appview/pages/repoinfo" 14 15 "tangled.org/core/types" 15 16 ··· 143 144 d *db.DB, 144 145 repoInfo repoinfo.RepoInfo, 145 146 shas []string, 146 - ) (map[string]db.Pipeline, error) { 147 - m := make(map[string]db.Pipeline) 147 + ) (map[string]models.Pipeline, error) { 148 + m := make(map[string]models.Pipeline) 148 149 149 150 if len(shas) == 0 { 150 151 return m, nil
+12 -3
appview/repo/router.go
··· 21 21 r.Route("/tags", func(r chi.Router) { 22 22 r.Get("/", rp.RepoTags) 23 23 r.Route("/{tag}", func(r chi.Router) { 24 - r.Use(middleware.AuthMiddleware(rp.oauth)) 25 - // require auth to download for now 26 24 r.Get("/download/{file}", rp.DownloadArtifact) 27 25 28 26 // require repo:push to upload or delete artifacts ··· 30 28 // additionally: only the uploader can truly delete an artifact 31 29 // (record+blob will live on their pds) 32 30 r.Group(func(r chi.Router) { 33 - r.With(mw.RepoPermissionMiddleware("repo:push")) 31 + r.Use(middleware.AuthMiddleware(rp.oauth)) 32 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 34 33 r.Post("/upload", rp.AttachArtifact) 35 34 r.Delete("/{file}", rp.DeleteArtifact) 36 35 }) ··· 64 63 r.Get("/*", rp.RepoCompare) 65 64 }) 66 65 66 + // label panel in issues/pulls/discussions/tasks 67 + r.Route("/label", func(r chi.Router) { 68 + r.Get("/", rp.LabelPanel) 69 + r.Get("/edit", rp.EditLabelPanel) 70 + }) 71 + 67 72 // settings routes, needs auth 68 73 r.Group(func(r chi.Router) { 69 74 r.Use(middleware.AuthMiddleware(rp.oauth)) ··· 76 81 r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 77 82 r.Get("/", rp.RepoSettings) 78 83 r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/spindle", rp.EditSpindle) 84 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Put("/label", rp.AddLabelDef) 85 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Delete("/label", rp.DeleteLabelDef) 86 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/subscribe", rp.SubscribeLabel) 87 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Post("/label/unsubscribe", rp.UnsubscribeLabel) 79 88 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 80 89 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 81 90 r.Put("/branches/default", rp.SetDefaultBranch)
+7 -5
appview/reporesolver/resolver.go
··· 16 16 "github.com/go-chi/chi/v5" 17 17 "tangled.org/core/appview/config" 18 18 "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/models" 19 20 "tangled.org/core/appview/oauth" 20 21 "tangled.org/core/appview/pages" 21 22 "tangled.org/core/appview/pages/repoinfo" ··· 24 25 ) 25 26 26 27 type ResolvedRepo struct { 27 - db.Repo 28 + models.Repo 28 29 OwnerId identity.Identity 29 30 CurrentDir string 30 31 Ref string ··· 44 45 } 45 46 46 47 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 47 - repo, ok := r.Context().Value("repo").(*db.Repo) 48 + repo, ok := r.Context().Value("repo").(*models.Repo) 48 49 if !ok { 49 50 log.Println("malformed middleware: `repo` not exist in context") 50 51 return nil, fmt.Errorf("malformed middleware") ··· 162 163 log.Println("failed to get repo source for ", repoAt, err) 163 164 } 164 165 165 - var sourceRepo *db.Repo 166 + var sourceRepo *models.Repo 166 167 if source != "" { 167 168 sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 168 169 if err != nil { ··· 184 185 OwnerDid: f.OwnerDid(), 185 186 OwnerHandle: f.OwnerHandle(), 186 187 Name: f.Name, 188 + Rkey: f.Repo.Rkey, 187 189 RepoAt: repoAt, 188 190 Description: f.Description, 189 191 IsStarred: isStarred, 190 192 Knot: knot, 191 193 Spindle: f.Spindle, 192 194 Roles: f.RolesInRepo(user), 193 - Stats: db.RepoStats{ 195 + Stats: models.RepoStats{ 194 196 StarCount: starCount, 195 197 IssueCount: issueCount, 196 198 PullCount: pullCount, ··· 210 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 211 213 if u != nil { 212 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 213 - return repoinfo.RolesInRepo{r} 215 + return repoinfo.RolesInRepo{Roles: r} 214 216 } else { 215 217 return repoinfo.RolesInRepo{} 216 218 }
+63
appview/search/query.go
··· 1 + package search 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + // Query represents a parsed search query 8 + type Query struct { 9 + // Text search terms (anything that's not a has: filter) 10 + Text string 11 + // Label filters from has:labelname syntax 12 + Labels []string 13 + } 14 + 15 + // Parse parses a search query string into a Query struct 16 + // Syntax: 17 + // - "has:enhancement" adds a label filter 18 + // - Other text becomes part of the text search 19 + func Parse(queryStr string) Query { 20 + q := Query{ 21 + Labels: []string{}, 22 + } 23 + 24 + // Split query into tokens 25 + tokens := strings.Fields(queryStr) 26 + var textParts []string 27 + 28 + for _, token := range tokens { 29 + // Check if it's a has: filter 30 + if strings.HasPrefix(token, "has:") { 31 + label := strings.TrimPrefix(token, "has:") 32 + if label != "" { 33 + q.Labels = append(q.Labels, label) 34 + } 35 + } else { 36 + // It's a text search term 37 + textParts = append(textParts, token) 38 + } 39 + } 40 + 41 + q.Text = strings.Join(textParts, " ") 42 + return q 43 + } 44 + 45 + // String converts a Query back to a query string 46 + func (q Query) String() string { 47 + var parts []string 48 + 49 + if q.Text != "" { 50 + parts = append(parts, q.Text) 51 + } 52 + 53 + for _, label := range q.Labels { 54 + parts = append(parts, "has:"+label) 55 + } 56 + 57 + return strings.Join(parts, " ") 58 + } 59 + 60 + // HasFilters returns true if the query has any search filters 61 + func (q Query) HasFilters() bool { 62 + return q.Text != "" || len(q.Labels) > 0 63 + }
+55 -3
appview/settings/settings.go
··· 16 16 "tangled.org/core/appview/db" 17 17 "tangled.org/core/appview/email" 18 18 "tangled.org/core/appview/middleware" 19 + "tangled.org/core/appview/models" 19 20 "tangled.org/core/appview/oauth" 20 21 "tangled.org/core/appview/pages" 21 22 "tangled.org/core/tid" ··· 40 41 {"Name": "profile", "Icon": "user"}, 41 42 {"Name": "keys", "Icon": "key"}, 42 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 43 45 } 44 46 ) 45 47 ··· 67 69 r.Post("/primary", s.emailsPrimary) 68 70 }) 69 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 70 77 return r 71 78 } 72 79 ··· 80 87 }) 81 88 } 82 89 90 + func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 91 + user := s.OAuth.GetUser(r) 92 + did := s.OAuth.GetDid(r) 93 + 94 + prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 + if err != nil { 96 + log.Printf("failed to get notification preferences: %s", err) 97 + s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 98 + return 99 + } 100 + 101 + s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 102 + LoggedInUser: user, 103 + Preferences: prefs, 104 + Tabs: settingsTabs, 105 + Tab: "notifications", 106 + }) 107 + } 108 + 109 + func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) { 110 + did := s.OAuth.GetDid(r) 111 + 112 + prefs := &models.NotificationPreferences{ 113 + UserDid: did, 114 + RepoStarred: r.FormValue("repo_starred") == "on", 115 + IssueCreated: r.FormValue("issue_created") == "on", 116 + IssueCommented: r.FormValue("issue_commented") == "on", 117 + IssueClosed: r.FormValue("issue_closed") == "on", 118 + PullCreated: r.FormValue("pull_created") == "on", 119 + PullCommented: r.FormValue("pull_commented") == "on", 120 + PullMerged: r.FormValue("pull_merged") == "on", 121 + Followed: r.FormValue("followed") == "on", 122 + EmailNotifications: r.FormValue("email_notifications") == "on", 123 + } 124 + 125 + err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 126 + if err != nil { 127 + log.Printf("failed to update notification preferences: %s", err) 128 + s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 129 + return 130 + } 131 + 132 + s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.") 133 + } 134 + 83 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 84 136 user := s.OAuth.GetUser(r) 85 137 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) ··· 185 237 } 186 238 defer tx.Rollback() 187 239 188 - if err := db.AddEmail(tx, db.Email{ 240 + if err := db.AddEmail(tx, models.Email{ 189 241 Did: did, 190 242 Address: emAddr, 191 243 Verified: false, ··· 418 470 } 419 471 420 472 // store in pds too 421 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 422 474 Collection: tangled.PublicKeyNSID, 423 475 Repo: did, 424 476 Rkey: rkey, ··· 475 527 476 528 if rkey != "" { 477 529 // remove from pds too 478 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 479 531 Collection: tangled.PublicKeyNSID, 480 532 Repo: did, 481 533 Rkey: rkey,
+68 -5
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "encoding/json" 6 + "errors" 5 7 "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "net/url" 8 11 "os" 9 12 "strings" 10 13 ··· 14 17 "tangled.org/core/appview/db" 15 18 "tangled.org/core/appview/dns" 16 19 "tangled.org/core/appview/email" 20 + "tangled.org/core/appview/models" 17 21 "tangled.org/core/appview/pages" 18 22 "tangled.org/core/appview/state/userutil" 19 - "tangled.org/core/appview/xrpcclient" 20 23 "tangled.org/core/idresolver" 21 24 ) 22 25 ··· 25 28 db *db.DB 26 29 cf *dns.Cloudflare 27 30 posthog posthog.Client 28 - xrpc *xrpcclient.Client 29 31 idResolver *idresolver.Resolver 30 32 pages *pages.Pages 31 33 l *slog.Logger ··· 115 117 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 118 switch r.Method { 117 119 case http.MethodGet: 118 - s.pages.Signup(w) 120 + s.pages.Signup(w, pages.SignupParams{ 121 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 + }) 119 123 case http.MethodPost: 120 124 if s.cf == nil { 121 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 + return 122 127 } 123 128 emailId := r.FormValue("email") 129 + cfToken := r.FormValue("cf-turnstile-response") 124 130 125 131 noticeId := "signup-msg" 132 + 133 + if err := s.validateCaptcha(cfToken, r); err != nil { 134 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 135 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 136 + return 137 + } 138 + 126 139 if !email.IsValidEmail(emailId) { 127 140 s.pages.Notice(w, noticeId, "Invalid email address.") 128 141 return ··· 163 176 s.pages.Notice(w, noticeId, "Failed to send email.") 164 177 return 165 178 } 166 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 179 + err = db.AddInflightSignup(s.db, models.InflightSignup{ 167 180 Email: emailId, 168 181 InviteCode: code, 169 182 }) ··· 229 242 return 230 243 } 231 244 232 - err = db.AddEmail(s.db, db.Email{ 245 + err = db.AddEmail(s.db, models.Email{ 233 246 Did: did, 234 247 Address: email, 235 248 Verified: true, ··· 254 267 return 255 268 } 256 269 } 270 + 271 + type turnstileResponse struct { 272 + Success bool `json:"success"` 273 + ErrorCodes []string `json:"error-codes,omitempty"` 274 + ChallengeTs string `json:"challenge_ts,omitempty"` 275 + Hostname string `json:"hostname,omitempty"` 276 + } 277 + 278 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 279 + if cfToken == "" { 280 + return errors.New("captcha token is empty") 281 + } 282 + 283 + if s.config.Cloudflare.TurnstileSecretKey == "" { 284 + return errors.New("turnstile secret key not configured") 285 + } 286 + 287 + data := url.Values{} 288 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 289 + data.Set("response", cfToken) 290 + 291 + // include the client IP if we have it 292 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 293 + data.Set("remoteip", remoteIP) 294 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 295 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 296 + data.Set("remoteip", strings.TrimSpace(ips[0])) 297 + } 298 + } else { 299 + data.Set("remoteip", r.RemoteAddr) 300 + } 301 + 302 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 303 + if err != nil { 304 + return fmt.Errorf("failed to verify turnstile token: %w", err) 305 + } 306 + defer resp.Body.Close() 307 + 308 + var turnstileResp turnstileResponse 309 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 310 + return fmt.Errorf("failed to decode turnstile response: %w", err) 311 + } 312 + 313 + if !turnstileResp.Success { 314 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 315 + return errors.New("turnstile validation failed") 316 + } 317 + 318 + return nil 319 + }
+9 -8
appview/spindles/spindles.go
··· 13 13 "tangled.org/core/appview/config" 14 14 "tangled.org/core/appview/db" 15 15 "tangled.org/core/appview/middleware" 16 + "tangled.org/core/appview/models" 16 17 "tangled.org/core/appview/oauth" 17 18 "tangled.org/core/appview/pages" 18 19 "tangled.org/core/appview/serververify" ··· 115 116 } 116 117 117 118 // organize repos by did 118 - repoMap := make(map[string][]db.Repo) 119 + repoMap := make(map[string][]models.Repo) 119 120 for _, r := range repos { 120 121 repoMap[r.Did] = append(repoMap[r.Did], r) 121 122 } ··· 163 164 s.Enforcer.E.LoadPolicy() 164 165 }() 165 166 166 - err = db.AddSpindle(tx, db.Spindle{ 167 + err = db.AddSpindle(tx, models.Spindle{ 167 168 Owner: syntax.DID(user.Did), 168 169 Instance: instance, 169 170 }) ··· 188 189 return 189 190 } 190 191 191 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 192 193 var exCid *string 193 194 if ex != nil { 194 195 exCid = ex.Cid 195 196 } 196 197 197 198 // re-announce by registering under same rkey 198 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 199 200 Collection: tangled.SpindleNSID, 200 201 Repo: user.Did, 201 202 Rkey: instance, ··· 331 332 return 332 333 } 333 334 334 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 335 336 Collection: tangled.SpindleNSID, 336 337 Repo: user.Did, 337 338 Rkey: instance, ··· 524 525 rkey := tid.TID() 525 526 526 527 // add member to db 527 - if err = db.AddSpindleMember(tx, db.SpindleMember{ 528 + if err = db.AddSpindleMember(tx, models.SpindleMember{ 528 529 Did: syntax.DID(user.Did), 529 530 Rkey: rkey, 530 531 Instance: instance, ··· 541 542 return 542 543 } 543 544 544 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 545 546 Collection: tangled.SpindleMemberNSID, 546 547 Repo: user.Did, 547 548 Rkey: rkey, ··· 682 683 } 683 684 684 685 // remove from pds 685 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 686 687 Collection: tangled.SpindleMemberNSID, 687 688 Repo: user.Did, 688 689 Rkey: members[0].Rkey,
+6 -5
appview/state/follow.go
··· 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 10 "tangled.org/core/api/tangled" 11 11 "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 12 13 "tangled.org/core/appview/pages" 13 14 "tangled.org/core/tid" 14 15 ) ··· 42 43 case http.MethodPost: 43 44 createdAt := time.Now().Format(time.RFC3339) 44 45 rkey := tid.TID() 45 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 46 47 Collection: tangled.GraphFollowNSID, 47 48 Repo: currentUser.Did, 48 49 Rkey: rkey, ··· 59 60 60 61 log.Println("created atproto record: ", resp.Uri) 61 62 62 - follow := &db.Follow{ 63 + follow := &models.Follow{ 63 64 UserDid: currentUser.Did, 64 65 SubjectDid: subjectIdent.DID.String(), 65 66 Rkey: rkey, ··· 75 76 76 77 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 78 UserDid: subjectIdent.DID.String(), 78 - FollowStatus: db.IsFollowing, 79 + FollowStatus: models.IsFollowing, 79 80 }) 80 81 81 82 return ··· 87 88 return 88 89 } 89 90 90 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 91 92 Collection: tangled.GraphFollowNSID, 92 93 Repo: currentUser.Did, 93 94 Rkey: follow.Rkey, ··· 106 107 107 108 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 108 109 UserDid: subjectIdent.DID.String(), 109 - FollowStatus: db.IsNotFollowing, 110 + FollowStatus: models.IsNotFollowing, 110 111 }) 111 112 112 113 s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+4 -4
appview/state/git_http.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/identity" 10 10 "github.com/go-chi/chi/v5" 11 - "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/models" 12 12 ) 13 13 14 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*db.Repo) 16 + repo := r.Context().Value("repo").(*models.Repo) 17 17 18 18 scheme := "https" 19 19 if s.config.Core.Dev { ··· 31 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 32 return 33 33 } 34 - repo := r.Context().Value("repo").(*db.Repo) 34 + repo := r.Context().Value("repo").(*models.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - repo := r.Context().Value("repo").(*db.Repo) 51 + repo := r.Context().Value("repo").(*models.Repo) 52 52 53 53 scheme := "https" 54 54 if s.config.Core.Dev {
+20 -6
appview/state/knotstream.go
··· 12 12 "tangled.org/core/appview/cache" 13 13 "tangled.org/core/appview/config" 14 14 "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 15 16 ec "tangled.org/core/eventconsumer" 16 17 "tangled.org/core/eventconsumer/cursor" 17 18 "tangled.org/core/log" ··· 124 125 } 125 126 } 126 127 127 - punch := db.Punch{ 128 + punch := models.Punch{ 128 129 Did: record.CommitterDid, 129 130 Date: time.Now(), 130 131 Count: count, ··· 156 157 return fmt.Errorf("%s is not a valid reference name", ref) 157 158 } 158 159 159 - var langs []db.RepoLanguage 160 + var langs []models.RepoLanguage 160 161 for _, l := range record.Meta.LangBreakdown.Inputs { 161 162 if l == nil { 162 163 continue 163 164 } 164 165 165 - langs = append(langs, db.RepoLanguage{ 166 + langs = append(langs, models.RepoLanguage{ 166 167 RepoAt: repo.RepoAt(), 167 168 Ref: ref.Short(), 168 169 IsDefaultRef: record.Meta.IsDefaultRef, ··· 171 172 }) 172 173 } 173 174 174 - return db.InsertRepoLanguages(d, langs) 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 175 189 } 176 190 177 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 207 221 } 208 222 209 223 // trigger info 210 - var trigger db.Trigger 224 + var trigger models.Trigger 211 225 var sha string 212 226 trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 213 227 switch trigger.Kind { ··· 234 248 return fmt.Errorf("failed to add trigger entry: %w", err) 235 249 } 236 250 237 - pipeline := db.Pipeline{ 251 + pipeline := models.Pipeline{ 238 252 Rkey: msg.Rkey, 239 253 Knot: source.Key(), 240 254 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+63
appview/state/login.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+29 -36
appview/state/profile.go
··· 17 17 "github.com/gorilla/feeds" 18 18 "tangled.org/core/api/tangled" 19 19 "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 20 21 "tangled.org/core/appview/pages" 21 22 ) 22 23 ··· 76 77 } 77 78 78 79 loggedInUser := s.oauth.GetUser(r) 79 - followStatus := db.IsNotFollowing 80 + followStatus := models.IsNotFollowing 80 81 if loggedInUser != nil { 81 82 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 83 } ··· 130 131 } 131 132 132 133 // filter out ones that are pinned 133 - pinnedRepos := []db.Repo{} 134 + pinnedRepos := []models.Repo{} 134 135 for i, r := range repos { 135 136 // if this is a pinned repo, add it 136 137 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 148 149 l.Error("failed to fetch collaborating repos", "err", err) 149 150 } 150 151 151 - pinnedCollaboratingRepos := []db.Repo{} 152 + pinnedCollaboratingRepos := []models.Repo{} 152 153 for _, r := range collaboratingRepos { 153 154 // if this is a pinned repo, add it 154 155 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 216 217 s.pages.Error500(w) 217 218 return 218 219 } 219 - var repoAts []string 220 + var repos []models.Repo 220 221 for _, s := range stars { 221 - repoAts = append(repoAts, string(s.RepoAt)) 222 - } 223 - 224 - repos, err := db.GetRepos( 225 - s.db, 226 - 0, 227 - db.FilterIn("at_uri", repoAts), 228 - ) 229 - if err != nil { 230 - l.Error("failed to get repos", "err", err) 231 - s.pages.Error500(w) 232 - return 222 + if s.Repo != nil { 223 + repos = append(repos, *s.Repo) 224 + } 233 225 } 234 226 235 227 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 271 263 272 264 func (s *State) followPage( 273 265 r *http.Request, 274 - fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 - extractDid func(db.Follow) string, 266 + fetchFollows func(db.Execer, string) ([]models.Follow, error), 267 + extractDid func(models.Follow) string, 276 268 ) (*FollowsPageParams, error) { 277 269 l := s.logger.With("handler", "reposPage") 278 270 ··· 329 321 followCards := make([]pages.FollowCard, len(follows)) 330 322 for i, did := range followDids { 331 323 followStats := followStatsMap[did] 332 - followStatus := db.IsNotFollowing 324 + followStatus := models.IsNotFollowing 333 325 if _, exists := loggedInUserFollowing[did]; exists { 334 - followStatus = db.IsFollowing 326 + followStatus = models.IsFollowing 335 327 } else if loggedInUser != nil && loggedInUser.Did == did { 336 - followStatus = db.IsSelf 328 + followStatus = models.IsSelf 337 329 } 338 330 339 - var profile *db.Profile 331 + var profile *models.Profile 340 332 if p, exists := profiles[did]; exists { 341 333 profile = p 342 334 } else { 343 - profile = &db.Profile{} 335 + profile = &models.Profile{} 344 336 profile.Did = did 345 337 } 346 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 347 340 UserDid: did, 348 341 FollowStatus: followStatus, 349 342 FollowersCount: followStats.Followers, ··· 358 351 } 359 352 360 353 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 - followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 354 + followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid }) 362 355 if err != nil { 363 356 s.pages.Notice(w, "all-followers", "Failed to load followers") 364 357 return ··· 372 365 } 373 366 374 367 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 - followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 368 + followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid }) 376 369 if err != nil { 377 370 s.pages.Notice(w, "all-following", "Failed to load following") 378 371 return ··· 453 446 return &feed, nil 454 447 } 455 448 456 - func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 449 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 457 450 for _, pull := range pulls { 458 451 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 459 452 if err != nil { ··· 466 459 return nil 467 460 } 468 461 469 - func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 462 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { 470 463 for _, issue := range issues { 471 464 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 465 if err != nil { ··· 478 471 return nil 479 472 } 480 473 481 - func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 474 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error { 482 475 for _, repo := range repos { 483 476 item, err := s.createRepoItem(ctx, repo, author) 484 477 if err != nil { ··· 489 482 return nil 490 483 } 491 484 492 - func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 485 + func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 486 return &feeds.Item{ 494 487 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 495 488 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, ··· 498 491 } 499 492 } 500 493 501 - func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 494 + func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 495 return &feeds.Item{ 503 496 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 497 Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, ··· 507 500 } 508 501 } 509 502 510 - func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 503 + func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 511 504 var title string 512 505 if repo.Source != nil { 513 506 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) ··· 558 551 stat1 := r.FormValue("stat1") 559 552 560 553 if stat0 != "" { 561 - profile.Stats[0].Kind = db.VanityStatKind(stat0) 554 + profile.Stats[0].Kind = models.VanityStatKind(stat0) 562 555 } 563 556 564 557 if stat1 != "" { 565 - profile.Stats[1].Kind = db.VanityStatKind(stat1) 558 + profile.Stats[1].Kind = models.VanityStatKind(stat1) 566 559 } 567 560 568 561 if err := db.ValidateProfile(s.db, profile); err != nil { ··· 613 606 s.updateProfile(profile, w, r) 614 607 } 615 608 616 - func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 609 + func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 617 610 user := s.oauth.GetUser(r) 618 611 tx, err := s.db.BeginTx(r.Context(), nil) 619 612 if err != nil { ··· 641 634 vanityStats = append(vanityStats, string(v.Kind)) 642 635 } 643 636 644 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 645 638 var cid *string 646 639 if ex != nil { 647 640 cid = ex.Cid 648 641 } 649 642 650 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 651 644 Collection: tangled.ActorProfileNSID, 652 645 Repo: user.Did, 653 646 Rkey: "self",
+13 -10
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/models" 14 15 "tangled.org/core/appview/pages" 15 16 "tangled.org/core/tid" 16 17 ) ··· 30 31 return 31 32 } 32 33 33 - reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind")) 34 35 if !ok { 35 36 log.Println("invalid reaction kind") 36 37 return ··· 46 47 case http.MethodPost: 47 48 createdAt := time.Now().Format(time.RFC3339) 48 49 rkey := tid.TID() 49 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 50 51 Collection: tangled.FeedReactionNSID, 51 52 Repo: currentUser.Did, 52 53 Rkey: rkey, ··· 69 70 return 70 71 } 71 72 72 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 73 74 if err != nil { 74 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 75 76 } 76 77 77 78 log.Println("created atproto record: ", resp.Uri) ··· 79 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 81 ThreadAt: subjectUri, 81 82 Kind: reactionKind, 82 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 83 85 IsReacted: true, 84 86 }) 85 87 ··· 91 93 return 92 94 } 93 95 94 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 97 Collection: tangled.FeedReactionNSID, 96 98 Repo: currentUser.Did, 97 99 Rkey: reaction.Rkey, ··· 108 110 // this is not an issue, the firehose event might have already done this 109 111 } 110 112 111 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 112 114 if err != nil { 113 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 114 116 return 115 117 } 116 118 117 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 120 ThreadAt: subjectUri, 119 121 Kind: reactionKind, 120 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 121 124 IsReacted: false, 122 125 }) 123 126
+27 -15
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 10 + "tangled.org/core/appview/labels" 11 11 "tangled.org/core/appview/middleware" 12 - oauthhandler "tangled.org/core/appview/oauth/handler" 12 + "tangled.org/core/appview/notifications" 13 13 "tangled.org/core/appview/pipelines" 14 14 "tangled.org/core/appview/pulls" 15 15 "tangled.org/core/appview/repo" ··· 34 34 35 35 router.Get("/favicon.svg", s.Favicon) 36 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 37 38 38 39 userRouter := s.UserRouter(&middleware) 39 40 standardRouter := s.StandardRouter(&middleware) ··· 90 91 r.Mount("/issues", s.IssuesRouter(mw)) 91 92 r.Mount("/pulls", s.PullsRouter(mw)) 92 93 r.Mount("/pipelines", s.PipelinesRouter(mw)) 94 + r.Mount("/labels", s.LabelsRouter(mw)) 93 95 94 96 // These routes get proxied to the knot 95 97 r.Get("/info/refs", s.InfoRefs) ··· 113 115 114 116 r.Get("/", s.HomeOrTimeline) 115 117 r.Get("/timeline", s.Timeline) 116 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 118 + r.Get("/upgradeBanner", s.UpgradeBanner) 117 119 118 120 // special-case handler for serving tangled.org/core 119 121 r.Get("/core", s.Core()) 120 122 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 126 + 121 127 r.Route("/repo", func(r chi.Router) { 122 128 r.Route("/new", func(r chi.Router) { 123 129 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 126 132 }) 127 133 // r.Post("/import", s.ImportRepo) 128 134 }) 135 + 136 + r.Get("/goodfirstissues", s.GoodFirstIssues) 129 137 130 138 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 131 139 r.Post("/", s.Follow) ··· 154 162 r.Mount("/strings", s.StringsRouter(mw)) 155 163 r.Mount("/knots", s.KnotsRouter()) 156 164 r.Mount("/spindles", s.SpindlesRouter()) 165 + r.Mount("/notifications", s.NotificationsRouter(mw)) 166 + 157 167 r.Mount("/signup", s.SignupRouter()) 158 - r.Mount("/", s.OAuthRouter()) 168 + r.Mount("/", s.oauth.Router()) 159 169 160 170 r.Get("/keys/{user}", s.Keys) 161 171 r.Get("/terms", s.TermsOfService) 162 172 r.Get("/privacy", s.PrivacyPolicy) 173 + r.Get("/brand", s.Brand) 163 174 164 175 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 165 176 s.pages.Error404(w) ··· 173 184 return func(w http.ResponseWriter, r *http.Request) { 174 185 if r.URL.Query().Get("go-get") == "1" { 175 186 w.Header().Set("Content-Type", "text/html") 176 - w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`)) 187 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 177 188 return 178 189 } 179 190 ··· 181 192 } 182 193 } 183 194 184 - func (s *State) OAuthRouter() http.Handler { 185 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 186 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 187 - return oauth.Router() 188 - } 189 - 190 195 func (s *State) SettingsRouter() http.Handler { 191 196 settings := &settings.Settings{ 192 197 Db: s.db, ··· 238 243 Db: s.db, 239 244 OAuth: s.oauth, 240 245 Pages: s.pages, 241 - Config: s.config, 242 - Enforcer: s.enforcer, 243 246 IdResolver: s.idResolver, 244 - Knotstream: s.knotstream, 245 247 Notifier: s.notifier, 246 248 Logger: logger, 247 249 } ··· 261 263 262 264 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 263 265 logger := log.New("repo") 264 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 266 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger, s.validator) 265 267 return repo.Router(mw) 266 268 } 267 269 268 270 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 269 271 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 270 272 return pipes.Router(mw) 273 + } 274 + 275 + func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 276 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 277 + return ls.Router(mw) 278 + } 279 + 280 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 281 + notifs := notifications.New(s.db, s.oauth, s.pages) 282 + return notifs.Router(mw) 271 283 } 272 284 273 285 func (s *State) SignupRouter() http.Handler {
+2 -1
appview/state/spindlestream.go
··· 13 13 "tangled.org/core/appview/cache" 14 14 "tangled.org/core/appview/config" 15 15 "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 16 17 ec "tangled.org/core/eventconsumer" 17 18 "tangled.org/core/eventconsumer/cursor" 18 19 "tangled.org/core/log" ··· 89 90 created = t 90 91 } 91 92 92 - status := db.PipelineStatus{ 93 + status := models.PipelineStatus{ 93 94 Spindle: source.Key(), 94 95 Rkey: msg.Rkey, 95 96 PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+6 -5
appview/state/star.go
··· 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 11 "tangled.org/core/api/tangled" 12 12 "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 13 14 "tangled.org/core/appview/pages" 14 15 "tangled.org/core/tid" 15 16 ) ··· 39 40 case http.MethodPost: 40 41 createdAt := time.Now().Format(time.RFC3339) 41 42 rkey := tid.TID() 42 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 43 44 Collection: tangled.FeedStarNSID, 44 45 Repo: currentUser.Did, 45 46 Rkey: rkey, ··· 55 56 } 56 57 log.Println("created atproto record: ", resp.Uri) 57 58 58 - star := &db.Star{ 59 + star := &models.Star{ 59 60 StarredByDid: currentUser.Did, 60 61 RepoAt: subjectUri, 61 62 Rkey: rkey, ··· 77 78 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 79 IsStarred: true, 79 80 RepoAt: subjectUri, 80 - Stats: db.RepoStats{ 81 + Stats: models.RepoStats{ 81 82 StarCount: starCount, 82 83 }, 83 84 }) ··· 91 92 return 92 93 } 93 94 94 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 96 Collection: tangled.FeedStarNSID, 96 97 Repo: currentUser.Did, 97 98 Rkey: star.Rkey, ··· 119 120 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 121 IsStarred: false, 121 122 RepoAt: subjectUri, 122 - Stats: db.RepoStats{ 123 + Stats: models.RepoStats{ 123 124 StarCount: starCount, 124 125 }, 125 126 })
+116 -32
appview/state/state.go
··· 11 11 "strings" 12 12 "time" 13 13 14 - comatproto "github.com/bluesky-social/indigo/api/atproto" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - lexutil "github.com/bluesky-social/indigo/lex/util" 17 - securejoin "github.com/cyphar/filepath-securejoin" 18 - "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 20 14 "tangled.org/core/api/tangled" 21 15 "tangled.org/core/appview" 22 16 "tangled.org/core/appview/cache" 23 17 "tangled.org/core/appview/cache/session" 24 18 "tangled.org/core/appview/config" 25 19 "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 26 21 "tangled.org/core/appview/notify" 22 + dbnotify "tangled.org/core/appview/notify/db" 23 + phnotify "tangled.org/core/appview/notify/posthog" 27 24 "tangled.org/core/appview/oauth" 28 25 "tangled.org/core/appview/pages" 29 - posthogService "tangled.org/core/appview/posthog" 30 26 "tangled.org/core/appview/reporesolver" 31 27 "tangled.org/core/appview/validator" 32 28 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 36 32 tlog "tangled.org/core/log" 37 33 "tangled.org/core/rbac" 38 34 "tangled.org/core/tid" 39 - // xrpcerr "tangled.org/core/xrpc/errors" 35 + 36 + comatproto "github.com/bluesky-social/indigo/api/atproto" 37 + atpclient "github.com/bluesky-social/indigo/atproto/client" 38 + "github.com/bluesky-social/indigo/atproto/syntax" 39 + lexutil "github.com/bluesky-social/indigo/lex/util" 40 + securejoin "github.com/cyphar/filepath-securejoin" 41 + "github.com/go-chi/chi/v5" 42 + "github.com/posthog/posthog-go" 40 43 ) 41 44 42 45 type State struct { ··· 74 77 res = idresolver.DefaultResolver() 75 78 } 76 79 77 - pgs := pages.NewPages(config, res) 80 + pages := pages.NewPages(config, res) 78 81 cache := cache.New(config.Redis.Addr) 79 82 sess := session.New(cache) 80 - oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d) 83 + oauth2, err := oauth.New(config) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + } 87 + validator := validator.New(d, res, enforcer) 82 88 83 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 90 if err != nil { ··· 87 93 88 94 repoResolver := reporesolver.New(config, enforcer, res, d) 89 95 90 - wrapper := db.DbWrapper{d} 96 + wrapper := db.DbWrapper{Execer: d} 91 97 jc, err := jetstream.NewJetstreamClient( 92 98 config.Jetstream.Endpoint, 93 99 "appview", ··· 102 108 tangled.StringNSID, 103 109 tangled.RepoIssueNSID, 104 110 tangled.RepoIssueCommentNSID, 111 + tangled.LabelDefinitionNSID, 112 + tangled.LabelOpNSID, 105 113 }, 106 114 nil, 107 115 slog.Default(), ··· 116 124 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 117 125 } 118 126 127 + if err := BackfillDefaultDefs(d, res); err != nil { 128 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 129 + } 130 + 119 131 ingester := appview.Ingester{ 120 132 Db: wrapper, 121 133 Enforcer: enforcer, ··· 142 154 spindlestream.Start(ctx) 143 155 144 156 var notifiers []notify.Notifier 157 + 158 + // Always add the database notifier 159 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 160 + 161 + // Add other notifiers in production only 145 162 if !config.Core.Dev { 146 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 163 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 147 164 } 148 165 notifier := notify.NewMergedNotifier(notifiers...) 149 166 150 167 state := &State{ 151 168 d, 152 169 notifier, 153 - oauth, 170 + oauth2, 154 171 enforcer, 155 - pgs, 172 + pages, 156 173 sess, 157 174 res, 158 175 posthog, ··· 186 203 s.pages.Favicon(w) 187 204 } 188 205 206 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 207 + const manifestJson = `{ 208 + "name": "tangled", 209 + "description": "tightly-knit social coding.", 210 + "icons": [ 211 + { 212 + "src": "/favicon.svg", 213 + "sizes": "144x144" 214 + } 215 + ], 216 + "start_url": "/", 217 + "id": "org.tangled", 218 + 219 + "display": "standalone", 220 + "background_color": "#111827", 221 + "theme_color": "#111827" 222 + }` 223 + 224 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 225 + w.Header().Set("Content-Type", "application/json") 226 + w.Write([]byte(manifestJson)) 227 + } 228 + 189 229 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 190 230 user := s.oauth.GetUser(r) 191 231 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 200 240 }) 201 241 } 202 242 243 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 244 + user := s.oauth.GetUser(r) 245 + s.pages.Brand(w, pages.BrandParams{ 246 + LoggedInUser: user, 247 + }) 248 + } 249 + 203 250 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 251 if s.oauth.GetUser(r) != nil { 205 252 s.Timeline(w, r) ··· 228 275 return 229 276 } 230 277 278 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 279 + if err != nil { 280 + // non-fatal 281 + } 282 + 231 283 s.pages.Timeline(w, pages.TimelineParams{ 232 284 LoggedInUser: user, 233 285 Timeline: timeline, 234 286 Repos: repos, 287 + GfiLabel: gfiLabel, 235 288 }) 236 289 } 237 290 238 291 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 239 292 user := s.oauth.GetUser(r) 293 + if user == nil { 294 + return 295 + } 296 + 240 297 l := s.logger.With("handler", "UpgradeBanner") 241 298 l = l.With("did", user.Did) 242 - l = l.With("handle", user.Handle) 243 299 244 300 regs, err := db.GetRegistrations( 245 301 s.db, ··· 379 435 380 436 user := s.oauth.GetUser(r) 381 437 l = l.With("did", user.Did) 382 - l = l.With("handle", user.Handle) 383 438 384 439 // form validation 385 440 domain := r.FormValue("domain") ··· 419 474 } 420 475 421 476 // Check for existing repos 422 - existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 477 + existingRepo, err := db.GetRepo( 478 + s.db, 479 + db.FilterEq("did", user.Did), 480 + db.FilterEq("name", repoName), 481 + ) 423 482 if err == nil && existingRepo != nil { 424 483 l.Info("repo exists") 425 484 s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) ··· 428 487 429 488 // create atproto record for this repo 430 489 rkey := tid.TID() 431 - repo := &db.Repo{ 490 + repo := &models.Repo{ 432 491 Did: user.Did, 433 492 Name: repoName, 434 493 Knot: domain, 435 494 Rkey: rkey, 436 495 Description: description, 496 + Created: time.Now(), 497 + Labels: models.DefaultLabelDefs(), 437 498 } 499 + record := repo.AsRecord() 438 500 439 - xrpcClient, err := s.oauth.AuthorizedClient(r) 501 + atpClient, err := s.oauth.AuthorizedClient(r) 440 502 if err != nil { 441 503 l.Info("PDS write failed", "err", err) 442 504 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 443 505 return 444 506 } 445 507 446 - createdAt := time.Now().Format(time.RFC3339) 447 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 508 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 448 509 Collection: tangled.RepoNSID, 449 510 Repo: user.Did, 450 511 Rkey: rkey, 451 512 Record: &lexutil.LexiconTypeDecoder{ 452 - Val: &tangled.Repo{ 453 - Knot: repo.Knot, 454 - Name: repoName, 455 - CreatedAt: createdAt, 456 - Owner: user.Did, 457 - }}, 513 + Val: &record, 514 + }, 458 515 }) 459 516 if err != nil { 460 517 l.Info("PDS write failed", "err", err) ··· 480 537 rollback := func() { 481 538 err1 := tx.Rollback() 482 539 err2 := s.enforcer.E.LoadPolicy() 483 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 540 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 484 541 485 542 // ignore txn complete errors, this is okay 486 543 if errors.Is(err1, sql.ErrTxDone) { ··· 553 610 aturi = "" 554 611 555 612 s.notifier.NewRepo(r.Context(), repo) 556 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 613 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 557 614 } 558 615 } 559 616 560 617 // this is used to rollback changes made to the PDS 561 618 // 562 619 // it is a no-op if the provided ATURI is empty 563 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 620 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 564 621 if aturi == "" { 565 622 return nil 566 623 } ··· 571 628 repo := parsed.Authority().String() 572 629 rkey := parsed.RecordKey().String() 573 630 574 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 631 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 575 632 Collection: collection, 576 633 Repo: repo, 577 634 Rkey: rkey, 578 635 }) 579 636 return err 580 637 } 638 + 639 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 640 + defaults := models.DefaultLabelDefs() 641 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 642 + if err != nil { 643 + return err 644 + } 645 + // already present 646 + if len(defaultLabels) == len(defaults) { 647 + return nil 648 + } 649 + 650 + labelDefs, err := models.FetchDefaultDefs(r) 651 + if err != nil { 652 + return err 653 + } 654 + 655 + // Insert each label definition to the database 656 + for _, labelDef := range labelDefs { 657 + _, err = db.AddLabelDefinition(e, &labelDef) 658 + if err != nil { 659 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 660 + } 661 + } 662 + 663 + return nil 664 + }
+12 -15
appview/strings/strings.go
··· 9 9 "time" 10 10 11 11 "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/config" 13 12 "tangled.org/core/appview/db" 14 13 "tangled.org/core/appview/middleware" 14 + "tangled.org/core/appview/models" 15 15 "tangled.org/core/appview/notify" 16 16 "tangled.org/core/appview/oauth" 17 17 "tangled.org/core/appview/pages" 18 18 "tangled.org/core/appview/pages/markup" 19 - "tangled.org/core/eventconsumer" 20 19 "tangled.org/core/idresolver" 21 - "tangled.org/core/rbac" 22 20 "tangled.org/core/tid" 23 21 24 22 "github.com/bluesky-social/indigo/api/atproto" 25 23 "github.com/bluesky-social/indigo/atproto/identity" 26 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 + "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 27 28 lexutil "github.com/bluesky-social/indigo/lex/util" 28 - "github.com/go-chi/chi/v5" 29 29 ) 30 30 31 31 type Strings struct { 32 32 Db *db.DB 33 33 OAuth *oauth.OAuth 34 34 Pages *pages.Pages 35 - Config *config.Config 36 - Enforcer *rbac.Enforcer 37 35 IdResolver *idresolver.Resolver 38 36 Logger *slog.Logger 39 - Knotstream *eventconsumer.Consumer 40 37 Notifier notify.Notifier 41 38 } 42 39 ··· 241 238 description := r.FormValue("description") 242 239 243 240 // construct new string from form values 244 - entry := db.String{ 241 + entry := models.String{ 245 242 Did: first.Did, 246 243 Rkey: first.Rkey, 247 244 Filename: filename, ··· 259 256 } 260 257 261 258 // first replace the existing record in the PDS 262 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 263 260 if err != nil { 264 261 fail("Failed to updated existing record.", err) 265 262 return 266 263 } 267 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 268 265 Collection: tangled.StringNSID, 269 266 Repo: entry.Did.String(), 270 267 Rkey: entry.Rkey, ··· 289 286 s.Notifier.EditString(r.Context(), &entry) 290 287 291 288 // if that went okay, redir to the string 292 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 293 290 } 294 291 295 292 } ··· 324 321 325 322 description := r.FormValue("description") 326 323 327 - string := db.String{ 324 + string := models.String{ 328 325 Did: syntax.DID(user.Did), 329 326 Rkey: tid.TID(), 330 327 Filename: filename, ··· 341 338 return 342 339 } 343 340 344 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 345 342 Collection: tangled.StringNSID, 346 343 Repo: user.Did, 347 344 Rkey: string.Rkey, ··· 365 362 s.Notifier.NewString(r.Context(), &string) 366 363 367 364 // successful 368 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 369 366 } 370 367 } 371 368 ··· 408 405 409 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 410 407 411 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 412 409 } 413 410 414 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+3 -2
appview/validator/issue.go
··· 5 5 "strings" 6 6 7 7 "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 8 9 ) 9 10 10 - func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 11 12 // if comments have parents, only ingest ones that are 1 level deep 12 13 if comment.ReplyTo != nil { 13 14 parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) ··· 32 33 return nil 33 34 } 34 35 35 - func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + func (v *Validator) ValidateIssue(issue *models.Issue) error { 36 37 if issue.Title == "" { 37 38 return fmt.Errorf("issue title is empty") 38 39 }
+217
appview/validator/label.go
··· 1 + package validator 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "regexp" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "golang.org/x/exp/slices" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/models" 13 + ) 14 + 15 + var ( 16 + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 17 + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 18 + // Color should be a valid hex color 19 + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 20 + // You can only label issues and pulls presently 21 + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 + ) 23 + 24 + func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 + if label.Name == "" { 26 + return fmt.Errorf("label name is empty") 27 + } 28 + if len(label.Name) > 40 { 29 + return fmt.Errorf("label name too long (max 40 graphemes)") 30 + } 31 + if len(label.Name) < 1 { 32 + return fmt.Errorf("label name too short (min 1 grapheme)") 33 + } 34 + if !labelNameRegex.MatchString(label.Name) { 35 + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 36 + } 37 + 38 + if !label.ValueType.IsConcreteType() { 39 + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) 40 + } 41 + 42 + // null type checks: cannot be enums, multiple or explicit format 43 + if label.ValueType.IsNull() && label.ValueType.IsEnum() { 44 + return fmt.Errorf("null type cannot be used in conjunction with enum type") 45 + } 46 + if label.ValueType.IsNull() && label.Multiple { 47 + return fmt.Errorf("null type labels cannot be multiple") 48 + } 49 + if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { 50 + return fmt.Errorf("format cannot be used in conjunction with null type") 51 + } 52 + 53 + // format checks: cannot be used with enum, or integers 54 + if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { 55 + return fmt.Errorf("enum types cannot be used in conjunction with format specification") 56 + } 57 + 58 + if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { 59 + return fmt.Errorf("format specifications are only permitted on string types") 60 + } 61 + 62 + // validate scope (nsid format) 63 + if label.Scope == nil { 64 + return fmt.Errorf("scope is required") 65 + } 66 + for _, s := range label.Scope { 67 + if _, err := syntax.ParseNSID(s); err != nil { 68 + return fmt.Errorf("failed to parse scope: %w", err) 69 + } 70 + if !slices.Contains(validScopes, s) { 71 + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 72 + } 73 + } 74 + 75 + // validate color if provided 76 + if label.Color != nil { 77 + color := strings.TrimSpace(*label.Color) 78 + if color == "" { 79 + // empty color is fine, set to nil 80 + label.Color = nil 81 + } else { 82 + if !colorRegex.MatchString(color) { 83 + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 84 + } 85 + // expand 3-digit hex to 6-digit hex 86 + if len(color) == 4 { // #ABC 87 + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 88 + } 89 + // convert to uppercase for consistency 90 + color = strings.ToUpper(color) 91 + label.Color = &color 92 + } 93 + } 94 + 95 + return nil 96 + } 97 + 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 + if labelDef == nil { 100 + return fmt.Errorf("label definition is required") 101 + } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 105 + if labelOp == nil { 106 + return fmt.Errorf("label operation is required") 107 + } 108 + 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 118 + } 119 + 120 + expectedKey := labelDef.AtUri().String() 121 + if labelOp.OperandKey != expectedKey { 122 + return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 123 + } 124 + 125 + if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 126 + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 127 + } 128 + 129 + if labelOp.Subject == "" { 130 + return fmt.Errorf("subject URI is required") 131 + } 132 + if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 133 + return fmt.Errorf("invalid subject URI: %w", err) 134 + } 135 + 136 + if err := v.validateOperandValue(labelDef, labelOp); err != nil { 137 + return fmt.Errorf("invalid operand value: %w", err) 138 + } 139 + 140 + // Validate performed time is not zero/invalid 141 + if labelOp.PerformedAt.IsZero() { 142 + return fmt.Errorf("performed_at timestamp is required") 143 + } 144 + 145 + return nil 146 + } 147 + 148 + func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 149 + valueType := labelDef.ValueType 150 + 151 + // this is permitted, it "unsets" a label 152 + if labelOp.OperandValue == "" { 153 + labelOp.Operation = models.LabelOperationDel 154 + return nil 155 + } 156 + 157 + switch valueType.Type { 158 + case models.ConcreteTypeNull: 159 + // For null type, value should be empty 160 + if labelOp.OperandValue != "null" { 161 + return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 162 + } 163 + 164 + case models.ConcreteTypeString: 165 + // For string type, validate enum constraints if present 166 + if valueType.IsEnum() { 167 + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 168 + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 169 + } 170 + } 171 + 172 + switch valueType.Format { 173 + case models.ValueTypeFormatDid: 174 + id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 175 + if err != nil { 176 + return fmt.Errorf("failed to resolve did/handle: %w", err) 177 + } 178 + 179 + labelOp.OperandValue = id.DID.String() 180 + 181 + case models.ValueTypeFormatAny, "": 182 + default: 183 + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 184 + } 185 + 186 + case models.ConcreteTypeInt: 187 + if labelOp.OperandValue == "" { 188 + return fmt.Errorf("integer type requires non-empty value") 189 + } 190 + if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { 191 + return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) 192 + } 193 + 194 + if valueType.IsEnum() { 195 + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 196 + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 197 + } 198 + } 199 + 200 + case models.ConcreteTypeBool: 201 + if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 202 + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 203 + } 204 + 205 + // validate enum constraints if present (though uncommon for booleans) 206 + if valueType.IsEnum() { 207 + if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 208 + return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 209 + } 210 + } 211 + 212 + default: 213 + return fmt.Errorf("unsupported value type: %q", valueType.Type) 214 + } 215 + 216 + return nil 217 + }
+27
appview/validator/string.go
··· 1 + package validator 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "unicode/utf8" 7 + 8 + "tangled.org/core/appview/models" 9 + ) 10 + 11 + func (v *Validator) ValidateString(s *models.String) error { 12 + var err error 13 + 14 + if utf8.RuneCountInString(s.Filename) > 140 { 15 + err = errors.Join(err, fmt.Errorf("filename too long")) 16 + } 17 + 18 + if utf8.RuneCountInString(s.Description) > 280 { 19 + err = errors.Join(err, fmt.Errorf("description too long")) 20 + } 21 + 22 + if len(s.Contents) == 0 { 23 + err = errors.Join(err, fmt.Errorf("contents is empty")) 24 + } 25 + 26 + return err 27 + }
+7 -1
appview/validator/validator.go
··· 3 3 import ( 4 4 "tangled.org/core/appview/db" 5 5 "tangled.org/core/appview/pages/markup" 6 + "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 6 8 ) 7 9 8 10 type Validator struct { 9 11 db *db.DB 10 12 sanitizer markup.Sanitizer 13 + resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 11 15 } 12 16 13 - func New(db *db.DB) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 14 18 return &Validator{ 15 19 db: db, 16 20 sanitizer: markup.NewSanitizer(), 21 + resolver: res, 22 + enforcer: enforcer, 17 23 } 18 24 }
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+9
consts/consts.go
··· 1 + package consts 2 + 3 + const ( 4 + TangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 5 + IcyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 6 + 7 + DefaultSpindle = "spindle.tangled.sh" 8 + DefaultKnot = "knot1.tangled.sh" 9 + )
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 25 26 26 ```yaml 27 27 when:
+4 -4
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 40 40 github.com/urfave/cli/v3 v3.3.3 41 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 43 + github.com/yuin/goldmark v1.7.13 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 50 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 51 52 ) 52 53 53 54 require ( ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+6 -4
go.sum
··· 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 436 438 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 439 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 440 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 442 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 443 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 444 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 445 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= ··· 652 654 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 655 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 656 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 h1:EpQ9MT09jSf4Zjs1+yFvB4CD/fBkFdx8UaDJDwO1Jk8= 658 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5/go.mod h1:BQFGoN2V+h5KtgKsQgWU73R55ILdDy/R5RZTrZi6wog= 657 659 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 660 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
-103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 29 30 - type TagList struct { 31 - refs []*TagReference 32 - r *git.Repository 33 - } 34 - 35 - // TagReference is used to list both tag and non-annotated tags. 36 - // Non-annotated tags should only contains a reference. 37 - // Annotated tags should contain its reference and its tag information. 38 - type TagReference struct { 39 - ref *plumbing.Reference 40 - tag *object.Tag 41 - } 42 - 43 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 48 35 mode fs.FileMode 49 36 modTime time.Time 50 37 isDir bool 51 - } 52 - 53 - func (self *TagList) Len() int { 54 - return len(self.refs) 55 - } 56 - 57 - func (self *TagList) Swap(i, j int) { 58 - self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 59 - } 60 - 61 - // sorting tags in reverse chronological order 62 - func (self *TagList) Less(i, j int) bool { 63 - var dateI time.Time 64 - var dateJ time.Time 65 - 66 - if self.refs[i].tag != nil { 67 - dateI = self.refs[i].tag.Tagger.When 68 - } else { 69 - c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 70 - if err != nil { 71 - dateI = time.Now() 72 - } else { 73 - dateI = c.Committer.When 74 - } 75 - } 76 - 77 - if self.refs[j].tag != nil { 78 - dateJ = self.refs[j].tag.Tagger.When 79 - } else { 80 - c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 81 - if err != nil { 82 - dateJ = time.Now() 83 - } else { 84 - dateJ = c.Committer.When 85 - } 86 - } 87 - 88 - return dateI.After(dateJ) 89 38 } 90 39 91 40 func Open(path string, ref string) (*GitRepo, error) { ··· 171 120 return g.r.CommitObject(h) 172 121 } 173 122 174 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 175 - c, err := g.r.CommitObject(g.h) 176 - if err != nil { 177 - return nil, fmt.Errorf("last commit: %w", err) 178 - } 179 - return c, nil 180 - } 181 - 182 123 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 124 c, err := g.r.CommitObject(g.h) 184 125 if err != nil { ··· 211 152 } 212 153 213 154 return buf.Bytes(), nil 214 - } 215 - 216 - func (g *GitRepo) FileContent(path string) (string, error) { 217 - c, err := g.r.CommitObject(g.h) 218 - if err != nil { 219 - return "", fmt.Errorf("commit object: %w", err) 220 - } 221 - 222 - tree, err := c.Tree() 223 - if err != nil { 224 - return "", fmt.Errorf("file tree: %w", err) 225 - } 226 - 227 - file, err := tree.File(path) 228 - if err != nil { 229 - return "", err 230 - } 231 - 232 - isbin, _ := file.IsBinary() 233 - 234 - if !isbin { 235 - return file.Contents() 236 - } else { 237 - return "", ErrBinaryFile 238 - } 239 155 } 240 156 241 157 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 326 func (i *infoWrapper) Sys() any { 411 327 return nil 412 328 } 413 - 414 - func (t *TagReference) Name() string { 415 - return t.ref.Name().Short() 416 - } 417 - 418 - func (t *TagReference) Message() string { 419 - if t.tag != nil { 420 - return t.tag.Message 421 - } 422 - return "" 423 - } 424 - 425 - func (t *TagReference) TagObject() *object.Tag { 426 - return t.tag 427 - } 428 - 429 - func (t *TagReference) Hash() plumbing.Hash { 430 - return t.ref.Hash() 431 - }
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+3 -3
knotserver/ingester.go
··· 141 141 return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 142 142 } 143 143 144 - didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 144 + didSlashRepo, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 145 145 if err != nil { 146 146 return fmt.Errorf("failed to construct relative repo path: %w", err) 147 147 } ··· 151 151 return fmt.Errorf("failed to construct absolute repo path: %w", err) 152 152 } 153 153 154 - gr, err := git.Open(repoPath, record.Source.Branch) 154 + gr, err := git.Open(repoPath, record.Source.Sha) 155 155 if err != nil { 156 156 return fmt.Errorf("failed to open git repository: %w", err) 157 157 } ··· 191 191 Kind: string(workflow.TriggerKindPullRequest), 192 192 PullRequest: &trigger, 193 193 Repo: &tangled.Pipeline_TriggerRepo{ 194 - Did: repo.Owner, 194 + Did: ident.DID.String(), 195 195 Knot: repo.Knot, 196 196 Repo: repo.Name, 197 197 },
-36
knotserver/util.go
··· 1 1 package knotserver 2 2 3 3 import ( 4 - "net/http" 5 - "os" 6 - "path/filepath" 7 - 8 4 "github.com/bluesky-social/indigo/atproto/syntax" 9 - securejoin "github.com/cyphar/filepath-securejoin" 10 - "github.com/go-chi/chi/v5" 11 5 ) 12 - 13 - func didPath(r *http.Request) string { 14 - did := chi.URLParam(r, "did") 15 - name := chi.URLParam(r, "name") 16 - path, _ := securejoin.SecureJoin(did, name) 17 - filepath.Clean(path) 18 - return path 19 - } 20 - 21 - func getDescription(path string) (desc string) { 22 - db, err := os.ReadFile(filepath.Join(path, "description")) 23 - if err == nil { 24 - desc = string(db) 25 - } else { 26 - desc = "" 27 - } 28 - return 29 - } 30 - func setContentDisposition(w http.ResponseWriter, name string) { 31 - h := "inline; filename=\"" + name + "\"" 32 - w.Header().Add("Content-Disposition", h) 33 - } 34 - 35 - func setGZipMIME(w http.ResponseWriter) { 36 - setMIME(w, "application/gzip") 37 - } 38 - 39 - func setMIME(w http.ResponseWriter, mime string) { 40 - w.Header().Add("Content-Type", mime) 41 - } 42 6 43 7 var TIDClock = syntax.NewTIDClock(0) 44 8
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+24
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 9 11 "tangled.org/core/knotserver/git" 10 12 xrpcerr "tangled.org/core/xrpc/errors" 11 13 ) ··· 43 45 return 44 46 } 45 47 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 46 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 68 for i, file := range files { ··· 83 103 Parent: parentPtr, 84 104 Dotdot: dotdotPtr, 85 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 86 110 } 87 111 88 112 writeJson(w, response)
-158
legal/privacy.md
··· 1 - # Privacy Policy 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - This Privacy Policy describes how Tangled ("we," "us," or "our") 6 - collects, uses, and shares your personal information when you use our 7 - platform and services (the "Service"). 8 - 9 - ## 1. Information We Collect 10 - 11 - ### Account Information 12 - 13 - When you create an account, we collect: 14 - 15 - - Your chosen username 16 - - Email address 17 - - Profile information you choose to provide 18 - - Authentication data 19 - 20 - ### Content and Activity 21 - 22 - We store: 23 - 24 - - Code repositories and associated metadata 25 - - Issues, pull requests, and comments 26 - - Activity logs and usage patterns 27 - - Public keys for authentication 28 - 29 - ## 2. Data Location and Hosting 30 - 31 - ### EU Data Hosting 32 - 33 - **All Tangled service data is hosted within the European Union.** 34 - Specifically: 35 - 36 - - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 - (*.tngl.sh) are located in Finland 38 - - **Application Data:** All other service data is stored on EU-based 39 - servers 40 - - **Data Processing:** All data processing occurs within EU 41 - jurisdiction 42 - 43 - ### External PDS Notice 44 - 45 - **Important:** If your account is hosted on Bluesky's PDS or other 46 - self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 - that data. The data protection, storage location, and privacy 48 - practices for such accounts are governed by the respective PDS 49 - provider's policies, not this Privacy Policy. We only control data 50 - processing within our own services and infrastructure. 51 - 52 - ## 3. Third-Party Data Processors 53 - 54 - We only share your data with the following third-party processors: 55 - 56 - ### Resend (Email Services) 57 - 58 - - **Purpose:** Sending transactional emails (account verification, 59 - notifications) 60 - - **Data Shared:** Email address and necessary message content 61 - 62 - ### Cloudflare (Image Caching) 63 - 64 - - **Purpose:** Caching and optimizing image delivery 65 - - **Data Shared:** Public images and associated metadata for caching 66 - purposes 67 - 68 - ### Posthog (Usage Metrics Tracking) 69 - 70 - - **Purpose:** Tracking usage and platform metrics 71 - - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 - information 73 - 74 - ## 4. How We Use Your Information 75 - 76 - We use your information to: 77 - 78 - - Provide and maintain the Service 79 - - Process your transactions and requests 80 - - Send you technical notices and support messages 81 - - Improve and develop new features 82 - - Ensure security and prevent fraud 83 - - Comply with legal obligations 84 - 85 - ## 5. Data Sharing and Disclosure 86 - 87 - We do not sell, trade, or rent your personal information. We may share 88 - your information only in the following circumstances: 89 - 90 - - With the third-party processors listed above 91 - - When required by law or legal process 92 - - To protect our rights, property, or safety, or that of our users 93 - - In connection with a merger, acquisition, or sale of assets (with 94 - appropriate protections) 95 - 96 - ## 6. Data Security 97 - 98 - We implement appropriate technical and organizational measures to 99 - protect your personal information against unauthorized access, 100 - alteration, disclosure, or destruction. However, no method of 101 - transmission over the Internet is 100% secure. 102 - 103 - ## 7. Data Retention 104 - 105 - We retain your personal information for as long as necessary to provide 106 - the Service and fulfill the purposes outlined in this Privacy Policy, 107 - unless a longer retention period is required by law. 108 - 109 - ## 8. Your Rights 110 - 111 - Under applicable data protection laws, you have the right to: 112 - 113 - - Access your personal information 114 - - Correct inaccurate information 115 - - Request deletion of your information 116 - - Object to processing of your information 117 - - Data portability 118 - - Withdraw consent (where applicable) 119 - 120 - ## 9. Cookies and Tracking 121 - 122 - We use cookies and similar technologies to: 123 - 124 - - Maintain your login session 125 - - Remember your preferences 126 - - Analyze usage patterns to improve the Service 127 - 128 - You can control cookie settings through your browser preferences. 129 - 130 - ## 10. Children's Privacy 131 - 132 - The Service is not intended for children under 16 years of age. We do 133 - not knowingly collect personal information from children under 16. If 134 - we become aware that we have collected such information, we will take 135 - steps to delete it. 136 - 137 - ## 11. International Data Transfers 138 - 139 - While all our primary data processing occurs within the EU, some of our 140 - third-party processors may process data outside the EU. When this 141 - occurs, we ensure appropriate safeguards are in place, such as Standard 142 - Contractual Clauses or adequacy decisions. 143 - 144 - ## 12. Changes to This Privacy Policy 145 - 146 - We may update this Privacy Policy from time to time. We will notify you 147 - of any changes by posting the new Privacy Policy on this page and 148 - updating the "Last updated" date. 149 - 150 - ## 13. Contact Information 151 - 152 - If you have any questions about this Privacy Policy or wish to exercise 153 - your rights, please contact us through our platform or via email. 154 - 155 - --- 156 - 157 - This Privacy Policy complies with the EU General Data Protection 158 - Regulation (GDPR) and other applicable data protection laws.
-109
legal/terms.md
··· 1 - # Terms of Service 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 - to and use of the Tangled platform and services (the "Service") 7 - operated by us ("Tangled," "we," "us," or "our"). 8 - 9 - ## 1. Acceptance of Terms 10 - 11 - By accessing or using our Service, you agree to be bound by these Terms. 12 - If you disagree with any part of these terms, then you may not access 13 - the Service. 14 - 15 - ## 2. Account Registration 16 - 17 - To use certain features of the Service, you must register for an 18 - account. You agree to provide accurate, current, and complete 19 - information during the registration process and to update such 20 - information to keep it accurate, current, and complete. 21 - 22 - ## 3. Account Termination 23 - 24 - > **Important Notice** 25 - > 26 - > **We reserve the right to terminate, suspend, or restrict access to 27 - > your account at any time, for any reason, or for no reason at all, at 28 - > our sole discretion.** This includes, but is not limited to, 29 - > termination for violation of these Terms, inappropriate conduct, spam, 30 - > abuse, or any other behavior we deem harmful to the Service or other 31 - > users. 32 - > 33 - > Account termination may result in the loss of access to your 34 - > repositories, data, and other content associated with your account. We 35 - > are not obligated to provide advance notice of termination, though we 36 - > may do so in our discretion. 37 - 38 - ## 4. Acceptable Use 39 - 40 - You agree not to use the Service to: 41 - 42 - - Violate any applicable laws or regulations 43 - - Infringe upon the rights of others 44 - - Upload, store, or share content that is illegal, harmful, threatening, 45 - abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 - objectionable 47 - - Engage in spam, phishing, or other deceptive practices 48 - - Attempt to gain unauthorized access to the Service or other users' 49 - accounts 50 - - Interfere with or disrupt the Service or servers connected to the 51 - Service 52 - 53 - ## 5. Content and Intellectual Property 54 - 55 - You retain ownership of the content you upload to the Service. By 56 - uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 - license to use, reproduce, modify, and distribute your content as 58 - necessary to provide the Service. 59 - 60 - ## 6. Privacy 61 - 62 - Your privacy is important to us. Please review our [Privacy 63 - Policy](/privacy), which also governs your use of the Service. 64 - 65 - ## 7. Disclaimers 66 - 67 - The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 - no warranties, expressed or implied, and hereby disclaim and negate all 69 - other warranties including without limitation, implied warranties or 70 - conditions of merchantability, fitness for a particular purpose, or 71 - non-infringement of intellectual property or other violation of rights. 72 - 73 - ## 8. Limitation of Liability 74 - 75 - In no event shall Tangled, nor its directors, employees, partners, 76 - agents, suppliers, or affiliates, be liable for any indirect, 77 - incidental, special, consequential, or punitive damages, including 78 - without limitation, loss of profits, data, use, goodwill, or other 79 - intangible losses, resulting from your use of the Service. 80 - 81 - ## 9. Indemnification 82 - 83 - You agree to defend, indemnify, and hold harmless Tangled and its 84 - affiliates, officers, directors, employees, and agents from and against 85 - any and all claims, damages, obligations, losses, liabilities, costs, 86 - or debt, and expenses (including attorney's fees). 87 - 88 - ## 10. Governing Law 89 - 90 - These Terms shall be interpreted and governed by the laws of Finland, 91 - without regard to its conflict of law provisions. 92 - 93 - ## 11. Changes to Terms 94 - 95 - We reserve the right to modify or replace these Terms at any time. If a 96 - revision is material, we will try to provide at least 30 days notice 97 - prior to any new terms taking effect. 98 - 99 - ## 12. Contact Information 100 - 101 - If you have any questions about these Terms of Service, please contact 102 - us through our platform or via email. 103 - 104 - --- 105 - 106 - These terms are effective as of the last updated date shown above and 107 - will remain in effect except with respect to any changes in their 108 - provisions in the future, which will be in effect immediately after 109 - being posted on this page.
+8 -5
lexicons/repo/repo.json
··· 12 12 "required": [ 13 13 "name", 14 14 "knot", 15 - "owner", 16 15 "createdAt" 17 16 ], 18 17 "properties": { 19 18 "name": { 20 19 "type": "string", 21 20 "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 21 }, 27 22 "knot": { 28 23 "type": "string", ··· 41 36 "type": "string", 42 37 "format": "uri", 43 38 "description": "source of the repo" 39 + }, 40 + "labels": { 41 + "type": "array", 42 + "description": "List of labels that this repo subscribes to", 43 + "items": { 44 + "type": "string", 45 + "format": "at-uri" 46 + } 44 47 }, 45 48 "createdAt": { 46 49 "type": "string",
+19
lexicons/repo/tree.json
··· 41 41 "type": "string", 42 42 "description": "Parent directory path" 43 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 44 49 "files": { 45 50 "type": "array", 46 51 "items": { ··· 69 74 "description": "Invalid request parameters" 70 75 } 71 76 ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 72 91 }, 73 92 "treeEntry": { 74 93 "type": "object",
+1 -1
nix/gomod2nix.toml
··· 527 527 [mod."lukechampine.com/blake3"] 528 528 version = "v1.4.1" 529 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 530 + [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 531 version = "v0.0.0-20250724194903-28e660378cb1" 532 532 hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+6 -6
spindle/ingester.go
··· 146 146 147 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 148 148 149 - l.Info("ingesting repo record") 149 + l.Info("ingesting repo record", "did", did) 150 150 151 151 switch e.Commit.Operation { 152 152 case models.CommitOperationCreate, models.CommitOperationUpdate: ··· 162 162 163 163 // no spindle configured for this repo 164 164 if record.Spindle == nil { 165 - l.Info("no spindle configured", "did", record.Owner, "name", record.Name) 165 + l.Info("no spindle configured", "name", record.Name) 166 166 return nil 167 167 } 168 168 169 169 // this repo did not want this spindle 170 170 if *record.Spindle != domain { 171 - l.Info("different spindle configured", "did", record.Owner, "name", record.Name, "spindle", *record.Spindle, "domain", domain) 171 + l.Info("different spindle configured", "name", record.Name, "spindle", *record.Spindle, "domain", domain) 172 172 return nil 173 173 } 174 174 175 175 // add this repo to the watch list 176 - if err := s.db.AddRepo(record.Knot, record.Owner, record.Name); err != nil { 176 + if err := s.db.AddRepo(record.Knot, did, record.Name); err != nil { 177 177 l.Error("failed to add repo", "error", err) 178 178 return fmt.Errorf("failed to add repo: %w", err) 179 179 } 180 180 181 - didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 181 + didSlashRepo, err := securejoin.SecureJoin(did, record.Name) 182 182 if err != nil { 183 183 return err 184 184 } 185 185 186 186 // add repo to rbac 187 - if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 187 + if err := s.e.AddRepo(did, rbac.ThisServer, didSlashRepo); err != nil { 188 188 l.Error("failed to add repo to enforcer", "error", err) 189 189 return fmt.Errorf("failed to add repo: %w", err) 190 190 }
+1 -1
spindle/xrpc/add_secret.go
··· 62 62 } 63 63 64 64 repo := resp.Value.Val.(*tangled.Repo) 65 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 65 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 66 66 if err != nil { 67 67 fail(xrpcerr.GenericError(err)) 68 68 return
+1 -1
spindle/xrpc/list_secrets.go
··· 57 57 } 58 58 59 59 repo := resp.Value.Val.(*tangled.Repo) 60 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 61 61 if err != nil { 62 62 fail(xrpcerr.GenericError(err)) 63 63 return
+1 -1
spindle/xrpc/remove_secret.go
··· 56 56 } 57 57 58 58 repo := resp.Value.Val.(*tangled.Repo) 59 - didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 59 + didPath, err := securejoin.SecureJoin(ident.DID.String(), repo.Name) 60 60 if err != nil { 61 61 fail(xrpcerr.GenericError(err)) 62 62 return
+7 -5
types/repo.go
··· 41 41 } 42 42 43 43 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 44 + Ref string `json:"ref,omitempty"` 45 + Parent string `json:"parent,omitempty"` 46 + Description string `json:"description,omitempty"` 47 + DotDot string `json:"dotdot,omitempty"` 48 + Files []NiceTree `json:"files,omitempty"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 49 51 } 50 52 51 53 type TagReference struct {