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

Compare changes

Choose any two refs to compare.

Changed files
+7557 -4328
.air
.tangled
workflows
api
tangled
appview
cache
session
commitverify
config
db
dns
issues
knots
labels
middleware
models
notifications
notify
oauth
pages
pagination
pipelines
posthog
pulls
repo
reporesolver
serververify
settings
signup
spindles
state
strings
validator
cmd
appview
combinediff
interdiff
knot
spindle
verifysig
crypto
docs
eventconsumer
guard
jetstream
keyfetch
knotserver
legal
lexicons
repo
nix
patchutil
rbac
spindle
types
workflow
xrpc
serviceauth
+1 -1
.air/knotserver.toml
··· 1 1 [build] 2 - cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 2 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 3 bin = ".bin/knot server" 4 4 root = "." 5 5
+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
+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.
+1 -1
appview/cache/session/store.go
··· 6 6 "fmt" 7 7 "time" 8 8 9 - "tangled.sh/tangled.sh/core/appview/cache" 9 + "tangled.org/core/appview/cache" 10 10 ) 11 11 12 12 type OAuthSession struct {
+5 -4
appview/commitverify/verify.go
··· 4 4 "log" 5 5 6 6 "github.com/go-git/go-git/v5/plumbing/object" 7 - "tangled.sh/tangled.sh/core/appview/db" 8 - "tangled.sh/tangled.sh/core/crypto" 9 - "tangled.sh/tangled.sh/core/types" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/crypto" 10 + "tangled.org/core/types" 10 11 ) 11 12 12 13 type verifiedCommit struct { ··· 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.sh/tangled.sh/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
+172 -10
appview/db/db.go
··· 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 ··· 931 953 _, err = tx.Exec(`drop table comments`) 932 954 return err 933 955 }) 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;") 934 1096 935 1097 return &DB{db}, nil 936 1098 }
+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 }
+23 -212
appview/db/issues.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/appview/pagination" 13 + "tangled.org/core/appview/models" 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 - Labels LabelState 34 - Repo *Repo 35 - } 36 - 37 - func (i *Issue) AtUri() syntax.ATURI { 38 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 39 - } 40 - 41 - func (i *Issue) AsRecord() tangled.RepoIssue { 42 - return tangled.RepoIssue{ 43 - Repo: i.RepoAt.String(), 44 - Title: i.Title, 45 - Body: &i.Body, 46 - CreatedAt: i.Created.Format(time.RFC3339), 47 - } 48 - } 49 - 50 - func (i *Issue) State() string { 51 - if i.Open { 52 - return "open" 53 - } 54 - return "closed" 55 - } 56 - 57 - type CommentListItem struct { 58 - Self *IssueComment 59 - Replies []*IssueComment 60 - } 61 - 62 - func (i *Issue) CommentList() []CommentListItem { 63 - // Create a map to quickly find comments by their aturi 64 - toplevel := make(map[string]*CommentListItem) 65 - var replies []*IssueComment 66 - 67 - // collect top level comments into the map 68 - for _, comment := range i.Comments { 69 - if comment.IsTopLevel() { 70 - toplevel[comment.AtUri().String()] = &CommentListItem{ 71 - Self: &comment, 72 - } 73 - } else { 74 - replies = append(replies, &comment) 75 - } 76 - } 77 - 78 - for _, r := range replies { 79 - parentAt := *r.ReplyTo 80 - if parent, exists := toplevel[parentAt]; exists { 81 - parent.Replies = append(parent.Replies, r) 82 - } 83 - } 84 - 85 - var listing []CommentListItem 86 - for _, v := range toplevel { 87 - listing = append(listing, *v) 88 - } 89 - 90 - // sort everything 91 - sortFunc := func(a, b *IssueComment) bool { 92 - return a.Created.Before(b.Created) 93 - } 94 - sort.Slice(listing, func(i, j int) bool { 95 - return sortFunc(listing[i].Self, listing[j].Self) 96 - }) 97 - for _, r := range listing { 98 - sort.Slice(r.Replies, func(i, j int) bool { 99 - return sortFunc(r.Replies[i], r.Replies[j]) 100 - }) 101 - } 102 - 103 - return listing 104 - } 105 - 106 - func (i *Issue) Participants() []string { 107 - participantSet := make(map[string]struct{}) 108 - participants := []string{} 109 - 110 - addParticipant := func(did string) { 111 - if _, exists := participantSet[did]; !exists { 112 - participantSet[did] = struct{}{} 113 - participants = append(participants, did) 114 - } 115 - } 116 - 117 - addParticipant(i.Did) 118 - 119 - for _, c := range i.Comments { 120 - addParticipant(c.Did) 121 - } 122 - 123 - return participants 124 - } 125 - 126 - func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 127 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 128 - if err != nil { 129 - created = time.Now() 130 - } 131 - 132 - body := "" 133 - if record.Body != nil { 134 - body = *record.Body 135 - } 136 - 137 - return Issue{ 138 - RepoAt: syntax.ATURI(record.Repo), 139 - Did: did, 140 - Rkey: rkey, 141 - Created: created, 142 - Title: record.Title, 143 - Body: body, 144 - Open: true, // new issues are open by default 145 - } 146 - } 147 - 148 - type IssueComment struct { 149 - Id int64 150 - Did string 151 - Rkey string 152 - IssueAt string 153 - ReplyTo *string 154 - Body string 155 - Created time.Time 156 - Edited *time.Time 157 - Deleted *time.Time 158 - } 159 - 160 - func (i *IssueComment) AtUri() syntax.ATURI { 161 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 162 - } 163 - 164 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 165 - return tangled.RepoIssueComment{ 166 - Body: i.Body, 167 - Issue: i.IssueAt, 168 - CreatedAt: i.Created.Format(time.RFC3339), 169 - ReplyTo: i.ReplyTo, 170 - } 171 - } 172 - 173 - func (i *IssueComment) IsTopLevel() bool { 174 - return i.ReplyTo == nil 175 - } 176 - 177 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 178 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 179 - if err != nil { 180 - created = time.Now() 181 - } 182 - 183 - ownerDid := did 184 - 185 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 186 - return nil, err 187 - } 188 - 189 - comment := IssueComment{ 190 - Did: ownerDid, 191 - Rkey: rkey, 192 - Body: record.Body, 193 - IssueAt: record.Issue, 194 - ReplyTo: record.ReplyTo, 195 - Created: created, 196 - } 197 - 198 - return &comment, nil 199 - } 200 - 201 - func PutIssue(tx *sql.Tx, issue *Issue) error { 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 202 18 // ensure sequence exists 203 19 _, err := tx.Exec(` 204 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 233 49 } 234 50 } 235 51 236 - func createNewIssue(tx *sql.Tx, issue *Issue) error { 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 237 53 // get next issue_id 238 54 var newIssueId int 239 55 err := tx.QueryRow(` 240 - update repo_issue_seqs 241 - set next_issue_id = next_issue_id + 1 242 - where repo_at = ? 56 + update repo_issue_seqs 57 + set next_issue_id = next_issue_id + 1 58 + where repo_at = ? 243 59 returning next_issue_id - 1 244 60 `, issue.RepoAt).Scan(&newIssueId) 245 61 if err != nil { ··· 256 72 return row.Scan(&issue.Id, &issue.IssueId) 257 73 } 258 74 259 - func updateIssue(tx *sql.Tx, issue *Issue) error { 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 260 76 // update existing issue 261 77 _, err := tx.Exec(` 262 78 update issues ··· 266 82 return err 267 83 } 268 84 269 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 270 - 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 271 87 272 88 var conditions []string 273 89 var args []any ··· 322 138 defer rows.Close() 323 139 324 140 for rows.Next() { 325 - var issue Issue 141 + var issue models.Issue 326 142 var createdAt string 327 143 var editedAt, deletedAt sql.Null[string] 328 144 var rowNum int64 ··· 375 191 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 376 192 } 377 193 378 - repoMap := make(map[string]*Repo) 194 + repoMap := make(map[string]*models.Repo) 379 195 for i := range repos { 380 196 repoMap[string(repos[i].RepoAt())] = &repos[i] 381 197 } ··· 415 231 } 416 232 } 417 233 418 - var issues []Issue 234 + var issues []models.Issue 419 235 for _, i := range issueMap { 420 236 issues = append(issues, *i) 421 237 } ··· 427 243 return issues, nil 428 244 } 429 245 430 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 246 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 431 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 432 248 } 433 249 434 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 435 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 436 252 row := e.QueryRow(query, repoAt, issueId) 437 253 438 - var issue Issue 254 + var issue models.Issue 439 255 var createdAt string 440 256 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 441 257 if err != nil { ··· 451 267 return &issue, nil 452 268 } 453 269 454 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 270 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 455 271 result, err := e.Exec( 456 272 `insert into issue_comments ( 457 273 did, ··· 513 329 return err 514 330 } 515 331 516 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 517 - var comments []IssueComment 332 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 + var comments []models.IssueComment 518 334 519 335 var conditions []string 520 336 var args []any ··· 550 366 } 551 367 552 368 for rows.Next() { 553 - var comment IssueComment 369 + var comment models.IssueComment 554 370 var created string 555 371 var rkey, edited, deleted, replyTo sql.Null[string] 556 372 err := rows.Scan( ··· 657 473 return err 658 474 } 659 475 660 - type IssueCount struct { 661 - Open int 662 - Closed int 663 - } 664 - 665 - func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 476 + func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 666 477 row := e.QueryRow(` 667 478 select 668 479 count(case when open = 1 then 1 end) as open_count, ··· 672 483 repoAt, 673 484 ) 674 485 675 - var count IssueCount 486 + var count models.IssueCount 676 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 677 - return IssueCount{0, 0}, err 488 + return models.IssueCount{}, err 678 489 } 679 490 680 491 return count, nil
+33 -496
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 6 "maps" 10 7 "slices" ··· 12 9 "time" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/syntax" 15 - "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/consts" 17 - ) 18 - 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" 12 + "tangled.org/core/appview/models" 33 13 ) 34 14 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) IsEnum() bool { 82 - return len(vt.Enum) > 0 83 - } 84 - 85 - func (vt ValueType) IsDidFormat() bool { 86 - return vt.Format == ValueTypeFormatDid 87 - } 88 - 89 - func (vt ValueType) IsAnyFormat() bool { 90 - return vt.Format == ValueTypeFormatAny 91 - } 92 - 93 - type LabelDefinition struct { 94 - Id int64 95 - Did string 96 - Rkey string 97 - 98 - Name string 99 - ValueType ValueType 100 - Scope []string 101 - Color *string 102 - Multiple bool 103 - Created time.Time 104 - } 105 - 106 - func (l *LabelDefinition) AtUri() syntax.ATURI { 107 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 108 - } 109 - 110 - func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 111 - vt := l.ValueType.AsRecord() 112 - return tangled.LabelDefinition{ 113 - Name: l.Name, 114 - Color: l.Color, 115 - CreatedAt: l.Created.Format(time.RFC3339), 116 - Multiple: &l.Multiple, 117 - Scope: l.Scope, 118 - ValueType: &vt, 119 - } 120 - } 121 - 122 - // random color for a given seed 123 - func randomColor(seed string) string { 124 - hash := sha1.Sum([]byte(seed)) 125 - hexStr := hex.EncodeToString(hash[:]) 126 - r := hexStr[0:2] 127 - g := hexStr[2:4] 128 - b := hexStr[4:6] 129 - 130 - return fmt.Sprintf("#%s%s%s", r, g, b) 131 - } 132 - 133 - func (ld LabelDefinition) GetColor() string { 134 - if ld.Color == nil { 135 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 136 - color := randomColor(seed) 137 - return color 138 - } 139 - 140 - return *ld.Color 141 - } 142 - 143 - func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 144 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 145 - if err != nil { 146 - created = time.Now() 147 - } 148 - 149 - multiple := false 150 - if record.Multiple != nil { 151 - multiple = *record.Multiple 152 - } 153 - 154 - var vt ValueType 155 - if record.ValueType != nil { 156 - vt = ValueTypeFromRecord(*record.ValueType) 157 - } 158 - 159 - return &LabelDefinition{ 160 - Did: did, 161 - Rkey: rkey, 162 - 163 - Name: record.Name, 164 - ValueType: vt, 165 - Scope: record.Scope, 166 - Color: record.Color, 167 - Multiple: multiple, 168 - Created: created, 169 - }, nil 170 - } 171 - 172 - func DeleteLabelDefinition(e Execer, filters ...filter) error { 173 - var conditions []string 174 - var args []any 175 - for _, filter := range filters { 176 - conditions = append(conditions, filter.Condition()) 177 - args = append(args, filter.Arg()...) 178 - } 179 - whereClause := "" 180 - if conditions != nil { 181 - whereClause = " where " + strings.Join(conditions, " and ") 182 - } 183 - query := fmt.Sprintf(`delete from label_definitions %s`, whereClause) 184 - _, err := e.Exec(query, args...) 185 - return err 186 - } 187 - 188 15 // no updating type for now 189 - func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { 16 + func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) { 190 17 result, err := e.Exec( 191 18 `insert into label_definitions ( 192 19 did, ··· 232 59 return id, nil 233 60 } 234 61 235 - func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) { 236 - 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 237 80 var conditions []string 238 81 var args []any 239 82 ··· 275 118 defer rows.Close() 276 119 277 120 for rows.Next() { 278 - var labelDefinition LabelDefinition 121 + var labelDefinition models.LabelDefinition 279 122 var createdAt, enumVariants, scopes string 280 123 var color sql.Null[string] 281 124 var multiple int ··· 324 167 } 325 168 326 169 // helper to get exactly one label def 327 - func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { 170 + func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 328 171 labels, err := GetLabelDefinitions(e, filters...) 329 172 if err != nil { 330 173 return nil, err ··· 341 184 return &labels[0], nil 342 185 } 343 186 344 - type LabelOp struct { 345 - Id int64 346 - Did string 347 - Rkey string 348 - Subject syntax.ATURI 349 - Operation LabelOperation 350 - OperandKey string 351 - OperandValue string 352 - PerformedAt time.Time 353 - IndexedAt time.Time 354 - } 355 - 356 - func (l LabelOp) SortAt() time.Time { 357 - createdAt := l.PerformedAt 358 - indexedAt := l.IndexedAt 359 - 360 - // if we don't have an indexedat, fall back to now 361 - if indexedAt.IsZero() { 362 - indexedAt = time.Now() 363 - } 364 - 365 - // if createdat is invalid (before epoch), treat as null -> return zero time 366 - if createdAt.Before(time.UnixMicro(0)) { 367 - return time.Time{} 368 - } 369 - 370 - // if createdat is <= indexedat, use createdat 371 - if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 372 - return createdAt 373 - } 374 - 375 - // otherwise, createdat is in the future relative to indexedat -> use indexedat 376 - return indexedAt 377 - } 378 - 379 - type LabelOperation string 380 - 381 - const ( 382 - LabelOperationAdd LabelOperation = "add" 383 - LabelOperationDel LabelOperation = "del" 384 - ) 385 - 386 - // a record can create multiple label ops 387 - func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 388 - performed, err := time.Parse(time.RFC3339, record.PerformedAt) 389 - if err != nil { 390 - performed = time.Now() 391 - } 392 - 393 - mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 394 - return LabelOp{ 395 - Did: did, 396 - Rkey: rkey, 397 - Subject: syntax.ATURI(record.Subject), 398 - OperandKey: operand.Key, 399 - OperandValue: operand.Value, 400 - PerformedAt: performed, 401 - } 402 - } 403 - 404 - var ops []LabelOp 405 - for _, o := range record.Add { 406 - if o != nil { 407 - op := mkOp(o) 408 - op.Operation = LabelOperationAdd 409 - ops = append(ops, op) 410 - } 411 - } 412 - for _, o := range record.Delete { 413 - if o != nil { 414 - op := mkOp(o) 415 - op.Operation = LabelOperationDel 416 - ops = append(ops, op) 417 - } 418 - } 419 - 420 - return ops 421 - } 422 - 423 - func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 424 - if len(ops) == 0 { 425 - return tangled.LabelOp{} 426 - } 427 - 428 - // use the first operation to establish common fields 429 - first := ops[0] 430 - record := tangled.LabelOp{ 431 - Subject: string(first.Subject), 432 - PerformedAt: first.PerformedAt.Format(time.RFC3339), 433 - } 434 - 435 - var addOperands []*tangled.LabelOp_Operand 436 - var deleteOperands []*tangled.LabelOp_Operand 437 - 438 - for _, op := range ops { 439 - operand := &tangled.LabelOp_Operand{ 440 - Key: op.OperandKey, 441 - Value: op.OperandValue, 442 - } 443 - 444 - switch op.Operation { 445 - case LabelOperationAdd: 446 - addOperands = append(addOperands, operand) 447 - case LabelOperationDel: 448 - deleteOperands = append(deleteOperands, operand) 449 - default: 450 - return tangled.LabelOp{} 451 - } 452 - } 453 - 454 - record.Add = addOperands 455 - record.Delete = deleteOperands 456 - 457 - return record 458 - } 459 - 460 - func AddLabelOp(e Execer, l *LabelOp) (int64, error) { 187 + func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) { 461 188 now := time.Now() 462 189 result, err := e.Exec( 463 190 `insert into label_ops ( ··· 500 227 return id, nil 501 228 } 502 229 503 - func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { 504 - var labelOps []LabelOp 230 + func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + var labelOps []models.LabelOp 505 232 var conditions []string 506 233 var args []any 507 234 ··· 541 268 defer rows.Close() 542 269 543 270 for rows.Next() { 544 - var labelOp LabelOp 271 + var labelOp models.LabelOp 545 272 var performedAt, indexedAt string 546 273 547 274 if err := rows.Scan( ··· 575 302 } 576 303 577 304 // get labels for a given list of subject URIs 578 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) { 305 + func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 579 306 ops, err := GetLabelOps(e, filters...) 580 307 if err != nil { 581 308 return nil, err 582 309 } 583 310 584 311 // group ops by subject 585 - opsBySubject := make(map[syntax.ATURI][]LabelOp) 312 + opsBySubject := make(map[syntax.ATURI][]models.LabelOp) 586 313 for _, op := range ops { 587 314 subject := syntax.ATURI(op.Subject) 588 315 opsBySubject[subject] = append(opsBySubject[subject], op) ··· 601 328 } 602 329 603 330 // apply label ops for each subject and collect results 604 - results := make(map[syntax.ATURI]LabelState) 331 + results := make(map[syntax.ATURI]models.LabelState) 605 332 for subject, subjectOps := range opsBySubject { 606 - state := NewLabelState() 333 + state := models.NewLabelState() 607 334 actx.ApplyLabelOps(state, subjectOps) 608 335 results[subject] = state 609 336 } ··· 611 338 return results, nil 612 339 } 613 340 614 - type set = map[string]struct{} 615 - 616 - type LabelState struct { 617 - inner map[string]set 618 - } 619 - 620 - func NewLabelState() LabelState { 621 - return LabelState{ 622 - inner: make(map[string]set), 623 - } 624 - } 625 - 626 - func (s LabelState) Inner() map[string]set { 627 - return s.inner 628 - } 629 - 630 - func (s LabelState) ContainsLabel(l string) bool { 631 - if valset, exists := s.inner[l]; exists { 632 - if valset != nil { 633 - return true 634 - } 635 - } 636 - 637 - return false 638 - } 639 - 640 - // go maps behavior in templates make this necessary, 641 - // indexing a map and getting `set` in return is apparently truthy 642 - func (s LabelState) ContainsLabelAndVal(l, v string) bool { 643 - if valset, exists := s.inner[l]; exists { 644 - if _, exists := valset[v]; exists { 645 - return true 646 - } 647 - } 648 - 649 - return false 650 - } 651 - 652 - func (s LabelState) GetValSet(l string) set { 653 - if valset, exists := s.inner[l]; exists { 654 - return valset 655 - } else { 656 - return make(set) 657 - } 658 - } 659 - 660 - type LabelApplicationCtx struct { 661 - Defs map[string]*LabelDefinition // labelAt -> labelDef 662 - } 663 - 664 - var ( 665 - LabelNoOpError = errors.New("no-op") 666 - ) 667 - 668 - func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) { 341 + func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 669 342 labels, err := GetLabelDefinitions(e, filters...) 670 343 if err != nil { 671 344 return nil, err 672 345 } 673 346 674 - defs := make(map[string]*LabelDefinition) 347 + defs := make(map[string]*models.LabelDefinition) 675 348 for _, l := range labels { 676 349 defs[l.AtUri().String()] = &l 677 350 } 678 351 679 - return &LabelApplicationCtx{defs}, nil 680 - } 681 - 682 - func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 683 - def, ok := c.Defs[op.OperandKey] 684 - if !ok { 685 - // this def was deleted, but an op exists, so we just skip over the op 686 - return nil 687 - } 688 - 689 - switch op.Operation { 690 - case LabelOperationAdd: 691 - // if valueset is empty, init it 692 - if state.inner[op.OperandKey] == nil { 693 - state.inner[op.OperandKey] = make(set) 694 - } 695 - 696 - // if valueset is populated & this val alr exists, this labelop is a noop 697 - if valueSet, exists := state.inner[op.OperandKey]; exists { 698 - if _, exists = valueSet[op.OperandValue]; exists { 699 - return LabelNoOpError 700 - } 701 - } 702 - 703 - if def.Multiple { 704 - // append to set 705 - state.inner[op.OperandKey][op.OperandValue] = struct{}{} 706 - } else { 707 - // reset to just this value 708 - state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 709 - } 710 - 711 - case LabelOperationDel: 712 - // if label DNE, then deletion is a no-op 713 - if valueSet, exists := state.inner[op.OperandKey]; !exists { 714 - return LabelNoOpError 715 - } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 716 - return LabelNoOpError 717 - } 718 - 719 - if def.Multiple { 720 - // remove from set 721 - delete(state.inner[op.OperandKey], op.OperandValue) 722 - } else { 723 - // reset the entire label 724 - delete(state.inner, op.OperandKey) 725 - } 726 - 727 - // if the map becomes empty, then set it to nil, this is just the inverse of add 728 - if len(state.inner[op.OperandKey]) == 0 { 729 - state.inner[op.OperandKey] = nil 730 - } 731 - 732 - } 733 - 734 - return nil 735 - } 736 - 737 - func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 738 - // sort label ops in sort order first 739 - slices.SortFunc(ops, func(a, b LabelOp) int { 740 - return a.SortAt().Compare(b.SortAt()) 741 - }) 742 - 743 - // apply ops in sequence 744 - for _, o := range ops { 745 - _ = c.ApplyLabelOp(state, o) 746 - } 747 - } 748 - 749 - // IsInverse checks if one label operation is the inverse of another 750 - // returns true if one is an add and the other is a delete with the same key and value 751 - func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 752 - if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 753 - return false 754 - } 755 - 756 - return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 757 - (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 758 - } 759 - 760 - // removes pairs of label operations that are inverses of each other 761 - // from the given slice. the function preserves the order of remaining operations. 762 - func ReduceLabelOps(ops []LabelOp) []LabelOp { 763 - if len(ops) <= 1 { 764 - return ops 765 - } 766 - 767 - keep := make([]bool, len(ops)) 768 - for i := range keep { 769 - keep[i] = true 770 - } 771 - 772 - for i := range ops { 773 - if !keep[i] { 774 - continue 775 - } 776 - 777 - for j := i + 1; j < len(ops); j++ { 778 - if !keep[j] { 779 - continue 780 - } 781 - 782 - if ops[i].IsInverse(ops[j]) { 783 - keep[i] = false 784 - keep[j] = false 785 - break // move to next i since this one is now eliminated 786 - } 787 - } 788 - } 789 - 790 - // build result slice with only kept operations 791 - var result []LabelOp 792 - for i, op := range ops { 793 - if keep[i] { 794 - result = append(result, op) 795 - } 796 - } 797 - 798 - return result 799 - } 800 - 801 - func DefaultLabelDefs() []string { 802 - rkeys := []string{ 803 - "wontfix", 804 - "duplicate", 805 - "assignee", 806 - "good-first-issue", 807 - "documentation", 808 - } 809 - 810 - defs := make([]string, len(rkeys)) 811 - for i, r := range rkeys { 812 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 813 - } 814 - 815 - return defs 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 816 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.sh/tangled.sh/core/spindle/models" 12 - "tangled.sh/tangled.sh/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.sh/tangled.sh/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
+193 -572
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.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/patchutil" 15 - "tangled.sh/tangled.sh/core/types" 16 - ) 17 - 18 - type PullState int 19 - 20 - const ( 21 - PullClosed PullState = iota 22 - PullOpen 23 - PullMerged 24 - PullDeleted 15 + "tangled.org/core/appview/models" 25 16 ) 26 17 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 432 - for _, p := range pulls { 433 - args[idx] = p.RepoAt 434 - idx += 1 435 - } 220 + var pullAts []syntax.ATURI 436 221 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 } 461 - 462 - createdTime, err := time.Parse(time.RFC3339, createdAt) 463 - if err != nil { 464 - return nil, err 465 - } 466 - s.Created = createdTime 233 + } 467 234 468 - if sourceRev.Valid { 469 - s.SourceRev = sourceRev.String 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 470 243 } 244 + } 471 245 472 - if p, ok := pulls[s.PullId]; ok { 473 - p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 474 - p.Submissions[s.RoundNumber] = &s 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 + for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 475 251 } 476 252 } 477 - if err := rows.Err(); err != nil { 478 - 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) 479 256 } 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 - 494 - args = []any{} 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 + } 495 261 for _, p := range pulls { 496 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 497 - } 498 - commentsRows, err := e.Query(commentsQuery, args...) 499 - if err != nil { 500 - return nil, err 501 - } 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 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 512 266 } 513 - if p, ok := pulls[pullId]; ok { 514 - p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 515 - } 516 - } 517 - if err := rows.Err(); err != nil { 518 - return nil, err 519 267 } 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 588 292 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 - } 601 - } 293 + return pulls[0], nil 294 + } 602 295 603 - if stackId.Valid { 604 - pull.StackId = stackId.String 605 - } 606 - if changeId.Valid { 607 - pull.ChangeId = changeId.String 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()...) 608 303 } 609 - if parentChangeId.Valid { 610 - pull.ParentChangeId = parentChangeId.String 304 + 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 { 363 + return nil, err 364 + } 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 { 659 370 return nil, err 660 371 } 661 - if len(submissionsMap) == 0 { 662 - return &pull, nil 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 376 + } 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 + }) 663 389 } 664 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()...) 668 400 } 669 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 670 - commentsQuery := fmt.Sprintf(` 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 405 + } 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 713 - } 714 - comment.Created = commentCreatedTime 715 - 716 - // Add the comment to its submission 717 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 718 - submission.Comments = append(submission.Comments, comment) 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 719 450 } 720 451 721 - } 722 - if err = commentsRows.Err(); err != nil { 723 - return nil, err 724 - } 725 - 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 - } 452 + comments = append(comments, comment) 736 453 } 737 454 738 - pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 739 - for _, submission := range submissionsMap { 740 - pull.Submissions[submission.RoundNumber] = submission 455 + if err := rows.Err(); err != nil { 456 + return nil, err 741 457 } 742 458 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 ··· 1029 736 1030 737 return pulls, nil 1031 738 } 1032 - 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 - } 1039 - 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) 1045 - 1046 - if position < 0 { 1047 - return nil 1048 - } 1049 - 1050 - return stack[position:] 1051 - } 1052 - 1053 - // all pulls below this pull (excluding self) in this stack 1054 - func (stack Stack) StrictlyBelow(pull *Pull) Stack { 1055 - below := stack.Below(pull) 1056 - 1057 - if len(below) > 0 { 1058 - return below[1:] 1059 - } 1060 - 1061 - return nil 1062 - } 1063 - 1064 - // all pulls above this pull (including self) in this stack 1065 - func (stack Stack) Above(pull *Pull) Stack { 1066 - position := stack.Position(pull) 1067 - 1068 - if position < 0 { 1069 - return nil 1070 - } 1071 - 1072 - return stack[:position+1] 1073 - } 1074 - 1075 - // all pulls below this pull (excluding self) in this stack 1076 - func (stack Stack) StrictlyAbove(pull *Pull) Stack { 1077 - above := stack.Above(pull) 1078 - 1079 - if len(above) > 0 { 1080 - return above[:len(above)-1] 1081 - } 1082 - 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") 1094 - } 1095 - return combined.String() 1096 - } 1097 - 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 1103 - 1104 - for _, p := range stack { 1105 - // stop at the first merged PR 1106 - if p.State == PullMerged || p.State == PullClosed { 1107 - break 1108 - } 1109 - 1110 - // skip over deleted PRs 1111 - if p.State != PullDeleted { 1112 - mergeable = append(mergeable, p) 1113 - } 1114 - } 1115 - 1116 - return mergeable 1117 - }
+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 {
+14 -63
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 { 65 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 + countMap := map[models.ReactionKind]int{} 67 + for _, kind := range models.OrderedReactionKinds { 117 68 count, err := GetReactionCount(e, threadAt, kind) 118 69 if err != nil { 119 - return map[ReactionKind]int{}, nil 70 + return map[models.ReactionKind]int{}, nil 120 71 } 121 72 countMap[kind] = count 122 73 } 123 74 return countMap, nil 124 75 } 125 76 126 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 77 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 127 78 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 79 return false 129 80 } else { ··· 131 82 } 132 83 } 133 84 134 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 - statusMap := map[ReactionKind]bool{} 136 - for _, kind := range OrderedReactionKinds { 85 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 86 + statusMap := map[models.ReactionKind]bool{} 87 + for _, kind := range models.OrderedReactionKinds { 137 88 count := GetReactionStatus(e, userDid, threadAt, kind) 138 89 statusMap[kind] = count 139 90 }
+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 {
+63 -87
appview/db/repos.go
··· 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 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 ··· 22 24 Created time.Time 23 25 Description string 24 26 Spindle string 25 - Labels []string 26 27 27 28 // optionally, populate this when querying for reverse mappings 28 - RepoStats *RepoStats 29 + RepoStats *models.RepoStats 29 30 30 31 // optional 31 32 Source string 32 33 } 33 34 34 - func (r *Repo) AsRecord() tangled.Repo { 35 - var source, spindle, description *string 36 - 37 - if r.Source != "" { 38 - source = &r.Source 39 - } 40 - 41 - if r.Spindle != "" { 42 - spindle = &r.Spindle 43 - } 44 - 45 - if r.Description != "" { 46 - description = &r.Description 47 - } 48 - 49 - return tangled.Repo{ 50 - Knot: r.Knot, 51 - Name: r.Name, 52 - Description: description, 53 - CreatedAt: r.Created.Format(time.RFC3339), 54 - Source: source, 55 - Spindle: spindle, 56 - Labels: r.Labels, 57 - } 58 - } 59 - 60 35 func (r Repo) RepoAt() syntax.ATURI { 61 36 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 62 37 } ··· 66 41 return p 67 42 } 68 43 69 - func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 70 - 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) 71 46 72 47 var conditions []string 73 48 var args []any ··· 88 63 89 64 repoQuery := fmt.Sprintf( 90 65 `select 66 + id, 91 67 did, 92 68 name, 93 69 knot, ··· 111 87 } 112 88 113 89 for rows.Next() { 114 - var repo Repo 90 + var repo models.Repo 115 91 var createdAt string 116 92 var description, source, spindle sql.NullString 117 93 118 94 err := rows.Scan( 95 + &repo.Id, 119 96 &repo.Did, 120 97 &repo.Name, 121 98 &repo.Knot, ··· 142 119 repo.Spindle = spindle.String 143 120 } 144 121 145 - repo.RepoStats = &RepoStats{} 122 + repo.RepoStats = &models.RepoStats{} 146 123 repoMap[repo.RepoAt()] = &repo 147 124 } 148 125 ··· 184 161 185 162 languageQuery := fmt.Sprintf( 186 163 ` 187 - select 188 - repo_at, language 189 - from 190 - repo_languages r1 191 - where 192 - 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) 193 175 and is_default_ref = 1 194 - and id = ( 195 - select id 196 - from repo_languages r2 197 - where r2.repo_at = r1.repo_at 198 - and r2.is_default_ref = 1 199 - order by bytes desc 200 - limit 1 201 - ); 176 + ) 177 + where rn = 1 202 178 `, 203 179 inClause, 204 180 ) ··· 290 266 inClause, 291 267 ) 292 268 args = append([]any{ 293 - PullOpen, 294 - PullMerged, 295 - PullClosed, 296 - PullDeleted, 269 + models.PullOpen, 270 + models.PullMerged, 271 + models.PullClosed, 272 + models.PullDeleted, 297 273 }, args...) 298 274 rows, err = e.Query( 299 275 pullCountQuery, ··· 320 296 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 321 297 } 322 298 323 - var repos []Repo 299 + var repos []models.Repo 324 300 for _, r := range repoMap { 325 301 repos = append(repos, *r) 326 302 } 327 303 328 - slices.SortFunc(repos, func(a, b Repo) int { 304 + slices.SortFunc(repos, func(a, b models.Repo) int { 329 305 if a.Created.After(b.Created) { 330 306 return -1 331 307 } ··· 336 312 } 337 313 338 314 // helper to get exactly one repo 339 - func GetRepo(e Execer, filters ...filter) (*Repo, error) { 315 + func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 340 316 repos, err := GetRepos(e, 0, filters...) 341 317 if err != nil { 342 318 return nil, err ··· 377 353 return count, nil 378 354 } 379 355 380 - func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { 381 - var repo Repo 356 + func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 + var repo models.Repo 382 358 var nullableDescription sql.NullString 383 359 384 - 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) 385 361 386 362 var createdAt string 387 - 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 { 388 364 return nil, err 389 365 } 390 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 399 375 return &repo, nil 400 376 } 401 377 402 - func AddRepo(e Execer, repo *Repo) error { 403 - _, err := e.Exec( 378 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 + _, err := tx.Exec( 404 380 `insert into repos 405 381 (did, name, knot, rkey, at_uri, description, source) 406 382 values (?, ?, ?, ?, ?, ?, ?)`, 407 383 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 408 384 ) 409 - return err 385 + if err != nil { 386 + return fmt.Errorf("failed to insert repo: %w", err) 387 + } 388 + 389 + for _, dl := range repo.Labels { 390 + if err := SubscribeLabel(tx, &models.RepoLabel{ 391 + RepoAt: repo.RepoAt(), 392 + LabelAt: syntax.ATURI(dl), 393 + }); err != nil { 394 + return fmt.Errorf("failed to subscribe to label: %w", err) 395 + } 396 + } 397 + 398 + return nil 410 399 } 411 400 412 401 func RemoveRepo(e Execer, did, name string) error { ··· 423 412 return nullableSource.String, nil 424 413 } 425 414 426 - func GetForksByDid(e Execer, did string) ([]Repo, error) { 427 - var repos []Repo 415 + func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 416 + var repos []models.Repo 428 417 429 418 rows, err := e.Query( 430 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 431 420 from repos r 432 421 left join collaborators c on r.at_uri = c.repo_at 433 422 where (r.did = ? or c.subject_did = ?) ··· 442 431 defer rows.Close() 443 432 444 433 for rows.Next() { 445 - var repo Repo 434 + var repo models.Repo 446 435 var createdAt string 447 436 var nullableDescription sql.NullString 448 437 var nullableSource sql.NullString 449 438 450 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 451 440 if err != nil { 452 441 return nil, err 453 442 } ··· 477 466 return repos, nil 478 467 } 479 468 480 - func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 481 - var repo Repo 469 + func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { 470 + var repo models.Repo 482 471 var createdAt string 483 472 var nullableDescription sql.NullString 484 473 var nullableSource sql.NullString 485 474 486 475 row := e.QueryRow( 487 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 488 477 from repos 489 478 where did = ? and name = ? and source is not null and source != ''`, 490 479 did, name, 491 480 ) 492 481 493 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 494 483 if err != nil { 495 484 return nil, err 496 485 } ··· 525 514 return err 526 515 } 527 516 528 - type RepoStats struct { 529 - Language string 530 - StarCount int 531 - IssueCount IssueCount 532 - PullCount PullCount 533 - } 534 - 535 - type RepoLabel struct { 536 - Id int64 537 - RepoAt syntax.ATURI 538 - LabelAt syntax.ATURI 539 - } 540 - 541 - func SubscribeLabel(e Execer, rl *RepoLabel) error { 517 + func SubscribeLabel(e Execer, rl *models.RepoLabel) error { 542 518 query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 543 519 544 520 _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) ··· 563 539 return err 564 540 } 565 541 566 - func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) { 542 + func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 567 543 var conditions []string 568 544 var args []any 569 545 for _, filter := range filters { ··· 584 560 } 585 561 defer rows.Close() 586 562 587 - var labels []RepoLabel 563 + var labels []models.RepoLabel 588 564 for rows.Next() { 589 - var label RepoLabel 565 + var label models.RepoLabel 590 566 591 567 err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 592 568 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.sh/tangled.sh/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,
+1 -1
appview/dns/cloudflare.go
··· 5 5 "fmt" 6 6 7 7 "github.com/cloudflare/cloudflare-go" 8 - "tangled.sh/tangled.sh/core/appview/config" 8 + "tangled.org/core/appview/config" 9 9 ) 10 10 11 11 type Record struct {
+146 -65
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 - "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/appview/config" 17 - "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/serververify" 19 - "tangled.sh/tangled.sh/core/appview/validator" 20 - "tangled.sh/tangled.sh/core/idresolver" 21 - "tangled.sh/tangled.sh/core/rbac" 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/serververify" 22 + "tangled.org/core/appview/validator" 23 + "tangled.org/core/idresolver" 24 + "tangled.org/core/rbac" 22 25 ) 23 26 24 27 type Ingester struct { ··· 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) ··· 79 82 err = i.ingestIssueComment(e) 80 83 case tangled.LabelDefinitionNSID: 81 84 err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 82 87 } 83 88 l = i.Logger.With("nsid", e.Commit.Collection) 84 89 } ··· 91 96 } 92 97 } 93 98 94 - func (i *Ingester) ingestStar(e *models.Event) error { 99 + func (i *Ingester) ingestStar(e *jmodels.Event) error { 95 100 var err error 96 101 did := e.Did 97 102 ··· 99 104 l = l.With("nsid", e.Commit.Collection) 100 105 101 106 switch e.Commit.Operation { 102 - case models.CommitOperationCreate, models.CommitOperationUpdate: 107 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 103 108 var subjectUri syntax.ATURI 104 109 105 110 raw := json.RawMessage(e.Commit.Record) ··· 115 120 l.Error("invalid record", "err", err) 116 121 return err 117 122 } 118 - err = db.AddStar(i.Db, &db.Star{ 123 + err = db.AddStar(i.Db, &models.Star{ 119 124 StarredByDid: did, 120 125 RepoAt: subjectUri, 121 126 Rkey: e.Commit.RKey, 122 127 }) 123 - case models.CommitOperationDelete: 128 + case jmodels.CommitOperationDelete: 124 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 125 130 } 126 131 ··· 131 136 return nil 132 137 } 133 138 134 - func (i *Ingester) ingestFollow(e *models.Event) error { 139 + func (i *Ingester) ingestFollow(e *jmodels.Event) error { 135 140 var err error 136 141 did := e.Did 137 142 ··· 139 144 l = l.With("nsid", e.Commit.Collection) 140 145 141 146 switch e.Commit.Operation { 142 - case models.CommitOperationCreate, models.CommitOperationUpdate: 147 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 143 148 raw := json.RawMessage(e.Commit.Record) 144 149 record := tangled.GraphFollow{} 145 150 err = json.Unmarshal(raw, &record) ··· 148 153 return err 149 154 } 150 155 151 - err = db.AddFollow(i.Db, &db.Follow{ 156 + err = db.AddFollow(i.Db, &models.Follow{ 152 157 UserDid: did, 153 158 SubjectDid: record.Subject, 154 159 Rkey: e.Commit.RKey, 155 160 }) 156 - case models.CommitOperationDelete: 161 + case jmodels.CommitOperationDelete: 157 162 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 158 163 } 159 164 ··· 164 169 return nil 165 170 } 166 171 167 - func (i *Ingester) ingestPublicKey(e *models.Event) error { 172 + func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { 168 173 did := e.Did 169 174 var err error 170 175 ··· 172 177 l = l.With("nsid", e.Commit.Collection) 173 178 174 179 switch e.Commit.Operation { 175 - case models.CommitOperationCreate, models.CommitOperationUpdate: 180 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 176 181 l.Debug("processing add of pubkey") 177 182 raw := json.RawMessage(e.Commit.Record) 178 183 record := tangled.PublicKey{} ··· 185 190 name := record.Name 186 191 key := record.Key 187 192 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 188 - case models.CommitOperationDelete: 193 + case jmodels.CommitOperationDelete: 189 194 l.Debug("processing delete of pubkey") 190 195 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 191 196 } ··· 197 202 return nil 198 203 } 199 204 200 - func (i *Ingester) ingestArtifact(e *models.Event) error { 205 + func (i *Ingester) ingestArtifact(e *jmodels.Event) error { 201 206 did := e.Did 202 207 var err error 203 208 ··· 205 210 l = l.With("nsid", e.Commit.Collection) 206 211 207 212 switch e.Commit.Operation { 208 - case models.CommitOperationCreate, models.CommitOperationUpdate: 213 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 209 214 raw := json.RawMessage(e.Commit.Record) 210 215 record := tangled.RepoArtifact{} 211 216 err = json.Unmarshal(raw, &record) ··· 234 239 createdAt = time.Now() 235 240 } 236 241 237 - artifact := db.Artifact{ 242 + artifact := models.Artifact{ 238 243 Did: did, 239 244 Rkey: e.Commit.RKey, 240 245 RepoAt: repoAt, ··· 247 252 } 248 253 249 254 err = db.AddArtifact(i.Db, artifact) 250 - case models.CommitOperationDelete: 255 + case jmodels.CommitOperationDelete: 251 256 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 252 257 } 253 258 ··· 258 263 return nil 259 264 } 260 265 261 - func (i *Ingester) ingestProfile(e *models.Event) error { 266 + func (i *Ingester) ingestProfile(e *jmodels.Event) error { 262 267 did := e.Did 263 268 var err error 264 269 ··· 270 275 } 271 276 272 277 switch e.Commit.Operation { 273 - case models.CommitOperationCreate, models.CommitOperationUpdate: 278 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 274 279 raw := json.RawMessage(e.Commit.Record) 275 280 record := tangled.ActorProfile{} 276 281 err = json.Unmarshal(raw, &record) ··· 298 303 } 299 304 } 300 305 301 - var stats [2]db.VanityStat 306 + var stats [2]models.VanityStat 302 307 for i, s := range record.Stats { 303 308 if i < 2 { 304 - stats[i].Kind = db.VanityStatKind(s) 309 + stats[i].Kind = models.VanityStatKind(s) 305 310 } 306 311 } 307 312 ··· 312 317 } 313 318 } 314 319 315 - profile := db.Profile{ 320 + profile := models.Profile{ 316 321 Did: did, 317 322 Description: description, 318 323 IncludeBluesky: includeBluesky, ··· 338 343 } 339 344 340 345 err = db.UpsertProfile(tx, &profile) 341 - case models.CommitOperationDelete: 346 + case jmodels.CommitOperationDelete: 342 347 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 343 348 } 344 349 ··· 349 354 return nil 350 355 } 351 356 352 - func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 357 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error { 353 358 did := e.Did 354 359 var err error 355 360 ··· 357 362 l = l.With("nsid", e.Commit.Collection) 358 363 359 364 switch e.Commit.Operation { 360 - case models.CommitOperationCreate: 365 + case jmodels.CommitOperationCreate: 361 366 raw := json.RawMessage(e.Commit.Record) 362 367 record := tangled.SpindleMember{} 363 368 err = json.Unmarshal(raw, &record) ··· 386 391 return fmt.Errorf("failed to index profile record, invalid db cast") 387 392 } 388 393 389 - err = db.AddSpindleMember(ddb, db.SpindleMember{ 394 + err = db.AddSpindleMember(ddb, models.SpindleMember{ 390 395 Did: syntax.DID(did), 391 396 Rkey: e.Commit.RKey, 392 397 Instance: record.Instance, ··· 402 407 } 403 408 404 409 l.Info("added spindle member") 405 - case models.CommitOperationDelete: 410 + case jmodels.CommitOperationDelete: 406 411 rkey := e.Commit.RKey 407 412 408 413 ddb, ok := i.Db.Execer.(*db.DB) ··· 455 460 return nil 456 461 } 457 462 458 - func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 463 + func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error { 459 464 did := e.Did 460 465 var err error 461 466 ··· 463 468 l = l.With("nsid", e.Commit.Collection) 464 469 465 470 switch e.Commit.Operation { 466 - case models.CommitOperationCreate: 471 + case jmodels.CommitOperationCreate: 467 472 raw := json.RawMessage(e.Commit.Record) 468 473 record := tangled.Spindle{} 469 474 err = json.Unmarshal(raw, &record) ··· 479 484 return fmt.Errorf("failed to index profile record, invalid db cast") 480 485 } 481 486 482 - err := db.AddSpindle(ddb, db.Spindle{ 487 + err := db.AddSpindle(ddb, models.Spindle{ 483 488 Owner: syntax.DID(did), 484 489 Instance: instance, 485 490 }) ··· 501 506 502 507 return nil 503 508 504 - case models.CommitOperationDelete: 509 + case jmodels.CommitOperationDelete: 505 510 instance := e.Commit.RKey 506 511 507 512 ddb, ok := i.Db.Execer.(*db.DB) ··· 569 574 return nil 570 575 } 571 576 572 - func (i *Ingester) ingestString(e *models.Event) error { 577 + func (i *Ingester) ingestString(e *jmodels.Event) error { 573 578 did := e.Did 574 579 rkey := e.Commit.RKey 575 580 ··· 584 589 } 585 590 586 591 switch e.Commit.Operation { 587 - case models.CommitOperationCreate, models.CommitOperationUpdate: 592 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 588 593 raw := json.RawMessage(e.Commit.Record) 589 594 record := tangled.String{} 590 595 err = json.Unmarshal(raw, &record) ··· 593 598 return err 594 599 } 595 600 596 - string := db.StringFromRecord(did, rkey, record) 601 + string := models.StringFromRecord(did, rkey, record) 597 602 598 - if err = string.Validate(); err != nil { 603 + if err = i.Validator.ValidateString(&string); err != nil { 599 604 l.Error("invalid record", "err", err) 600 605 return err 601 606 } ··· 607 612 608 613 return nil 609 614 610 - case models.CommitOperationDelete: 615 + case jmodels.CommitOperationDelete: 611 616 if err := db.DeleteString( 612 617 ddb, 613 618 db.FilterEq("did", did), ··· 623 628 return nil 624 629 } 625 630 626 - func (i *Ingester) ingestKnotMember(e *models.Event) error { 631 + func (i *Ingester) ingestKnotMember(e *jmodels.Event) error { 627 632 did := e.Did 628 633 var err error 629 634 ··· 631 636 l = l.With("nsid", e.Commit.Collection) 632 637 633 638 switch e.Commit.Operation { 634 - case models.CommitOperationCreate: 639 + case jmodels.CommitOperationCreate: 635 640 raw := json.RawMessage(e.Commit.Record) 636 641 record := tangled.KnotMember{} 637 642 err = json.Unmarshal(raw, &record) ··· 661 666 } 662 667 663 668 l.Info("added knot member") 664 - case models.CommitOperationDelete: 669 + case jmodels.CommitOperationDelete: 665 670 // we don't store knot members in a table (like we do for spindle) 666 671 // and we can't remove this just yet. possibly fixed if we switch 667 672 // to either: ··· 675 680 return nil 676 681 } 677 682 678 - func (i *Ingester) ingestKnot(e *models.Event) error { 683 + func (i *Ingester) ingestKnot(e *jmodels.Event) error { 679 684 did := e.Did 680 685 var err error 681 686 ··· 683 688 l = l.With("nsid", e.Commit.Collection) 684 689 685 690 switch e.Commit.Operation { 686 - case models.CommitOperationCreate: 691 + case jmodels.CommitOperationCreate: 687 692 raw := json.RawMessage(e.Commit.Record) 688 693 record := tangled.Knot{} 689 694 err = json.Unmarshal(raw, &record) ··· 718 723 719 724 return nil 720 725 721 - case models.CommitOperationDelete: 726 + case jmodels.CommitOperationDelete: 722 727 domain := e.Commit.RKey 723 728 724 729 ddb, ok := i.Db.Execer.(*db.DB) ··· 778 783 779 784 return nil 780 785 } 781 - func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 786 + func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error { 782 787 did := e.Did 783 788 rkey := e.Commit.RKey 784 789 ··· 793 798 } 794 799 795 800 switch e.Commit.Operation { 796 - case models.CommitOperationCreate, models.CommitOperationUpdate: 801 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 797 802 raw := json.RawMessage(e.Commit.Record) 798 803 record := tangled.RepoIssue{} 799 804 err = json.Unmarshal(raw, &record) ··· 802 807 return err 803 808 } 804 809 805 - issue := db.IssueFromRecord(did, rkey, record) 810 + issue := models.IssueFromRecord(did, rkey, record) 806 811 807 812 if err := i.Validator.ValidateIssue(&issue); err != nil { 808 813 return fmt.Errorf("failed to validate issue: %w", err) ··· 829 834 830 835 return nil 831 836 832 - case models.CommitOperationDelete: 837 + case jmodels.CommitOperationDelete: 833 838 if err := db.DeleteIssues( 834 839 ddb, 835 840 db.FilterEq("did", did), ··· 845 850 return nil 846 851 } 847 852 848 - func (i *Ingester) ingestIssueComment(e *models.Event) error { 853 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 849 854 did := e.Did 850 855 rkey := e.Commit.RKey 851 856 ··· 860 865 } 861 866 862 867 switch e.Commit.Operation { 863 - case models.CommitOperationCreate, models.CommitOperationUpdate: 868 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 864 869 raw := json.RawMessage(e.Commit.Record) 865 870 record := tangled.RepoIssueComment{} 866 871 err = json.Unmarshal(raw, &record) ··· 868 873 return fmt.Errorf("invalid record: %w", err) 869 874 } 870 875 871 - comment, err := db.IssueCommentFromRecord(did, rkey, record) 876 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 872 877 if err != nil { 873 878 return fmt.Errorf("failed to parse comment from record: %w", err) 874 879 } ··· 884 889 885 890 return nil 886 891 887 - case models.CommitOperationDelete: 892 + case jmodels.CommitOperationDelete: 888 893 if err := db.DeleteIssueComments( 889 894 ddb, 890 895 db.FilterEq("did", did), ··· 899 904 return nil 900 905 } 901 906 902 - func (i *Ingester) ingestLabelDefinition(e *models.Event) error { 907 + func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { 903 908 did := e.Did 904 909 rkey := e.Commit.RKey 905 910 ··· 914 919 } 915 920 916 921 switch e.Commit.Operation { 917 - case models.CommitOperationCreate, models.CommitOperationUpdate: 922 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 918 923 raw := json.RawMessage(e.Commit.Record) 919 924 record := tangled.LabelDefinition{} 920 925 err = json.Unmarshal(raw, &record) ··· 922 927 return fmt.Errorf("invalid record: %w", err) 923 928 } 924 929 925 - def, err := db.LabelDefinitionFromRecord(did, rkey, record) 930 + def, err := models.LabelDefinitionFromRecord(did, rkey, record) 926 931 if err != nil { 927 932 return fmt.Errorf("failed to parse labeldef from record: %w", err) 928 933 } ··· 938 943 939 944 return nil 940 945 941 - case models.CommitOperationDelete: 946 + case jmodels.CommitOperationDelete: 942 947 if err := db.DeleteLabelDefinition( 943 948 ddb, 944 949 db.FilterEq("did", did), ··· 952 957 953 958 return nil 954 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 + }
+44 -31
appview/issues/issues.go
··· 16 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 17 "github.com/go-chi/chi/v5" 18 18 19 - "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/config" 21 - "tangled.sh/tangled.sh/core/appview/db" 22 - "tangled.sh/tangled.sh/core/appview/notify" 23 - "tangled.sh/tangled.sh/core/appview/oauth" 24 - "tangled.sh/tangled.sh/core/appview/pages" 25 - "tangled.sh/tangled.sh/core/appview/pagination" 26 - "tangled.sh/tangled.sh/core/appview/reporesolver" 27 - "tangled.sh/tangled.sh/core/appview/validator" 28 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 - "tangled.sh/tangled.sh/core/idresolver" 30 - tlog "tangled.sh/tangled.sh/core/log" 31 - "tangled.sh/tangled.sh/core/tid" 19 + "tangled.org/core/api/tangled" 20 + "tangled.org/core/appview/config" 21 + "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/notify" 24 + "tangled.org/core/appview/oauth" 25 + "tangled.org/core/appview/pages" 26 + "tangled.org/core/appview/pagination" 27 + "tangled.org/core/appview/reporesolver" 28 + "tangled.org/core/appview/validator" 29 + "tangled.org/core/appview/xrpcclient" 30 + "tangled.org/core/idresolver" 31 + tlog "tangled.org/core/log" 32 + "tangled.org/core/tid" 32 33 ) 33 34 34 35 type Issues struct { ··· 75 76 return 76 77 } 77 78 78 - issue, ok := r.Context().Value("issue").(*db.Issue) 79 + issue, ok := r.Context().Value("issue").(*models.Issue) 79 80 if !ok { 80 81 l.Error("failed to get issue") 81 82 rp.pages.Error404(w) ··· 87 88 l.Error("failed to get issue reactions", "err", err) 88 89 } 89 90 90 - userReactions := map[db.ReactionKind]bool{} 91 + userReactions := map[models.ReactionKind]bool{} 91 92 if user != nil { 92 93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 94 } ··· 103 104 return 104 105 } 105 106 106 - defs := make(map[string]*db.LabelDefinition) 107 + defs := make(map[string]*models.LabelDefinition) 107 108 for _, l := range labelDefs { 108 109 defs[l.AtUri().String()] = &l 109 110 } ··· 113 114 RepoInfo: f.RepoInfo(user), 114 115 Issue: issue, 115 116 CommentList: issue.CommentList(), 116 - OrderedReactionKinds: db.OrderedReactionKinds, 117 + OrderedReactionKinds: models.OrderedReactionKinds, 117 118 Reactions: reactionCountMap, 118 119 UserReacted: userReactions, 119 120 LabelDefs: defs, ··· 129 130 return 130 131 } 131 132 132 - issue, ok := r.Context().Value("issue").(*db.Issue) 133 + issue, ok := r.Context().Value("issue").(*models.Issue) 133 134 if !ok { 134 135 l.Error("failed to get issue") 135 136 rp.pages.Error404(w) ··· 225 226 return 226 227 } 227 228 228 - issue, ok := r.Context().Value("issue").(*db.Issue) 229 + issue, ok := r.Context().Value("issue").(*models.Issue) 229 230 if !ok { 230 231 l.Error("failed to get issue") 231 232 rp.pages.Notice(w, noticeId, "Failed to delete issue.") ··· 272 273 return 273 274 } 274 275 275 - issue, ok := r.Context().Value("issue").(*db.Issue) 276 + issue, ok := r.Context().Value("issue").(*models.Issue) 276 277 if !ok { 277 278 l.Error("failed to get issue") 278 279 rp.pages.Error404(w) ··· 300 301 return 301 302 } 302 303 304 + // notify about the issue closure 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 306 + 303 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 304 308 return 305 309 } else { ··· 318 322 return 319 323 } 320 324 321 - issue, ok := r.Context().Value("issue").(*db.Issue) 325 + issue, ok := r.Context().Value("issue").(*models.Issue) 322 326 if !ok { 323 327 l.Error("failed to get issue") 324 328 rp.pages.Error404(w) ··· 362 366 return 363 367 } 364 368 365 - issue, ok := r.Context().Value("issue").(*db.Issue) 369 + issue, ok := r.Context().Value("issue").(*models.Issue) 366 370 if !ok { 367 371 l.Error("failed to get issue") 368 372 rp.pages.Error404(w) ··· 381 385 replyTo = &replyToUri 382 386 } 383 387 384 - comment := db.IssueComment{ 388 + comment := models.IssueComment{ 385 389 Did: user.Did, 386 390 Rkey: tid.TID(), 387 391 IssueAt: issue.AtUri().String(), ··· 433 437 434 438 // reset atUri to make rollback a no-op 435 439 atUri = "" 440 + 441 + // notify about the new comment 442 + comment.Id = commentId 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 444 + 436 445 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 437 446 } 438 447 ··· 445 454 return 446 455 } 447 456 448 - issue, ok := r.Context().Value("issue").(*db.Issue) 457 + issue, ok := r.Context().Value("issue").(*models.Issue) 449 458 if !ok { 450 459 l.Error("failed to get issue") 451 460 rp.pages.Error404(w) ··· 486 495 return 487 496 } 488 497 489 - issue, ok := r.Context().Value("issue").(*db.Issue) 498 + issue, ok := r.Context().Value("issue").(*models.Issue) 490 499 if !ok { 491 500 l.Error("failed to get issue") 492 501 rp.pages.Error404(w) ··· 590 599 return 591 600 } 592 601 593 - issue, ok := r.Context().Value("issue").(*db.Issue) 602 + issue, ok := r.Context().Value("issue").(*models.Issue) 594 603 if !ok { 595 604 l.Error("failed to get issue") 596 605 rp.pages.Error404(w) ··· 631 640 return 632 641 } 633 642 634 - issue, ok := r.Context().Value("issue").(*db.Issue) 643 + issue, ok := r.Context().Value("issue").(*models.Issue) 635 644 if !ok { 636 645 l.Error("failed to get issue") 637 646 rp.pages.Error404(w) ··· 672 681 return 673 682 } 674 683 675 - issue, ok := r.Context().Value("issue").(*db.Issue) 684 + issue, ok := r.Context().Value("issue").(*models.Issue) 676 685 if !ok { 677 686 l.Error("failed to get issue") 678 687 rp.pages.Error404(w) ··· 789 798 return 790 799 } 791 800 792 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 793 806 if err != nil { 794 807 log.Println("failed to fetch labels", err) 795 808 rp.pages.Error503(w) 796 809 return 797 810 } 798 811 799 - defs := make(map[string]*db.LabelDefinition) 812 + defs := make(map[string]*models.LabelDefinition) 800 813 for _, l := range labelDefs { 801 814 defs[l.AtUri().String()] = &l 802 815 } ··· 828 841 RepoInfo: f.RepoInfo(user), 829 842 }) 830 843 case http.MethodPost: 831 - issue := &db.Issue{ 844 + issue := &models.Issue{ 832 845 RepoAt: f.RepoAt(), 833 846 Rkey: tid.TID(), 834 847 Title: r.FormValue("title"),
+1 -1
appview/issues/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
+14 -13
appview/knots/knots.go
··· 9 9 "time" 10 10 11 11 "github.com/go-chi/chi/v5" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/middleware" 16 - "tangled.sh/tangled.sh/core/appview/oauth" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/serververify" 19 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 - "tangled.sh/tangled.sh/core/eventconsumer" 21 - "tangled.sh/tangled.sh/core/idresolver" 22 - "tangled.sh/tangled.sh/core/rbac" 23 - "tangled.sh/tangled.sh/core/tid" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/middleware" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/oauth" 18 + "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/serververify" 20 + "tangled.org/core/appview/xrpcclient" 21 + "tangled.org/core/eventconsumer" 22 + "tangled.org/core/idresolver" 23 + "tangled.org/core/rbac" 24 + "tangled.org/core/tid" 24 25 25 26 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 27 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 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 }
+32 -24
appview/labels/labels.go
··· 14 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 15 "github.com/go-chi/chi/v5" 16 16 17 - "tangled.sh/tangled.sh/core/api/tangled" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/middleware" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 21 - "tangled.sh/tangled.sh/core/appview/pages" 22 - "tangled.sh/tangled.sh/core/appview/validator" 23 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 - "tangled.sh/tangled.sh/core/log" 25 - "tangled.sh/tangled.sh/core/tid" 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/middleware" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/oauth" 22 + "tangled.org/core/appview/pages" 23 + "tangled.org/core/appview/validator" 24 + "tangled.org/core/appview/xrpcclient" 25 + "tangled.org/core/log" 26 + "tangled.org/core/rbac" 27 + "tangled.org/core/tid" 26 28 ) 27 29 28 30 type Labels struct { ··· 31 33 db *db.DB 32 34 logger *slog.Logger 33 35 validator *validator.Validator 36 + enforcer *rbac.Enforcer 34 37 } 35 38 36 39 func New( ··· 38 41 pages *pages.Pages, 39 42 db *db.DB, 40 43 validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 41 45 ) *Labels { 42 46 logger := log.New("labels") 43 47 ··· 47 51 db: db, 48 52 logger: logger, 49 53 validator: validator, 54 + enforcer: enforcer, 50 55 } 51 56 } 52 57 ··· 85 90 repoAt := r.Form.Get("repo") 86 91 subjectUri := r.Form.Get("subject") 87 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 + 88 99 // find all the labels that this repo subscribes to 89 100 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 90 101 if err != nil { ··· 103 114 return 104 115 } 105 116 106 - l.logger.Info("actx", "labels", labelAts) 107 - l.logger.Info("actx", "defs", actx.Defs) 108 - 109 117 // calculate the start state by applying already known labels 110 118 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 111 119 if err != nil { ··· 113 121 return 114 122 } 115 123 116 - labelState := db.NewLabelState() 124 + labelState := models.NewLabelState() 117 125 actx.ApplyLabelOps(labelState, existingOps) 118 126 119 - var labelOps []db.LabelOp 127 + var labelOps []models.LabelOp 120 128 121 129 // first delete all existing state 122 130 for key, vals := range labelState.Inner() { 123 131 for val := range vals { 124 - labelOps = append(labelOps, db.LabelOp{ 132 + labelOps = append(labelOps, models.LabelOp{ 125 133 Did: did, 126 134 Rkey: rkey, 127 135 Subject: syntax.ATURI(subjectUri), 128 - Operation: db.LabelOperationDel, 136 + Operation: models.LabelOperationDel, 129 137 OperandKey: key, 130 138 OperandValue: val, 131 139 PerformedAt: performedAt, ··· 141 149 } 142 150 143 151 for _, val := range vals { 144 - labelOps = append(labelOps, db.LabelOp{ 152 + labelOps = append(labelOps, models.LabelOp{ 145 153 Did: did, 146 154 Rkey: rkey, 147 155 Subject: syntax.ATURI(subjectUri), 148 - Operation: db.LabelOperationAdd, 156 + Operation: models.LabelOperationAdd, 149 157 OperandKey: key, 150 158 OperandValue: val, 151 159 PerformedAt: performedAt, ··· 154 162 } 155 163 } 156 164 157 - // reduce the opset 158 - labelOps = db.ReduceLabelOps(labelOps) 159 - 160 165 for i := range labelOps { 161 166 def := actx.Defs[labelOps[i].OperandKey] 162 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 163 168 fail(fmt.Sprintf("Invalid form data: %s", err), err) 164 169 return 165 170 } 166 171 } 167 172 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 + 168 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 169 177 validLabelOps := labelOps[:0] 170 178 for _, op := range labelOps { 171 - if err = actx.ApplyLabelOp(labelState, op); err != db.LabelNoOpError { 179 + if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { 172 180 validLabelOps = append(validLabelOps, op) 173 181 } 174 182 } ··· 180 188 } 181 189 182 190 // create an atproto record of valid ops 183 - record := db.LabelOpsAsRecord(validLabelOps) 191 + record := models.LabelOpsAsRecord(validLabelOps) 184 192 185 193 client, err := l.oauth.AuthorizedClient(r) 186 194 if err != nil {
+16 -7
appview/middleware/middleware.go
··· 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 15 - "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/oauth" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/pagination" 19 - "tangled.sh/tangled.sh/core/appview/reporesolver" 20 - "tangled.sh/tangled.sh/core/idresolver" 21 - "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/pagination" 19 + "tangled.org/core/appview/reporesolver" 20 + "tangled.org/core/idresolver" 21 + "tangled.org/core/rbac" 22 22 ) 23 23 24 24 type Middleware struct { ··· 42 42 } 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 + 46 + func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 + return func(next http.Handler) http.Handler { 48 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + _, _, _ = mw.oauth.GetSession(r) 50 + next.ServeHTTP(w, r) 51 + }) 52 + } 53 + } 45 54 46 55 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 56 return func(next http.Handler) http.Handler {
+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 + }
+194
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 + Labels LabelState 29 + Repo *Repo 30 + } 31 + 32 + func (i *Issue) AtUri() syntax.ATURI { 33 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 34 + } 35 + 36 + func (i *Issue) AsRecord() tangled.RepoIssue { 37 + return tangled.RepoIssue{ 38 + Repo: i.RepoAt.String(), 39 + Title: i.Title, 40 + Body: &i.Body, 41 + CreatedAt: i.Created.Format(time.RFC3339), 42 + } 43 + } 44 + 45 + func (i *Issue) State() string { 46 + if i.Open { 47 + return "open" 48 + } 49 + return "closed" 50 + } 51 + 52 + type CommentListItem struct { 53 + Self *IssueComment 54 + Replies []*IssueComment 55 + } 56 + 57 + func (i *Issue) CommentList() []CommentListItem { 58 + // Create a map to quickly find comments by their aturi 59 + toplevel := make(map[string]*CommentListItem) 60 + var replies []*IssueComment 61 + 62 + // collect top level comments into the map 63 + for _, comment := range i.Comments { 64 + if comment.IsTopLevel() { 65 + toplevel[comment.AtUri().String()] = &CommentListItem{ 66 + Self: &comment, 67 + } 68 + } else { 69 + replies = append(replies, &comment) 70 + } 71 + } 72 + 73 + for _, r := range replies { 74 + parentAt := *r.ReplyTo 75 + if parent, exists := toplevel[parentAt]; exists { 76 + parent.Replies = append(parent.Replies, r) 77 + } 78 + } 79 + 80 + var listing []CommentListItem 81 + for _, v := range toplevel { 82 + listing = append(listing, *v) 83 + } 84 + 85 + // sort everything 86 + sortFunc := func(a, b *IssueComment) bool { 87 + return a.Created.Before(b.Created) 88 + } 89 + sort.Slice(listing, func(i, j int) bool { 90 + return sortFunc(listing[i].Self, listing[j].Self) 91 + }) 92 + for _, r := range listing { 93 + sort.Slice(r.Replies, func(i, j int) bool { 94 + return sortFunc(r.Replies[i], r.Replies[j]) 95 + }) 96 + } 97 + 98 + return listing 99 + } 100 + 101 + func (i *Issue) Participants() []string { 102 + participantSet := make(map[string]struct{}) 103 + participants := []string{} 104 + 105 + addParticipant := func(did string) { 106 + if _, exists := participantSet[did]; !exists { 107 + participantSet[did] = struct{}{} 108 + participants = append(participants, did) 109 + } 110 + } 111 + 112 + addParticipant(i.Did) 113 + 114 + for _, c := range i.Comments { 115 + addParticipant(c.Did) 116 + } 117 + 118 + return participants 119 + } 120 + 121 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 122 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 123 + if err != nil { 124 + created = time.Now() 125 + } 126 + 127 + body := "" 128 + if record.Body != nil { 129 + body = *record.Body 130 + } 131 + 132 + return Issue{ 133 + RepoAt: syntax.ATURI(record.Repo), 134 + Did: did, 135 + Rkey: rkey, 136 + Created: created, 137 + Title: record.Title, 138 + Body: body, 139 + Open: true, // new issues are open by default 140 + } 141 + } 142 + 143 + type IssueComment struct { 144 + Id int64 145 + Did string 146 + Rkey string 147 + IssueAt string 148 + ReplyTo *string 149 + Body string 150 + Created time.Time 151 + Edited *time.Time 152 + Deleted *time.Time 153 + } 154 + 155 + func (i *IssueComment) AtUri() syntax.ATURI { 156 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 157 + } 158 + 159 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 160 + return tangled.RepoIssueComment{ 161 + Body: i.Body, 162 + Issue: i.IssueAt, 163 + CreatedAt: i.Created.Format(time.RFC3339), 164 + ReplyTo: i.ReplyTo, 165 + } 166 + } 167 + 168 + func (i *IssueComment) IsTopLevel() bool { 169 + return i.ReplyTo == nil 170 + } 171 + 172 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 173 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 174 + if err != nil { 175 + created = time.Now() 176 + } 177 + 178 + ownerDid := did 179 + 180 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 181 + return nil, err 182 + } 183 + 184 + comment := IssueComment{ 185 + Did: ownerDid, 186 + Rkey: rkey, 187 + Body: record.Body, 188 + IssueAt: record.Issue, 189 + ReplyTo: record.ReplyTo, 190 + Created: created, 191 + } 192 + 193 + return &comment, nil 194 + }
+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 + }
+57
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 + }
+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 + }
+168
appview/notifications/notifications.go
··· 1 + package notifications 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/middleware" 12 + "tangled.org/core/appview/oauth" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + ) 16 + 17 + type Notifications struct { 18 + db *db.DB 19 + oauth *oauth.OAuth 20 + pages *pages.Pages 21 + } 22 + 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 24 + return &Notifications{ 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + } 29 + } 30 + 31 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 + r := chi.NewRouter() 33 + 34 + r.Use(middleware.AuthMiddleware(n.oauth)) 35 + 36 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 + 38 + r.Get("/count", n.getUnreadCount) 39 + r.Post("/{id}/read", n.markRead) 40 + r.Post("/read-all", n.markAllRead) 41 + r.Delete("/{id}", n.deleteNotification) 42 + 43 + return r 44 + } 45 + 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + userDid := n.oauth.GetDid(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", userDid), 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", userDid), 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(), userDid) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 + } 80 + 81 + unreadCount := 0 82 + 83 + user := n.oauth.GetUser(r) 84 + if user == nil { 85 + http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 + return 87 + } 88 + 89 + fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 90 + LoggedInUser: user, 91 + Notifications: notifications, 92 + UnreadCount: unreadCount, 93 + Page: page, 94 + Total: total, 95 + })) 96 + } 97 + 98 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 + user := n.oauth.GetUser(r) 100 + count, err := db.CountNotifications( 101 + n.db, 102 + db.FilterEq("recipient_did", user.Did), 103 + db.FilterEq("read", 0), 104 + ) 105 + if err != nil { 106 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 107 + return 108 + } 109 + 110 + params := pages.NotificationCountParams{ 111 + Count: count, 112 + } 113 + err = n.pages.NotificationCount(w, params) 114 + if err != nil { 115 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 116 + return 117 + } 118 + } 119 + 120 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 121 + userDid := n.oauth.GetDid(r) 122 + 123 + idStr := chi.URLParam(r, "id") 124 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 125 + if err != nil { 126 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 127 + return 128 + } 129 + 130 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 131 + if err != nil { 132 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 133 + return 134 + } 135 + 136 + w.WriteHeader(http.StatusNoContent) 137 + } 138 + 139 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 140 + userDid := n.oauth.GetDid(r) 141 + 142 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 143 + if err != nil { 144 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 145 + return 146 + } 147 + 148 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 149 + } 150 + 151 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 152 + userDid := n.oauth.GetDid(r) 153 + 154 + idStr := chi.URLParam(r, "id") 155 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 156 + if err != nil { 157 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 158 + return 159 + } 160 + 161 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 162 + if err != nil { 163 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 164 + return 165 + } 166 + 167 + w.WriteHeader(http.StatusOK) 168 + }
+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.sh/tangled.sh/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.sh/tangled.sh/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 + }
+12 -12
appview/oauth/handler/handler.go
··· 16 16 "github.com/gorilla/sessions" 17 17 "github.com/lestrrat-go/jwx/v2/jwk" 18 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/consts" 28 + "tangled.org/core/idresolver" 29 + "tangled.org/core/rbac" 30 + "tangled.org/core/tid" 19 31 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 20 - tangled "tangled.sh/tangled.sh/core/api/tangled" 21 - sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 22 - "tangled.sh/tangled.sh/core/appview/config" 23 - "tangled.sh/tangled.sh/core/appview/db" 24 - "tangled.sh/tangled.sh/core/appview/middleware" 25 - "tangled.sh/tangled.sh/core/appview/oauth" 26 - "tangled.sh/tangled.sh/core/appview/oauth/client" 27 - "tangled.sh/tangled.sh/core/appview/pages" 28 - "tangled.sh/tangled.sh/core/consts" 29 - "tangled.sh/tangled.sh/core/idresolver" 30 - "tangled.sh/tangled.sh/core/rbac" 31 - "tangled.sh/tangled.sh/core/tid" 32 32 ) 33 33 34 34 const (
+4 -4
appview/oauth/oauth.go
··· 9 9 10 10 indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 11 "github.com/gorilla/sessions" 12 + sessioncache "tangled.org/core/appview/cache/session" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/oauth/client" 15 + xrpc "tangled.org/core/appview/xrpcclient" 12 16 oauth "tangled.sh/icyphox.sh/atproto-oauth" 13 17 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 14 - sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 15 - "tangled.sh/tangled.sh/core/appview/config" 16 - "tangled.sh/tangled.sh/core/appview/oauth/client" 17 - xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient" 18 18 ) 19 19 20 20 type OAuth struct {
+18 -18
appview/pages/funcmap.go
··· 19 19 20 20 "github.com/dustin/go-humanize" 21 21 "github.com/go-enry/go-enry/v2" 22 - "tangled.sh/tangled.sh/core/appview/filetree" 23 - "tangled.sh/tangled.sh/core/appview/pages/markup" 24 - "tangled.sh/tangled.sh/core/crypto" 22 + "tangled.org/core/appview/filetree" 23 + "tangled.org/core/appview/pages/markup" 24 + "tangled.org/core/crypto" 25 25 ) 26 26 27 27 func (p *Pages) funcMap() template.FuncMap { ··· 141 141 "relTimeFmt": humanize.Time, 142 142 "shortRelTimeFmt": func(t time.Time) string { 143 143 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 144 - {time.Second, "now", time.Second}, 145 - {2 * time.Second, "1s %s", 1}, 146 - {time.Minute, "%ds %s", time.Second}, 147 - {2 * time.Minute, "1min %s", 1}, 148 - {time.Hour, "%dmin %s", time.Minute}, 149 - {2 * time.Hour, "1hr %s", 1}, 150 - {humanize.Day, "%dhrs %s", time.Hour}, 151 - {2 * humanize.Day, "1d %s", 1}, 152 - {20 * humanize.Day, "%dd %s", humanize.Day}, 153 - {8 * humanize.Week, "%dw %s", humanize.Week}, 154 - {humanize.Year, "%dmo %s", humanize.Month}, 155 - {18 * humanize.Month, "1y %s", 1}, 156 - {2 * humanize.Year, "2y %s", 1}, 157 - {humanize.LongTime, "%dy %s", humanize.Year}, 158 - {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}, 159 159 }) 160 160 }, 161 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
+2 -2
appview/pages/markup/markdown.go
··· 22 22 "github.com/yuin/goldmark/util" 23 23 htmlparse "golang.org/x/net/html" 24 24 25 - "tangled.sh/tangled.sh/core/api/tangled" 26 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 25 + "tangled.org/core/api/tangled" 26 + "tangled.org/core/appview/pages/repoinfo" 27 27 ) 28 28 29 29 // RendererType defines the type of renderer to use based on context
+210 -130
appview/pages/pages.go
··· 16 16 "strings" 17 17 "sync" 18 18 19 - "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/commitverify" 21 - "tangled.sh/tangled.sh/core/appview/config" 22 - "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/oauth" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 - "tangled.sh/tangled.sh/core/appview/pagination" 27 - "tangled.sh/tangled.sh/core/idresolver" 28 - "tangled.sh/tangled.sh/core/patchutil" 29 - "tangled.sh/tangled.sh/core/types" 19 + "tangled.org/core/api/tangled" 20 + "tangled.org/core/appview/commitverify" 21 + "tangled.org/core/appview/config" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/oauth" 24 + "tangled.org/core/appview/pages/markup" 25 + "tangled.org/core/appview/pages/repoinfo" 26 + "tangled.org/core/appview/pagination" 27 + "tangled.org/core/idresolver" 28 + "tangled.org/core/patchutil" 29 + "tangled.org/core/types" 30 30 31 31 "github.com/alecthomas/chroma/v2" 32 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" ··· 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 ··· 694 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 695 769 params.Active = "overview" 696 770 697 - if params.ReadmeFileName != "" { 698 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 771 + p.rctx.RepoInfo = params.RepoInfo 772 + p.rctx.RepoInfo.Ref = params.Ref 773 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 699 774 775 + if params.ReadmeFileName != "" { 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 - Labels []db.LabelDefinition 844 - DefaultLabels []db.LabelDefinition 845 - SubscribedLabels map[string]struct{} 846 - Active string 847 - Tabs []map[string]any 848 - Tab string 849 - 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 850 927 } 851 928 852 929 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 888 965 LoggedInUser *oauth.User 889 966 RepoInfo repoinfo.RepoInfo 890 967 Active string 891 - Issues []db.Issue 892 - LabelDefs map[string]*db.LabelDefinition 968 + Issues []models.Issue 969 + LabelDefs map[string]*models.LabelDefinition 893 970 Page pagination.Page 894 971 FilteringByOpen bool 895 972 } ··· 903 980 LoggedInUser *oauth.User 904 981 RepoInfo repoinfo.RepoInfo 905 982 Active string 906 - Issue *db.Issue 907 - CommentList []db.CommentListItem 908 - LabelDefs map[string]*db.LabelDefinition 983 + Issue *models.Issue 984 + CommentList []models.CommentListItem 985 + LabelDefs map[string]*models.LabelDefinition 909 986 910 - OrderedReactionKinds []db.ReactionKind 911 - Reactions map[db.ReactionKind]int 912 - UserReacted map[db.ReactionKind]bool 987 + OrderedReactionKinds []models.ReactionKind 988 + Reactions map[models.ReactionKind]int 989 + UserReacted map[models.ReactionKind]bool 913 990 } 914 991 915 992 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 920 997 type EditIssueParams struct { 921 998 LoggedInUser *oauth.User 922 999 RepoInfo repoinfo.RepoInfo 923 - Issue *db.Issue 1000 + Issue *models.Issue 924 1001 Action string 925 1002 } 926 1003 ··· 931 1008 932 1009 type ThreadReactionFragmentParams struct { 933 1010 ThreadAt syntax.ATURI 934 - Kind db.ReactionKind 1011 + Kind models.ReactionKind 935 1012 Count int 936 1013 IsReacted bool 937 1014 } ··· 943 1020 type RepoNewIssueParams struct { 944 1021 LoggedInUser *oauth.User 945 1022 RepoInfo repoinfo.RepoInfo 946 - Issue *db.Issue // existing issue if any -- passed when editing 1023 + Issue *models.Issue // existing issue if any -- passed when editing 947 1024 Active string 948 1025 Action string 949 1026 } ··· 957 1034 type EditIssueCommentParams struct { 958 1035 LoggedInUser *oauth.User 959 1036 RepoInfo repoinfo.RepoInfo 960 - Issue *db.Issue 961 - Comment *db.IssueComment 1037 + Issue *models.Issue 1038 + Comment *models.IssueComment 962 1039 } 963 1040 964 1041 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 968 1045 type ReplyIssueCommentPlaceholderParams struct { 969 1046 LoggedInUser *oauth.User 970 1047 RepoInfo repoinfo.RepoInfo 971 - Issue *db.Issue 972 - Comment *db.IssueComment 1048 + Issue *models.Issue 1049 + Comment *models.IssueComment 973 1050 } 974 1051 975 1052 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 979 1056 type ReplyIssueCommentParams struct { 980 1057 LoggedInUser *oauth.User 981 1058 RepoInfo repoinfo.RepoInfo 982 - Issue *db.Issue 983 - Comment *db.IssueComment 1059 + Issue *models.Issue 1060 + Comment *models.IssueComment 984 1061 } 985 1062 986 1063 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 990 1067 type IssueCommentBodyParams struct { 991 1068 LoggedInUser *oauth.User 992 1069 RepoInfo repoinfo.RepoInfo 993 - Issue *db.Issue 994 - Comment *db.IssueComment 1070 + Issue *models.Issue 1071 + Comment *models.IssueComment 995 1072 } 996 1073 997 1074 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1018 1095 type RepoPullsParams struct { 1019 1096 LoggedInUser *oauth.User 1020 1097 RepoInfo repoinfo.RepoInfo 1021 - Pulls []*db.Pull 1098 + Pulls []*models.Pull 1022 1099 Active string 1023 - FilteringBy db.PullState 1024 - Stacks map[string]db.Stack 1025 - Pipelines map[string]db.Pipeline 1100 + FilteringBy models.PullState 1101 + Stacks map[string]models.Stack 1102 + Pipelines map[string]models.Pipeline 1103 + LabelDefs map[string]*models.LabelDefinition 1026 1104 } 1027 1105 1028 1106 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1052 1130 LoggedInUser *oauth.User 1053 1131 RepoInfo repoinfo.RepoInfo 1054 1132 Active string 1055 - Pull *db.Pull 1056 - Stack db.Stack 1057 - AbandonedPulls []*db.Pull 1133 + Pull *models.Pull 1134 + Stack models.Stack 1135 + AbandonedPulls []*models.Pull 1058 1136 MergeCheck types.MergeCheckResponse 1059 1137 ResubmitCheck ResubmitResult 1060 - Pipelines map[string]db.Pipeline 1138 + Pipelines map[string]models.Pipeline 1139 + 1140 + OrderedReactionKinds []models.ReactionKind 1141 + Reactions map[models.ReactionKind]int 1142 + UserReacted map[models.ReactionKind]bool 1061 1143 1062 - OrderedReactionKinds []db.ReactionKind 1063 - Reactions map[db.ReactionKind]int 1064 - UserReacted map[db.ReactionKind]bool 1144 + LabelDefs map[string]*models.LabelDefinition 1065 1145 } 1066 1146 1067 1147 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1072 1152 type RepoPullPatchParams struct { 1073 1153 LoggedInUser *oauth.User 1074 1154 RepoInfo repoinfo.RepoInfo 1075 - Pull *db.Pull 1076 - Stack db.Stack 1155 + Pull *models.Pull 1156 + Stack models.Stack 1077 1157 Diff *types.NiceDiff 1078 1158 Round int 1079 - Submission *db.PullSubmission 1080 - OrderedReactionKinds []db.ReactionKind 1159 + Submission *models.PullSubmission 1160 + OrderedReactionKinds []models.ReactionKind 1081 1161 DiffOpts types.DiffOpts 1082 1162 } 1083 1163 ··· 1089 1169 type RepoPullInterdiffParams struct { 1090 1170 LoggedInUser *oauth.User 1091 1171 RepoInfo repoinfo.RepoInfo 1092 - Pull *db.Pull 1172 + Pull *models.Pull 1093 1173 Round int 1094 1174 Interdiff *patchutil.InterdiffResult 1095 - OrderedReactionKinds []db.ReactionKind 1175 + OrderedReactionKinds []models.ReactionKind 1096 1176 DiffOpts types.DiffOpts 1097 1177 } 1098 1178 ··· 1121 1201 1122 1202 type PullCompareForkParams struct { 1123 1203 RepoInfo repoinfo.RepoInfo 1124 - Forks []db.Repo 1204 + Forks []models.Repo 1125 1205 Selected string 1126 1206 } 1127 1207 ··· 1142 1222 type PullResubmitParams struct { 1143 1223 LoggedInUser *oauth.User 1144 1224 RepoInfo repoinfo.RepoInfo 1145 - Pull *db.Pull 1225 + Pull *models.Pull 1146 1226 SubmissionId int 1147 1227 } 1148 1228 ··· 1153 1233 type PullActionsParams struct { 1154 1234 LoggedInUser *oauth.User 1155 1235 RepoInfo repoinfo.RepoInfo 1156 - Pull *db.Pull 1236 + Pull *models.Pull 1157 1237 RoundNumber int 1158 1238 MergeCheck types.MergeCheckResponse 1159 1239 ResubmitCheck ResubmitResult 1160 - Stack db.Stack 1240 + Stack models.Stack 1161 1241 } 1162 1242 1163 1243 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1167 1247 type PullNewCommentParams struct { 1168 1248 LoggedInUser *oauth.User 1169 1249 RepoInfo repoinfo.RepoInfo 1170 - Pull *db.Pull 1250 + Pull *models.Pull 1171 1251 RoundNumber int 1172 1252 } 1173 1253 ··· 1178 1258 type RepoCompareParams struct { 1179 1259 LoggedInUser *oauth.User 1180 1260 RepoInfo repoinfo.RepoInfo 1181 - Forks []db.Repo 1261 + Forks []models.Repo 1182 1262 Branches []types.Branch 1183 1263 Tags []*types.TagReference 1184 1264 Base string ··· 1197 1277 type RepoCompareNewParams struct { 1198 1278 LoggedInUser *oauth.User 1199 1279 RepoInfo repoinfo.RepoInfo 1200 - Forks []db.Repo 1280 + Forks []models.Repo 1201 1281 Branches []types.Branch 1202 1282 Tags []*types.TagReference 1203 1283 Base string ··· 1235 1315 type LabelPanelParams struct { 1236 1316 LoggedInUser *oauth.User 1237 1317 RepoInfo repoinfo.RepoInfo 1238 - Defs map[string]*db.LabelDefinition 1318 + Defs map[string]*models.LabelDefinition 1239 1319 Subject string 1240 - State db.LabelState 1320 + State models.LabelState 1241 1321 } 1242 1322 1243 1323 func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { ··· 1247 1327 type EditLabelPanelParams struct { 1248 1328 LoggedInUser *oauth.User 1249 1329 RepoInfo repoinfo.RepoInfo 1250 - Defs map[string]*db.LabelDefinition 1330 + Defs map[string]*models.LabelDefinition 1251 1331 Subject string 1252 - State db.LabelState 1332 + State models.LabelState 1253 1333 } 1254 1334 1255 1335 func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { ··· 1259 1339 type PipelinesParams struct { 1260 1340 LoggedInUser *oauth.User 1261 1341 RepoInfo repoinfo.RepoInfo 1262 - Pipelines []db.Pipeline 1342 + Pipelines []models.Pipeline 1263 1343 Active string 1264 1344 } 1265 1345 ··· 1291 1371 type WorkflowParams struct { 1292 1372 LoggedInUser *oauth.User 1293 1373 RepoInfo repoinfo.RepoInfo 1294 - Pipeline db.Pipeline 1374 + Pipeline models.Pipeline 1295 1375 Workflow string 1296 1376 LogUrl string 1297 1377 Active string ··· 1307 1387 Action string 1308 1388 1309 1389 // this is supplied in the case of editing an existing string 1310 - String db.String 1390 + String models.String 1311 1391 } 1312 1392 1313 1393 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1317 1397 type StringsDashboardParams struct { 1318 1398 LoggedInUser *oauth.User 1319 1399 Card ProfileCard 1320 - Strings []db.String 1400 + Strings []models.String 1321 1401 } 1322 1402 1323 1403 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1326 1406 1327 1407 type StringTimelineParams struct { 1328 1408 LoggedInUser *oauth.User 1329 - Strings []db.String 1409 + Strings []models.String 1330 1410 } 1331 1411 1332 1412 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1338 1418 ShowRendered bool 1339 1419 RenderToggle bool 1340 1420 RenderedContents template.HTML 1341 - String db.String 1342 - Stats db.StringStats 1421 + String models.String 1422 + Stats models.StringStats 1343 1423 Owner identity.Identity 1344 1424 } 1345 1425
+4 -4
appview/pages/repoinfo/repoinfo.go
··· 7 7 "strings" 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.sh/tangled.sh/core/appview/db" 11 - "tangled.sh/tangled.sh/core/appview/state/userutil" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/state/userutil" 12 12 ) 13 13 14 14 func (r RepoInfo) OwnerWithAt() string { ··· 60 60 Spindle string 61 61 RepoAt syntax.ATURI 62 62 IsStarred bool 63 - Stats db.RepoStats 63 + Stats models.RepoStats 64 64 Roles RolesInRepo 65 - Source *db.Repo 65 + Source *models.Repo 66 66 SourceHandle string 67 67 Ref string 68 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 }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="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"> 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 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
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] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 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"> 26 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" 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;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <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> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 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>
+17 -7
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 54 {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 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"
+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">
+3 -3
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"> ··· 29 29 <code 30 30 class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 31 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 32 + data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 34 <button 35 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 36 class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+26
appview/pages/templates/repo/fragments/participants.html
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-6 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 }}
+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
+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 }}
+55
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 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
··· 22 22 "Defs" $.LabelDefs 23 23 "Subject" $.Issue.AtUri 24 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 26 </div> 27 27 </div> 28 28 {{ end }} ··· 122 122 </div> 123 123 {{ end }} 124 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 125 152 126 {{ define "repoAfter" }} 153 127 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ 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 - <div 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 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 42 </div> 93 43 {{ block "pagination" . }} {{ end }} 94 44 {{ end }}
+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 }}
+30 -12
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 }}
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 108 <span class="before:content-['·']"></span> 109 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 111 118 </div> 112 119 </div> 113 120 {{ if .StackId }}
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 46 47 47 {{ define "defaultLabelSettings" }} 48 48 <div class="flex flex-col gap-2"> 49 - <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 50 - <p class="text-gray-500 dark:text-gray-400"> 51 - Manage your issues and pulls by creating labels to categorize them. Only 52 - repository owners may configure labels. You may choose to subscribe to 53 - default labels, or create entirely custom labels. 54 - </p> 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> 55 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"> 56 86 {{ range .DefaultLabels }} 57 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+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 }}
+10 -10
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 }}
+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>
+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 }}
+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
+10 -10
appview/pipelines/pipelines.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/appview/config" 13 - "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/oauth" 15 - "tangled.sh/tangled.sh/core/appview/pages" 16 - "tangled.sh/tangled.sh/core/appview/reporesolver" 17 - "tangled.sh/tangled.sh/core/eventconsumer" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 20 - "tangled.sh/tangled.sh/core/rbac" 21 - spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 12 + "tangled.org/core/appview/config" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/oauth" 15 + "tangled.org/core/appview/pages" 16 + "tangled.org/core/appview/reporesolver" 17 + "tangled.org/core/eventconsumer" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + spindlemodel "tangled.org/core/spindle/models" 22 22 23 23 "github.com/go-chi/chi/v5" 24 24 "github.com/gorilla/websocket"
+1 -1
appview/pipelines/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
-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.sh/tangled.sh/core/appview/db" 9 - "tangled.sh/tangled.sh/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 - }
+124 -75
appview/pulls/pulls.go
··· 12 12 "strings" 13 13 "time" 14 14 15 - "tangled.sh/tangled.sh/core/api/tangled" 16 - "tangled.sh/tangled.sh/core/appview/config" 17 - "tangled.sh/tangled.sh/core/appview/db" 18 - "tangled.sh/tangled.sh/core/appview/notify" 19 - "tangled.sh/tangled.sh/core/appview/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - "tangled.sh/tangled.sh/core/appview/pages/markup" 22 - "tangled.sh/tangled.sh/core/appview/reporesolver" 23 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 - "tangled.sh/tangled.sh/core/idresolver" 25 - "tangled.sh/tangled.sh/core/patchutil" 26 - "tangled.sh/tangled.sh/core/tid" 27 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/appview/config" 17 + "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/models" 19 + "tangled.org/core/appview/notify" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/pages/markup" 23 + "tangled.org/core/appview/reporesolver" 24 + "tangled.org/core/appview/xrpcclient" 25 + "tangled.org/core/idresolver" 26 + "tangled.org/core/patchutil" 27 + "tangled.org/core/tid" 28 + "tangled.org/core/types" 28 29 29 30 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 31 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 75 76 return 76 77 } 77 78 78 - pull, ok := r.Context().Value("pull").(*db.Pull) 79 + pull, ok := r.Context().Value("pull").(*models.Pull) 79 80 if !ok { 80 81 log.Println("failed to get pull") 81 82 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 83 84 } 84 85 85 86 // can be nil if this pull is not stacked 86 - stack, _ := r.Context().Value("stack").(db.Stack) 87 + stack, _ := r.Context().Value("stack").(models.Stack) 87 88 88 89 roundNumberStr := chi.URLParam(r, "round") 89 90 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 123 124 return 124 125 } 125 126 126 - pull, ok := r.Context().Value("pull").(*db.Pull) 127 + pull, ok := r.Context().Value("pull").(*models.Pull) 127 128 if !ok { 128 129 log.Println("failed to get pull") 129 130 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 131 132 } 132 133 133 134 // 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) 135 + stack, _ := r.Context().Value("stack").(models.Stack) 136 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 136 137 137 138 totalIdents := 1 138 139 for _, submission := range pull.Submissions { ··· 159 160 160 161 repoInfo := f.RepoInfo(user) 161 162 162 - m := make(map[string]db.Pipeline) 163 + m := make(map[string]models.Pipeline) 163 164 164 165 var shas []string 165 166 for _, s := range pull.Submissions { ··· 194 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 196 } 196 197 197 - userReactions := map[db.ReactionKind]bool{} 198 + userReactions := map[models.ReactionKind]bool{} 198 199 if user != nil { 199 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 201 } 201 202 203 + labelDefs, err := db.GetLabelDefinitions( 204 + s.db, 205 + db.FilterIn("at_uri", f.Repo.Labels), 206 + db.FilterContains("scope", tangled.RepoPullNSID), 207 + ) 208 + if err != nil { 209 + log.Println("failed to fetch labels", err) 210 + s.pages.Error503(w) 211 + return 212 + } 213 + 214 + defs := make(map[string]*models.LabelDefinition) 215 + for _, l := range labelDefs { 216 + defs[l.AtUri().String()] = &l 217 + } 218 + 202 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 220 LoggedInUser: user, 204 221 RepoInfo: repoInfo, ··· 209 226 ResubmitCheck: resubmitResult, 210 227 Pipelines: m, 211 228 212 - OrderedReactionKinds: db.OrderedReactionKinds, 229 + OrderedReactionKinds: models.OrderedReactionKinds, 213 230 Reactions: reactionCountMap, 214 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 215 234 }) 216 235 } 217 236 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 { 237 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 238 + if pull.State == models.PullMerged { 220 239 return types.MergeCheckResponse{} 221 240 } 222 241 ··· 282 301 return result 283 302 } 284 303 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 { 304 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 305 + if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 287 306 return pages.Unknown 288 307 } 289 308 ··· 356 375 diffOpts.Split = true 357 376 } 358 377 359 - pull, ok := r.Context().Value("pull").(*db.Pull) 378 + pull, ok := r.Context().Value("pull").(*models.Pull) 360 379 if !ok { 361 380 log.Println("failed to get pull") 362 381 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 363 382 return 364 383 } 365 384 366 - stack, _ := r.Context().Value("stack").(db.Stack) 385 + stack, _ := r.Context().Value("stack").(models.Stack) 367 386 368 387 roundId := chi.URLParam(r, "round") 369 388 roundIdInt, err := strconv.Atoi(roundId) ··· 403 422 diffOpts.Split = true 404 423 } 405 424 406 - pull, ok := r.Context().Value("pull").(*db.Pull) 425 + pull, ok := r.Context().Value("pull").(*models.Pull) 407 426 if !ok { 408 427 log.Println("failed to get pull") 409 428 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 451 470 } 452 471 453 472 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 454 - pull, ok := r.Context().Value("pull").(*db.Pull) 473 + pull, ok := r.Context().Value("pull").(*models.Pull) 455 474 if !ok { 456 475 log.Println("failed to get pull") 457 476 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 474 493 user := s.oauth.GetUser(r) 475 494 params := r.URL.Query() 476 495 477 - state := db.PullOpen 496 + state := models.PullOpen 478 497 switch params.Get("state") { 479 498 case "closed": 480 - state = db.PullClosed 499 + state = models.PullClosed 481 500 case "merged": 482 - state = db.PullMerged 501 + state = models.PullMerged 483 502 } 484 503 485 504 f, err := s.repoResolver.Resolve(r) ··· 500 519 } 501 520 502 521 for _, p := range pulls { 503 - var pullSourceRepo *db.Repo 522 + var pullSourceRepo *models.Repo 504 523 if p.PullSource != nil { 505 524 if p.PullSource.RepoAt != nil { 506 525 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 515 534 } 516 535 517 536 // we want to group all stacked PRs into just one list 518 - stacks := make(map[string]db.Stack) 537 + stacks := make(map[string]models.Stack) 519 538 var shas []string 520 539 n := 0 521 540 for _, p := range pulls { ··· 551 570 log.Printf("failed to fetch pipeline statuses: %s", err) 552 571 // non-fatal 553 572 } 554 - m := make(map[string]db.Pipeline) 573 + m := make(map[string]models.Pipeline) 555 574 for _, p := range ps { 556 575 m[p.Sha] = p 557 576 } 558 577 578 + labelDefs, err := db.GetLabelDefinitions( 579 + s.db, 580 + db.FilterIn("at_uri", f.Repo.Labels), 581 + db.FilterContains("scope", tangled.RepoPullNSID), 582 + ) 583 + if err != nil { 584 + log.Println("failed to fetch labels", err) 585 + s.pages.Error503(w) 586 + return 587 + } 588 + 589 + defs := make(map[string]*models.LabelDefinition) 590 + for _, l := range labelDefs { 591 + defs[l.AtUri().String()] = &l 592 + } 593 + 559 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 560 595 LoggedInUser: s.oauth.GetUser(r), 561 596 RepoInfo: f.RepoInfo(user), 562 597 Pulls: pulls, 598 + LabelDefs: defs, 563 599 FilteringBy: state, 564 600 Stacks: stacks, 565 601 Pipelines: m, ··· 574 610 return 575 611 } 576 612 577 - pull, ok := r.Context().Value("pull").(*db.Pull) 613 + pull, ok := r.Context().Value("pull").(*models.Pull) 578 614 if !ok { 579 615 log.Println("failed to get pull") 580 616 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 647 683 return 648 684 } 649 685 650 - comment := &db.PullComment{ 686 + comment := &models.PullComment{ 651 687 OwnerDid: user.Did, 652 688 RepoAt: f.RepoAt().String(), 653 689 PullId: pull.PullId, ··· 890 926 return 891 927 } 892 928 893 - pullSource := &db.PullSource{ 929 + pullSource := &models.PullSource{ 894 930 Branch: sourceBranch, 895 931 } 896 932 recordPullSource := &tangled.RepoPull_Source{ ··· 1000 1036 forkAtUri := fork.RepoAt() 1001 1037 forkAtUriStr := forkAtUri.String() 1002 1038 1003 - pullSource := &db.PullSource{ 1039 + pullSource := &models.PullSource{ 1004 1040 Branch: sourceBranch, 1005 1041 RepoAt: &forkAtUri, 1006 1042 } ··· 1021 1057 title, body, targetBranch string, 1022 1058 patch string, 1023 1059 sourceRev string, 1024 - pullSource *db.PullSource, 1060 + pullSource *models.PullSource, 1025 1061 recordPullSource *tangled.RepoPull_Source, 1026 1062 isStacked bool, 1027 1063 ) { ··· 1057 1093 1058 1094 // We've already checked earlier if it's diff-based and title is empty, 1059 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1060 - if title == "" { 1096 + if title == "" || body == "" { 1061 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1062 1098 if err != nil { 1063 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1068 1104 return 1069 1105 } 1070 1106 1071 - title = formatPatches[0].Title 1072 - body = formatPatches[0].Body 1107 + if title == "" { 1108 + title = formatPatches[0].Title 1109 + } 1110 + if body == "" { 1111 + body = formatPatches[0].Body 1112 + } 1073 1113 } 1074 1114 1075 1115 rkey := tid.TID() 1076 - initialSubmission := db.PullSubmission{ 1116 + initialSubmission := models.PullSubmission{ 1077 1117 Patch: patch, 1078 1118 SourceRev: sourceRev, 1079 1119 } 1080 - pull := &db.Pull{ 1120 + pull := &models.Pull{ 1081 1121 Title: title, 1082 1122 Body: body, 1083 1123 TargetBranch: targetBranch, 1084 1124 OwnerDid: user.Did, 1085 1125 RepoAt: f.RepoAt(), 1086 1126 Rkey: rkey, 1087 - Submissions: []*db.PullSubmission{ 1127 + Submissions: []*models.PullSubmission{ 1088 1128 &initialSubmission, 1089 1129 }, 1090 1130 PullSource: pullSource, ··· 1143 1183 targetBranch string, 1144 1184 patch string, 1145 1185 sourceRev string, 1146 - pullSource *db.PullSource, 1186 + pullSource *models.PullSource, 1147 1187 ) { 1148 1188 // run some necessary checks for stacked-prs first 1149 1189 ··· 1451 1491 return 1452 1492 } 1453 1493 1454 - pull, ok := r.Context().Value("pull").(*db.Pull) 1494 + pull, ok := r.Context().Value("pull").(*models.Pull) 1455 1495 if !ok { 1456 1496 log.Println("failed to get pull") 1457 1497 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1482 1522 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1483 1523 user := s.oauth.GetUser(r) 1484 1524 1485 - pull, ok := r.Context().Value("pull").(*db.Pull) 1525 + pull, ok := r.Context().Value("pull").(*models.Pull) 1486 1526 if !ok { 1487 1527 log.Println("failed to get pull") 1488 1528 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1509 1549 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1510 1550 user := s.oauth.GetUser(r) 1511 1551 1512 - pull, ok := r.Context().Value("pull").(*db.Pull) 1552 + pull, ok := r.Context().Value("pull").(*models.Pull) 1513 1553 if !ok { 1514 1554 log.Println("failed to get pull") 1515 1555 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1572 1612 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1573 1613 user := s.oauth.GetUser(r) 1574 1614 1575 - pull, ok := r.Context().Value("pull").(*db.Pull) 1615 + pull, ok := r.Context().Value("pull").(*models.Pull) 1576 1616 if !ok { 1577 1617 log.Println("failed to get pull") 1578 1618 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1665 1705 } 1666 1706 1667 1707 // validate a resubmission against a pull request 1668 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1708 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1669 1709 if patch == "" { 1670 1710 return fmt.Errorf("Patch is empty.") 1671 1711 } ··· 1686 1726 r *http.Request, 1687 1727 f *reporesolver.ResolvedRepo, 1688 1728 user *oauth.User, 1689 - pull *db.Pull, 1729 + pull *models.Pull, 1690 1730 patch string, 1691 1731 sourceRev string, 1692 1732 ) { ··· 1790 1830 r *http.Request, 1791 1831 f *reporesolver.ResolvedRepo, 1792 1832 user *oauth.User, 1793 - pull *db.Pull, 1833 + pull *models.Pull, 1794 1834 patch string, 1795 1835 stackId string, 1796 1836 ) { 1797 1837 targetBranch := pull.TargetBranch 1798 1838 1799 - origStack, _ := r.Context().Value("stack").(db.Stack) 1839 + origStack, _ := r.Context().Value("stack").(models.Stack) 1800 1840 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1801 1841 if err != nil { 1802 1842 log.Println("failed to create resubmitted stack", err) ··· 1805 1845 } 1806 1846 1807 1847 // find the diff between the stacks, first, map them by changeId 1808 - origById := make(map[string]*db.Pull) 1809 - newById := make(map[string]*db.Pull) 1848 + origById := make(map[string]*models.Pull) 1849 + newById := make(map[string]*models.Pull) 1810 1850 for _, p := range origStack { 1811 1851 origById[p.ChangeId] = p 1812 1852 } ··· 1819 1859 // commits that got updated: corresponding pull is resubmitted & new round begins 1820 1860 // 1821 1861 // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1822 - additions := make(map[string]*db.Pull) 1823 - deletions := make(map[string]*db.Pull) 1862 + additions := make(map[string]*models.Pull) 1863 + deletions := make(map[string]*models.Pull) 1824 1864 unchanged := make(map[string]struct{}) 1825 1865 updated := make(map[string]struct{}) 1826 1866 ··· 1880 1920 // deleted pulls are marked as deleted in the DB 1881 1921 for _, p := range deletions { 1882 1922 // do not do delete already merged PRs 1883 - if p.State == db.PullMerged { 1923 + if p.State == models.PullMerged { 1884 1924 continue 1885 1925 } 1886 1926 ··· 1925 1965 np, _ := newById[id] 1926 1966 1927 1967 // do not update already merged PRs 1928 - if op.State == db.PullMerged { 1968 + if op.State == models.PullMerged { 1929 1969 continue 1930 1970 } 1931 1971 ··· 2046 2086 return 2047 2087 } 2048 2088 2049 - pull, ok := r.Context().Value("pull").(*db.Pull) 2089 + pull, ok := r.Context().Value("pull").(*models.Pull) 2050 2090 if !ok { 2051 2091 log.Println("failed to get pull") 2052 2092 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2053 2093 return 2054 2094 } 2055 2095 2056 - var pullsToMerge db.Stack 2096 + var pullsToMerge models.Stack 2057 2097 pullsToMerge = append(pullsToMerge, pull) 2058 2098 if pull.IsStacked() { 2059 - stack, ok := r.Context().Value("stack").(db.Stack) 2099 + stack, ok := r.Context().Value("stack").(models.Stack) 2060 2100 if !ok { 2061 2101 log.Println("failed to get stack") 2062 2102 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 2146 2186 return 2147 2187 } 2148 2188 2189 + // notify about the pull merge 2190 + for _, p := range pullsToMerge { 2191 + s.notifier.NewPullMerged(r.Context(), p) 2192 + } 2193 + 2149 2194 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2150 2195 } 2151 2196 ··· 2158 2203 return 2159 2204 } 2160 2205 2161 - pull, ok := r.Context().Value("pull").(*db.Pull) 2206 + pull, ok := r.Context().Value("pull").(*models.Pull) 2162 2207 if !ok { 2163 2208 log.Println("failed to get pull") 2164 2209 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2186 2231 } 2187 2232 defer tx.Rollback() 2188 2233 2189 - var pullsToClose []*db.Pull 2234 + var pullsToClose []*models.Pull 2190 2235 pullsToClose = append(pullsToClose, pull) 2191 2236 2192 2237 // if this PR is stacked, then we want to close all PRs below this one on the stack 2193 2238 if pull.IsStacked() { 2194 - stack := r.Context().Value("stack").(db.Stack) 2239 + stack := r.Context().Value("stack").(models.Stack) 2195 2240 subStack := stack.StrictlyBelow(pull) 2196 2241 pullsToClose = append(pullsToClose, subStack...) 2197 2242 } ··· 2213 2258 return 2214 2259 } 2215 2260 2261 + for _, p := range pullsToClose { 2262 + s.notifier.NewPullClosed(r.Context(), p) 2263 + } 2264 + 2216 2265 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2217 2266 } 2218 2267 ··· 2226 2275 return 2227 2276 } 2228 2277 2229 - pull, ok := r.Context().Value("pull").(*db.Pull) 2278 + pull, ok := r.Context().Value("pull").(*models.Pull) 2230 2279 if !ok { 2231 2280 log.Println("failed to get pull") 2232 2281 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2254 2303 } 2255 2304 defer tx.Rollback() 2256 2305 2257 - var pullsToReopen []*db.Pull 2306 + var pullsToReopen []*models.Pull 2258 2307 pullsToReopen = append(pullsToReopen, pull) 2259 2308 2260 2309 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2261 2310 if pull.IsStacked() { 2262 - stack := r.Context().Value("stack").(db.Stack) 2311 + stack := r.Context().Value("stack").(models.Stack) 2263 2312 subStack := stack.StrictlyAbove(pull) 2264 2313 pullsToReopen = append(pullsToReopen, subStack...) 2265 2314 } ··· 2284 2333 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2285 2334 } 2286 2335 2287 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2336 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2288 2337 formatPatches, err := patchutil.ExtractPatches(patch) 2289 2338 if err != nil { 2290 2339 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2296 2345 } 2297 2346 2298 2347 // the stack is identified by a UUID 2299 - var stack db.Stack 2348 + var stack models.Stack 2300 2349 parentChangeId := "" 2301 2350 for _, fp := range formatPatches { 2302 2351 // all patches must have a jj change-id ··· 2309 2358 body := fp.Body 2310 2359 rkey := tid.TID() 2311 2360 2312 - initialSubmission := db.PullSubmission{ 2361 + initialSubmission := models.PullSubmission{ 2313 2362 Patch: fp.Raw, 2314 2363 SourceRev: fp.SHA, 2315 2364 } 2316 - pull := db.Pull{ 2365 + pull := models.Pull{ 2317 2366 Title: title, 2318 2367 Body: body, 2319 2368 TargetBranch: targetBranch, 2320 2369 OwnerDid: user.Did, 2321 2370 RepoAt: f.RepoAt(), 2322 2371 Rkey: rkey, 2323 - Submissions: []*db.PullSubmission{ 2372 + Submissions: []*models.PullSubmission{ 2324 2373 &initialSubmission, 2325 2374 }, 2326 2375 PullSource: pullSource,
+1 -1
appview/pulls/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
+49 -22
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" ··· 16 17 "github.com/go-chi/chi/v5" 17 18 "github.com/go-git/go-git/v5/plumbing" 18 19 "github.com/ipfs/go-cid" 19 - "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/db" 21 - "tangled.sh/tangled.sh/core/appview/pages" 22 - "tangled.sh/tangled.sh/core/appview/reporesolver" 23 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 - "tangled.sh/tangled.sh/core/tid" 25 - "tangled.sh/tangled.sh/core/types" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/pages" 24 + "tangled.org/core/appview/reporesolver" 25 + "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/tid" 27 + "tangled.org/core/types" 26 28 ) 27 29 28 30 // TODO: proper statuses here on early exit ··· 100 102 } 101 103 defer tx.Rollback() 102 104 103 - artifact := db.Artifact{ 105 + artifact := models.Artifact{ 104 106 Did: user.Did, 105 107 Rkey: rkey, 106 108 RepoAt: f.RepoAt(), ··· 133 135 }) 134 136 } 135 137 136 - // TODO: proper statuses here on early exit 137 138 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 138 - tagParam := chi.URLParam(r, "tag") 139 - filename := chi.URLParam(r, "file") 140 139 f, err := rp.repoResolver.Resolve(r) 141 140 if err != nil { 142 141 log.Println("failed to get repo and knot", err) 142 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 143 143 return 144 144 } 145 145 146 + tagParam := chi.URLParam(r, "tag") 147 + filename := chi.URLParam(r, "file") 148 + 146 149 tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 150 if err != nil { 148 151 log.Println("failed to resolve tag", err) ··· 150 153 return 151 154 } 152 155 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 156 artifacts, err := db.GetArtifact( 160 157 rp.db, 161 158 db.FilterEq("repo_at", f.RepoAt()), ··· 164 161 ) 165 162 if err != nil { 166 163 log.Println("failed to get artifacts", err) 164 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 167 165 return 168 166 } 167 + 169 168 if len(artifacts) != 1 { 170 - log.Printf("too many or too little artifacts found") 169 + log.Printf("too many or too few artifacts found") 170 + http.Error(w, "artifact not found", http.StatusNotFound) 171 171 return 172 172 } 173 173 174 174 artifact := artifacts[0] 175 175 176 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 176 + ownerPds := f.OwnerId.PDSEndpoint() 177 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 178 + q := url.Query() 179 + q.Set("cid", artifact.BlobCid.String()) 180 + q.Set("did", artifact.Did) 181 + url.RawQuery = q.Encode() 182 + 183 + req, err := http.NewRequest(http.MethodGet, url.String(), nil) 177 184 if err != nil { 178 - log.Println("failed to get blob from pds", err) 185 + log.Println("failed to create request", err) 186 + http.Error(w, "failed to create request", http.StatusInternalServerError) 187 + return 188 + } 189 + req.Header.Set("Content-Type", "application/json") 190 + 191 + resp, err := http.DefaultClient.Do(req) 192 + if err != nil { 193 + log.Println("failed to make request", err) 194 + http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 179 195 return 180 196 } 197 + defer resp.Body.Close() 181 198 182 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 183 - w.Write(getBlobResp) 199 + // copy status code and relevant headers from upstream response 200 + w.WriteHeader(resp.StatusCode) 201 + for key, values := range resp.Header { 202 + for _, v := range values { 203 + w.Header().Add(key, v) 204 + } 205 + } 206 + 207 + // stream the body directly to the client 208 + if _, err := io.Copy(w, resp.Body); err != nil { 209 + log.Println("error streaming response to client:", err) 210 + } 184 211 } 185 212 186 213 // TODO: proper statuses here on early exit
+10 -9
appview/repo/feed.go
··· 8 8 "slices" 9 9 "time" 10 10 11 - "tangled.sh/tangled.sh/core/appview/db" 12 - "tangled.sh/tangled.sh/core/appview/pagination" 13 - "tangled.sh/tangled.sh/core/appview/reporesolver" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pagination" 14 + "tangled.org/core/appview/reporesolver" 14 15 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 "github.com/gorilla/feeds" ··· 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
+26 -30
appview/repo/index.go
··· 17 17 18 18 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 19 "github.com/go-git/go-git/v5/plumbing" 20 - "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview/commitverify" 22 - "tangled.sh/tangled.sh/core/appview/db" 23 - "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 - "tangled.sh/tangled.sh/core/appview/reporesolver" 26 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 27 - "tangled.sh/tangled.sh/core/types" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/commitverify" 22 + "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/models" 24 + "tangled.org/core/appview/pages" 25 + "tangled.org/core/appview/reporesolver" 26 + "tangled.org/core/appview/xrpcclient" 27 + "tangled.org/core/types" 28 28 29 29 "github.com/go-chi/chi/v5" 30 30 "github.com/go-enry/go-enry/v2" ··· 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, ··· 200 200 }) 201 201 } 202 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 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) 208 214 } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 219 + } 209 220 } 210 221 211 222 var total int64 ··· 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{
+110 -83
appview/repo/repo.go
··· 20 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 22 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 - "tangled.sh/tangled.sh/core/api/tangled" 24 - "tangled.sh/tangled.sh/core/appview/commitverify" 25 - "tangled.sh/tangled.sh/core/appview/config" 26 - "tangled.sh/tangled.sh/core/appview/db" 27 - "tangled.sh/tangled.sh/core/appview/notify" 28 - "tangled.sh/tangled.sh/core/appview/oauth" 29 - "tangled.sh/tangled.sh/core/appview/pages" 30 - "tangled.sh/tangled.sh/core/appview/pages/markup" 31 - "tangled.sh/tangled.sh/core/appview/reporesolver" 32 - "tangled.sh/tangled.sh/core/appview/validator" 33 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 34 - "tangled.sh/tangled.sh/core/eventconsumer" 35 - "tangled.sh/tangled.sh/core/idresolver" 36 - "tangled.sh/tangled.sh/core/patchutil" 37 - "tangled.sh/tangled.sh/core/rbac" 38 - "tangled.sh/tangled.sh/core/tid" 39 - "tangled.sh/tangled.sh/core/types" 40 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 23 + "tangled.org/core/api/tangled" 24 + "tangled.org/core/appview/commitverify" 25 + "tangled.org/core/appview/config" 26 + "tangled.org/core/appview/db" 27 + "tangled.org/core/appview/models" 28 + "tangled.org/core/appview/notify" 29 + "tangled.org/core/appview/oauth" 30 + "tangled.org/core/appview/pages" 31 + "tangled.org/core/appview/pages/markup" 32 + "tangled.org/core/appview/reporesolver" 33 + "tangled.org/core/appview/validator" 34 + xrpcclient "tangled.org/core/appview/xrpcclient" 35 + "tangled.org/core/eventconsumer" 36 + "tangled.org/core/idresolver" 37 + "tangled.org/core/patchutil" 38 + "tangled.org/core/rbac" 39 + "tangled.org/core/tid" 40 + "tangled.org/core/types" 41 + "tangled.org/core/xrpc/serviceauth" 41 42 42 43 securejoin "github.com/cyphar/filepath-securejoin" 43 44 "github.com/go-chi/chi/v5" ··· 399 400 log.Println(err) 400 401 // non-fatal 401 402 } 402 - var pipeline *db.Pipeline 403 + var pipeline *models.Pipeline 403 404 if p, ok := pipelines[result.Diff.Commit.This]; ok { 404 405 pipeline = &p 405 406 } ··· 448 449 return 449 450 } 450 451 451 - // readme content 452 - var ( 453 - readmeContent string 454 - readmeFileName string 455 - ) 456 - 457 - for _, filename := range markup.ReadmeFilenames { 458 - path := fmt.Sprintf("%s/%s", treePath, filename) 459 - blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo) 460 - if err != nil { 461 - continue 462 - } 463 - 464 - if blobResp == nil { 465 - continue 466 - } 467 - 468 - readmeContent = blobResp.Content 469 - readmeFileName = path 470 - break 471 - } 472 - 473 452 // Convert XRPC response to internal types.RepoTreeResponse 474 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 475 454 for i, xrpcFile := range xrpcResp.Files { ··· 505 484 if xrpcResp.Dotdot != nil { 506 485 result.DotDot = *xrpcResp.Dotdot 507 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 508 491 509 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 510 493 // so we can safely redirect to the "parent" (which is the same file). ··· 531 514 BreadCrumbs: breadcrumbs, 532 515 TreePath: treePath, 533 516 RepoInfo: f.RepoInfo(user), 534 - Readme: readmeContent, 535 - ReadmeFileName: readmeFileName, 536 517 RepoTreeResponse: result, 537 518 }) 538 519 } ··· 575 556 } 576 557 577 558 // convert artifacts to map for easy UI building 578 - artifactMap := make(map[plumbing.Hash][]db.Artifact) 559 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 579 560 for _, a := range artifacts { 580 561 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 581 562 } 582 563 583 - var danglingArtifacts []db.Artifact 564 + var danglingArtifacts []models.Artifact 584 565 for _, a := range artifacts { 585 566 found := false 586 567 for _, t := range result.Tags { ··· 1004 985 concreteType = "null" 1005 986 } 1006 987 1007 - format := db.ValueTypeFormatAny 988 + format := models.ValueTypeFormatAny 1008 989 if valueFormat == "did" { 1009 - format = db.ValueTypeFormatDid 990 + format = models.ValueTypeFormatDid 1010 991 } 1011 992 1012 - valueType := db.ValueType{ 1013 - Type: db.ConcreteType(concreteType), 993 + valueType := models.ValueType{ 994 + Type: models.ConcreteType(concreteType), 1014 995 Format: format, 1015 996 Enum: variants, 1016 997 } 1017 998 1018 - label := db.LabelDefinition{ 999 + label := models.LabelDefinition{ 1019 1000 Did: user.Did, 1020 1001 Rkey: tid.TID(), 1021 1002 Name: name, ··· 1109 1090 return 1110 1091 } 1111 1092 1112 - err = db.SubscribeLabel(tx, &db.RepoLabel{ 1093 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1113 1094 RepoAt: f.RepoAt(), 1114 1095 LabelAt: label.AtUri(), 1115 1096 }) ··· 1247 1228 return 1248 1229 } 1249 1230 1231 + if err := r.ParseForm(); err != nil { 1232 + l.Error("invalid form", "err", err) 1233 + return 1234 + } 1235 + 1250 1236 errorId := "default-label-operation" 1251 1237 fail := func(msg string, err error) { 1252 1238 l.Error(msg, "err", err) 1253 1239 rp.pages.Notice(w, errorId, msg) 1254 1240 } 1255 1241 1256 - labelAt := r.FormValue("label") 1257 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1242 + labelAts := r.Form["label"] 1243 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1258 1244 if err != nil { 1259 1245 fail("Failed to subscribe to label.", err) 1260 1246 return 1261 1247 } 1262 1248 1263 1249 newRepo := f.Repo 1264 - newRepo.Labels = append(newRepo.Labels, labelAt) 1250 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1251 + 1252 + // dedup 1253 + slices.Sort(newRepo.Labels) 1254 + newRepo.Labels = slices.Compact(newRepo.Labels) 1255 + 1265 1256 repoRecord := newRepo.AsRecord() 1266 1257 1267 1258 client, err := rp.oauth.AuthorizedClient(r) ··· 1285 1276 }, 1286 1277 }) 1287 1278 1288 - err = db.SubscribeLabel(rp.db, &db.RepoLabel{ 1289 - RepoAt: f.RepoAt(), 1290 - LabelAt: syntax.ATURI(labelAt), 1291 - }) 1279 + tx, err := rp.db.Begin() 1292 1280 if err != nil { 1281 + fail("Failed to subscribe to label.", err) 1282 + return 1283 + } 1284 + defer tx.Rollback() 1285 + 1286 + for _, l := range labelAts { 1287 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1288 + RepoAt: f.RepoAt(), 1289 + LabelAt: syntax.ATURI(l), 1290 + }) 1291 + if err != nil { 1292 + fail("Failed to subscribe to label.", err) 1293 + return 1294 + } 1295 + } 1296 + 1297 + if err := tx.Commit(); err != nil { 1293 1298 fail("Failed to subscribe to label.", err) 1294 1299 return 1295 1300 } ··· 1310 1315 return 1311 1316 } 1312 1317 1318 + if err := r.ParseForm(); err != nil { 1319 + l.Error("invalid form", "err", err) 1320 + return 1321 + } 1322 + 1313 1323 errorId := "default-label-operation" 1314 1324 fail := func(msg string, err error) { 1315 1325 l.Error(msg, "err", err) 1316 1326 rp.pages.Notice(w, errorId, msg) 1317 1327 } 1318 1328 1319 - labelAt := r.FormValue("label") 1320 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1329 + labelAts := r.Form["label"] 1330 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1321 1331 if err != nil { 1322 1332 fail("Failed to unsubscribe to label.", err) 1323 1333 return ··· 1327 1337 newRepo := f.Repo 1328 1338 var updated []string 1329 1339 for _, l := range newRepo.Labels { 1330 - if l != labelAt { 1340 + if !slices.Contains(labelAts, l) { 1331 1341 updated = append(updated, l) 1332 1342 } 1333 1343 } ··· 1358 1368 err = db.UnsubscribeLabel( 1359 1369 rp.db, 1360 1370 db.FilterEq("repo_at", f.RepoAt()), 1361 - db.FilterEq("label_at", labelAt), 1371 + db.FilterIn("label_at", labelAts), 1362 1372 ) 1363 1373 if err != nil { 1364 1374 fail("Failed to unsubscribe label.", err) ··· 1395 1405 return 1396 1406 } 1397 1407 1398 - defs := make(map[string]*db.LabelDefinition) 1408 + defs := make(map[string]*models.LabelDefinition) 1399 1409 for _, l := range labelDefs { 1400 1410 defs[l.AtUri().String()] = &l 1401 1411 } ··· 1443 1453 return 1444 1454 } 1445 1455 1446 - defs := make(map[string]*db.LabelDefinition) 1456 + defs := make(map[string]*models.LabelDefinition) 1447 1457 for _, l := range labelDefs { 1448 1458 defs[l.AtUri().String()] = &l 1449 1459 } ··· 1566 1576 return 1567 1577 } 1568 1578 1569 - err = db.AddCollaborator(tx, db.Collaborator{ 1579 + err = db.AddCollaborator(tx, models.Collaborator{ 1570 1580 Did: syntax.DID(currentUser.Did), 1571 1581 Rkey: rkey, 1572 1582 SubjectDid: collaboratorIdent.DID, ··· 1894 1904 return 1895 1905 } 1896 1906 1897 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs())) 1907 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1898 1908 if err != nil { 1899 1909 log.Println("failed to fetch labels", err) 1900 1910 rp.pages.Error503(w) ··· 1926 1936 subscribedLabels[l] = struct{}{} 1927 1937 } 1928 1938 1939 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1940 + // if all default labels are subbed, show the "unsubscribe all" button 1941 + shouldSubscribeAll := false 1942 + for _, dl := range defaultLabels { 1943 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1944 + // one of the default labels is not subscribed to 1945 + shouldSubscribeAll = true 1946 + break 1947 + } 1948 + } 1949 + 1929 1950 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1930 - LoggedInUser: user, 1931 - RepoInfo: f.RepoInfo(user), 1932 - Branches: result.Branches, 1933 - Labels: labels, 1934 - DefaultLabels: defaultLabels, 1935 - SubscribedLabels: subscribedLabels, 1936 - Tabs: settingsTabs, 1937 - Tab: "general", 1951 + LoggedInUser: user, 1952 + RepoInfo: f.RepoInfo(user), 1953 + Branches: result.Branches, 1954 + Labels: labels, 1955 + DefaultLabels: defaultLabels, 1956 + SubscribedLabels: subscribedLabels, 1957 + ShouldSubscribeAll: shouldSubscribeAll, 1958 + Tabs: settingsTabs, 1959 + Tab: "general", 1938 1960 }) 1939 1961 } 1940 1962 ··· 2107 2129 } 2108 2130 2109 2131 // choose a name for a fork 2110 - forkName := f.Name 2132 + forkName := r.FormValue("repo_name") 2133 + if forkName == "" { 2134 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2135 + return 2136 + } 2137 + 2111 2138 // this check is *only* to see if the forked repo name already exists 2112 2139 // in the user's account. 2113 2140 existingRepo, err := db.GetRepo( 2114 2141 rp.db, 2115 2142 db.FilterEq("did", user.Did), 2116 - db.FilterEq("name", f.Name), 2143 + db.FilterEq("name", forkName), 2117 2144 ) 2118 2145 if err != nil { 2119 - if errors.Is(err, sql.ErrNoRows) { 2120 - // no existing repo with this name found, we can use the name as is 2121 - } else { 2146 + if !errors.Is(err, sql.ErrNoRows) { 2122 2147 log.Println("error fetching existing repo from db", "err", err) 2123 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2124 2149 return 2125 2150 } 2126 2151 } else if existingRepo != nil { 2127 - // repo with this name already exists, append random string 2128 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2152 + // repo with this name already exists 2153 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2154 + return 2129 2155 } 2130 2156 l = l.With("forkName", forkName) 2131 2157 ··· 2141 2167 2142 2168 // create an atproto record for this fork 2143 2169 rkey := tid.TID() 2144 - repo := &db.Repo{ 2170 + repo := &models.Repo{ 2145 2171 Did: user.Did, 2146 2172 Name: forkName, 2147 2173 Knot: targetKnot, 2148 2174 Rkey: rkey, 2149 2175 Source: sourceAt, 2150 - Description: existingRepo.Description, 2176 + Description: f.Repo.Description, 2151 2177 Created: time.Now(), 2178 + Labels: models.DefaultLabelDefs(), 2152 2179 } 2153 2180 record := repo.AsRecord() 2154 2181
+6 -5
appview/repo/repo_util.go
··· 9 9 "sort" 10 10 "strings" 11 11 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages/repoinfo" 15 + "tangled.org/core/types" 15 16 16 17 "github.com/go-git/go-git/v5/plumbing/object" 17 18 ) ··· 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
+3 -4
appview/repo/router.go
··· 4 4 "net/http" 5 5 6 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 7 + "tangled.org/core/appview/middleware" 8 8 ) 9 9 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { ··· 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 })
+13 -12
appview/reporesolver/resolver.go
··· 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 15 securejoin "github.com/cyphar/filepath-securejoin" 16 16 "github.com/go-chi/chi/v5" 17 - "tangled.sh/tangled.sh/core/appview/config" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 22 - "tangled.sh/tangled.sh/core/idresolver" 23 - "tangled.sh/tangled.sh/core/rbac" 17 + "tangled.org/core/appview/config" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/pages/repoinfo" 23 + "tangled.org/core/idresolver" 24 + "tangled.org/core/rbac" 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 { ··· 191 192 Knot: knot, 192 193 Spindle: f.Spindle, 193 194 Roles: f.RolesInRepo(user), 194 - Stats: db.RepoStats{ 195 + Stats: models.RepoStats{ 195 196 StarCount: starCount, 196 197 IssueCount: issueCount, 197 198 PullCount: pullCount, ··· 211 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 212 213 if u != nil { 213 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 214 - return repoinfo.RolesInRepo{r} 215 + return repoinfo.RolesInRepo{Roles: r} 215 216 } else { 216 217 return repoinfo.RolesInRepo{} 217 218 }
+4 -4
appview/serververify/verify.go
··· 6 6 "fmt" 7 7 8 8 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/appview/db" 11 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 - "tangled.sh/tangled.sh/core/rbac" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 13 13 ) 14 14 15 15 var (
+62 -10
appview/settings/settings.go
··· 11 11 "time" 12 12 13 13 "github.com/go-chi/chi/v5" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview/config" 16 - "tangled.sh/tangled.sh/core/appview/db" 17 - "tangled.sh/tangled.sh/core/appview/email" 18 - "tangled.sh/tangled.sh/core/appview/middleware" 19 - "tangled.sh/tangled.sh/core/appview/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - "tangled.sh/tangled.sh/core/tid" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/config" 16 + "tangled.org/core/appview/db" 17 + "tangled.org/core/appview/email" 18 + "tangled.org/core/appview/middleware" 19 + "tangled.org/core/appview/models" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/tid" 22 23 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 25 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 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, ··· 246 298 if s.Config.Core.Dev { 247 299 appUrl = "http://" + s.Config.Core.ListenAddr 248 300 } else { 249 - appUrl = "https://tangled.sh" 301 + appUrl = s.Config.Core.AppviewHost 250 302 } 251 303 252 304 return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+76 -11
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 11 14 "github.com/go-chi/chi/v5" 12 15 "github.com/posthog/posthog-go" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/dns" 16 - "tangled.sh/tangled.sh/core/appview/email" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/state/userutil" 19 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 - "tangled.sh/tangled.sh/core/idresolver" 16 + "tangled.org/core/appview/config" 17 + "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/dns" 19 + "tangled.org/core/appview/email" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/state/userutil" 23 + "tangled.org/core/appview/xrpcclient" 24 + "tangled.org/core/idresolver" 21 25 ) 22 26 23 27 type Signup struct { ··· 115 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 120 switch r.Method { 117 121 case http.MethodGet: 118 - s.pages.Signup(w) 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 119 125 case http.MethodPost: 120 126 if s.cf == nil { 121 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 122 129 } 123 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 124 132 125 133 noticeId := "signup-msg" 134 + 135 + if err := s.validateCaptcha(cfToken, r); err != nil { 136 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 + return 139 + } 140 + 126 141 if !email.IsValidEmail(emailId) { 127 142 s.pages.Notice(w, noticeId, "Invalid email address.") 128 143 return ··· 163 178 s.pages.Notice(w, noticeId, "Failed to send email.") 164 179 return 165 180 } 166 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 181 + err = db.AddInflightSignup(s.db, models.InflightSignup{ 167 182 Email: emailId, 168 183 InviteCode: code, 169 184 }) ··· 229 244 return 230 245 } 231 246 232 - err = db.AddEmail(s.db, db.Email{ 247 + err = db.AddEmail(s.db, models.Email{ 233 248 Did: did, 234 249 Address: email, 235 250 Verified: true, ··· 254 269 return 255 270 } 256 271 } 272 + 273 + type turnstileResponse struct { 274 + Success bool `json:"success"` 275 + ErrorCodes []string `json:"error-codes,omitempty"` 276 + ChallengeTs string `json:"challenge_ts,omitempty"` 277 + Hostname string `json:"hostname,omitempty"` 278 + } 279 + 280 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 281 + if cfToken == "" { 282 + return errors.New("captcha token is empty") 283 + } 284 + 285 + if s.config.Cloudflare.TurnstileSecretKey == "" { 286 + return errors.New("turnstile secret key not configured") 287 + } 288 + 289 + data := url.Values{} 290 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 291 + data.Set("response", cfToken) 292 + 293 + // include the client IP if we have it 294 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 295 + data.Set("remoteip", remoteIP) 296 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 297 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 298 + data.Set("remoteip", strings.TrimSpace(ips[0])) 299 + } 300 + } else { 301 + data.Set("remoteip", r.RemoteAddr) 302 + } 303 + 304 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 305 + if err != nil { 306 + return fmt.Errorf("failed to verify turnstile token: %w", err) 307 + } 308 + defer resp.Body.Close() 309 + 310 + var turnstileResp turnstileResponse 311 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 312 + return fmt.Errorf("failed to decode turnstile response: %w", err) 313 + } 314 + 315 + if !turnstileResp.Success { 316 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 317 + return errors.New("turnstile validation failed") 318 + } 319 + 320 + return nil 321 + }
+15 -14
appview/spindles/spindles.go
··· 9 9 "time" 10 10 11 11 "github.com/go-chi/chi/v5" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/middleware" 16 - "tangled.sh/tangled.sh/core/appview/oauth" 17 - "tangled.sh/tangled.sh/core/appview/pages" 18 - "tangled.sh/tangled.sh/core/appview/serververify" 19 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 - "tangled.sh/tangled.sh/core/idresolver" 21 - "tangled.sh/tangled.sh/core/rbac" 22 - "tangled.sh/tangled.sh/core/tid" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/middleware" 16 + "tangled.org/core/appview/models" 17 + "tangled.org/core/appview/oauth" 18 + "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/serververify" 20 + "tangled.org/core/appview/xrpcclient" 21 + "tangled.org/core/idresolver" 22 + "tangled.org/core/rbac" 23 + "tangled.org/core/tid" 23 24 24 25 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 26 "github.com/bluesky-social/indigo/atproto/syntax" ··· 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 }) ··· 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,
+8 -7
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/appview/db" 12 - "tangled.sh/tangled.sh/core/appview/pages" 13 - "tangled.sh/tangled.sh/core/tid" 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/tid" 14 15 ) 15 16 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 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 ··· 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.sh/tangled.sh/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 {
+29 -15
appview/state/knotstream.go
··· 8 8 "slices" 9 9 "time" 10 10 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/cache" 13 - "tangled.sh/tangled.sh/core/appview/config" 14 - "tangled.sh/tangled.sh/core/appview/db" 15 - ec "tangled.sh/tangled.sh/core/eventconsumer" 16 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 17 - "tangled.sh/tangled.sh/core/log" 18 - "tangled.sh/tangled.sh/core/rbac" 19 - "tangled.sh/tangled.sh/core/workflow" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/cache" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + ec "tangled.org/core/eventconsumer" 17 + "tangled.org/core/eventconsumer/cursor" 18 + "tangled.org/core/log" 19 + "tangled.org/core/rbac" 20 + "tangled.org/core/workflow" 20 21 21 22 "github.com/bluesky-social/indigo/atproto/syntax" 22 23 "github.com/go-git/go-git/v5/plumbing" ··· 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),
+30 -37
appview/state/profile.go
··· 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" 17 17 "github.com/gorilla/feeds" 18 - "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 21 22 ) 22 23 23 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 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 {
+6 -5
appview/state/reaction.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 11 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/pages" 15 - "tangled.sh/tangled.sh/core/tid" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/appview/pages" 16 + "tangled.org/core/tid" 16 17 ) 17 18 18 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 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 -16
appview/state/router.go
··· 6 6 7 7 "github.com/go-chi/chi/v5" 8 8 "github.com/gorilla/sessions" 9 - "tangled.sh/tangled.sh/core/appview/issues" 10 - "tangled.sh/tangled.sh/core/appview/knots" 11 - "tangled.sh/tangled.sh/core/appview/labels" 12 - "tangled.sh/tangled.sh/core/appview/middleware" 13 - oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 14 - "tangled.sh/tangled.sh/core/appview/pipelines" 15 - "tangled.sh/tangled.sh/core/appview/pulls" 16 - "tangled.sh/tangled.sh/core/appview/repo" 17 - "tangled.sh/tangled.sh/core/appview/settings" 18 - "tangled.sh/tangled.sh/core/appview/signup" 19 - "tangled.sh/tangled.sh/core/appview/spindles" 20 - "tangled.sh/tangled.sh/core/appview/state/userutil" 21 - avstrings "tangled.sh/tangled.sh/core/appview/strings" 22 - "tangled.sh/tangled.sh/core/log" 9 + "tangled.org/core/appview/issues" 10 + "tangled.org/core/appview/knots" 11 + "tangled.org/core/appview/labels" 12 + "tangled.org/core/appview/middleware" 13 + "tangled.org/core/appview/notifications" 14 + oauthhandler "tangled.org/core/appview/oauth/handler" 15 + "tangled.org/core/appview/pipelines" 16 + "tangled.org/core/appview/pulls" 17 + "tangled.org/core/appview/repo" 18 + "tangled.org/core/appview/settings" 19 + "tangled.org/core/appview/signup" 20 + "tangled.org/core/appview/spindles" 21 + "tangled.org/core/appview/state/userutil" 22 + avstrings "tangled.org/core/appview/strings" 23 + "tangled.org/core/log" 23 24 ) 24 25 25 26 func (s *State) Router() http.Handler { ··· 33 34 s.pages, 34 35 ) 35 36 37 + router.Use(middleware.TryRefreshSession()) 36 38 router.Get("/favicon.svg", s.Favicon) 37 39 router.Get("/favicon.ico", s.Favicon) 40 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 41 39 42 userRouter := s.UserRouter(&middleware) 40 43 standardRouter := s.StandardRouter(&middleware) ··· 115 118 116 119 r.Get("/", s.HomeOrTimeline) 117 120 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 121 + r.Get("/upgradeBanner", s.UpgradeBanner) 122 + 123 + // special-case handler for serving tangled.org/core 124 + r.Get("/core", s.Core()) 119 125 120 126 r.Route("/repo", func(r chi.Router) { 121 127 r.Route("/new", func(r chi.Router) { ··· 126 132 // r.Post("/import", s.ImportRepo) 127 133 }) 128 134 135 + r.Get("/goodfirstissues", s.GoodFirstIssues) 136 + 129 137 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 130 138 r.Post("/", s.Follow) 131 139 r.Delete("/", s.Follow) ··· 153 161 r.Mount("/strings", s.StringsRouter(mw)) 154 162 r.Mount("/knots", s.KnotsRouter()) 155 163 r.Mount("/spindles", s.SpindlesRouter()) 164 + r.Mount("/notifications", s.NotificationsRouter(mw)) 165 + 156 166 r.Mount("/signup", s.SignupRouter()) 157 167 r.Mount("/", s.OAuthRouter()) 158 168 159 169 r.Get("/keys/{user}", s.Keys) 160 170 r.Get("/terms", s.TermsOfService) 161 171 r.Get("/privacy", s.PrivacyPolicy) 172 + r.Get("/brand", s.Brand) 162 173 163 174 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 164 175 s.pages.Error404(w) 165 176 }) 166 177 return r 178 + } 179 + 180 + // Core serves tangled.org/core go-import meta tags, and redirects 181 + // to the core repository if accessed normally. 182 + func (s *State) Core() http.HandlerFunc { 183 + return func(w http.ResponseWriter, r *http.Request) { 184 + if r.URL.Query().Get("go-get") == "1" { 185 + w.Header().Set("Content-Type", "text/html") 186 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 187 + return 188 + } 189 + 190 + http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 191 + } 167 192 } 168 193 169 194 func (s *State) OAuthRouter() http.Handler { ··· 253 278 } 254 279 255 280 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 256 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 281 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 257 282 return ls.Router(mw) 283 + } 284 + 285 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 286 + notifs := notifications.New(s.db, s.oauth, s.pages) 287 + return notifs.Router(mw) 258 288 } 259 289 260 290 func (s *State) SignupRouter() http.Handler {
+11 -10
appview/state/spindlestream.go
··· 9 9 "time" 10 10 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/cache" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 - "tangled.sh/tangled.sh/core/appview/db" 16 - ec "tangled.sh/tangled.sh/core/eventconsumer" 17 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 18 - "tangled.sh/tangled.sh/core/log" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - spindle "tangled.sh/tangled.sh/core/spindle/models" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/cache" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/db" 16 + "tangled.org/core/appview/models" 17 + ec "tangled.org/core/eventconsumer" 18 + "tangled.org/core/eventconsumer/cursor" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + spindle "tangled.org/core/spindle/models" 21 22 ) 22 23 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 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:"),
+8 -7
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/appview/pages" 14 - "tangled.sh/tangled.sh/core/tid" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages" 15 + "tangled.org/core/tid" 15 16 ) 16 17 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 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 }) ··· 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 })
+105 -26
appview/state/state.go
··· 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 18 "github.com/go-chi/chi/v5" 19 19 "github.com/posthog/posthog-go" 20 - "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 - "tangled.sh/tangled.sh/core/appview/cache" 23 - "tangled.sh/tangled.sh/core/appview/cache/session" 24 - "tangled.sh/tangled.sh/core/appview/config" 25 - "tangled.sh/tangled.sh/core/appview/db" 26 - "tangled.sh/tangled.sh/core/appview/notify" 27 - "tangled.sh/tangled.sh/core/appview/oauth" 28 - "tangled.sh/tangled.sh/core/appview/pages" 29 - posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 - "tangled.sh/tangled.sh/core/appview/reporesolver" 31 - "tangled.sh/tangled.sh/core/appview/validator" 32 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 - "tangled.sh/tangled.sh/core/eventconsumer" 34 - "tangled.sh/tangled.sh/core/idresolver" 35 - "tangled.sh/tangled.sh/core/jetstream" 36 - tlog "tangled.sh/tangled.sh/core/log" 37 - "tangled.sh/tangled.sh/core/rbac" 38 - "tangled.sh/tangled.sh/core/tid" 39 - // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview" 22 + "tangled.org/core/appview/cache" 23 + "tangled.org/core/appview/cache/session" 24 + "tangled.org/core/appview/config" 25 + "tangled.org/core/appview/db" 26 + "tangled.org/core/appview/models" 27 + "tangled.org/core/appview/notify" 28 + dbnotify "tangled.org/core/appview/notify/db" 29 + phnotify "tangled.org/core/appview/notify/posthog" 30 + "tangled.org/core/appview/oauth" 31 + "tangled.org/core/appview/pages" 32 + "tangled.org/core/appview/reporesolver" 33 + "tangled.org/core/appview/validator" 34 + xrpcclient "tangled.org/core/appview/xrpcclient" 35 + "tangled.org/core/eventconsumer" 36 + "tangled.org/core/idresolver" 37 + "tangled.org/core/jetstream" 38 + tlog "tangled.org/core/log" 39 + "tangled.org/core/rbac" 40 + "tangled.org/core/tid" 40 41 ) 41 42 42 43 type State struct { ··· 78 79 cache := cache.New(config.Redis.Addr) 79 80 sess := session.New(cache) 80 81 oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 82 + validator := validator.New(d, res, enforcer) 82 83 83 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 85 if err != nil { ··· 87 88 88 89 repoResolver := reporesolver.New(config, enforcer, res, d) 89 90 90 - wrapper := db.DbWrapper{d} 91 + wrapper := db.DbWrapper{Execer: d} 91 92 jc, err := jetstream.NewJetstreamClient( 92 93 config.Jetstream.Endpoint, 93 94 "appview", ··· 103 104 tangled.RepoIssueNSID, 104 105 tangled.RepoIssueCommentNSID, 105 106 tangled.LabelDefinitionNSID, 107 + tangled.LabelOpNSID, 106 108 }, 107 109 nil, 108 110 slog.Default(), ··· 115 117 ) 116 118 if err != nil { 117 119 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 120 + } 121 + 122 + if err := BackfillDefaultDefs(d, res); err != nil { 123 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 118 124 } 119 125 120 126 ingester := appview.Ingester{ ··· 143 149 spindlestream.Start(ctx) 144 150 145 151 var notifiers []notify.Notifier 152 + 153 + // Always add the database notifier 154 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 155 + 156 + // Add other notifiers in production only 146 157 if !config.Core.Dev { 147 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 158 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 148 159 } 149 160 notifier := notify.NewMergedNotifier(notifiers...) 150 161 ··· 187 198 s.pages.Favicon(w) 188 199 } 189 200 201 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 202 + const manifestJson = `{ 203 + "name": "tangled", 204 + "description": "tightly-knit social coding.", 205 + "icons": [ 206 + { 207 + "src": "/favicon.svg", 208 + "sizes": "144x144" 209 + } 210 + ], 211 + "start_url": "/", 212 + "id": "org.tangled", 213 + 214 + "display": "standalone", 215 + "background_color": "#111827", 216 + "theme_color": "#111827" 217 + }` 218 + 219 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 220 + w.Header().Set("Content-Type", "application/json") 221 + w.Write([]byte(manifestJson)) 222 + } 223 + 190 224 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 191 225 user := s.oauth.GetUser(r) 192 226 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 201 235 }) 202 236 } 203 237 238 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 239 + user := s.oauth.GetUser(r) 240 + s.pages.Brand(w, pages.BrandParams{ 241 + LoggedInUser: user, 242 + }) 243 + } 244 + 204 245 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 205 246 if s.oauth.GetUser(r) != nil { 206 247 s.Timeline(w, r) ··· 229 270 return 230 271 } 231 272 232 - s.pages.Timeline(w, pages.TimelineParams{ 273 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 274 + if err != nil { 275 + // non-fatal 276 + } 277 + 278 + fmt.Println(s.pages.Timeline(w, pages.TimelineParams{ 233 279 LoggedInUser: user, 234 280 Timeline: timeline, 235 281 Repos: repos, 236 - }) 282 + GfiLabel: gfiLabel, 283 + })) 237 284 } 238 285 239 286 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 240 287 user := s.oauth.GetUser(r) 288 + if user == nil { 289 + return 290 + } 291 + 241 292 l := s.logger.With("handler", "UpgradeBanner") 242 293 l = l.With("did", user.Did) 243 294 l = l.With("handle", user.Handle) ··· 433 484 434 485 // create atproto record for this repo 435 486 rkey := tid.TID() 436 - repo := &db.Repo{ 487 + repo := &models.Repo{ 437 488 Did: user.Did, 438 489 Name: repoName, 439 490 Knot: domain, 440 491 Rkey: rkey, 441 492 Description: description, 442 493 Created: time.Now(), 494 + Labels: models.DefaultLabelDefs(), 443 495 } 444 496 record := repo.AsRecord() 445 497 ··· 580 632 }) 581 633 return err 582 634 } 635 + 636 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 637 + defaults := models.DefaultLabelDefs() 638 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 639 + if err != nil { 640 + return err 641 + } 642 + // already present 643 + if len(defaultLabels) == len(defaults) { 644 + return nil 645 + } 646 + 647 + labelDefs, err := models.FetchDefaultDefs(r) 648 + if err != nil { 649 + return err 650 + } 651 + 652 + // Insert each label definition to the database 653 + for _, labelDef := range labelDefs { 654 + _, err = db.AddLabelDefinition(e, &labelDef) 655 + if err != nil { 656 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 657 + } 658 + } 659 + 660 + return nil 661 + }
+12 -11
appview/strings/strings.go
··· 8 8 "strconv" 9 9 "time" 10 10 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/appview/middleware" 14 - "tangled.sh/tangled.sh/core/appview/notify" 15 - "tangled.sh/tangled.sh/core/appview/oauth" 16 - "tangled.sh/tangled.sh/core/appview/pages" 17 - "tangled.sh/tangled.sh/core/appview/pages/markup" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/tid" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/middleware" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/appview/notify" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/pages/markup" 19 + "tangled.org/core/idresolver" 20 + "tangled.org/core/tid" 20 21 21 22 "github.com/bluesky-social/indigo/api/atproto" 22 23 "github.com/bluesky-social/indigo/atproto/identity" ··· 235 236 description := r.FormValue("description") 236 237 237 238 // construct new string from form values 238 - entry := db.String{ 239 + entry := models.String{ 239 240 Did: first.Did, 240 241 Rkey: first.Rkey, 241 242 Filename: filename, ··· 318 319 319 320 description := r.FormValue("description") 320 321 321 - string := db.String{ 322 + string := models.String{ 322 323 Did: syntax.DID(user.Did), 323 324 Rkey: tid.TID(), 324 325 Filename: filename,
+4 -3
appview/validator/issue.go
··· 4 4 "fmt" 5 5 "strings" 6 6 7 - "tangled.sh/tangled.sh/core/appview/db" 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 }
+27 -13
appview/validator/label.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "golang.org/x/exp/slices" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/models" 13 13 ) 14 14 15 15 var ( ··· 21 21 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 22 ) 23 23 24 - func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { 24 + func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 25 if label.Name == "" { 26 26 return fmt.Errorf("label name is empty") 27 27 } ··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 102 105 if labelOp == nil { 103 106 return fmt.Errorf("label operation is required") 104 107 } 105 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 + 106 120 expectedKey := labelDef.AtUri().String() 107 121 if labelOp.OperandKey != expectedKey { 108 122 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 109 123 } 110 124 111 - if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel { 125 + if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 112 126 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 113 127 } 114 128 ··· 131 145 return nil 132 146 } 133 147 134 - func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 148 + func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 135 149 valueType := labelDef.ValueType 136 150 137 151 // this is permitted, it "unsets" a label 138 152 if labelOp.OperandValue == "" { 139 - labelOp.Operation = db.LabelOperationDel 153 + labelOp.Operation = models.LabelOperationDel 140 154 return nil 141 155 } 142 156 143 157 switch valueType.Type { 144 - case db.ConcreteTypeNull: 158 + case models.ConcreteTypeNull: 145 159 // For null type, value should be empty 146 160 if labelOp.OperandValue != "null" { 147 161 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 148 162 } 149 163 150 - case db.ConcreteTypeString: 164 + case models.ConcreteTypeString: 151 165 // For string type, validate enum constraints if present 152 166 if valueType.IsEnum() { 153 167 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { ··· 156 170 } 157 171 158 172 switch valueType.Format { 159 - case db.ValueTypeFormatDid: 173 + case models.ValueTypeFormatDid: 160 174 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 161 175 if err != nil { 162 176 return fmt.Errorf("failed to resolve did/handle: %w", err) ··· 164 178 165 179 labelOp.OperandValue = id.DID.String() 166 180 167 - case db.ValueTypeFormatAny, "": 181 + case models.ValueTypeFormatAny, "": 168 182 default: 169 183 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 170 184 } 171 185 172 - case db.ConcreteTypeInt: 186 + case models.ConcreteTypeInt: 173 187 if labelOp.OperandValue == "" { 174 188 return fmt.Errorf("integer type requires non-empty value") 175 189 } ··· 183 197 } 184 198 } 185 199 186 - case db.ConcreteTypeBool: 200 + case models.ConcreteTypeBool: 187 201 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 188 202 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 189 203 }
+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 -4
appview/validator/validator.go
··· 1 1 package validator 2 2 3 3 import ( 4 - "tangled.sh/tangled.sh/core/appview/db" 5 - "tangled.sh/tangled.sh/core/appview/pages/markup" 6 - "tangled.sh/tangled.sh/core/idresolver" 4 + "tangled.org/core/appview/db" 5 + "tangled.org/core/appview/pages/markup" 6 + "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 7 8 ) 8 9 9 10 type Validator struct { 10 11 db *db.DB 11 12 sanitizer markup.Sanitizer 12 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 13 15 } 14 16 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 16 18 return &Validator{ 17 19 db: db, 18 20 sanitizer: markup.NewSanitizer(), 19 21 resolver: res, 22 + enforcer: enforcer, 20 23 } 21 24 }
+2 -2
cmd/appview/main.go
··· 7 7 "net/http" 8 8 "os" 9 9 10 - "tangled.sh/tangled.sh/core/appview/config" 11 - "tangled.sh/tangled.sh/core/appview/state" 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/state" 12 12 ) 13 13 14 14 func main() {
+1 -1
cmd/combinediff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+1 -1
cmd/gen.go
··· 2 2 3 3 import ( 4 4 cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.sh/tangled.sh/core/api/tangled" 5 + "tangled.org/core/api/tangled" 6 6 ) 7 7 8 8 func main() {
+1 -1
cmd/interdiff/main.go
··· 5 5 "os" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 8 + "tangled.org/core/patchutil" 9 9 ) 10 10 11 11 func main() {
+5 -5
cmd/knot/main.go
··· 5 5 "os" 6 6 7 7 "github.com/urfave/cli/v3" 8 - "tangled.sh/tangled.sh/core/guard" 9 - "tangled.sh/tangled.sh/core/hook" 10 - "tangled.sh/tangled.sh/core/keyfetch" 11 - "tangled.sh/tangled.sh/core/knotserver" 12 - "tangled.sh/tangled.sh/core/log" 8 + "tangled.org/core/guard" 9 + "tangled.org/core/hook" 10 + "tangled.org/core/keyfetch" 11 + "tangled.org/core/knotserver" 12 + "tangled.org/core/log" 13 13 ) 14 14 15 15 func main() {
+3 -3
cmd/spindle/main.go
··· 4 4 "context" 5 5 "os" 6 6 7 - "tangled.sh/tangled.sh/core/log" 8 - "tangled.sh/tangled.sh/core/spindle" 9 - _ "tangled.sh/tangled.sh/core/tid" 7 + "tangled.org/core/log" 8 + "tangled.org/core/spindle" 9 + _ "tangled.org/core/tid" 10 10 ) 11 11 12 12 func main() {
+1 -1
cmd/verifysig/main.go
··· 7 7 "os" 8 8 "strings" 9 9 10 - "tangled.sh/tangled.sh/core/crypto" 10 + "tangled.org/core/crypto" 11 11 ) 12 12 13 13 func parseCommitObject(commitData string) (string, string, error) {
+1 -1
crypto/verify.go
··· 9 9 10 10 "github.com/hiddeco/sshsig" 11 11 "golang.org/x/crypto/ssh" 12 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/types" 13 13 ) 14 14 15 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
+2 -2
docs/knot-hosting.md
··· 19 19 First, clone this repository: 20 20 21 21 ``` 22 - git clone https://tangled.sh/@tangled.sh/core 22 + git clone https://tangled.org/@tangled.org/core 23 23 ``` 24 24 25 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 130 131 131 You should now have a running knot server! You can finalize 132 132 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 133 + [/knots](https://tangled.org/knots) page. This simply creates 134 134 a record on your PDS to announce the existence of the knot. 135 135 136 136 ### custom paths
+4 -5
docs/migrations.md
··· 14 14 For knots: 15 15 16 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.sh/knots) and 17 + - Head to the [knot dashboard](https://tangled.org/knots) and 18 18 hit the "retry" button to verify your knot 19 19 20 20 For spindles: 21 21 22 22 - Upgrade to latest tag (v1.9.0 or above) 23 23 - Head to the [spindle 24 - dashboard](https://tangled.sh/spindles) and hit the 24 + dashboard](https://tangled.org/spindles) and hit the 25 25 "retry" button to verify your spindle 26 26 27 27 ## Upgrading from v1.7.x ··· 38 38 environment variable entirely 39 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 40 your DID. You can find your DID in the 41 - [settings](https://tangled.sh/settings) page. 41 + [settings](https://tangled.org/settings) page. 42 42 - Restart your knot once you have replaced the environment 43 43 variable 44 - - Head to the [knot dashboard](https://tangled.sh/knots) and 44 + - Head to the [knot dashboard](https://tangled.org/knots) and 45 45 hit the "retry" button to verify your knot. This simply 46 46 writes a `sh.tangled.knot` record to your PDS. 47 47 ··· 57 57 }; 58 58 }; 59 59 ``` 60 -
+1 -1
docs/spindle/openbao.md
··· 44 44 ### production 45 45 46 46 You would typically use a systemd service with a configuration file. Refer to 47 - [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 47 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 48 achieved using Nix. 49 49 50 50 Then, initialize the bao server:
+3 -3
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: ··· 73 73 - nodejs 74 74 - go 75 75 # custom registry 76 - git+https://tangled.sh/@example.com/my_pkg: 76 + git+https://tangled.org/@example.com/my_pkg: 77 77 - my_pkg 78 78 ``` 79 79 ··· 141 141 - nodejs 142 142 - go 143 143 # custom registry 144 - git+https://tangled.sh/@example.com/my_pkg: 144 + git+https://tangled.org/@example.com/my_pkg: 145 145 - my_pkg 146 146 147 147 environment:
+2 -2
eventconsumer/consumer.go
··· 9 9 "sync" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 - "tangled.sh/tangled.sh/core/log" 12 + "tangled.org/core/eventconsumer/cursor" 13 + "tangled.org/core/log" 14 14 15 15 "github.com/avast/retry-go/v4" 16 16 "github.com/gorilla/websocket"
+1 -1
eventconsumer/cursor/redis.go
··· 5 5 "fmt" 6 6 "strconv" 7 7 8 - "tangled.sh/tangled.sh/core/appview/cache" 8 + "tangled.org/core/appview/cache" 9 9 ) 10 10 11 11 const (
+2 -2
go.mod
··· 1 - module tangled.sh/tangled.sh/core 1 + module tangled.org/core 2 2 3 3 go 1.24.4 4 4 ··· 43 43 github.com/yuin/goldmark v1.7.12 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 ··· 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
+2 -2
guard/guard.go
··· 15 15 "github.com/bluesky-social/indigo/atproto/identity" 16 16 securejoin "github.com/cyphar/filepath-securejoin" 17 17 "github.com/urfave/cli/v3" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 20 ) 21 21 22 22 func Command() *cli.Command {
+1 -1
jetstream/jetstream.go
··· 13 13 "github.com/bluesky-social/jetstream/pkg/client" 14 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 15 "github.com/bluesky-social/jetstream/pkg/models" 16 - "tangled.sh/tangled.sh/core/log" 16 + "tangled.org/core/log" 17 17 ) 18 18 19 19 type DB interface {
+1 -1
keyfetch/keyfetch.go
··· 10 10 "strings" 11 11 12 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 13 + "tangled.org/core/log" 14 14 ) 15 15 16 16 func Command() *cli.Command {
+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) {
+1 -1
knotserver/db/events.go
··· 4 4 "fmt" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/notifier" 7 + "tangled.org/core/notifier" 8 8 ) 9 9 10 10 type Event struct {
+1 -1
knotserver/db/pubkeys.go
··· 4 4 "strconv" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 7 + "tangled.org/core/api/tangled" 8 8 ) 9 9 10 10 type PublicKey struct {
+1 -1
knotserver/git/branch.go
··· 9 9 10 10 "github.com/go-git/go-git/v5/plumbing" 11 11 "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.sh/tangled.sh/core/types" 12 + "tangled.org/core/types" 13 13 ) 14 14 15 15 func (g *GitRepo) Branches() ([]types.Branch, error) {
+2 -2
knotserver/git/diff.go
··· 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 13 "github.com/go-git/go-git/v5/plumbing" 14 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 17 ) 18 18 19 19 func (g *GitRepo) Diff() (*types.NiceDiff, 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 -1
knotserver/git/post_receive.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.org/core/api/tangled" 13 13 14 14 "github.com/go-git/go-git/v5/plumbing" 15 15 )
+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 }
+1 -1
knotserver/git/tree.go
··· 8 8 "time" 9 9 10 10 "github.com/go-git/go-git/v5/plumbing/object" 11 - "tangled.sh/tangled.sh/core/types" 11 + "tangled.org/core/types" 12 12 ) 13 13 14 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+1 -1
knotserver/git.go
··· 10 10 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 12 "github.com/go-chi/chi/v5" 13 - "tangled.sh/tangled.sh/core/knotserver/git/service" 13 + "tangled.org/core/knotserver/git/service" 14 14 ) 15 15 16 16 func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
-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 - }
+8 -8
knotserver/ingester.go
··· 15 15 "github.com/bluesky-social/indigo/xrpc" 16 16 "github.com/bluesky-social/jetstream/pkg/models" 17 17 securejoin "github.com/cyphar/filepath-securejoin" 18 - "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/idresolver" 20 - "tangled.sh/tangled.sh/core/knotserver/db" 21 - "tangled.sh/tangled.sh/core/knotserver/git" 22 - "tangled.sh/tangled.sh/core/log" 23 - "tangled.sh/tangled.sh/core/rbac" 24 - "tangled.sh/tangled.sh/core/workflow" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/idresolver" 20 + "tangled.org/core/knotserver/db" 21 + "tangled.org/core/knotserver/git" 22 + "tangled.org/core/log" 23 + "tangled.org/core/rbac" 24 + "tangled.org/core/workflow" 25 25 ) 26 26 27 27 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 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 }
+8 -8
knotserver/internal.go
··· 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 14 "github.com/go-chi/chi/v5" 15 15 "github.com/go-chi/chi/v5/middleware" 16 - "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/hook" 18 - "tangled.sh/tangled.sh/core/knotserver/config" 19 - "tangled.sh/tangled.sh/core/knotserver/db" 20 - "tangled.sh/tangled.sh/core/knotserver/git" 21 - "tangled.sh/tangled.sh/core/notifier" 22 - "tangled.sh/tangled.sh/core/rbac" 23 - "tangled.sh/tangled.sh/core/workflow" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/hook" 18 + "tangled.org/core/knotserver/config" 19 + "tangled.org/core/knotserver/db" 20 + "tangled.org/core/knotserver/git" 21 + "tangled.org/core/notifier" 22 + "tangled.org/core/rbac" 23 + "tangled.org/core/workflow" 24 24 ) 25 25 26 26 type InternalHandle struct {
+9 -9
knotserver/router.go
··· 7 7 "net/http" 8 8 9 9 "github.com/go-chi/chi/v5" 10 - "tangled.sh/tangled.sh/core/idresolver" 11 - "tangled.sh/tangled.sh/core/jetstream" 12 - "tangled.sh/tangled.sh/core/knotserver/config" 13 - "tangled.sh/tangled.sh/core/knotserver/db" 14 - "tangled.sh/tangled.sh/core/knotserver/xrpc" 15 - tlog "tangled.sh/tangled.sh/core/log" 16 - "tangled.sh/tangled.sh/core/notifier" 17 - "tangled.sh/tangled.sh/core/rbac" 18 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 10 + "tangled.org/core/idresolver" 11 + "tangled.org/core/jetstream" 12 + "tangled.org/core/knotserver/config" 13 + "tangled.org/core/knotserver/db" 14 + "tangled.org/core/knotserver/xrpc" 15 + tlog "tangled.org/core/log" 16 + "tangled.org/core/notifier" 17 + "tangled.org/core/rbac" 18 + "tangled.org/core/xrpc/serviceauth" 19 19 ) 20 20 21 21 type Knot struct {
+8 -8
knotserver/server.go
··· 6 6 "net/http" 7 7 8 8 "github.com/urfave/cli/v3" 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/hook" 11 - "tangled.sh/tangled.sh/core/jetstream" 12 - "tangled.sh/tangled.sh/core/knotserver/config" 13 - "tangled.sh/tangled.sh/core/knotserver/db" 14 - "tangled.sh/tangled.sh/core/log" 15 - "tangled.sh/tangled.sh/core/notifier" 16 - "tangled.sh/tangled.sh/core/rbac" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/hook" 11 + "tangled.org/core/jetstream" 12 + "tangled.org/core/knotserver/config" 13 + "tangled.org/core/knotserver/db" 14 + "tangled.org/core/log" 15 + "tangled.org/core/notifier" 16 + "tangled.org/core/rbac" 17 17 ) 18 18 19 19 func Command() *cli.Command {
+5 -5
knotserver/xrpc/create_repo.go
··· 13 13 "github.com/bluesky-social/indigo/xrpc" 14 14 securejoin "github.com/cyphar/filepath-securejoin" 15 15 gogit "github.com/go-git/go-git/v5" 16 - "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/hook" 18 - "tangled.sh/tangled.sh/core/knotserver/git" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/hook" 18 + "tangled.org/core/knotserver/git" 19 + "tangled.org/core/rbac" 20 + xrpcerr "tangled.org/core/xrpc/errors" 21 21 ) 22 22 23 23 func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/delete_repo.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 "github.com/bluesky-social/indigo/xrpc" 13 13 securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/rbac" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5 -5
knotserver/xrpc/fork_status.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/types" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/types" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 16 ) 17 17 18 18 func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/fork_sync.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + "tangled.org/core/rbac" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 15 ) 16 16 17 17 func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/hidden_ref.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 16 ) 17 17 18 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/list_keys.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+6 -6
knotserver/xrpc/merge.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 securejoin "github.com/cyphar/filepath-securejoin" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/knotserver/git" 13 - "tangled.sh/tangled.sh/core/patchutil" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/types" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + "tangled.org/core/patchutil" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/types" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/merge_check.go
··· 7 7 "net/http" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/owner.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 8 ) 9 9 10 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/repo_archive.go
··· 8 8 9 9 "github.com/go-git/go-git/v5/plumbing" 10 10 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/repo_blob.go
··· 9 9 "slices" 10 10 "strings" 11 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 15 ) 16 16 17 17 func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 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"),
+3 -3
knotserver/xrpc/repo_branch.go
··· 5 5 "net/url" 6 6 "time" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/knotserver/git" 10 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 11 ) 12 12 13 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_branches.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_compare.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_diff.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "tangled.sh/tangled.sh/core/knotserver/git" 7 - "tangled.sh/tangled.sh/core/types" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_get_default_branch.go
··· 4 4 "net/http" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/knotserver/git" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/knotserver/git" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_languages.go
··· 6 6 "net/http" 7 7 "time" 8 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 - "tangled.sh/tangled.sh/core/knotserver/git" 11 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/knotserver/git" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 12 ) 13 13 14 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_log.go
··· 4 4 "net/http" 5 5 "strconv" 6 6 7 - "tangled.sh/tangled.sh/core/knotserver/git" 8 - "tangled.sh/tangled.sh/core/types" 9 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 10 ) 11 11 12 12 func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_tags.go
··· 7 7 "github.com/go-git/go-git/v5/plumbing" 8 8 "github.com/go-git/go-git/v5/plumbing/object" 9 9 10 - "tangled.sh/tangled.sh/core/knotserver/git" 11 - "tangled.sh/tangled.sh/core/types" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+27 -3
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/knotserver/git" 10 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 11 13 ) 12 14 13 15 func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 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)
+4 -4
knotserver/xrpc/set_default_branch.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/knotserver/git" 14 - "tangled.sh/tangled.sh/core/rbac" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 15 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 const ActorDid string = "ActorDid"
+2 -2
knotserver/xrpc/version.go
··· 5 5 "net/http" 6 6 "runtime/debug" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 ) 10 10 11 11 // version is set during build time. ··· 24 24 var modified bool 25 25 26 26 for _, mod := range info.Deps { 27 - if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 27 + if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" { 28 28 modVer = mod.Version 29 29 break 30 30 }
+9 -9
knotserver/xrpc/xrpc.go
··· 7 7 "strings" 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - "tangled.sh/tangled.sh/core/jetstream" 13 - "tangled.sh/tangled.sh/core/knotserver/config" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/notifier" 16 - "tangled.sh/tangled.sh/core/rbac" 17 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 18 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/idresolver" 12 + "tangled.org/core/jetstream" 13 + "tangled.org/core/knotserver/config" 14 + "tangled.org/core/knotserver/db" 15 + "tangled.org/core/notifier" 16 + "tangled.org/core/rbac" 17 + xrpcerr "tangled.org/core/xrpc/errors" 18 + "tangled.org/core/xrpc/serviceauth" 19 19 20 20 "github.com/go-chi/chi/v5" 21 21 )
-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.
+1 -1
lexicon-build-config.json
··· 3 3 "package": "tangled", 4 4 "prefix": "sh.tangled", 5 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 7 "gen-server": true 8 8 } 9 9 ]
+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",
+2 -2
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"; ··· 16 16 tags = ["libsqlite3"]; 17 17 18 18 ldflags = [ 19 - "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 19 + "-X tangled.org/core/knotserver/xrpc.version=${version}" 20 20 ]; 21 21 22 22 env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+1 -1
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/types" 8 + "tangled.org/core/types" 9 9 ) 10 10 11 11 type InterdiffResult struct {
+1 -1
patchutil/patchutil.go
··· 10 10 "strings" 11 11 12 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 13 + "tangled.org/core/types" 14 14 ) 15 15 16 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+1 -1
rbac/rbac_test.go
··· 4 4 "database/sql" 5 5 "testing" 6 6 7 - "tangled.sh/tangled.sh/core/rbac" 7 + "tangled.org/core/rbac" 8 8 9 9 adapter "github.com/Blank-Xu/sql-adapter" 10 10 "github.com/casbin/casbin/v2"
+4 -4
spindle/db/events.go
··· 5 5 "fmt" 6 6 "time" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/notifier" 10 - "tangled.sh/tangled.sh/core/spindle/models" 11 - "tangled.sh/tangled.sh/core/tid" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 12 ) 13 13 14 14 type Event struct {
+5 -5
spindle/engine/engine.go
··· 8 8 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "golang.org/x/sync/errgroup" 11 - "tangled.sh/tangled.sh/core/notifier" 12 - "tangled.sh/tangled.sh/core/spindle/config" 13 - "tangled.sh/tangled.sh/core/spindle/db" 14 - "tangled.sh/tangled.sh/core/spindle/models" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 11 + "tangled.org/core/notifier" 12 + "tangled.org/core/spindle/config" 13 + "tangled.org/core/spindle/db" 14 + "tangled.org/core/spindle/models" 15 + "tangled.org/core/spindle/secrets" 16 16 ) 17 17 18 18 var (
+6 -6
spindle/engines/nixery/engine.go
··· 19 19 "github.com/docker/docker/client" 20 20 "github.com/docker/docker/pkg/stdcopy" 21 21 "gopkg.in/yaml.v3" 22 - "tangled.sh/tangled.sh/core/api/tangled" 23 - "tangled.sh/tangled.sh/core/log" 24 - "tangled.sh/tangled.sh/core/spindle/config" 25 - "tangled.sh/tangled.sh/core/spindle/engine" 26 - "tangled.sh/tangled.sh/core/spindle/models" 27 - "tangled.sh/tangled.sh/core/spindle/secrets" 22 + "tangled.org/core/api/tangled" 23 + "tangled.org/core/log" 24 + "tangled.org/core/spindle/config" 25 + "tangled.org/core/spindle/engine" 26 + "tangled.org/core/spindle/models" 27 + "tangled.org/core/spindle/secrets" 28 28 ) 29 29 30 30 const (
+2 -2
spindle/engines/nixery/setup_steps.go
··· 5 5 "path" 6 6 "strings" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 10 ) 11 11 12 12 func nixConfStep() Step {
+5 -5
spindle/ingester.go
··· 7 7 "fmt" 8 8 "time" 9 9 10 - "tangled.sh/tangled.sh/core/api/tangled" 11 - "tangled.sh/tangled.sh/core/eventconsumer" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/db" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/eventconsumer" 12 + "tangled.org/core/idresolver" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/db" 15 15 16 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 17 17 "github.com/bluesky-social/indigo/atproto/identity"
+2 -2
spindle/models/engine.go
··· 4 4 "context" 5 5 "time" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/spindle/secrets" 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/spindle/secrets" 9 9 ) 10 10 11 11 type Engine interface {
+1 -1
spindle/models/models.go
··· 5 5 "regexp" 6 6 "slices" 7 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 )
+17 -17
spindle/server.go
··· 9 9 "net/http" 10 10 11 11 "github.com/go-chi/chi/v5" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/eventconsumer" 14 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 - "tangled.sh/tangled.sh/core/idresolver" 16 - "tangled.sh/tangled.sh/core/jetstream" 17 - "tangled.sh/tangled.sh/core/log" 18 - "tangled.sh/tangled.sh/core/notifier" 19 - "tangled.sh/tangled.sh/core/rbac" 20 - "tangled.sh/tangled.sh/core/spindle/config" 21 - "tangled.sh/tangled.sh/core/spindle/db" 22 - "tangled.sh/tangled.sh/core/spindle/engine" 23 - "tangled.sh/tangled.sh/core/spindle/engines/nixery" 24 - "tangled.sh/tangled.sh/core/spindle/models" 25 - "tangled.sh/tangled.sh/core/spindle/queue" 26 - "tangled.sh/tangled.sh/core/spindle/secrets" 27 - "tangled.sh/tangled.sh/core/spindle/xrpc" 28 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/eventconsumer" 14 + "tangled.org/core/eventconsumer/cursor" 15 + "tangled.org/core/idresolver" 16 + "tangled.org/core/jetstream" 17 + "tangled.org/core/log" 18 + "tangled.org/core/notifier" 19 + "tangled.org/core/rbac" 20 + "tangled.org/core/spindle/config" 21 + "tangled.org/core/spindle/db" 22 + "tangled.org/core/spindle/engine" 23 + "tangled.org/core/spindle/engines/nixery" 24 + "tangled.org/core/spindle/models" 25 + "tangled.org/core/spindle/queue" 26 + "tangled.org/core/spindle/secrets" 27 + "tangled.org/core/spindle/xrpc" 28 + "tangled.org/core/xrpc/serviceauth" 29 29 ) 30 30 31 31 //go:embed motd
+1 -1
spindle/stream.go
··· 10 10 "strconv" 11 11 "time" 12 12 13 - "tangled.sh/tangled.sh/core/spindle/models" 13 + "tangled.org/core/spindle/models" 14 14 15 15 "github.com/go-chi/chi/v5" 16 16 "github.com/gorilla/websocket"
+4 -4
spindle/xrpc/add_secret.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/spindle/secrets" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
+4 -4
spindle/xrpc/list_secrets.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 11 "github.com/bluesky-social/indigo/xrpc" 12 12 securejoin "github.com/cyphar/filepath-securejoin" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/rbac" 15 - "tangled.sh/tangled.sh/core/spindle/secrets" 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/rbac" 15 + "tangled.org/core/spindle/secrets" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 17 ) 18 18 19 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
+2 -2
spindle/xrpc/owner.go
··· 4 4 "encoding/json" 5 5 "net/http" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 9 ) 10 10 11 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+4 -4
spindle/xrpc/remove_secret.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/bluesky-social/indigo/xrpc" 11 11 securejoin "github.com/cyphar/filepath-securejoin" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/secrets" 15 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/secrets" 15 + xrpcerr "tangled.org/core/xrpc/errors" 16 16 ) 17 17 18 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
+9 -9
spindle/xrpc/xrpc.go
··· 8 8 9 9 "github.com/go-chi/chi/v5" 10 10 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/idresolver" 13 - "tangled.sh/tangled.sh/core/rbac" 14 - "tangled.sh/tangled.sh/core/spindle/config" 15 - "tangled.sh/tangled.sh/core/spindle/db" 16 - "tangled.sh/tangled.sh/core/spindle/models" 17 - "tangled.sh/tangled.sh/core/spindle/secrets" 18 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 - "tangled.sh/tangled.sh/core/xrpc/serviceauth" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/idresolver" 13 + "tangled.org/core/rbac" 14 + "tangled.org/core/spindle/config" 15 + "tangled.org/core/spindle/db" 16 + "tangled.org/core/spindle/models" 17 + "tangled.org/core/spindle/secrets" 18 + xrpcerr "tangled.org/core/xrpc/errors" 19 + "tangled.org/core/xrpc/serviceauth" 20 20 ) 21 21 22 22 const ActorDid string = "ActorDid"
+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 {
+1 -1
workflow/compile.go
··· 4 4 "errors" 5 5 "fmt" 6 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 7 + "tangled.org/core/api/tangled" 8 8 ) 9 9 10 10 type RawWorkflow struct {
+1 -1
workflow/compile_test.go
··· 5 5 "testing" 6 6 7 7 "github.com/stretchr/testify/assert" 8 - "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.org/core/api/tangled" 9 9 ) 10 10 11 11 var trigger = tangled.Pipeline_TriggerMetadata{
+1 -1
workflow/def.go
··· 6 6 "slices" 7 7 "strings" 8 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.org/core/api/tangled" 10 10 11 11 "github.com/go-git/go-git/v5/plumbing" 12 12 "gopkg.in/yaml.v3"
+2 -2
xrpc/serviceauth/service_auth.go
··· 8 8 "strings" 9 9 10 10 "github.com/bluesky-social/indigo/atproto/auth" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + "tangled.org/core/idresolver" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 13 ) 14 14 15 15 const ActorDid string = "ActorDid"