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

Compare changes

Choose any two refs to compare.

Changed files
+8917 -5304
.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
search
serververify
settings
signup
spindles
state
strings
validator
xrpcclient
cmd
appview
combinediff
genjwks
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 }
+282 -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 } 422 238 423 239 sort.Slice(issues, func(i, j int) bool { 240 + if issues[i].Created.Equal(issues[j].Created) { 241 + // Tiebreaker: use issue_id for stable sort 242 + return issues[i].IssueId > issues[j].IssueId 243 + } 424 244 return issues[i].Created.After(issues[j].Created) 425 245 }) 426 246 427 247 return issues, nil 428 248 } 429 249 430 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 250 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 431 251 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 432 252 } 433 253 434 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 254 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 435 255 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 436 256 row := e.QueryRow(query, repoAt, issueId) 437 257 438 - var issue Issue 258 + var issue models.Issue 439 259 var createdAt string 440 260 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 441 261 if err != nil { ··· 451 271 return &issue, nil 452 272 } 453 273 454 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 274 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 455 275 result, err := e.Exec( 456 276 `insert into issue_comments ( 457 277 did, ··· 513 333 return err 514 334 } 515 335 516 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 517 - var comments []IssueComment 336 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 337 + var comments []models.IssueComment 518 338 519 339 var conditions []string 520 340 var args []any ··· 550 370 } 551 371 552 372 for rows.Next() { 553 - var comment IssueComment 373 + var comment models.IssueComment 554 374 var created string 555 375 var rkey, edited, deleted, replyTo sql.Null[string] 556 376 err := rows.Scan( ··· 657 477 return err 658 478 } 659 479 660 - type IssueCount struct { 661 - Open int 662 - Closed int 663 - } 664 - 665 - func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 480 + func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 666 481 row := e.QueryRow(` 667 482 select 668 483 count(case when open = 1 then 1 end) as open_count, ··· 672 487 repoAt, 673 488 ) 674 489 675 - var count IssueCount 490 + var count models.IssueCount 676 491 if err := row.Scan(&count.Open, &count.Closed); err != nil { 677 - return IssueCount{0, 0}, err 492 + return models.IssueCount{}, err 678 493 } 679 494 680 495 return count, nil 681 496 } 497 + 498 + func SearchIssues(e Execer, page pagination.Page, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]models.Issue, error) { 499 + var conditions []string 500 + var args []any 501 + 502 + for _, filter := range filters { 503 + conditions = append(conditions, filter.Condition()) 504 + args = append(args, filter.Arg()...) 505 + } 506 + 507 + if text != "" { 508 + searchPattern := "%" + text + "%" 509 + conditions = append(conditions, "(title like ? or body like ?)") 510 + args = append(args, searchPattern, searchPattern) 511 + } 512 + 513 + whereClause := "" 514 + if len(conditions) > 0 { 515 + whereClause = " where " + strings.Join(conditions, " and ") 516 + } 517 + 518 + pLower := FilterGte("row_num", page.Offset+1) 519 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 520 + args = append(args, pLower.Arg()...) 521 + args = append(args, pUpper.Arg()...) 522 + paginationClause := " where " + pLower.Condition() + " and " + pUpper.Condition() 523 + 524 + query := fmt.Sprintf( 525 + ` 526 + select * from ( 527 + select 528 + id, 529 + did, 530 + rkey, 531 + repo_at, 532 + issue_id, 533 + title, 534 + body, 535 + open, 536 + created, 537 + edited, 538 + deleted, 539 + row_number() over (order by created desc) as row_num 540 + from 541 + issues 542 + %s 543 + ) ranked_issues 544 + %s 545 + `, 546 + whereClause, 547 + paginationClause, 548 + ) 549 + 550 + rows, err := e.Query(query, args...) 551 + if err != nil { 552 + return nil, fmt.Errorf("failed to query issues: %w", err) 553 + } 554 + defer rows.Close() 555 + 556 + issueMap := make(map[string]*models.Issue) 557 + for rows.Next() { 558 + var issue models.Issue 559 + var createdAt string 560 + var editedAt, deletedAt sql.Null[string] 561 + var rowNum int64 562 + 563 + err := rows.Scan( 564 + &issue.Id, 565 + &issue.Did, 566 + &issue.Rkey, 567 + &issue.RepoAt, 568 + &issue.IssueId, 569 + &issue.Title, 570 + &issue.Body, 571 + &issue.Open, 572 + &createdAt, 573 + &editedAt, 574 + &deletedAt, 575 + &rowNum, 576 + ) 577 + if err != nil { 578 + return nil, fmt.Errorf("failed to scan issue: %w", err) 579 + } 580 + 581 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 582 + issue.Created = t 583 + } 584 + if editedAt.Valid { 585 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 586 + issue.Edited = &t 587 + } 588 + } 589 + if deletedAt.Valid { 590 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 591 + issue.Deleted = &t 592 + } 593 + } 594 + 595 + atUri := issue.AtUri().String() 596 + issueMap[atUri] = &issue 597 + } 598 + 599 + repoAts := make([]string, 0, len(issueMap)) 600 + for _, issue := range issueMap { 601 + repoAts = append(repoAts, string(issue.RepoAt)) 602 + } 603 + 604 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 605 + if err != nil { 606 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 607 + } 608 + 609 + repoMap := make(map[string]*models.Repo) 610 + for i := range repos { 611 + repoMap[string(repos[i].RepoAt())] = &repos[i] 612 + } 613 + 614 + for issueAt, i := range issueMap { 615 + if r, ok := repoMap[string(i.RepoAt)]; ok { 616 + i.Repo = r 617 + } else { 618 + delete(issueMap, issueAt) 619 + } 620 + } 621 + 622 + issueAts := slices.Collect(maps.Keys(issueMap)) 623 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 624 + if err != nil { 625 + return nil, fmt.Errorf("failed to query comments: %w", err) 626 + } 627 + for i := range comments { 628 + issueAt := comments[i].IssueAt 629 + if issue, ok := issueMap[issueAt]; ok { 630 + issue.Comments = append(issue.Comments, comments[i]) 631 + } 632 + } 633 + 634 + allLabels, err := GetLabels(e, FilterIn("subject", issueAts)) 635 + if err != nil { 636 + return nil, fmt.Errorf("failed to query labels: %w", err) 637 + } 638 + for issueAt, labels := range allLabels { 639 + if issue, ok := issueMap[issueAt.String()]; ok { 640 + issue.Labels = labels 641 + } 642 + } 643 + 644 + reactionCounts := make(map[string]int) 645 + if len(issueAts) > 0 { 646 + reactionArgs := make([]any, len(issueAts)) 647 + for i, v := range issueAts { 648 + reactionArgs[i] = v 649 + } 650 + rows, err := e.Query(` 651 + select thread_at, count(*) as total 652 + from reactions 653 + where thread_at in (`+strings.Repeat("?,", len(issueAts)-1)+"?"+`) 654 + group by thread_at 655 + `, reactionArgs...) 656 + if err == nil { 657 + defer rows.Close() 658 + for rows.Next() { 659 + var threadAt string 660 + var count int 661 + if err := rows.Scan(&threadAt, &count); err == nil { 662 + reactionCounts[threadAt] = count 663 + } 664 + } 665 + } 666 + } 667 + 668 + if len(labels) > 0 { 669 + if len(issueMap) > 0 { 670 + var repoAt string 671 + for _, issue := range issueMap { 672 + repoAt = string(issue.RepoAt) 673 + break 674 + } 675 + 676 + repo, err := GetRepoByAtUri(e, repoAt) 677 + if err == nil && len(repo.Labels) > 0 { 678 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 679 + if err == nil { 680 + labelNameToUri := make(map[string]string) 681 + for _, def := range labelDefs { 682 + labelNameToUri[def.Name] = def.AtUri().String() 683 + } 684 + 685 + for issueAt, issue := range issueMap { 686 + hasAllLabels := true 687 + for _, labelName := range labels { 688 + labelUri, found := labelNameToUri[labelName] 689 + if !found { 690 + hasAllLabels = false 691 + break 692 + } 693 + if !issue.Labels.ContainsLabel(labelUri) { 694 + hasAllLabels = false 695 + break 696 + } 697 + } 698 + if !hasAllLabels { 699 + delete(issueMap, issueAt) 700 + } 701 + } 702 + } 703 + } 704 + } 705 + } 706 + 707 + var issues []models.Issue 708 + for _, i := range issueMap { 709 + i.ReactionCount = reactionCounts[i.AtUri().String()] 710 + issues = append(issues, *i) 711 + } 712 + 713 + sort.Slice(issues, func(i, j int) bool { 714 + var less bool 715 + 716 + switch sortBy { 717 + case "comments": 718 + if len(issues[i].Comments) == len(issues[j].Comments) { 719 + // Tiebreaker: use issue_id for stable sort 720 + less = issues[i].IssueId > issues[j].IssueId 721 + } else { 722 + less = len(issues[i].Comments) > len(issues[j].Comments) 723 + } 724 + case "reactions": 725 + iCount := reactionCounts[issues[i].AtUri().String()] 726 + jCount := reactionCounts[issues[j].AtUri().String()] 727 + if iCount == jCount { 728 + // Tiebreaker: use issue_id for stable sort 729 + less = issues[i].IssueId > issues[j].IssueId 730 + } else { 731 + less = iCount > jCount 732 + } 733 + case "created": 734 + fallthrough 735 + default: 736 + if issues[i].Created.Equal(issues[j].Created) { 737 + // Tiebreaker: use issue_id for stable sort 738 + less = issues[i].IssueId > issues[j].IssueId 739 + } else { 740 + less = issues[i].Created.After(issues[j].Created) 741 + } 742 + } 743 + 744 + if sortOrder == "asc" { 745 + return !less 746 + } 747 + return less 748 + }) 749 + 750 + return issues, nil 751 + }
+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
+364 -547
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 7 9 "slices" 8 10 "sort" 9 11 "strings" 10 12 "time" 11 13 12 14 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/patchutil" 15 - "tangled.sh/tangled.sh/core/types" 15 + "tangled.org/core/appview/models" 16 16 ) 17 17 18 - type PullState int 19 - 20 - const ( 21 - PullClosed PullState = iota 22 - PullOpen 23 - PullMerged 24 - PullDeleted 25 - ) 26 - 27 - func (p PullState) String() string { 28 - switch p { 29 - case PullOpen: 30 - return "open" 31 - case PullMerged: 32 - return "merged" 33 - case PullClosed: 34 - return "closed" 35 - case PullDeleted: 36 - return "deleted" 37 - default: 38 - return "closed" 39 - } 40 - } 41 - 42 - func (p PullState) IsOpen() bool { 43 - return p == PullOpen 44 - } 45 - func (p PullState) IsMerged() bool { 46 - return p == PullMerged 47 - } 48 - func (p PullState) IsClosed() bool { 49 - return p == PullClosed 50 - } 51 - func (p PullState) IsDeleted() bool { 52 - return p == PullDeleted 53 - } 54 - 55 - type Pull struct { 56 - // ids 57 - ID int 58 - PullId int 59 - 60 - // at ids 61 - RepoAt syntax.ATURI 62 - OwnerDid string 63 - Rkey string 64 - 65 - // content 66 - Title string 67 - Body string 68 - TargetBranch string 69 - State PullState 70 - Submissions []*PullSubmission 71 - 72 - // stacking 73 - StackId string // nullable string 74 - ChangeId string // nullable string 75 - ParentChangeId string // nullable string 76 - 77 - // meta 78 - Created time.Time 79 - PullSource *PullSource 80 - 81 - // optionally, populate this when querying for reverse mappings 82 - Repo *Repo 83 - } 84 - 85 - func (p Pull) AsRecord() tangled.RepoPull { 86 - var source *tangled.RepoPull_Source 87 - if p.PullSource != nil { 88 - s := p.PullSource.AsRecord() 89 - source = &s 90 - source.Sha = p.LatestSha() 91 - } 92 - 93 - record := tangled.RepoPull{ 94 - Title: p.Title, 95 - Body: &p.Body, 96 - CreatedAt: p.Created.Format(time.RFC3339), 97 - Target: &tangled.RepoPull_Target{ 98 - Repo: p.RepoAt.String(), 99 - Branch: p.TargetBranch, 100 - }, 101 - Patch: p.LatestPatch(), 102 - Source: source, 103 - } 104 - return record 105 - } 106 - 107 - type PullSource struct { 108 - Branch string 109 - RepoAt *syntax.ATURI 110 - 111 - // optionally populate this for reverse mappings 112 - Repo *Repo 113 - } 114 - 115 - func (p PullSource) AsRecord() tangled.RepoPull_Source { 116 - var repoAt *string 117 - if p.RepoAt != nil { 118 - s := p.RepoAt.String() 119 - repoAt = &s 120 - } 121 - record := tangled.RepoPull_Source{ 122 - Branch: p.Branch, 123 - Repo: repoAt, 124 - } 125 - return record 126 - } 127 - 128 - type PullSubmission struct { 129 - // ids 130 - ID int 131 - PullId int 132 - 133 - // at ids 134 - RepoAt syntax.ATURI 135 - 136 - // content 137 - RoundNumber int 138 - Patch string 139 - Comments []PullComment 140 - SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 141 - 142 - // meta 143 - Created time.Time 144 - } 145 - 146 - type PullComment struct { 147 - // ids 148 - ID int 149 - PullId int 150 - SubmissionId int 151 - 152 - // at ids 153 - RepoAt string 154 - OwnerDid string 155 - CommentAt string 156 - 157 - // content 158 - Body string 159 - 160 - // meta 161 - Created time.Time 162 - } 163 - 164 - func (p *Pull) LatestPatch() string { 165 - latestSubmission := p.Submissions[p.LastRoundNumber()] 166 - return latestSubmission.Patch 167 - } 168 - 169 - func (p *Pull) LatestSha() string { 170 - latestSubmission := p.Submissions[p.LastRoundNumber()] 171 - return latestSubmission.SourceRev 172 - } 173 - 174 - func (p *Pull) PullAt() syntax.ATURI { 175 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 176 - } 177 - 178 - func (p *Pull) LastRoundNumber() int { 179 - return len(p.Submissions) - 1 180 - } 181 - 182 - func (p *Pull) IsPatchBased() bool { 183 - return p.PullSource == nil 184 - } 185 - 186 - func (p *Pull) IsBranchBased() bool { 187 - if p.PullSource != nil { 188 - if p.PullSource.RepoAt != nil { 189 - return p.PullSource.RepoAt == &p.RepoAt 190 - } else { 191 - // no repo specified 192 - return true 193 - } 194 - } 195 - return false 196 - } 197 - 198 - func (p *Pull) IsForkBased() bool { 199 - if p.PullSource != nil { 200 - if p.PullSource.RepoAt != nil { 201 - // make sure repos are different 202 - return p.PullSource.RepoAt != &p.RepoAt 203 - } 204 - } 205 - return false 206 - } 207 - 208 - func (p *Pull) IsStacked() bool { 209 - return p.StackId != "" 210 - } 211 - 212 - func (s PullSubmission) IsFormatPatch() bool { 213 - return patchutil.IsFormatPatch(s.Patch) 214 - } 215 - 216 - func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 217 - patches, err := patchutil.ExtractPatches(s.Patch) 218 - if err != nil { 219 - log.Println("error extracting patches from submission:", err) 220 - return []types.FormatPatch{} 221 - } 222 - 223 - return patches 224 - } 225 - 226 - func NewPull(tx *sql.Tx, pull *Pull) error { 18 + func NewPull(tx *sql.Tx, pull *models.Pull) error { 227 19 _, err := tx.Exec(` 228 20 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 229 21 values (?, 1) ··· 244 36 } 245 37 246 38 pull.PullId = nextId 247 - pull.State = PullOpen 39 + pull.State = models.PullOpen 248 40 249 41 var sourceBranch, sourceRepoAt *string 250 42 if pull.PullSource != nil { ··· 266 58 parentChangeId = &pull.ParentChangeId 267 59 } 268 60 269 - _, err = tx.Exec( 61 + result, err := tx.Exec( 270 62 ` 271 63 insert into pulls ( 272 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 290 82 return err 291 83 } 292 84 85 + // Set the database primary key ID 86 + id, err := result.LastInsertId() 87 + if err != nil { 88 + return err 89 + } 90 + pull.ID = int(id) 91 + 293 92 _, err = tx.Exec(` 294 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 295 - values (?, ?, ?, ?, ?) 296 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 297 96 return err 298 97 } 299 98 ··· 311 110 return pullId - 1, err 312 111 } 313 112 314 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 315 - pulls := make(map[int]*Pull) 113 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 316 115 317 116 var conditions []string 318 117 var args []any ··· 332 131 333 132 query := fmt.Sprintf(` 334 133 select 134 + id, 335 135 owner_did, 336 136 repo_at, 337 137 pull_id, ··· 361 161 defer rows.Close() 362 162 363 163 for rows.Next() { 364 - var pull Pull 164 + var pull models.Pull 365 165 var createdAt string 366 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 367 167 err := rows.Scan( 168 + &pull.ID, 368 169 &pull.OwnerDid, 369 170 &pull.RepoAt, 370 171 &pull.PullId, ··· 391 192 pull.Created = createdTime 392 193 393 194 if sourceBranch.Valid { 394 - pull.PullSource = &PullSource{ 195 + pull.PullSource = &models.PullSource{ 395 196 Branch: sourceBranch.String, 396 197 } 397 198 if sourceRepoAt.Valid { ··· 413 214 pull.ParentChangeId = parentChangeId.String 414 215 } 415 216 416 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 417 218 } 418 219 419 - // get latest round no. for each pull 420 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 421 - submissionsQuery := fmt.Sprintf(` 422 - select 423 - id, pull_id, round_number, patch, created, source_rev 424 - from 425 - pull_submissions 426 - where 427 - repo_at in (%s) and pull_id in (%s) 428 - `, inClause, inClause) 429 - 430 - args = make([]any, len(pulls)*2) 431 - idx := 0 220 + var pullAts []syntax.ATURI 432 221 for _, p := range pulls { 433 - args[idx] = p.RepoAt 434 - idx += 1 435 - } 436 - for _, p := range pulls { 437 - args[idx] = p.PullId 438 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 439 223 } 440 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 441 225 if err != nil { 442 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 443 227 } 444 - defer submissionsRows.Close() 445 228 446 - for submissionsRows.Next() { 447 - var s PullSubmission 448 - var sourceRev sql.NullString 449 - var createdAt string 450 - err := submissionsRows.Scan( 451 - &s.ID, 452 - &s.PullId, 453 - &s.RoundNumber, 454 - &s.Patch, 455 - &createdAt, 456 - &sourceRev, 457 - ) 458 - if err != nil { 459 - return nil, err 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 460 232 } 233 + } 461 234 462 - createdTime, err := time.Parse(time.RFC3339, createdAt) 463 - if err != nil { 464 - return nil, err 465 - } 466 - s.Created = createdTime 467 - 468 - if sourceRev.Valid { 469 - s.SourceRev = sourceRev.String 470 - } 471 - 472 - if p, ok := pulls[s.PullId]; ok { 473 - p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 474 - p.Submissions[s.RoundNumber] = &s 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 239 + } 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 475 243 } 476 244 } 477 - if err := rows.Err(); err != nil { 478 - return nil, err 479 - } 480 - 481 - // get comment count on latest submission on each pull 482 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 483 - commentsQuery := fmt.Sprintf(` 484 - select 485 - count(id), pull_id 486 - from 487 - pull_comments 488 - where 489 - submission_id in (%s) 490 - group by 491 - submission_id 492 - `, inClause) 493 245 494 - args = []any{} 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 495 248 for _, p := range pulls { 496 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 497 252 } 498 - commentsRows, err := e.Query(commentsQuery, args...) 499 - if err != nil { 500 - return nil, err 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 501 256 } 502 - defer commentsRows.Close() 503 - 504 - for commentsRows.Next() { 505 - var commentCount, pullId int 506 - err := commentsRows.Scan( 507 - &commentCount, 508 - &pullId, 509 - ) 510 - if err != nil { 511 - return nil, err 512 - } 513 - if p, ok := pulls[pullId]; ok { 514 - p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 260 + } 261 + for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 515 266 } 516 267 } 517 - if err := rows.Err(); err != nil { 518 - return nil, err 519 - } 520 268 521 - orderedByPullId := []*Pull{} 269 + orderedByPullId := []*models.Pull{} 522 270 for _, p := range pulls { 523 271 orderedByPullId = append(orderedByPullId, p) 524 272 } ··· 529 277 return orderedByPullId, nil 530 278 } 531 279 532 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 280 + func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 533 281 return GetPullsWithLimit(e, 0, filters...) 534 282 } 535 283 536 - func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { 537 - query := ` 538 - select 539 - owner_did, 540 - pull_id, 541 - created, 542 - title, 543 - state, 544 - target_branch, 545 - repo_at, 546 - body, 547 - rkey, 548 - source_branch, 549 - source_repo_at, 550 - stack_id, 551 - change_id, 552 - parent_change_id 553 - from 554 - pulls 555 - where 556 - repo_at = ? and pull_id = ? 557 - ` 558 - row := e.QueryRow(query, repoAt, pullId) 559 - 560 - var pull Pull 561 - var createdAt string 562 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 563 - err := row.Scan( 564 - &pull.OwnerDid, 565 - &pull.PullId, 566 - &createdAt, 567 - &pull.Title, 568 - &pull.State, 569 - &pull.TargetBranch, 570 - &pull.RepoAt, 571 - &pull.Body, 572 - &pull.Rkey, 573 - &sourceBranch, 574 - &sourceRepoAt, 575 - &stackId, 576 - &changeId, 577 - &parentChangeId, 578 - ) 284 + func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 579 286 if err != nil { 580 287 return nil, err 581 288 } 582 - 583 - createdTime, err := time.Parse(time.RFC3339, createdAt) 584 - if err != nil { 585 - return nil, err 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 586 291 } 587 - pull.Created = createdTime 292 + 293 + return pulls[0], nil 294 + } 588 295 589 - // populate source 590 - if sourceBranch.Valid { 591 - pull.PullSource = &PullSource{ 592 - Branch: sourceBranch.String, 593 - } 594 - if sourceRepoAt.Valid { 595 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 596 - if err != nil { 597 - return nil, err 598 - } 599 - pull.PullSource.RepoAt = &sourceRepoAtParsed 600 - } 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 601 303 } 602 304 603 - if stackId.Valid { 604 - pull.StackId = stackId.String 605 - } 606 - if changeId.Valid { 607 - pull.ChangeId = changeId.String 608 - } 609 - if parentChangeId.Valid { 610 - pull.ParentChangeId = parentChangeId.String 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 611 308 } 612 309 613 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 614 311 select 615 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 616 318 from 617 319 pull_submissions 618 - where 619 - repo_at = ? and pull_id = ? 620 - ` 621 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 622 326 if err != nil { 623 327 return nil, err 624 328 } 625 - defer submissionsRows.Close() 329 + defer rows.Close() 626 330 627 - submissionsMap := make(map[int]*PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 628 332 629 - for submissionsRows.Next() { 630 - var submission PullSubmission 631 - var submissionCreatedStr string 632 - var submissionSourceRev sql.NullString 633 - err := submissionsRows.Scan( 333 + for rows.Next() { 334 + var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 634 338 &submission.ID, 635 - &submission.PullId, 636 - &submission.RepoAt, 339 + &submission.PullAt, 637 340 &submission.RoundNumber, 638 341 &submission.Patch, 639 - &submissionCreatedStr, 640 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 641 344 ) 642 345 if err != nil { 643 346 return nil, err 644 347 } 645 348 646 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 647 350 if err != nil { 648 351 return nil, err 649 352 } 650 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 651 354 652 - if submissionSourceRev.Valid { 653 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 654 357 } 655 358 656 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 657 360 } 658 - if err = submissionsRows.Close(); err != nil { 361 + 362 + if err := rows.Err(); err != nil { 659 363 return nil, err 660 364 } 661 - if len(submissionsMap) == 0 { 662 - return &pull, nil 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 370 + return nil, err 371 + } 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 663 376 } 664 377 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 + } 383 + 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 + } 390 + 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 665 396 var args []any 666 - for k := range submissionsMap { 667 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 400 + } 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 668 405 } 669 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 670 - commentsQuery := fmt.Sprintf(` 406 + 407 + query := fmt.Sprintf(` 671 408 select 672 409 id, 673 410 pull_id, ··· 679 416 created 680 417 from 681 418 pull_comments 682 - where 683 - submission_id IN (%s) 419 + %s 684 420 order by 685 421 created asc 686 - `, inClause) 687 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 688 425 if err != nil { 689 426 return nil, err 690 427 } 691 - defer commentsRows.Close() 428 + defer rows.Close() 692 429 693 - for commentsRows.Next() { 694 - var comment PullComment 695 - var commentCreatedStr string 696 - err := commentsRows.Scan( 430 + var comments []models.PullComment 431 + for rows.Next() { 432 + var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 697 435 &comment.ID, 698 436 &comment.PullId, 699 437 &comment.SubmissionId, ··· 701 439 &comment.OwnerDid, 702 440 &comment.CommentAt, 703 441 &comment.Body, 704 - &commentCreatedStr, 442 + &createdAt, 705 443 ) 706 444 if err != nil { 707 445 return nil, err 708 446 } 709 447 710 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 711 - if err != nil { 712 - return nil, err 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 713 450 } 714 - comment.Created = commentCreatedTime 715 451 716 - // Add the comment to its submission 717 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 718 - submission.Comments = append(submission.Comments, comment) 719 - } 452 + comments = append(comments, comment) 453 + } 720 454 721 - } 722 - if err = commentsRows.Err(); err != nil { 455 + if err := rows.Err(); err != nil { 723 456 return nil, err 724 457 } 725 458 726 - var pullSourceRepo *Repo 727 - if pull.PullSource != nil { 728 - if pull.PullSource.RepoAt != nil { 729 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 730 - if err != nil { 731 - log.Printf("failed to get repo by at uri: %v", err) 732 - } else { 733 - pull.PullSource.Repo = pullSourceRepo 734 - } 735 - } 736 - } 737 - 738 - pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 739 - for _, submission := range submissionsMap { 740 - pull.Submissions[submission.RoundNumber] = submission 741 - } 742 - 743 - return &pull, nil 459 + return comments, nil 744 460 } 745 461 746 462 // timeframe here is directly passed into the sql query filter, and any 747 463 // timeframe in the past should be negative; e.g.: "-3 months" 748 - func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) { 749 - var pulls []Pull 464 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 465 + var pulls []models.Pull 750 466 751 467 rows, err := e.Query(` 752 468 select ··· 775 491 defer rows.Close() 776 492 777 493 for rows.Next() { 778 - var pull Pull 779 - var repo Repo 494 + var pull models.Pull 495 + var repo models.Repo 780 496 var pullCreatedAt, repoCreatedAt string 781 497 err := rows.Scan( 782 498 &pull.OwnerDid, ··· 819 535 return pulls, nil 820 536 } 821 537 822 - func NewPullComment(e Execer, comment *PullComment) (int64, error) { 538 + func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 823 539 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 824 540 res, err := e.Exec( 825 541 query, ··· 842 558 return i, nil 843 559 } 844 560 845 - func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error { 561 + func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { 846 562 _, err := e.Exec( 847 563 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, 848 564 pullState, 849 565 repoAt, 850 566 pullId, 851 - PullDeleted, // only update state of non-deleted pulls 852 - PullMerged, // only update state of non-merged pulls 567 + models.PullDeleted, // only update state of non-deleted pulls 568 + models.PullMerged, // only update state of non-merged pulls 853 569 ) 854 570 return err 855 571 } 856 572 857 573 func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error { 858 - err := SetPullState(e, repoAt, pullId, PullClosed) 574 + err := SetPullState(e, repoAt, pullId, models.PullClosed) 859 575 return err 860 576 } 861 577 862 578 func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error { 863 - err := SetPullState(e, repoAt, pullId, PullOpen) 579 + err := SetPullState(e, repoAt, pullId, models.PullOpen) 864 580 return err 865 581 } 866 582 867 583 func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error { 868 - err := SetPullState(e, repoAt, pullId, PullMerged) 584 + err := SetPullState(e, repoAt, pullId, models.PullMerged) 869 585 return err 870 586 } 871 587 872 588 func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error { 873 - err := SetPullState(e, repoAt, pullId, PullDeleted) 589 + err := SetPullState(e, repoAt, pullId, models.PullDeleted) 874 590 return err 875 591 } 876 592 877 - func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 593 + func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 878 594 newRoundNumber := len(pull.Submissions) 879 595 _, err := e.Exec(` 880 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 881 - values (?, ?, ?, ?, ?) 882 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 883 599 884 600 return err 885 601 } ··· 931 647 return err 932 648 } 933 649 934 - type PullCount struct { 935 - Open int 936 - Merged int 937 - Closed int 938 - Deleted int 939 - } 940 - 941 - func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) { 650 + func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) { 942 651 row := e.QueryRow(` 943 652 select 944 653 count(case when state = ? then 1 end) as open_count, ··· 947 656 count(case when state = ? then 1 end) as deleted_count 948 657 from pulls 949 658 where repo_at = ?`, 950 - PullOpen, 951 - PullMerged, 952 - PullClosed, 953 - PullDeleted, 659 + models.PullOpen, 660 + models.PullMerged, 661 + models.PullClosed, 662 + models.PullDeleted, 954 663 repoAt, 955 664 ) 956 665 957 - var count PullCount 666 + var count models.PullCount 958 667 if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil { 959 - return PullCount{0, 0, 0, 0}, err 668 + return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err 960 669 } 961 670 962 671 return count, nil 963 672 } 964 - 965 - type Stack []*Pull 966 673 967 674 // change-id parent-change-id 968 675 // ··· 972 679 // 1 x <------' nil (BOT) 973 680 // 974 681 // `w` is parent of none, so it is the top of the stack 975 - func GetStack(e Execer, stackId string) (Stack, error) { 682 + func GetStack(e Execer, stackId string) (models.Stack, error) { 976 683 unorderedPulls, err := GetPulls( 977 684 e, 978 685 FilterEq("stack_id", stackId), 979 - FilterNotEq("state", PullDeleted), 686 + FilterNotEq("state", models.PullDeleted), 980 687 ) 981 688 if err != nil { 982 689 return nil, err 983 690 } 984 691 // map of parent-change-id to pull 985 - changeIdMap := make(map[string]*Pull, len(unorderedPulls)) 986 - parentMap := make(map[string]*Pull, len(unorderedPulls)) 692 + changeIdMap := make(map[string]*models.Pull, len(unorderedPulls)) 693 + parentMap := make(map[string]*models.Pull, len(unorderedPulls)) 987 694 for _, p := range unorderedPulls { 988 695 changeIdMap[p.ChangeId] = p 989 696 if p.ParentChangeId != "" { ··· 992 699 } 993 700 994 701 // the top of the stack is the pull that is not a parent of any pull 995 - var topPull *Pull 702 + var topPull *models.Pull 996 703 for _, maybeTop := range unorderedPulls { 997 704 if _, ok := parentMap[maybeTop.ChangeId]; !ok { 998 705 topPull = maybeTop ··· 1000 707 } 1001 708 } 1002 709 1003 - pulls := []*Pull{} 710 + pulls := []*models.Pull{} 1004 711 for { 1005 712 pulls = append(pulls, topPull) 1006 713 if topPull.ParentChangeId != "" { ··· 1017 724 return pulls, nil 1018 725 } 1019 726 1020 - func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) { 727 + func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 1021 728 pulls, err := GetPulls( 1022 729 e, 1023 730 FilterEq("stack_id", stackId), 1024 - FilterEq("state", PullDeleted), 731 + FilterEq("state", models.PullDeleted), 1025 732 ) 1026 733 if err != nil { 1027 734 return nil, err ··· 1030 737 return pulls, nil 1031 738 } 1032 739 1033 - // position of this pull in the stack 1034 - func (stack Stack) Position(pull *Pull) int { 1035 - return slices.IndexFunc(stack, func(p *Pull) bool { 1036 - return p.ChangeId == pull.ChangeId 1037 - }) 1038 - } 740 + func SearchPulls(e Execer, text string, labels []string, sortBy string, sortOrder string, filters ...filter) ([]*models.Pull, error) { 741 + var conditions []string 742 + var args []any 1039 743 1040 - // all pulls below this pull (including self) in this stack 1041 - // 1042 - // nil if this pull does not belong to this stack 1043 - func (stack Stack) Below(pull *Pull) Stack { 1044 - position := stack.Position(pull) 744 + for _, filter := range filters { 745 + conditions = append(conditions, filter.Condition()) 746 + args = append(args, filter.Arg()...) 747 + } 1045 748 1046 - if position < 0 { 1047 - return nil 749 + if text != "" { 750 + searchPattern := "%" + text + "%" 751 + conditions = append(conditions, "title like ?") 752 + args = append(args, searchPattern) 1048 753 } 1049 754 1050 - return stack[position:] 1051 - } 755 + whereClause := "" 756 + if len(conditions) > 0 { 757 + whereClause = " where " + strings.Join(conditions, " and ") 758 + } 1052 759 1053 - // all pulls below this pull (excluding self) in this stack 1054 - func (stack Stack) StrictlyBelow(pull *Pull) Stack { 1055 - below := stack.Below(pull) 760 + query := fmt.Sprintf(` 761 + select 762 + id, 763 + owner_did, 764 + pull_id, 765 + title, 766 + body, 767 + target_branch, 768 + repo_at, 769 + rkey, 770 + state, 771 + source_branch, 772 + source_repo_at, 773 + stack_id, 774 + change_id, 775 + parent_change_id, 776 + created 777 + from pulls 778 + %s 779 + order by created desc 780 + `, whereClause) 1056 781 1057 - if len(below) > 0 { 1058 - return below[1:] 782 + rows, err := e.Query(query, args...) 783 + if err != nil { 784 + return nil, fmt.Errorf("failed to query pulls: %w", err) 1059 785 } 786 + defer rows.Close() 1060 787 1061 - return nil 1062 - } 788 + pullMap := make(map[string]*models.Pull) 789 + for rows.Next() { 790 + var pull models.Pull 791 + var createdAt string 792 + var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.Null[string] 1063 793 1064 - // all pulls above this pull (including self) in this stack 1065 - func (stack Stack) Above(pull *Pull) Stack { 1066 - position := stack.Position(pull) 794 + err := rows.Scan( 795 + &pull.ID, 796 + &pull.OwnerDid, 797 + &pull.PullId, 798 + &pull.Title, 799 + &pull.Body, 800 + &pull.TargetBranch, 801 + &pull.RepoAt, 802 + &pull.Rkey, 803 + &pull.State, 804 + &sourceBranch, 805 + &sourceRepoAt, 806 + &stackId, 807 + &changeId, 808 + &parentChangeId, 809 + &createdAt, 810 + ) 811 + if err != nil { 812 + return nil, fmt.Errorf("failed to scan pull: %w", err) 813 + } 814 + 815 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 816 + pull.Created = t 817 + } 818 + 819 + if sourceBranch.Valid || sourceRepoAt.Valid { 820 + pull.PullSource = &models.PullSource{} 821 + if sourceBranch.Valid { 822 + pull.PullSource.Branch = sourceBranch.V 823 + } 824 + if sourceRepoAt.Valid { 825 + uri := syntax.ATURI(sourceRepoAt.V) 826 + pull.PullSource.RepoAt = &uri 827 + } 828 + } 829 + 830 + if stackId.Valid { 831 + pull.StackId = stackId.V 832 + } 833 + if changeId.Valid { 834 + pull.ChangeId = changeId.V 835 + } 836 + if parentChangeId.Valid { 837 + pull.ParentChangeId = parentChangeId.V 838 + } 839 + 840 + pullAt := pull.PullAt().String() 841 + pullMap[pullAt] = &pull 842 + } 843 + 844 + // Load submissions and labels 845 + for _, pull := range pullMap { 846 + submissionsMap, err := GetPullSubmissions(e, FilterEq("pull_at", pull.PullAt().String())) 847 + if err != nil { 848 + return nil, fmt.Errorf("failed to query submissions: %w", err) 849 + } 850 + if subs, ok := submissionsMap[pull.PullAt()]; ok { 851 + pull.Submissions = subs 852 + } 853 + } 1067 854 1068 - if position < 0 { 1069 - return nil 855 + // Collect labels 856 + pullAts := slices.Collect(maps.Keys(pullMap)) 857 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 858 + if err != nil { 859 + return nil, fmt.Errorf("failed to query labels: %w", err) 860 + } 861 + for pullAt, labels := range allLabels { 862 + if pull, ok := pullMap[pullAt.String()]; ok { 863 + pull.Labels = labels 864 + } 1070 865 } 1071 866 1072 - return stack[:position+1] 1073 - } 867 + // Filter by labels if specified 868 + if len(labels) > 0 { 869 + if len(pullMap) > 0 { 870 + var repoAt string 871 + for _, pull := range pullMap { 872 + repoAt = string(pull.RepoAt) 873 + break 874 + } 1074 875 1075 - // all pulls below this pull (excluding self) in this stack 1076 - func (stack Stack) StrictlyAbove(pull *Pull) Stack { 1077 - above := stack.Above(pull) 876 + repo, err := GetRepoByAtUri(e, repoAt) 877 + if err == nil && len(repo.Labels) > 0 { 878 + labelDefs, err := GetLabelDefinitions(e, FilterIn("at_uri", repo.Labels)) 879 + if err == nil { 880 + labelNameToUri := make(map[string]string) 881 + for _, def := range labelDefs { 882 + labelNameToUri[def.Name] = def.AtUri().String() 883 + } 1078 884 1079 - if len(above) > 0 { 1080 - return above[:len(above)-1] 885 + for pullAt, pull := range pullMap { 886 + hasAllLabels := true 887 + for _, labelName := range labels { 888 + labelUri, found := labelNameToUri[labelName] 889 + if !found { 890 + hasAllLabels = false 891 + break 892 + } 893 + if !pull.Labels.ContainsLabel(labelUri) { 894 + hasAllLabels = false 895 + break 896 + } 897 + } 898 + if !hasAllLabels { 899 + delete(pullMap, pullAt) 900 + } 901 + } 902 + } 903 + } 904 + } 1081 905 } 1082 906 1083 - return nil 1084 - } 1085 - 1086 - // the combined format-patches of all the newest submissions in this stack 1087 - func (stack Stack) CombinedPatch() string { 1088 - // go in reverse order because the bottom of the stack is the last element in the slice 1089 - var combined strings.Builder 1090 - for idx := range stack { 1091 - pull := stack[len(stack)-1-idx] 1092 - combined.WriteString(pull.LatestPatch()) 1093 - combined.WriteString("\n") 907 + var pulls []*models.Pull 908 + for _, p := range pullMap { 909 + pulls = append(pulls, p) 1094 910 } 1095 - return combined.String() 1096 - } 1097 911 1098 - // filter out PRs that are "active" 1099 - // 1100 - // PRs that are still open are active 1101 - func (stack Stack) Mergeable() Stack { 1102 - var mergeable Stack 912 + sort.Slice(pulls, func(i, j int) bool { 913 + var less bool 1103 914 1104 - for _, p := range stack { 1105 - // stop at the first merged PR 1106 - if p.State == PullMerged || p.State == PullClosed { 1107 - break 915 + switch sortBy { 916 + case "created": 917 + fallthrough 918 + default: 919 + if pulls[i].Created.Equal(pulls[j].Created) { 920 + // Tiebreaker: use pull_id for stable sort 921 + less = pulls[i].PullId > pulls[j].PullId 922 + } else { 923 + less = pulls[i].Created.After(pulls[j].Created) 924 + } 1108 925 } 1109 926 1110 - // skip over deleted PRs 1111 - if p.State != PullDeleted { 1112 - mergeable = append(mergeable, p) 927 + if sortOrder == "asc" { 928 + return !less 1113 929 } 1114 - } 930 + return less 931 + }) 1115 932 1116 - return mergeable 933 + return pulls, nil 1117 934 }
+7 -16
appview/db/punchcard.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 + 9 + "tangled.org/core/appview/models" 8 10 ) 9 11 10 - type Punch struct { 11 - Did string 12 - Date time.Time 13 - Count int 14 - } 15 - 16 12 // this adds to the existing count 17 - func AddPunch(e Execer, punch Punch) error { 13 + func AddPunch(e Execer, punch models.Punch) error { 18 14 _, err := e.Exec(` 19 15 insert into punchcard (did, date, count) 20 16 values (?, ?, ?) ··· 24 20 return err 25 21 } 26 22 27 - type Punchcard struct { 28 - Total int 29 - Punches []Punch 30 - } 31 - 32 - func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 - punchcard := &Punchcard{} 23 + func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + punchcard := &models.Punchcard{} 34 25 now := time.Now() 35 26 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 27 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) 37 28 for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) { 38 - punchcard.Punches = append(punchcard.Punches, Punch{ 29 + punchcard.Punches = append(punchcard.Punches, models.Punch{ 39 30 Date: d, 40 31 Count: 0, 41 32 }) ··· 68 59 defer rows.Close() 69 60 70 61 for rows.Next() { 71 - var punch Punch 62 + var punch models.Punch 72 63 var date string 73 64 var count sql.NullInt64 74 65 if err := rows.Scan(&date, &count); err != nil {
+45 -67
appview/db/reaction.go
··· 5 5 "time" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type ReactionKind string 11 - 12 - const ( 13 - Like ReactionKind = "👍" 14 - Unlike ReactionKind = "👎" 15 - Laugh ReactionKind = "😆" 16 - Celebration ReactionKind = "🎉" 17 - Confused ReactionKind = "🫤" 18 - Heart ReactionKind = "❤️" 19 - Rocket ReactionKind = "🚀" 20 - Eyes ReactionKind = "👀" 8 + "tangled.org/core/appview/models" 21 9 ) 22 10 23 - func (rk ReactionKind) String() string { 24 - return string(rk) 25 - } 26 - 27 - var OrderedReactionKinds = []ReactionKind{ 28 - Like, 29 - Unlike, 30 - Laugh, 31 - Celebration, 32 - Confused, 33 - Heart, 34 - Rocket, 35 - Eyes, 36 - } 37 - 38 - func ParseReactionKind(raw string) (ReactionKind, bool) { 39 - k, ok := (map[string]ReactionKind{ 40 - "👍": Like, 41 - "👎": Unlike, 42 - "😆": Laugh, 43 - "🎉": Celebration, 44 - "🫤": Confused, 45 - "❤️": Heart, 46 - "🚀": Rocket, 47 - "👀": Eyes, 48 - })[raw] 49 - return k, ok 50 - } 51 - 52 - type Reaction struct { 53 - ReactedByDid string 54 - ThreadAt syntax.ATURI 55 - Created time.Time 56 - Rkey string 57 - Kind ReactionKind 58 - } 59 - 60 - func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 11 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 61 12 query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 13 _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 14 return err 64 15 } 65 16 66 17 // Get a reaction record 67 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 18 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 68 19 query := ` 69 20 select reacted_by_did, thread_at, created, rkey 70 21 from reactions 71 22 where reacted_by_did = ? and thread_at = ? and kind = ?` 72 23 row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 24 74 - var reaction Reaction 25 + var reaction models.Reaction 75 26 var created string 76 27 err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 28 if err != nil { ··· 90 41 } 91 42 92 43 // Remove a reaction 93 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 44 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 94 45 _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 46 return err 96 47 } ··· 101 52 return err 102 53 } 103 54 104 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 55 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 105 56 count := 0 106 57 err := e.QueryRow( 107 58 `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) ··· 111 62 return count, nil 112 63 } 113 64 114 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 - countMap := map[ReactionKind]int{} 116 - for _, kind := range OrderedReactionKinds { 117 - count, err := GetReactionCount(e, threadAt, kind) 118 - if err != nil { 119 - return map[ReactionKind]int{}, nil 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 81 + for _, kind := range models.OrderedReactionKinds { 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 120 91 } 121 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 122 99 } 123 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 124 102 } 125 103 126 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 104 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 127 105 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 106 return false 129 107 } else { ··· 131 109 } 132 110 } 133 111 134 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 - statusMap := map[ReactionKind]bool{} 136 - for _, kind := range OrderedReactionKinds { 112 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 113 + statusMap := map[models.ReactionKind]bool{} 114 + for _, kind := range models.OrderedReactionKinds { 137 115 count := GetReactionStatus(e, userDid, threadAt, kind) 138 116 statusMap[kind] = count 139 117 }
+4 -43
appview/db/registration.go
··· 5 5 "fmt" 6 6 "strings" 7 7 "time" 8 - ) 9 - 10 - // Registration represents a knot registration. Knot would've been a better 11 - // name but we're stuck with this for historical reasons. 12 - type Registration struct { 13 - Id int64 14 - Domain string 15 - ByDid string 16 - Created *time.Time 17 - Registered *time.Time 18 - NeedsUpgrade bool 19 - } 20 8 21 - func (r *Registration) Status() Status { 22 - if r.NeedsUpgrade { 23 - return NeedsUpgrade 24 - } else if r.Registered != nil { 25 - return Registered 26 - } else { 27 - return Pending 28 - } 29 - } 30 - 31 - func (r *Registration) IsRegistered() bool { 32 - return r.Status() == Registered 33 - } 34 - 35 - func (r *Registration) IsNeedsUpgrade() bool { 36 - return r.Status() == NeedsUpgrade 37 - } 38 - 39 - func (r *Registration) IsPending() bool { 40 - return r.Status() == Pending 41 - } 42 - 43 - type Status uint32 44 - 45 - const ( 46 - Registered Status = iota 47 - Pending 48 - NeedsUpgrade 9 + "tangled.org/core/appview/models" 49 10 ) 50 11 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 - var registrations []Registration 12 + func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + var registrations []models.Registration 53 14 54 15 var conditions []string 55 16 var args []any ··· 81 42 var createdAt string 82 43 var registeredAt sql.Null[string] 83 44 var needsUpgrade int 84 - var reg Registration 45 + var reg models.Registration 85 46 86 47 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 48 if err != nil {
+78 -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) ··· 396 372 repo.Description = "" 397 373 } 398 374 375 + // Load labels for this repo 376 + rows, err := e.Query(`select label_at from repo_labels where repo_at = ?`, atUri) 377 + if err != nil { 378 + return nil, fmt.Errorf("failed to load repo labels: %w", err) 379 + } 380 + defer rows.Close() 381 + 382 + for rows.Next() { 383 + var labelAt string 384 + if err := rows.Scan(&labelAt); err != nil { 385 + continue 386 + } 387 + repo.Labels = append(repo.Labels, labelAt) 388 + } 389 + 399 390 return &repo, nil 400 391 } 401 392 402 - func AddRepo(e Execer, repo *Repo) error { 403 - _, err := e.Exec( 393 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 394 + _, err := tx.Exec( 404 395 `insert into repos 405 396 (did, name, knot, rkey, at_uri, description, source) 406 397 values (?, ?, ?, ?, ?, ?, ?)`, 407 398 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 408 399 ) 409 - return err 400 + if err != nil { 401 + return fmt.Errorf("failed to insert repo: %w", err) 402 + } 403 + 404 + for _, dl := range repo.Labels { 405 + if err := SubscribeLabel(tx, &models.RepoLabel{ 406 + RepoAt: repo.RepoAt(), 407 + LabelAt: syntax.ATURI(dl), 408 + }); err != nil { 409 + return fmt.Errorf("failed to subscribe to label: %w", err) 410 + } 411 + } 412 + 413 + return nil 410 414 } 411 415 412 416 func RemoveRepo(e Execer, did, name string) error { ··· 423 427 return nullableSource.String, nil 424 428 } 425 429 426 - func GetForksByDid(e Execer, did string) ([]Repo, error) { 427 - var repos []Repo 430 + func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 431 + var repos []models.Repo 428 432 429 433 rows, err := e.Query( 430 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 434 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 431 435 from repos r 432 436 left join collaborators c on r.at_uri = c.repo_at 433 437 where (r.did = ? or c.subject_did = ?) ··· 442 446 defer rows.Close() 443 447 444 448 for rows.Next() { 445 - var repo Repo 449 + var repo models.Repo 446 450 var createdAt string 447 451 var nullableDescription sql.NullString 448 452 var nullableSource sql.NullString 449 453 450 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 454 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 451 455 if err != nil { 452 456 return nil, err 453 457 } ··· 477 481 return repos, nil 478 482 } 479 483 480 - func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 481 - var repo Repo 484 + func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { 485 + var repo models.Repo 482 486 var createdAt string 483 487 var nullableDescription sql.NullString 484 488 var nullableSource sql.NullString 485 489 486 490 row := e.QueryRow( 487 - `select did, name, knot, rkey, description, created, source 491 + `select id, did, name, knot, rkey, description, created, source 488 492 from repos 489 493 where did = ? and name = ? and source is not null and source != ''`, 490 494 did, name, 491 495 ) 492 496 493 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 497 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 494 498 if err != nil { 495 499 return nil, err 496 500 } ··· 525 529 return err 526 530 } 527 531 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 { 532 + func SubscribeLabel(e Execer, rl *models.RepoLabel) error { 542 533 query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 543 534 544 535 _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) ··· 563 554 return err 564 555 } 565 556 566 - func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) { 557 + func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 567 558 var conditions []string 568 559 var args []any 569 560 for _, filter := range filters { ··· 584 575 } 585 576 defer rows.Close() 586 577 587 - var labels []RepoLabel 578 + var labels []models.RepoLabel 588 579 for rows.Next() { 589 - var label RepoLabel 580 + var label models.RepoLabel 590 581 591 582 err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 592 583 if err != nil {
+4 -9
appview/db/signup.go
··· 1 1 package db 2 2 3 - import "time" 3 + import ( 4 + "tangled.org/core/appview/models" 5 + ) 4 6 5 - type InflightSignup struct { 6 - Id int64 7 - Email string 8 - InviteCode string 9 - Created time.Time 10 - } 11 - 12 - func AddInflightSignup(e Execer, signup InflightSignup) error { 7 + func AddInflightSignup(e Execer, signup models.InflightSignup) error { 13 8 query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 9 _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 10 return err
+9 -27
appview/db/spindle.go
··· 6 6 "strings" 7 7 "time" 8 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 10 10 ) 11 11 12 - type Spindle struct { 13 - Id int 14 - Owner syntax.DID 15 - Instance string 16 - Verified *time.Time 17 - Created time.Time 18 - NeedsUpgrade bool 19 - } 20 - 21 - type SpindleMember struct { 22 - Id int 23 - Did syntax.DID // owner of the record 24 - Rkey string // rkey of the record 25 - Instance string 26 - Subject syntax.DID // the member being added 27 - Created time.Time 28 - } 29 - 30 - func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) { 31 - var spindles []Spindle 12 + func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + var spindles []models.Spindle 32 14 33 15 var conditions []string 34 16 var args []any ··· 59 41 defer rows.Close() 60 42 61 43 for rows.Next() { 62 - var spindle Spindle 44 + var spindle models.Spindle 63 45 var createdAt string 64 46 var verified sql.NullString 65 47 var needsUpgrade int ··· 100 82 } 101 83 102 84 // if there is an existing spindle with the same instance, this returns an error 103 - func AddSpindle(e Execer, spindle Spindle) error { 85 + func AddSpindle(e Execer, spindle models.Spindle) error { 104 86 _, err := e.Exec( 105 87 `insert into spindles (owner, instance) values (?, ?)`, 106 88 spindle.Owner, ··· 151 133 return err 152 134 } 153 135 154 - func AddSpindleMember(e Execer, member SpindleMember) error { 136 + func AddSpindleMember(e Execer, member models.SpindleMember) error { 155 137 _, err := e.Exec( 156 138 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 157 139 member.Did, ··· 181 163 return err 182 164 } 183 165 184 - func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) { 185 - var members []SpindleMember 166 + func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + var members []models.SpindleMember 186 168 187 169 var conditions []string 188 170 var args []any ··· 213 195 defer rows.Close() 214 196 215 197 for rows.Next() { 216 - var member SpindleMember 198 + var member models.SpindleMember 217 199 var createdAt string 218 200 219 201 if err := rows.Scan(
+27 -39
appview/db/star.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "log" 8 + "slices" 8 9 "strings" 9 10 "time" 10 11 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 12 14 ) 13 15 14 - type Star struct { 15 - StarredByDid string 16 - RepoAt syntax.ATURI 17 - Created time.Time 18 - Rkey string 19 - 20 - // optionally, populate this when querying for reverse mappings 21 - Repo *Repo 22 - } 23 - 24 - func (star *Star) ResolveRepo(e Execer) error { 25 - if star.Repo != nil { 26 - return nil 27 - } 28 - 29 - repo, err := GetRepoByAtUri(e, star.RepoAt.String()) 30 - if err != nil { 31 - return err 32 - } 33 - 34 - star.Repo = repo 35 - return nil 36 - } 37 - 38 - func AddStar(e Execer, star *Star) error { 16 + func AddStar(e Execer, star *models.Star) error { 39 17 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 40 18 _, err := e.Exec( 41 19 query, ··· 47 25 } 48 26 49 27 // Get a star record 50 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 28 + func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 51 29 query := ` 52 30 select starred_by_did, repo_at, created, rkey 53 31 from stars 54 32 where starred_by_did = ? and repo_at = ?` 55 33 row := e.QueryRow(query, starredByDid, repoAt) 56 34 57 - var star Star 35 + var star models.Star 58 36 var created string 59 37 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 60 38 if err != nil { ··· 152 130 func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 153 131 return getStarStatuses(e, userDid, repoAts) 154 132 } 155 - func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 133 + func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 156 134 var conditions []string 157 135 var args []any 158 136 for _, filter := range filters { ··· 184 162 return nil, err 185 163 } 186 164 187 - starMap := make(map[string][]Star) 165 + starMap := make(map[string][]models.Star) 188 166 for rows.Next() { 189 - var star Star 167 + var star models.Star 190 168 var created string 191 169 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 192 170 if err != nil { ··· 227 205 } 228 206 } 229 207 230 - var stars []Star 208 + var stars []models.Star 231 209 for _, s := range starMap { 232 210 stars = append(stars, s...) 233 211 } 212 + 213 + slices.SortFunc(stars, func(a, b models.Star) int { 214 + if a.Created.After(b.Created) { 215 + return -1 216 + } 217 + if b.Created.After(a.Created) { 218 + return 1 219 + } 220 + return 0 221 + }) 234 222 235 223 return stars, nil 236 224 } ··· 259 247 return count, nil 260 248 } 261 249 262 - func GetAllStars(e Execer, limit int) ([]Star, error) { 263 - var stars []Star 250 + func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 + var stars []models.Star 264 252 265 253 rows, err := e.Query(` 266 254 select ··· 283 271 defer rows.Close() 284 272 285 273 for rows.Next() { 286 - var star Star 287 - var repo Repo 274 + var star models.Star 275 + var repo models.Repo 288 276 var starCreatedAt, repoCreatedAt string 289 277 290 278 if err := rows.Scan( ··· 322 310 } 323 311 324 312 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 325 - func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 313 + func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 326 314 // first, get the top repo URIs by star count from the last week 327 315 query := ` 328 316 with recent_starred_repos as ( ··· 366 354 } 367 355 368 356 if len(repoUris) == 0 { 369 - return []Repo{}, nil 357 + return []models.Repo{}, nil 370 358 } 371 359 372 360 // get full repo data ··· 376 364 } 377 365 378 366 // sort repos by the original trending order 379 - repoMap := make(map[string]Repo) 367 + repoMap := make(map[string]models.Repo) 380 368 for _, repo := range repos { 381 369 repoMap[repo.RepoAt().String()] = repo 382 370 } 383 371 384 - orderedRepos := make([]Repo, 0, len(repoUris)) 372 + orderedRepos := make([]models.Repo, 0, len(repoUris)) 385 373 for _, uri := range repoUris { 386 374 if repo, exists := repoMap[uri]; exists { 387 375 orderedRepos = append(orderedRepos, repo)
+5 -110
appview/db/strings.go
··· 1 1 package db 2 2 3 3 import ( 4 - "bytes" 5 4 "database/sql" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "strings" 10 8 "time" 11 - "unicode/utf8" 12 9 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "tangled.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 + }
+89 -44
appview/issues/issues.go
··· 12 12 "time" 13 13 14 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 lexutil "github.com/bluesky-social/indigo/lex/util" 17 18 "github.com/go-chi/chi/v5" 18 19 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" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/config" 22 + "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/models" 24 + "tangled.org/core/appview/notify" 25 + "tangled.org/core/appview/oauth" 26 + "tangled.org/core/appview/pages" 27 + "tangled.org/core/appview/pagination" 28 + "tangled.org/core/appview/reporesolver" 29 + "tangled.org/core/appview/search" 30 + "tangled.org/core/appview/validator" 31 + "tangled.org/core/idresolver" 32 + tlog "tangled.org/core/log" 33 + "tangled.org/core/tid" 32 34 ) 33 35 34 36 type Issues struct { ··· 75 77 return 76 78 } 77 79 78 - issue, ok := r.Context().Value("issue").(*db.Issue) 80 + issue, ok := r.Context().Value("issue").(*models.Issue) 79 81 if !ok { 80 82 l.Error("failed to get issue") 81 83 rp.pages.Error404(w) 82 84 return 83 85 } 84 86 85 - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 87 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 86 88 if err != nil { 87 89 l.Error("failed to get issue reactions", "err", err) 88 90 } 89 91 90 - userReactions := map[db.ReactionKind]bool{} 92 + userReactions := map[models.ReactionKind]bool{} 91 93 if user != nil { 92 94 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 95 } ··· 103 105 return 104 106 } 105 107 106 - defs := make(map[string]*db.LabelDefinition) 108 + defs := make(map[string]*models.LabelDefinition) 107 109 for _, l := range labelDefs { 108 110 defs[l.AtUri().String()] = &l 109 111 } ··· 113 115 RepoInfo: f.RepoInfo(user), 114 116 Issue: issue, 115 117 CommentList: issue.CommentList(), 116 - OrderedReactionKinds: db.OrderedReactionKinds, 117 - Reactions: reactionCountMap, 118 + OrderedReactionKinds: models.OrderedReactionKinds, 119 + Reactions: reactionMap, 118 120 UserReacted: userReactions, 119 121 LabelDefs: defs, 120 122 }) ··· 129 131 return 130 132 } 131 133 132 - issue, ok := r.Context().Value("issue").(*db.Issue) 134 + issue, ok := r.Context().Value("issue").(*models.Issue) 133 135 if !ok { 134 136 l.Error("failed to get issue") 135 137 rp.pages.Error404(w) ··· 165 167 return 166 168 } 167 169 168 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 169 171 if err != nil { 170 172 l.Error("failed to get record", "err", err) 171 173 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 172 174 return 173 175 } 174 176 175 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 176 178 Collection: tangled.RepoIssueNSID, 177 179 Repo: user.Did, 178 180 Rkey: newIssue.Rkey, ··· 225 227 return 226 228 } 227 229 228 - issue, ok := r.Context().Value("issue").(*db.Issue) 230 + issue, ok := r.Context().Value("issue").(*models.Issue) 229 231 if !ok { 230 232 l.Error("failed to get issue") 231 233 rp.pages.Notice(w, noticeId, "Failed to delete issue.") ··· 240 242 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 241 243 return 242 244 } 243 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 244 246 Collection: tangled.RepoIssueNSID, 245 247 Repo: issue.Did, 246 248 Rkey: issue.Rkey, ··· 272 274 return 273 275 } 274 276 275 - issue, ok := r.Context().Value("issue").(*db.Issue) 277 + issue, ok := r.Context().Value("issue").(*models.Issue) 276 278 if !ok { 277 279 l.Error("failed to get issue") 278 280 rp.pages.Error404(w) ··· 299 301 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 300 302 return 301 303 } 304 + 305 + // notify about the issue closure 306 + rp.notifier.NewIssueClosed(r.Context(), issue) 302 307 303 308 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 304 309 return ··· 318 323 return 319 324 } 320 325 321 - issue, ok := r.Context().Value("issue").(*db.Issue) 326 + issue, ok := r.Context().Value("issue").(*models.Issue) 322 327 if !ok { 323 328 l.Error("failed to get issue") 324 329 rp.pages.Error404(w) ··· 362 367 return 363 368 } 364 369 365 - issue, ok := r.Context().Value("issue").(*db.Issue) 370 + issue, ok := r.Context().Value("issue").(*models.Issue) 366 371 if !ok { 367 372 l.Error("failed to get issue") 368 373 rp.pages.Error404(w) ··· 381 386 replyTo = &replyToUri 382 387 } 383 388 384 - comment := db.IssueComment{ 389 + comment := models.IssueComment{ 385 390 Did: user.Did, 386 391 Rkey: tid.TID(), 387 392 IssueAt: issue.AtUri().String(), ··· 404 409 } 405 410 406 411 // create a record first 407 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 412 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 408 413 Collection: tangled.RepoIssueCommentNSID, 409 414 Repo: comment.Did, 410 415 Rkey: comment.Rkey, ··· 433 438 434 439 // reset atUri to make rollback a no-op 435 440 atUri = "" 441 + 442 + // notify about the new comment 443 + comment.Id = commentId 444 + rp.notifier.NewIssueComment(r.Context(), &comment) 445 + 436 446 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 437 447 } 438 448 ··· 445 455 return 446 456 } 447 457 448 - issue, ok := r.Context().Value("issue").(*db.Issue) 458 + issue, ok := r.Context().Value("issue").(*models.Issue) 449 459 if !ok { 450 460 l.Error("failed to get issue") 451 461 rp.pages.Error404(w) ··· 486 496 return 487 497 } 488 498 489 - issue, ok := r.Context().Value("issue").(*db.Issue) 499 + issue, ok := r.Context().Value("issue").(*models.Issue) 490 500 if !ok { 491 501 l.Error("failed to get issue") 492 502 rp.pages.Error404(w) ··· 550 560 // rkey is optional, it was introduced later 551 561 if newComment.Rkey != "" { 552 562 // update the record on pds 553 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 554 564 if err != nil { 555 565 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 556 566 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 557 567 return 558 568 } 559 569 560 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 570 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 561 571 Collection: tangled.RepoIssueCommentNSID, 562 572 Repo: user.Did, 563 573 Rkey: newComment.Rkey, ··· 590 600 return 591 601 } 592 602 593 - issue, ok := r.Context().Value("issue").(*db.Issue) 603 + issue, ok := r.Context().Value("issue").(*models.Issue) 594 604 if !ok { 595 605 l.Error("failed to get issue") 596 606 rp.pages.Error404(w) ··· 631 641 return 632 642 } 633 643 634 - issue, ok := r.Context().Value("issue").(*db.Issue) 644 + issue, ok := r.Context().Value("issue").(*models.Issue) 635 645 if !ok { 636 646 l.Error("failed to get issue") 637 647 rp.pages.Error404(w) ··· 672 682 return 673 683 } 674 684 675 - issue, ok := r.Context().Value("issue").(*db.Issue) 685 + issue, ok := r.Context().Value("issue").(*models.Issue) 676 686 if !ok { 677 687 l.Error("failed to get issue") 678 688 rp.pages.Error404(w) ··· 724 734 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 725 735 return 726 736 } 727 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 737 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 728 738 Collection: tangled.RepoIssueCommentNSID, 729 739 Repo: user.Did, 730 740 Rkey: comment.Rkey, ··· 750 760 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 751 761 params := r.URL.Query() 752 762 state := params.Get("state") 763 + searchQuery := params.Get("q") 764 + sortBy := params.Get("sort_by") 765 + sortOrder := params.Get("sort_order") 766 + 767 + // Use for template (preserve empty values) 768 + templateSortBy := sortBy 769 + templateSortOrder := sortOrder 770 + 771 + // Default sort values for queries 772 + if sortBy == "" { 773 + sortBy = "created" 774 + } 775 + if sortOrder == "" { 776 + sortOrder = "desc" 777 + } 778 + 753 779 isOpen := true 754 780 switch state { 755 781 case "open": ··· 777 803 if isOpen { 778 804 openVal = 1 779 805 } 780 - issues, err := db.GetIssuesPaginated( 806 + 807 + var issues []models.Issue 808 + 809 + // Parse the search query (even if empty, to handle label filters) 810 + query := search.Parse(searchQuery) 811 + 812 + // Always use search function to handle sorting 813 + issues, err = db.SearchIssues( 781 814 rp.db, 782 815 page, 816 + query.Text, 817 + query.Labels, 818 + sortBy, 819 + sortOrder, 783 820 db.FilterEq("repo_at", f.RepoAt()), 784 821 db.FilterEq("open", openVal), 785 822 ) 823 + 786 824 if err != nil { 787 825 log.Println("failed to get issues", err) 788 826 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 789 827 return 790 828 } 791 829 792 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 830 + labelDefs, err := db.GetLabelDefinitions( 831 + rp.db, 832 + db.FilterIn("at_uri", f.Repo.Labels), 833 + db.FilterContains("scope", tangled.RepoIssueNSID), 834 + ) 793 835 if err != nil { 794 836 log.Println("failed to fetch labels", err) 795 837 rp.pages.Error503(w) 796 838 return 797 839 } 798 840 799 - defs := make(map[string]*db.LabelDefinition) 841 + defs := make(map[string]*models.LabelDefinition) 800 842 for _, l := range labelDefs { 801 843 defs[l.AtUri().String()] = &l 802 844 } ··· 808 850 LabelDefs: defs, 809 851 FilteringByOpen: isOpen, 810 852 Page: page, 853 + SearchQuery: searchQuery, 854 + SortBy: templateSortBy, 855 + SortOrder: templateSortOrder, 811 856 }) 812 857 } 813 858 ··· 828 873 RepoInfo: f.RepoInfo(user), 829 874 }) 830 875 case http.MethodPost: 831 - issue := &db.Issue{ 876 + issue := &models.Issue{ 832 877 RepoAt: f.RepoAt(), 833 878 Rkey: tid.TID(), 834 879 Title: r.FormValue("title"), ··· 852 897 rp.pages.Notice(w, "issues", "Failed to create issue.") 853 898 return 854 899 } 855 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 900 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 856 901 Collection: tangled.RepoIssueNSID, 857 902 Repo: user.Did, 858 903 Rkey: issue.Rkey, ··· 910 955 // this is used to rollback changes made to the PDS 911 956 // 912 957 // it is a no-op if the provided ATURI is empty 913 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 958 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 914 959 if aturi == "" { 915 960 return nil 916 961 } ··· 921 966 repo := parsed.Authority().String() 922 967 rkey := parsed.RecordKey().String() 923 968 924 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 969 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 925 970 Collection: collection, 926 971 Repo: repo, 927 972 Rkey: rkey,
+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 {
+20 -19
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 } ··· 184 185 return 185 186 } 186 187 187 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 188 189 var exCid *string 189 190 if ex != nil { 190 191 exCid = ex.Cid 191 192 } 192 193 193 194 // re-announce by registering under same rkey 194 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 195 196 Collection: tangled.KnotNSID, 196 197 Repo: user.Did, 197 198 Rkey: domain, ··· 322 323 return 323 324 } 324 325 325 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 326 327 Collection: tangled.KnotNSID, 327 328 Repo: user.Did, 328 329 Rkey: domain, ··· 430 431 return 431 432 } 432 433 433 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 434 435 var exCid *string 435 436 if ex != nil { 436 437 exCid = ex.Cid 437 438 } 438 439 439 440 // ignore the error here 440 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 441 442 Collection: tangled.KnotNSID, 442 443 Repo: user.Did, 443 444 Rkey: domain, ··· 554 555 555 556 rkey := tid.TID() 556 557 557 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 558 559 Collection: tangled.KnotMemberNSID, 559 560 Repo: user.Did, 560 561 Rkey: rkey,
+36 -28
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/appview/db" 14 + "tangled.org/core/appview/middleware" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/oauth" 17 + "tangled.org/core/appview/pages" 18 + "tangled.org/core/appview/validator" 19 + "tangled.org/core/log" 20 + "tangled.org/core/rbac" 21 + "tangled.org/core/tid" 22 + 12 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 13 25 "github.com/bluesky-social/indigo/atproto/syntax" 14 26 lexutil "github.com/bluesky-social/indigo/lex/util" 15 27 "github.com/go-chi/chi/v5" 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" 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 } 172 + 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 167 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 { ··· 188 196 return 189 197 } 190 198 191 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 192 200 Collection: tangled.LabelOpNSID, 193 201 Repo: did, 194 202 Rkey: rkey, ··· 244 252 // this is used to rollback changes made to the PDS 245 253 // 246 254 // it is a no-op if the provided ATURI is empty 247 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 248 256 if aturi == "" { 249 257 return nil 250 258 } ··· 255 263 repo := parsed.Authority().String() 256 264 rkey := parsed.RecordKey().String() 257 265 258 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 259 267 Collection: collection, 260 268 Repo: repo, 261 269 Rkey: rkey,
+12 -12
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 { ··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 49 returnURL := "/" ··· 63 63 } 64 64 } 65 65 66 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 67 67 if err != nil { 68 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 69 redirectFunc(w, r) 70 70 return 71 71 } 72 72 73 - if !auth { 74 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 75 redirectFunc(w, r) 76 76 return 77 77 }
+30
appview/models/artifact.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/ipfs/go-cid" 10 + "tangled.org/core/api/tangled" 11 + ) 12 + 13 + type Artifact struct { 14 + Id uint64 15 + Did string 16 + Rkey string 17 + 18 + RepoAt syntax.ATURI 19 + Tag plumbing.Hash 20 + CreatedAt time.Time 21 + 22 + BlobCid cid.Cid 23 + Name string 24 + Size uint64 25 + MimeType string 26 + } 27 + 28 + func (a *Artifact) ArtifactAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 30 + }
+21
appview/models/collaborator.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Collaborator struct { 10 + // identifiers for the record 11 + Id int64 12 + Did syntax.DID 13 + Rkey string 14 + 15 + // content 16 + SubjectDid syntax.DID 17 + RepoAt syntax.ATURI 18 + 19 + // meta 20 + Created time.Time 21 + }
+16
appview/models/email.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+38
appview/models/follow.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Follow struct { 8 + UserDid string 9 + SubjectDid string 10 + FollowedAt time.Time 11 + Rkey string 12 + } 13 + 14 + type FollowStats struct { 15 + Followers int64 16 + Following int64 17 + } 18 + 19 + type FollowStatus int 20 + 21 + const ( 22 + IsNotFollowing FollowStatus = iota 23 + IsFollowing 24 + IsSelf 25 + ) 26 + 27 + func (s FollowStatus) String() string { 28 + switch s { 29 + case IsNotFollowing: 30 + return "IsNotFollowing" 31 + case IsFollowing: 32 + return "IsFollowing" 33 + case IsSelf: 34 + return "IsSelf" 35 + default: 36 + return "IsNotFollowing" 37 + } 38 + }
+195
appview/models/issue.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Issue struct { 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + 25 + // optionally, populate this when querying for reverse mappings 26 + // like comment counts, parent repo etc. 27 + Comments []IssueComment 28 + ReactionCount int 29 + Labels LabelState 30 + Repo *Repo 31 + } 32 + 33 + func (i *Issue) AtUri() syntax.ATURI { 34 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 35 + } 36 + 37 + func (i *Issue) AsRecord() tangled.RepoIssue { 38 + return tangled.RepoIssue{ 39 + Repo: i.RepoAt.String(), 40 + Title: i.Title, 41 + Body: &i.Body, 42 + CreatedAt: i.Created.Format(time.RFC3339), 43 + } 44 + } 45 + 46 + func (i *Issue) State() string { 47 + if i.Open { 48 + return "open" 49 + } 50 + return "closed" 51 + } 52 + 53 + type CommentListItem struct { 54 + Self *IssueComment 55 + Replies []*IssueComment 56 + } 57 + 58 + func (i *Issue) CommentList() []CommentListItem { 59 + // Create a map to quickly find comments by their aturi 60 + toplevel := make(map[string]*CommentListItem) 61 + var replies []*IssueComment 62 + 63 + // collect top level comments into the map 64 + for _, comment := range i.Comments { 65 + if comment.IsTopLevel() { 66 + toplevel[comment.AtUri().String()] = &CommentListItem{ 67 + Self: &comment, 68 + } 69 + } else { 70 + replies = append(replies, &comment) 71 + } 72 + } 73 + 74 + for _, r := range replies { 75 + parentAt := *r.ReplyTo 76 + if parent, exists := toplevel[parentAt]; exists { 77 + parent.Replies = append(parent.Replies, r) 78 + } 79 + } 80 + 81 + var listing []CommentListItem 82 + for _, v := range toplevel { 83 + listing = append(listing, *v) 84 + } 85 + 86 + // sort everything 87 + sortFunc := func(a, b *IssueComment) bool { 88 + return a.Created.Before(b.Created) 89 + } 90 + sort.Slice(listing, func(i, j int) bool { 91 + return sortFunc(listing[i].Self, listing[j].Self) 92 + }) 93 + for _, r := range listing { 94 + sort.Slice(r.Replies, func(i, j int) bool { 95 + return sortFunc(r.Replies[i], r.Replies[j]) 96 + }) 97 + } 98 + 99 + return listing 100 + } 101 + 102 + func (i *Issue) Participants() []string { 103 + participantSet := make(map[string]struct{}) 104 + participants := []string{} 105 + 106 + addParticipant := func(did string) { 107 + if _, exists := participantSet[did]; !exists { 108 + participantSet[did] = struct{}{} 109 + participants = append(participants, did) 110 + } 111 + } 112 + 113 + addParticipant(i.Did) 114 + 115 + for _, c := range i.Comments { 116 + addParticipant(c.Did) 117 + } 118 + 119 + return participants 120 + } 121 + 122 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 123 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 124 + if err != nil { 125 + created = time.Now() 126 + } 127 + 128 + body := "" 129 + if record.Body != nil { 130 + body = *record.Body 131 + } 132 + 133 + return Issue{ 134 + RepoAt: syntax.ATURI(record.Repo), 135 + Did: did, 136 + Rkey: rkey, 137 + Created: created, 138 + Title: record.Title, 139 + Body: body, 140 + Open: true, // new issues are open by default 141 + } 142 + } 143 + 144 + type IssueComment struct { 145 + Id int64 146 + Did string 147 + Rkey string 148 + IssueAt string 149 + ReplyTo *string 150 + Body string 151 + Created time.Time 152 + Edited *time.Time 153 + Deleted *time.Time 154 + } 155 + 156 + func (i *IssueComment) AtUri() syntax.ATURI { 157 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 158 + } 159 + 160 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 161 + return tangled.RepoIssueComment{ 162 + Body: i.Body, 163 + Issue: i.IssueAt, 164 + CreatedAt: i.Created.Format(time.RFC3339), 165 + ReplyTo: i.ReplyTo, 166 + } 167 + } 168 + 169 + func (i *IssueComment) IsTopLevel() bool { 170 + return i.ReplyTo == nil 171 + } 172 + 173 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 174 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 175 + if err != nil { 176 + created = time.Now() 177 + } 178 + 179 + ownerDid := did 180 + 181 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 182 + return nil, err 183 + } 184 + 185 + comment := IssueComment{ 186 + Did: ownerDid, 187 + Rkey: rkey, 188 + Body: record.Body, 189 + IssueAt: record.Issue, 190 + ReplyTo: record.ReplyTo, 191 + Created: created, 192 + } 193 + 194 + return &comment, nil 195 + }
+542
appview/models/label.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "crypto/sha1" 6 + "encoding/hex" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "slices" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 19 + ) 20 + 21 + type ConcreteType string 22 + 23 + const ( 24 + ConcreteTypeNull ConcreteType = "null" 25 + ConcreteTypeString ConcreteType = "string" 26 + ConcreteTypeInt ConcreteType = "integer" 27 + ConcreteTypeBool ConcreteType = "boolean" 28 + ) 29 + 30 + type ValueTypeFormat string 31 + 32 + const ( 33 + ValueTypeFormatAny ValueTypeFormat = "any" 34 + ValueTypeFormatDid ValueTypeFormat = "did" 35 + ) 36 + 37 + // ValueType represents an atproto lexicon type definition with constraints 38 + type ValueType struct { 39 + Type ConcreteType `json:"type"` 40 + Format ValueTypeFormat `json:"format,omitempty"` 41 + Enum []string `json:"enum,omitempty"` 42 + } 43 + 44 + func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 45 + return tangled.LabelDefinition_ValueType{ 46 + Type: string(vt.Type), 47 + Format: string(vt.Format), 48 + Enum: vt.Enum, 49 + } 50 + } 51 + 52 + func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 53 + return ValueType{ 54 + Type: ConcreteType(record.Type), 55 + Format: ValueTypeFormat(record.Format), 56 + Enum: record.Enum, 57 + } 58 + } 59 + 60 + func (vt ValueType) IsConcreteType() bool { 61 + return vt.Type == ConcreteTypeNull || 62 + vt.Type == ConcreteTypeString || 63 + vt.Type == ConcreteTypeInt || 64 + vt.Type == ConcreteTypeBool 65 + } 66 + 67 + func (vt ValueType) IsNull() bool { 68 + return vt.Type == ConcreteTypeNull 69 + } 70 + 71 + func (vt ValueType) IsString() bool { 72 + return vt.Type == ConcreteTypeString 73 + } 74 + 75 + func (vt ValueType) IsInt() bool { 76 + return vt.Type == ConcreteTypeInt 77 + } 78 + 79 + func (vt ValueType) IsBool() bool { 80 + return vt.Type == ConcreteTypeBool 81 + } 82 + 83 + func (vt ValueType) IsEnum() bool { 84 + return len(vt.Enum) > 0 85 + } 86 + 87 + func (vt ValueType) IsDidFormat() bool { 88 + return vt.Format == ValueTypeFormatDid 89 + } 90 + 91 + func (vt ValueType) IsAnyFormat() bool { 92 + return vt.Format == ValueTypeFormatAny 93 + } 94 + 95 + type LabelDefinition struct { 96 + Id int64 97 + Did string 98 + Rkey string 99 + 100 + Name string 101 + ValueType ValueType 102 + Scope []string 103 + Color *string 104 + Multiple bool 105 + Created time.Time 106 + } 107 + 108 + func (l *LabelDefinition) AtUri() syntax.ATURI { 109 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 110 + } 111 + 112 + func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 113 + vt := l.ValueType.AsRecord() 114 + return tangled.LabelDefinition{ 115 + Name: l.Name, 116 + Color: l.Color, 117 + CreatedAt: l.Created.Format(time.RFC3339), 118 + Multiple: &l.Multiple, 119 + Scope: l.Scope, 120 + ValueType: &vt, 121 + } 122 + } 123 + 124 + // random color for a given seed 125 + func randomColor(seed string) string { 126 + hash := sha1.Sum([]byte(seed)) 127 + hexStr := hex.EncodeToString(hash[:]) 128 + r := hexStr[0:2] 129 + g := hexStr[2:4] 130 + b := hexStr[4:6] 131 + 132 + return fmt.Sprintf("#%s%s%s", r, g, b) 133 + } 134 + 135 + func (ld LabelDefinition) GetColor() string { 136 + if ld.Color == nil { 137 + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 138 + color := randomColor(seed) 139 + return color 140 + } 141 + 142 + return *ld.Color 143 + } 144 + 145 + func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 146 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 147 + if err != nil { 148 + created = time.Now() 149 + } 150 + 151 + multiple := false 152 + if record.Multiple != nil { 153 + multiple = *record.Multiple 154 + } 155 + 156 + var vt ValueType 157 + if record.ValueType != nil { 158 + vt = ValueTypeFromRecord(*record.ValueType) 159 + } 160 + 161 + return &LabelDefinition{ 162 + Did: did, 163 + Rkey: rkey, 164 + 165 + Name: record.Name, 166 + ValueType: vt, 167 + Scope: record.Scope, 168 + Color: record.Color, 169 + Multiple: multiple, 170 + Created: created, 171 + }, nil 172 + } 173 + 174 + type LabelOp struct { 175 + Id int64 176 + Did string 177 + Rkey string 178 + Subject syntax.ATURI 179 + Operation LabelOperation 180 + OperandKey string 181 + OperandValue string 182 + PerformedAt time.Time 183 + IndexedAt time.Time 184 + } 185 + 186 + func (l LabelOp) SortAt() time.Time { 187 + createdAt := l.PerformedAt 188 + indexedAt := l.IndexedAt 189 + 190 + // if we don't have an indexedat, fall back to now 191 + if indexedAt.IsZero() { 192 + indexedAt = time.Now() 193 + } 194 + 195 + // if createdat is invalid (before epoch), treat as null -> return zero time 196 + if createdAt.Before(time.UnixMicro(0)) { 197 + return time.Time{} 198 + } 199 + 200 + // if createdat is <= indexedat, use createdat 201 + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 202 + return createdAt 203 + } 204 + 205 + // otherwise, createdat is in the future relative to indexedat -> use indexedat 206 + return indexedAt 207 + } 208 + 209 + type LabelOperation string 210 + 211 + const ( 212 + LabelOperationAdd LabelOperation = "add" 213 + LabelOperationDel LabelOperation = "del" 214 + ) 215 + 216 + // a record can create multiple label ops 217 + func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 218 + performed, err := time.Parse(time.RFC3339, record.PerformedAt) 219 + if err != nil { 220 + performed = time.Now() 221 + } 222 + 223 + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 224 + return LabelOp{ 225 + Did: did, 226 + Rkey: rkey, 227 + Subject: syntax.ATURI(record.Subject), 228 + OperandKey: operand.Key, 229 + OperandValue: operand.Value, 230 + PerformedAt: performed, 231 + } 232 + } 233 + 234 + var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 + if o != nil { 238 + op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 + ops = append(ops, op) 241 + } 242 + } 243 + for _, o := range record.Add { 244 + if o != nil { 245 + op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 + ops = append(ops, op) 248 + } 249 + } 250 + 251 + return ops 252 + } 253 + 254 + func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 255 + if len(ops) == 0 { 256 + return tangled.LabelOp{} 257 + } 258 + 259 + // use the first operation to establish common fields 260 + first := ops[0] 261 + record := tangled.LabelOp{ 262 + Subject: string(first.Subject), 263 + PerformedAt: first.PerformedAt.Format(time.RFC3339), 264 + } 265 + 266 + var addOperands []*tangled.LabelOp_Operand 267 + var deleteOperands []*tangled.LabelOp_Operand 268 + 269 + for _, op := range ops { 270 + operand := &tangled.LabelOp_Operand{ 271 + Key: op.OperandKey, 272 + Value: op.OperandValue, 273 + } 274 + 275 + switch op.Operation { 276 + case LabelOperationAdd: 277 + addOperands = append(addOperands, operand) 278 + case LabelOperationDel: 279 + deleteOperands = append(deleteOperands, operand) 280 + default: 281 + return tangled.LabelOp{} 282 + } 283 + } 284 + 285 + record.Add = addOperands 286 + record.Delete = deleteOperands 287 + 288 + return record 289 + } 290 + 291 + type set = map[string]struct{} 292 + 293 + type LabelState struct { 294 + inner map[string]set 295 + } 296 + 297 + func NewLabelState() LabelState { 298 + return LabelState{ 299 + inner: make(map[string]set), 300 + } 301 + } 302 + 303 + func (s LabelState) Inner() map[string]set { 304 + return s.inner 305 + } 306 + 307 + func (s LabelState) ContainsLabel(l string) bool { 308 + if valset, exists := s.inner[l]; exists { 309 + if valset != nil { 310 + return true 311 + } 312 + } 313 + 314 + return false 315 + } 316 + 317 + // go maps behavior in templates make this necessary, 318 + // indexing a map and getting `set` in return is apparently truthy 319 + func (s LabelState) ContainsLabelAndVal(l, v string) bool { 320 + if valset, exists := s.inner[l]; exists { 321 + if _, exists := valset[v]; exists { 322 + return true 323 + } 324 + } 325 + 326 + return false 327 + } 328 + 329 + func (s LabelState) GetValSet(l string) set { 330 + if valset, exists := s.inner[l]; exists { 331 + return valset 332 + } else { 333 + return make(set) 334 + } 335 + } 336 + 337 + type LabelApplicationCtx struct { 338 + Defs map[string]*LabelDefinition // labelAt -> labelDef 339 + } 340 + 341 + var ( 342 + LabelNoOpError = errors.New("no-op") 343 + ) 344 + 345 + func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 346 + def, ok := c.Defs[op.OperandKey] 347 + if !ok { 348 + // this def was deleted, but an op exists, so we just skip over the op 349 + return nil 350 + } 351 + 352 + switch op.Operation { 353 + case LabelOperationAdd: 354 + // if valueset is empty, init it 355 + if state.inner[op.OperandKey] == nil { 356 + state.inner[op.OperandKey] = make(set) 357 + } 358 + 359 + // if valueset is populated & this val alr exists, this labelop is a noop 360 + if valueSet, exists := state.inner[op.OperandKey]; exists { 361 + if _, exists = valueSet[op.OperandValue]; exists { 362 + return LabelNoOpError 363 + } 364 + } 365 + 366 + if def.Multiple { 367 + // append to set 368 + state.inner[op.OperandKey][op.OperandValue] = struct{}{} 369 + } else { 370 + // reset to just this value 371 + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 372 + } 373 + 374 + case LabelOperationDel: 375 + // if label DNE, then deletion is a no-op 376 + if valueSet, exists := state.inner[op.OperandKey]; !exists { 377 + return LabelNoOpError 378 + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 379 + return LabelNoOpError 380 + } 381 + 382 + if def.Multiple { 383 + // remove from set 384 + delete(state.inner[op.OperandKey], op.OperandValue) 385 + } else { 386 + // reset the entire label 387 + delete(state.inner, op.OperandKey) 388 + } 389 + 390 + // if the map becomes empty, then set it to nil, this is just the inverse of add 391 + if len(state.inner[op.OperandKey]) == 0 { 392 + state.inner[op.OperandKey] = nil 393 + } 394 + 395 + } 396 + 397 + return nil 398 + } 399 + 400 + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 401 + // sort label ops in sort order first 402 + slices.SortFunc(ops, func(a, b LabelOp) int { 403 + return a.SortAt().Compare(b.SortAt()) 404 + }) 405 + 406 + // apply ops in sequence 407 + for _, o := range ops { 408 + _ = c.ApplyLabelOp(state, o) 409 + } 410 + } 411 + 412 + // IsInverse checks if one label operation is the inverse of another 413 + // returns true if one is an add and the other is a delete with the same key and value 414 + func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 415 + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 416 + return false 417 + } 418 + 419 + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 420 + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 421 + } 422 + 423 + // removes pairs of label operations that are inverses of each other 424 + // from the given slice. the function preserves the order of remaining operations. 425 + func ReduceLabelOps(ops []LabelOp) []LabelOp { 426 + if len(ops) <= 1 { 427 + return ops 428 + } 429 + 430 + keep := make([]bool, len(ops)) 431 + for i := range keep { 432 + keep[i] = true 433 + } 434 + 435 + for i := range ops { 436 + if !keep[i] { 437 + continue 438 + } 439 + 440 + for j := i + 1; j < len(ops); j++ { 441 + if !keep[j] { 442 + continue 443 + } 444 + 445 + if ops[i].IsInverse(ops[j]) { 446 + keep[i] = false 447 + keep[j] = false 448 + break // move to next i since this one is now eliminated 449 + } 450 + } 451 + } 452 + 453 + // build result slice with only kept operations 454 + var result []LabelOp 455 + for i, op := range ops { 456 + if keep[i] { 457 + result = append(result, op) 458 + } 459 + } 460 + 461 + return result 462 + } 463 + 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 + func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 + } 480 + } 481 + 482 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 + if err != nil { 485 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 + } 487 + pdsEndpoint := resolved.PDSEndpoint() 488 + if pdsEndpoint == "" { 489 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 + } 491 + client := &xrpc.Client{ 492 + Host: pdsEndpoint, 493 + } 494 + 495 + var labelDefs []LabelDefinition 496 + 497 + for _, dl := range DefaultLabelDefs() { 498 + atUri := syntax.ATURI(dl) 499 + parsedUri, err := syntax.ParseATURI(string(atUri)) 500 + if err != nil { 501 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 + } 503 + record, err := atproto.RepoGetRecord( 504 + context.Background(), 505 + client, 506 + "", 507 + parsedUri.Collection().String(), 508 + parsedUri.Authority().String(), 509 + parsedUri.RecordKey().String(), 510 + ) 511 + if err != nil { 512 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 513 + } 514 + 515 + if record != nil { 516 + bytes, err := record.Value.MarshalJSON() 517 + if err != nil { 518 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 519 + } 520 + 521 + raw := json.RawMessage(bytes) 522 + labelRecord := tangled.LabelDefinition{} 523 + err = json.Unmarshal(raw, &labelRecord) 524 + if err != nil { 525 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 526 + } 527 + 528 + labelDef, err := LabelDefinitionFromRecord( 529 + parsedUri.Authority().String(), 530 + parsedUri.RecordKey().String(), 531 + labelRecord, 532 + ) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 535 + } 536 + 537 + labelDefs = append(labelDefs, *labelDef) 538 + } 539 + } 540 + 541 + return labelDefs, nil 542 + }
+14
appview/models/language.go
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
+82
appview/models/notifications.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type NotificationType string 8 + 9 + const ( 10 + NotificationTypeRepoStarred NotificationType = "repo_starred" 11 + NotificationTypeIssueCreated NotificationType = "issue_created" 12 + NotificationTypeIssueCommented NotificationType = "issue_commented" 13 + NotificationTypePullCreated NotificationType = "pull_created" 14 + NotificationTypePullCommented NotificationType = "pull_commented" 15 + NotificationTypeFollowed NotificationType = "followed" 16 + NotificationTypePullMerged NotificationType = "pull_merged" 17 + NotificationTypeIssueClosed NotificationType = "issue_closed" 18 + NotificationTypePullClosed NotificationType = "pull_closed" 19 + ) 20 + 21 + type Notification struct { 22 + ID int64 23 + RecipientDid string 24 + ActorDid string 25 + Type NotificationType 26 + EntityType string 27 + EntityId string 28 + Read bool 29 + Created time.Time 30 + 31 + // foreign key references 32 + RepoId *int64 33 + IssueId *int64 34 + PullId *int64 35 + } 36 + 37 + // lucide icon that represents this notification 38 + func (n *Notification) Icon() string { 39 + switch n.Type { 40 + case NotificationTypeRepoStarred: 41 + return "star" 42 + case NotificationTypeIssueCreated: 43 + return "circle-dot" 44 + case NotificationTypeIssueCommented: 45 + return "message-square" 46 + case NotificationTypeIssueClosed: 47 + return "ban" 48 + case NotificationTypePullCreated: 49 + return "git-pull-request-create" 50 + case NotificationTypePullCommented: 51 + return "message-square" 52 + case NotificationTypePullMerged: 53 + return "git-merge" 54 + case NotificationTypePullClosed: 55 + return "git-pull-request-closed" 56 + case NotificationTypeFollowed: 57 + return "user-plus" 58 + default: 59 + return "" 60 + } 61 + } 62 + 63 + type NotificationWithEntity struct { 64 + *Notification 65 + Repo *Repo 66 + Issue *Issue 67 + Pull *Pull 68 + } 69 + 70 + type NotificationPreferences struct { 71 + ID int64 72 + UserDid string 73 + RepoStarred bool 74 + IssueCreated bool 75 + IssueCommented bool 76 + PullCreated bool 77 + PullCommented bool 78 + Followed bool 79 + PullMerged bool 80 + IssueClosed bool 81 + EmailNotifications bool 82 + }
+130
appview/models/pipeline.go
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + spindle "tangled.org/core/spindle/models" 10 + "tangled.org/core/workflow" 11 + ) 12 + 13 + type Pipeline struct { 14 + Id int 15 + Rkey string 16 + Knot string 17 + RepoOwner syntax.DID 18 + RepoName string 19 + TriggerId int 20 + Sha string 21 + Created time.Time 22 + 23 + // populate when querying for reverse mappings 24 + Trigger *Trigger 25 + Statuses map[string]WorkflowStatus 26 + } 27 + 28 + type WorkflowStatus struct { 29 + Data []PipelineStatus 30 + } 31 + 32 + func (w WorkflowStatus) Latest() PipelineStatus { 33 + return w.Data[len(w.Data)-1] 34 + } 35 + 36 + // time taken by this workflow to reach an "end state" 37 + func (w WorkflowStatus) TimeTaken() time.Duration { 38 + var start, end *time.Time 39 + for _, s := range w.Data { 40 + if s.Status.IsStart() { 41 + start = &s.Created 42 + } 43 + if s.Status.IsFinish() { 44 + end = &s.Created 45 + } 46 + } 47 + 48 + if start != nil && end != nil && end.After(*start) { 49 + return end.Sub(*start) 50 + } 51 + 52 + return 0 53 + } 54 + 55 + func (p Pipeline) Counts() map[string]int { 56 + m := make(map[string]int) 57 + for _, w := range p.Statuses { 58 + m[w.Latest().Status.String()] += 1 59 + } 60 + return m 61 + } 62 + 63 + func (p Pipeline) TimeTaken() time.Duration { 64 + var s time.Duration 65 + for _, w := range p.Statuses { 66 + s += w.TimeTaken() 67 + } 68 + return s 69 + } 70 + 71 + func (p Pipeline) Workflows() []string { 72 + var ws []string 73 + for v := range p.Statuses { 74 + ws = append(ws, v) 75 + } 76 + slices.Sort(ws) 77 + return ws 78 + } 79 + 80 + // if we know that a spindle has picked up this pipeline, then it is Responding 81 + func (p Pipeline) IsResponding() bool { 82 + return len(p.Statuses) != 0 83 + } 84 + 85 + type Trigger struct { 86 + Id int 87 + Kind workflow.TriggerKind 88 + 89 + // push trigger fields 90 + PushRef *string 91 + PushNewSha *string 92 + PushOldSha *string 93 + 94 + // pull request trigger fields 95 + PRSourceBranch *string 96 + PRTargetBranch *string 97 + PRSourceSha *string 98 + PRAction *string 99 + } 100 + 101 + func (t *Trigger) IsPush() bool { 102 + return t != nil && t.Kind == workflow.TriggerKindPush 103 + } 104 + 105 + func (t *Trigger) IsPullRequest() bool { 106 + return t != nil && t.Kind == workflow.TriggerKindPullRequest 107 + } 108 + 109 + func (t *Trigger) TargetRef() string { 110 + if t.IsPush() { 111 + return plumbing.ReferenceName(*t.PushRef).Short() 112 + } else if t.IsPullRequest() { 113 + return *t.PRTargetBranch 114 + } 115 + 116 + return "" 117 + } 118 + 119 + type PipelineStatus struct { 120 + ID int 121 + Spindle string 122 + Rkey string 123 + PipelineKnot string 124 + PipelineRkey string 125 + Created time.Time 126 + Workflow string 127 + Status spindle.StatusKind 128 + Error *string 129 + ExitCode int 130 + }
+177
appview/models/profile.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + type Profile struct { 11 + // ids 12 + ID int 13 + Did string 14 + 15 + // data 16 + Description string 17 + IncludeBluesky bool 18 + Location string 19 + Links [5]string 20 + Stats [2]VanityStat 21 + PinnedRepos [6]syntax.ATURI 22 + } 23 + 24 + func (p Profile) IsLinksEmpty() bool { 25 + for _, l := range p.Links { 26 + if l != "" { 27 + return false 28 + } 29 + } 30 + return true 31 + } 32 + 33 + func (p Profile) IsStatsEmpty() bool { 34 + for _, s := range p.Stats { 35 + if s.Kind != "" { 36 + return false 37 + } 38 + } 39 + return true 40 + } 41 + 42 + func (p Profile) IsPinnedReposEmpty() bool { 43 + for _, r := range p.PinnedRepos { 44 + if r != "" { 45 + return false 46 + } 47 + } 48 + return true 49 + } 50 + 51 + type VanityStatKind string 52 + 53 + const ( 54 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 55 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 56 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 57 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 58 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 59 + VanityStatRepositoryCount VanityStatKind = "repository-count" 60 + ) 61 + 62 + func (v VanityStatKind) String() string { 63 + switch v { 64 + case VanityStatMergedPRCount: 65 + return "Merged PRs" 66 + case VanityStatClosedPRCount: 67 + return "Closed PRs" 68 + case VanityStatOpenPRCount: 69 + return "Open PRs" 70 + case VanityStatOpenIssueCount: 71 + return "Open Issues" 72 + case VanityStatClosedIssueCount: 73 + return "Closed Issues" 74 + case VanityStatRepositoryCount: 75 + return "Repositories" 76 + } 77 + return "" 78 + } 79 + 80 + type VanityStat struct { 81 + Kind VanityStatKind 82 + Value uint64 83 + } 84 + 85 + func (p *Profile) ProfileAt() syntax.ATURI { 86 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 87 + } 88 + 89 + type RepoEvent struct { 90 + Repo *Repo 91 + Source *Repo 92 + } 93 + 94 + type ProfileTimeline struct { 95 + ByMonth []ByMonth 96 + } 97 + 98 + func (p *ProfileTimeline) IsEmpty() bool { 99 + if p == nil { 100 + return true 101 + } 102 + 103 + for _, m := range p.ByMonth { 104 + if !m.IsEmpty() { 105 + return false 106 + } 107 + } 108 + 109 + return true 110 + } 111 + 112 + type ByMonth struct { 113 + RepoEvents []RepoEvent 114 + IssueEvents IssueEvents 115 + PullEvents PullEvents 116 + } 117 + 118 + func (b ByMonth) IsEmpty() bool { 119 + return len(b.RepoEvents) == 0 && 120 + len(b.IssueEvents.Items) == 0 && 121 + len(b.PullEvents.Items) == 0 122 + } 123 + 124 + type IssueEvents struct { 125 + Items []*Issue 126 + } 127 + 128 + type IssueEventStats struct { 129 + Open int 130 + Closed int 131 + } 132 + 133 + func (i IssueEvents) Stats() IssueEventStats { 134 + var open, closed int 135 + for _, issue := range i.Items { 136 + if issue.Open { 137 + open += 1 138 + } else { 139 + closed += 1 140 + } 141 + } 142 + 143 + return IssueEventStats{ 144 + Open: open, 145 + Closed: closed, 146 + } 147 + } 148 + 149 + type PullEvents struct { 150 + Items []*Pull 151 + } 152 + 153 + func (p PullEvents) Stats() PullEventStats { 154 + var open, merged, closed int 155 + for _, pull := range p.Items { 156 + switch pull.State { 157 + case PullOpen: 158 + open += 1 159 + case PullMerged: 160 + merged += 1 161 + case PullClosed: 162 + closed += 1 163 + } 164 + } 165 + 166 + return PullEventStats{ 167 + Open: open, 168 + Merged: merged, 169 + Closed: closed, 170 + } 171 + } 172 + 173 + type PullEventStats struct { 174 + Closed int 175 + Open int 176 + Merged int 177 + }
+25
appview/models/pubkey.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + type PublicKey struct { 9 + Did string `json:"did"` 10 + Key string `json:"key"` 11 + Name string `json:"name"` 12 + Rkey string `json:"rkey"` 13 + Created *time.Time 14 + } 15 + 16 + func (p PublicKey) MarshalJSON() ([]byte, error) { 17 + type Alias PublicKey 18 + return json.Marshal(&struct { 19 + Created string `json:"created"` 20 + *Alias 21 + }{ 22 + Created: p.Created.Format(time.RFC3339), 23 + Alias: (*Alias)(&p), 24 + }) 25 + }
+352
appview/models/pull.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "slices" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/patchutil" 13 + "tangled.org/core/types" 14 + ) 15 + 16 + type PullState int 17 + 18 + const ( 19 + PullClosed PullState = iota 20 + PullOpen 21 + PullMerged 22 + PullDeleted 23 + ) 24 + 25 + func (p PullState) String() string { 26 + switch p { 27 + case PullOpen: 28 + return "open" 29 + case PullMerged: 30 + return "merged" 31 + case PullClosed: 32 + return "closed" 33 + case PullDeleted: 34 + return "deleted" 35 + default: 36 + return "closed" 37 + } 38 + } 39 + 40 + func (p PullState) IsOpen() bool { 41 + return p == PullOpen 42 + } 43 + func (p PullState) IsMerged() bool { 44 + return p == PullMerged 45 + } 46 + func (p PullState) IsClosed() bool { 47 + return p == PullClosed 48 + } 49 + func (p PullState) IsDeleted() bool { 50 + return p == PullDeleted 51 + } 52 + 53 + type Pull struct { 54 + // ids 55 + ID int 56 + PullId int 57 + 58 + // at ids 59 + RepoAt syntax.ATURI 60 + OwnerDid string 61 + Rkey string 62 + 63 + // content 64 + Title string 65 + Body string 66 + TargetBranch string 67 + State PullState 68 + Submissions []*PullSubmission 69 + 70 + // stacking 71 + StackId string // nullable string 72 + ChangeId string // nullable string 73 + ParentChangeId string // nullable string 74 + 75 + // meta 76 + Created time.Time 77 + PullSource *PullSource 78 + 79 + // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 + } 83 + 84 + func (p Pull) AsRecord() tangled.RepoPull { 85 + var source *tangled.RepoPull_Source 86 + if p.PullSource != nil { 87 + s := p.PullSource.AsRecord() 88 + source = &s 89 + source.Sha = p.LatestSha() 90 + } 91 + 92 + record := tangled.RepoPull{ 93 + Title: p.Title, 94 + Body: &p.Body, 95 + CreatedAt: p.Created.Format(time.RFC3339), 96 + Target: &tangled.RepoPull_Target{ 97 + Repo: p.RepoAt.String(), 98 + Branch: p.TargetBranch, 99 + }, 100 + Patch: p.LatestPatch(), 101 + Source: source, 102 + } 103 + return record 104 + } 105 + 106 + type PullSource struct { 107 + Branch string 108 + RepoAt *syntax.ATURI 109 + 110 + // optionally populate this for reverse mappings 111 + Repo *Repo 112 + } 113 + 114 + func (p PullSource) AsRecord() tangled.RepoPull_Source { 115 + var repoAt *string 116 + if p.RepoAt != nil { 117 + s := p.RepoAt.String() 118 + repoAt = &s 119 + } 120 + record := tangled.RepoPull_Source{ 121 + Branch: p.Branch, 122 + Repo: repoAt, 123 + } 124 + return record 125 + } 126 + 127 + type PullSubmission struct { 128 + // ids 129 + ID int 130 + 131 + // at ids 132 + PullAt syntax.ATURI 133 + 134 + // content 135 + RoundNumber int 136 + Patch string 137 + Comments []PullComment 138 + SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 + 140 + // meta 141 + Created time.Time 142 + } 143 + 144 + type PullComment struct { 145 + // ids 146 + ID int 147 + PullId int 148 + SubmissionId int 149 + 150 + // at ids 151 + RepoAt string 152 + OwnerDid string 153 + CommentAt string 154 + 155 + // content 156 + Body string 157 + 158 + // meta 159 + Created time.Time 160 + } 161 + 162 + func (p *Pull) LatestPatch() string { 163 + latestSubmission := p.Submissions[p.LastRoundNumber()] 164 + return latestSubmission.Patch 165 + } 166 + 167 + func (p *Pull) LatestSha() string { 168 + latestSubmission := p.Submissions[p.LastRoundNumber()] 169 + return latestSubmission.SourceRev 170 + } 171 + 172 + func (p *Pull) PullAt() syntax.ATURI { 173 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 + } 175 + 176 + func (p *Pull) LastRoundNumber() int { 177 + return len(p.Submissions) - 1 178 + } 179 + 180 + func (p *Pull) IsPatchBased() bool { 181 + return p.PullSource == nil 182 + } 183 + 184 + func (p *Pull) IsBranchBased() bool { 185 + if p.PullSource != nil { 186 + if p.PullSource.RepoAt != nil { 187 + return p.PullSource.RepoAt == &p.RepoAt 188 + } else { 189 + // no repo specified 190 + return true 191 + } 192 + } 193 + return false 194 + } 195 + 196 + func (p *Pull) IsForkBased() bool { 197 + if p.PullSource != nil { 198 + if p.PullSource.RepoAt != nil { 199 + // make sure repos are different 200 + return p.PullSource.RepoAt != &p.RepoAt 201 + } 202 + } 203 + return false 204 + } 205 + 206 + func (p *Pull) IsStacked() bool { 207 + return p.StackId != "" 208 + } 209 + 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 + func (s PullSubmission) IsFormatPatch() bool { 233 + return patchutil.IsFormatPatch(s.Patch) 234 + } 235 + 236 + func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 237 + patches, err := patchutil.ExtractPatches(s.Patch) 238 + if err != nil { 239 + log.Println("error extracting patches from submission:", err) 240 + return []types.FormatPatch{} 241 + } 242 + 243 + return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 + } 265 + 266 + type Stack []*Pull 267 + 268 + // position of this pull in the stack 269 + func (stack Stack) Position(pull *Pull) int { 270 + return slices.IndexFunc(stack, func(p *Pull) bool { 271 + return p.ChangeId == pull.ChangeId 272 + }) 273 + } 274 + 275 + // all pulls below this pull (including self) in this stack 276 + // 277 + // nil if this pull does not belong to this stack 278 + func (stack Stack) Below(pull *Pull) Stack { 279 + position := stack.Position(pull) 280 + 281 + if position < 0 { 282 + return nil 283 + } 284 + 285 + return stack[position:] 286 + } 287 + 288 + // all pulls below this pull (excluding self) in this stack 289 + func (stack Stack) StrictlyBelow(pull *Pull) Stack { 290 + below := stack.Below(pull) 291 + 292 + if len(below) > 0 { 293 + return below[1:] 294 + } 295 + 296 + return nil 297 + } 298 + 299 + // all pulls above this pull (including self) in this stack 300 + func (stack Stack) Above(pull *Pull) Stack { 301 + position := stack.Position(pull) 302 + 303 + if position < 0 { 304 + return nil 305 + } 306 + 307 + return stack[:position+1] 308 + } 309 + 310 + // all pulls below this pull (excluding self) in this stack 311 + func (stack Stack) StrictlyAbove(pull *Pull) Stack { 312 + above := stack.Above(pull) 313 + 314 + if len(above) > 0 { 315 + return above[:len(above)-1] 316 + } 317 + 318 + return nil 319 + } 320 + 321 + // the combined format-patches of all the newest submissions in this stack 322 + func (stack Stack) CombinedPatch() string { 323 + // go in reverse order because the bottom of the stack is the last element in the slice 324 + var combined strings.Builder 325 + for idx := range stack { 326 + pull := stack[len(stack)-1-idx] 327 + combined.WriteString(pull.LatestPatch()) 328 + combined.WriteString("\n") 329 + } 330 + return combined.String() 331 + } 332 + 333 + // filter out PRs that are "active" 334 + // 335 + // PRs that are still open are active 336 + func (stack Stack) Mergeable() Stack { 337 + var mergeable Stack 338 + 339 + for _, p := range stack { 340 + // stop at the first merged PR 341 + if p.State == PullMerged || p.State == PullClosed { 342 + break 343 + } 344 + 345 + // skip over deleted PRs 346 + if p.State != PullDeleted { 347 + mergeable = append(mergeable, p) 348 + } 349 + } 350 + 351 + return mergeable 352 + }
+14
appview/models/punchcard.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+62
appview/models/reaction.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type ReactionKind string 10 + 11 + const ( 12 + Like ReactionKind = "👍" 13 + Unlike ReactionKind = "👎" 14 + Laugh ReactionKind = "😆" 15 + Celebration ReactionKind = "🎉" 16 + Confused ReactionKind = "🫤" 17 + Heart ReactionKind = "❤️" 18 + Rocket ReactionKind = "🚀" 19 + Eyes ReactionKind = "👀" 20 + ) 21 + 22 + func (rk ReactionKind) String() string { 23 + return string(rk) 24 + } 25 + 26 + var OrderedReactionKinds = []ReactionKind{ 27 + Like, 28 + Unlike, 29 + Laugh, 30 + Celebration, 31 + Confused, 32 + Heart, 33 + Rocket, 34 + Eyes, 35 + } 36 + 37 + func ParseReactionKind(raw string) (ReactionKind, bool) { 38 + k, ok := (map[string]ReactionKind{ 39 + "👍": Like, 40 + "👎": Unlike, 41 + "😆": Laugh, 42 + "🎉": Celebration, 43 + "🫤": Confused, 44 + "❤️": Heart, 45 + "🚀": Rocket, 46 + "👀": Eyes, 47 + })[raw] 48 + return k, ok 49 + } 50 + 51 + type Reaction struct { 52 + ReactedByDid string 53 + ThreadAt syntax.ATURI 54 + Created time.Time 55 + Rkey string 56 + Kind ReactionKind 57 + } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+44
appview/models/registration.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Registration represents a knot registration. Knot would've been a better 6 + // name but we're stuck with this for historical reasons. 7 + type Registration struct { 8 + Id int64 9 + Domain string 10 + ByDid string 11 + Created *time.Time 12 + Registered *time.Time 13 + NeedsUpgrade bool 14 + } 15 + 16 + func (r *Registration) Status() Status { 17 + if r.NeedsUpgrade { 18 + return NeedsUpgrade 19 + } else if r.Registered != nil { 20 + return Registered 21 + } else { 22 + return Pending 23 + } 24 + } 25 + 26 + func (r *Registration) IsRegistered() bool { 27 + return r.Status() == Registered 28 + } 29 + 30 + func (r *Registration) IsNeedsUpgrade() bool { 31 + return r.Status() == NeedsUpgrade 32 + } 33 + 34 + func (r *Registration) IsPending() bool { 35 + return r.Status() == Pending 36 + } 37 + 38 + type Status uint32 39 + 40 + const ( 41 + Registered Status = iota 42 + Pending 43 + NeedsUpgrade 44 + )
+93
appview/models/repo.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + securejoin "github.com/cyphar/filepath-securejoin" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Repo struct { 13 + Id int64 14 + Did string 15 + Name string 16 + Knot string 17 + Rkey string 18 + Created time.Time 19 + Description string 20 + Spindle string 21 + Labels []string 22 + 23 + // optionally, populate this when querying for reverse mappings 24 + RepoStats *RepoStats 25 + 26 + // optional 27 + Source string 28 + } 29 + 30 + func (r *Repo) AsRecord() tangled.Repo { 31 + var source, spindle, description *string 32 + 33 + if r.Source != "" { 34 + source = &r.Source 35 + } 36 + 37 + if r.Spindle != "" { 38 + spindle = &r.Spindle 39 + } 40 + 41 + if r.Description != "" { 42 + description = &r.Description 43 + } 44 + 45 + return tangled.Repo{ 46 + Knot: r.Knot, 47 + Name: r.Name, 48 + Description: description, 49 + CreatedAt: r.Created.Format(time.RFC3339), 50 + Source: source, 51 + Spindle: spindle, 52 + Labels: r.Labels, 53 + } 54 + } 55 + 56 + func (r Repo) RepoAt() syntax.ATURI { 57 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 58 + } 59 + 60 + func (r Repo) DidSlashRepo() string { 61 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 + return p 63 + } 64 + 65 + type RepoStats struct { 66 + Language string 67 + StarCount int 68 + IssueCount IssueCount 69 + PullCount PullCount 70 + } 71 + 72 + type IssueCount struct { 73 + Open int 74 + Closed int 75 + } 76 + 77 + type PullCount struct { 78 + Open int 79 + Merged int 80 + Closed int 81 + Deleted int 82 + } 83 + 84 + type RepoLabel struct { 85 + Id int64 86 + RepoAt syntax.ATURI 87 + LabelAt syntax.ATURI 88 + } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+10
appview/models/signup.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+25
appview/models/spindle.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Spindle struct { 10 + Id int 11 + Owner syntax.DID 12 + Instance string 13 + Verified *time.Time 14 + Created time.Time 15 + NeedsUpgrade bool 16 + } 17 + 18 + type SpindleMember struct { 19 + Id int 20 + Did syntax.DID // owner of the record 21 + Rkey string // rkey of the record 22 + Instance string 23 + Subject syntax.DID // the member being added 24 + Created time.Time 25 + }
+17
appview/models/star.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Star struct { 10 + StarredByDid string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + 15 + // optionally, populate this when querying for reverse mappings 16 + Repo *Repo 17 + }
+95
appview/models/string.go
··· 1 + package models 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + type String struct { 15 + Did syntax.DID 16 + Rkey string 17 + 18 + Filename string 19 + Description string 20 + Contents string 21 + Created time.Time 22 + Edited *time.Time 23 + } 24 + 25 + func (s *String) StringAt() syntax.ATURI { 26 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 + } 28 + 29 + func (s *String) AsRecord() tangled.String { 30 + return tangled.String{ 31 + Filename: s.Filename, 32 + Description: s.Description, 33 + Contents: s.Contents, 34 + CreatedAt: s.Created.Format(time.RFC3339), 35 + } 36 + } 37 + 38 + func StringFromRecord(did, rkey string, record tangled.String) String { 39 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 40 + if err != nil { 41 + created = time.Now() 42 + } 43 + return String{ 44 + Did: syntax.DID(did), 45 + Rkey: rkey, 46 + Filename: record.Filename, 47 + Description: record.Description, 48 + Contents: record.Contents, 49 + Created: created, 50 + } 51 + } 52 + 53 + type StringStats struct { 54 + LineCount uint64 55 + ByteCount uint64 56 + } 57 + 58 + func (s String) Stats() StringStats { 59 + lineCount, err := countLines(strings.NewReader(s.Contents)) 60 + if err != nil { 61 + // non-fatal 62 + // TODO: log this? 63 + } 64 + 65 + return StringStats{ 66 + LineCount: uint64(lineCount), 67 + ByteCount: uint64(len(s.Contents)), 68 + } 69 + } 70 + 71 + func countLines(r io.Reader) (int, error) { 72 + buf := make([]byte, 32*1024) 73 + bufLen := 0 74 + count := 0 75 + nl := []byte{'\n'} 76 + 77 + for { 78 + c, err := r.Read(buf) 79 + if c > 0 { 80 + bufLen += c 81 + } 82 + count += bytes.Count(buf[:c], nl) 83 + 84 + switch { 85 + case err == io.EOF: 86 + /* handle last line not having a newline at the end */ 87 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 88 + count++ 89 + } 90 + return count, nil 91 + case err != nil: 92 + return 0, err 93 + } 94 + } 95 + }
+23
appview/models/timeline.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type TimelineEvent struct { 6 + *Repo 7 + *Follow 8 + *Star 9 + 10 + EventAt time.Time 11 + 12 + // optional: populate only if Repo is a fork 13 + Source *Repo 14 + 15 + // optional: populate only if event is Follow 16 + *Profile 17 + *FollowStats 18 + *FollowStatus 19 + 20 + // optional: populate only if event is Repo 21 + IsStarred bool 22 + StarCount int64 23 + }
+166
appview/notifications/notifications.go
··· 1 + package notifications 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/middleware" 11 + "tangled.org/core/appview/oauth" 12 + "tangled.org/core/appview/pages" 13 + "tangled.org/core/appview/pagination" 14 + ) 15 + 16 + type Notifications struct { 17 + db *db.DB 18 + oauth *oauth.OAuth 19 + pages *pages.Pages 20 + } 21 + 22 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 23 + return &Notifications{ 24 + db: database, 25 + oauth: oauthHandler, 26 + pages: pagesHandler, 27 + } 28 + } 29 + 30 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 31 + r := chi.NewRouter() 32 + 33 + r.Get("/count", n.getUnreadCount) 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(middleware.AuthMiddleware(n.oauth)) 37 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 38 + r.Post("/{id}/read", n.markRead) 39 + r.Post("/read-all", n.markAllRead) 40 + r.Delete("/{id}", n.deleteNotification) 41 + }) 42 + 43 + return r 44 + } 45 + 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + user := n.oauth.GetUser(r) 48 + 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 54 + 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", user.Did), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 63 + } 64 + 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", user.Did), 69 + ) 70 + if err != nil { 71 + log.Println("failed to get notifications:", err) 72 + n.pages.Error500(w) 73 + return 74 + } 75 + 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 + } 80 + 81 + unreadCount := 0 82 + 83 + n.pages.Notifications(w, pages.NotificationsParams{ 84 + LoggedInUser: user, 85 + Notifications: notifications, 86 + UnreadCount: unreadCount, 87 + Page: page, 88 + Total: total, 89 + }) 90 + } 91 + 92 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 + user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 98 + count, err := db.CountNotifications( 99 + n.db, 100 + db.FilterEq("recipient_did", user.Did), 101 + db.FilterEq("read", 0), 102 + ) 103 + if err != nil { 104 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 105 + return 106 + } 107 + 108 + params := pages.NotificationCountParams{ 109 + Count: count, 110 + } 111 + err = n.pages.NotificationCount(w, params) 112 + if err != nil { 113 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 114 + return 115 + } 116 + } 117 + 118 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 119 + userDid := n.oauth.GetDid(r) 120 + 121 + idStr := chi.URLParam(r, "id") 122 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 123 + if err != nil { 124 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 125 + return 126 + } 127 + 128 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 129 + if err != nil { 130 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 131 + return 132 + } 133 + 134 + w.WriteHeader(http.StatusNoContent) 135 + } 136 + 137 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 138 + userDid := n.oauth.GetDid(r) 139 + 140 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 141 + if err != nil { 142 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 143 + return 144 + } 145 + 146 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 147 + } 148 + 149 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 150 + userDid := n.oauth.GetDid(r) 151 + 152 + idStr := chi.URLParam(r, "id") 153 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 154 + if err != nil { 155 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 156 + return 157 + } 158 + 159 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 160 + if err != nil { 161 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 162 + return 163 + } 164 + 165 + w.WriteHeader(http.StatusOK) 166 + }
+429
appview/notify/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + "tangled.org/core/idresolver" 11 + ) 12 + 13 + type databaseNotifier struct { 14 + db *db.DB 15 + res *idresolver.Resolver 16 + } 17 + 18 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 19 + return &databaseNotifier{ 20 + db: database, 21 + res: resolver, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &databaseNotifier{} 26 + 27 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + // no-op for now 29 + } 30 + 31 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 + var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 + if err != nil { 35 + log.Printf("NewStar: failed to get repos: %v", err) 36 + return 37 + } 38 + 39 + // don't notify yourself 40 + if repo.Did == star.StarredByDid { 41 + return 42 + } 43 + 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 67 + } 68 + 69 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 + // no-op 71 + } 72 + 73 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 + if err != nil { 76 + log.Printf("NewIssue: failed to get repos: %v", err) 77 + return 78 + } 79 + 80 + if repo.Did == issue.Did { 81 + return 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 + if err != nil { 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 + return 88 + } 89 + if !prefs.IssueCreated { 90 + return 91 + } 92 + 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 102 + 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 108 + } 109 + 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 + if err != nil { 113 + log.Printf("NewIssueComment: failed to get issues: %v", err) 114 + return 115 + } 116 + if len(issues) == 0 { 117 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 118 + return 119 + } 120 + issue := issues[0] 121 + 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 + if err != nil { 124 + log.Printf("NewIssueComment: failed to get repos: %v", err) 125 + return 126 + } 127 + 128 + recipients := make(map[string]bool) 129 + 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 + } 138 + } 139 + 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 149 + 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 161 + 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 167 + } 168 + 169 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 186 + 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 192 + } 193 + 194 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 195 + // no-op 196 + } 197 + 198 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 + if err != nil { 201 + log.Printf("NewPull: failed to get repos: %v", err) 202 + return 203 + } 204 + 205 + if repo.Did == pull.OwnerDid { 206 + return 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 + if err != nil { 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 + return 213 + } 214 + if !prefs.PullCreated { 215 + return 216 + } 217 + 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 227 + 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 233 + } 234 + 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 239 + if err != nil { 240 + log.Printf("NewPullComment: failed to get pulls: %v", err) 241 + return 242 + } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + return 246 + } 247 + pull := pulls[0] 248 + 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 + if err != nil { 251 + log.Printf("NewPullComment: failed to get repos: %v", err) 252 + return 253 + } 254 + 255 + recipients := make(map[string]bool) 256 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 265 + } 266 + 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 287 + 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 293 + } 294 + 295 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 296 + // no-op 297 + } 298 + 299 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 300 + // no-op 301 + } 302 + 303 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 304 + // no-op 305 + } 306 + 307 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 308 + // no-op 309 + } 310 + 311 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 + // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 + if err != nil { 315 + log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 + return 317 + } 318 + 319 + // Don't notify yourself 320 + if repo.Did == issue.Did { 321 + return 322 + } 323 + 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 333 + 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 342 + } 343 + 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 349 + } 350 + 351 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 + // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 + if err != nil { 355 + log.Printf("NewPullMerged: failed to get repos: %v", err) 356 + return 357 + } 358 + 359 + // Don't notify yourself 360 + if repo.Did == pull.OwnerDid { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 + if err != nil { 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 + return 369 + } 370 + if !prefs.PullMerged { 371 + return 372 + } 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 + } 383 + 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + return 388 + } 389 + } 390 + 391 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 + // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 + if err != nil { 395 + log.Printf("NewPullClosed: failed to get repos: %v", err) 396 + return 397 + } 398 + 399 + // Don't notify yourself 400 + if repo.Did == pull.OwnerDid { 401 + return 402 + } 403 + 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 + if err != nil { 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 + return 409 + } 410 + if !prefs.PullMerged { 411 + return 412 + } 413 + 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 + } 423 + 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 427 + return 428 + } 429 + }
+35 -12
appview/notify/merged_notifier.go
··· 3 3 import ( 4 4 "context" 5 5 6 - "tangled.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 + }
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 - "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
+2 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - "tangled.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 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
+65
appview/oauth/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
+108 -203
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 6 "net/http" 7 - "net/url" 8 7 "time" 9 8 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 14 "github.com/gorilla/sessions" 12 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 13 - "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" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 + "tangled.org/core/appview/config" 18 17 ) 19 18 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 19 + func New(config *config.Config) (*OAuth, error) { 20 + 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 25 23 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 31 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 32 50 } 33 51 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 36 57 } 37 58 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 60 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 61 + userSession, err := o.SessStore.Get(r, SessionName) 41 62 if err != nil { 42 63 return err 43 64 } 44 65 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 48 69 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 50 75 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 76 + return nil, fmt.Errorf("error getting user session: %w", err) 52 77 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 65 80 } 66 81 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 74 86 } 75 87 76 - did := userSession.Values[SessionDid].(string) 88 + sessId := userSession.Values[SessionId].(string) 77 89 78 - err = o.sess.DeleteSession(r.Context(), did) 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 91 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 93 } 82 94 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 95 + return clientSess, nil 86 96 } 87 97 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 92 - } 93 - 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 98 100 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 101 + return fmt.Errorf("error getting user session: %w", err) 102 + } 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 100 105 } 101 106 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 103 109 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 105 111 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 112 112 - self := o.ClientMetadata() 113 + sessId := userSession.Values[SessionId].(string) 113 114 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 119 117 120 - if err != nil { 121 - return nil, false, err 122 - } 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 123 121 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 122 + return errors.Join(err1, err2) 123 + } 128 124 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 127 + if err != nil { 128 + return nil, err 129 + } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 140 133 } 141 - 142 - return session, auth, nil 134 + return pubKey, nil 143 135 } 144 136 145 137 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 138 + Did string 139 + Pds string 149 140 } 150 141 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 153 144 154 - if err != nil || clientSession.IsNew { 145 + if err != nil || sess.IsNew { 155 146 return nil 156 147 } 157 148 158 149 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 162 152 } 163 153 } 164 154 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 170 158 } 171 159 172 - return clientSession.Values[SessionDid].(string) 160 + return "" 173 161 } 174 162 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 177 165 if err != nil { 178 166 return nil, fmt.Errorf("error getting session: %w", err) 179 167 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 168 + return session.APIClient(), nil 208 169 } 209 170 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 171 // this is a higher level abstraction on ServerGetServiceAuth 213 172 type ServiceClientOpts struct { 214 173 service string ··· 259 218 return scheme + s.service 260 219 } 261 220 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 222 opts := ServiceClientOpts{} 264 223 for _, o := range os { 265 224 o(&opts) 266 225 } 267 226 268 - authorizedClient, err := o.AuthorizedClient(r) 227 + client, err := o.AuthorizedClient(r) 269 228 if err != nil { 270 229 return nil, err 271 230 } ··· 276 235 opts.exp = sixty 277 236 } 278 237 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 239 if err != nil { 281 240 return nil, err 282 241 } 283 242 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 286 245 AccessJwt: resp.Token, 287 246 }, 288 247 Host: opts.Host(), ··· 291 250 }, 292 251 }, nil 293 252 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+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
+217 -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 972 + SearchQuery string 973 + SortBy string 974 + SortOrder string 895 975 } 896 976 897 977 func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { ··· 903 983 LoggedInUser *oauth.User 904 984 RepoInfo repoinfo.RepoInfo 905 985 Active string 906 - Issue *db.Issue 907 - CommentList []db.CommentListItem 908 - LabelDefs map[string]*db.LabelDefinition 986 + Issue *models.Issue 987 + CommentList []models.CommentListItem 988 + LabelDefs map[string]*models.LabelDefinition 909 989 910 - OrderedReactionKinds []db.ReactionKind 911 - Reactions map[db.ReactionKind]int 912 - UserReacted map[db.ReactionKind]bool 990 + OrderedReactionKinds []models.ReactionKind 991 + Reactions map[models.ReactionKind]models.ReactionDisplayData 992 + UserReacted map[models.ReactionKind]bool 913 993 } 914 994 915 995 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 920 1000 type EditIssueParams struct { 921 1001 LoggedInUser *oauth.User 922 1002 RepoInfo repoinfo.RepoInfo 923 - Issue *db.Issue 1003 + Issue *models.Issue 924 1004 Action string 925 1005 } 926 1006 ··· 931 1011 932 1012 type ThreadReactionFragmentParams struct { 933 1013 ThreadAt syntax.ATURI 934 - Kind db.ReactionKind 1014 + Kind models.ReactionKind 935 1015 Count int 1016 + Users []string 936 1017 IsReacted bool 937 1018 } 938 1019 ··· 943 1024 type RepoNewIssueParams struct { 944 1025 LoggedInUser *oauth.User 945 1026 RepoInfo repoinfo.RepoInfo 946 - Issue *db.Issue // existing issue if any -- passed when editing 1027 + Issue *models.Issue // existing issue if any -- passed when editing 947 1028 Active string 948 1029 Action string 949 1030 } ··· 957 1038 type EditIssueCommentParams struct { 958 1039 LoggedInUser *oauth.User 959 1040 RepoInfo repoinfo.RepoInfo 960 - Issue *db.Issue 961 - Comment *db.IssueComment 1041 + Issue *models.Issue 1042 + Comment *models.IssueComment 962 1043 } 963 1044 964 1045 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 968 1049 type ReplyIssueCommentPlaceholderParams struct { 969 1050 LoggedInUser *oauth.User 970 1051 RepoInfo repoinfo.RepoInfo 971 - Issue *db.Issue 972 - Comment *db.IssueComment 1052 + Issue *models.Issue 1053 + Comment *models.IssueComment 973 1054 } 974 1055 975 1056 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 979 1060 type ReplyIssueCommentParams struct { 980 1061 LoggedInUser *oauth.User 981 1062 RepoInfo repoinfo.RepoInfo 982 - Issue *db.Issue 983 - Comment *db.IssueComment 1063 + Issue *models.Issue 1064 + Comment *models.IssueComment 984 1065 } 985 1066 986 1067 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 990 1071 type IssueCommentBodyParams struct { 991 1072 LoggedInUser *oauth.User 992 1073 RepoInfo repoinfo.RepoInfo 993 - Issue *db.Issue 994 - Comment *db.IssueComment 1074 + Issue *models.Issue 1075 + Comment *models.IssueComment 995 1076 } 996 1077 997 1078 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1018 1099 type RepoPullsParams struct { 1019 1100 LoggedInUser *oauth.User 1020 1101 RepoInfo repoinfo.RepoInfo 1021 - Pulls []*db.Pull 1102 + Pulls []*models.Pull 1022 1103 Active string 1023 - FilteringBy db.PullState 1024 - Stacks map[string]db.Stack 1025 - Pipelines map[string]db.Pipeline 1104 + FilteringBy models.PullState 1105 + Stacks map[string]models.Stack 1106 + Pipelines map[string]models.Pipeline 1107 + LabelDefs map[string]*models.LabelDefinition 1108 + SearchQuery string 1109 + SortBy string 1110 + SortOrder string 1026 1111 } 1027 1112 1028 1113 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1052 1137 LoggedInUser *oauth.User 1053 1138 RepoInfo repoinfo.RepoInfo 1054 1139 Active string 1055 - Pull *db.Pull 1056 - Stack db.Stack 1057 - AbandonedPulls []*db.Pull 1140 + Pull *models.Pull 1141 + Stack models.Stack 1142 + AbandonedPulls []*models.Pull 1058 1143 MergeCheck types.MergeCheckResponse 1059 1144 ResubmitCheck ResubmitResult 1060 - Pipelines map[string]db.Pipeline 1145 + Pipelines map[string]models.Pipeline 1146 + 1147 + OrderedReactionKinds []models.ReactionKind 1148 + Reactions map[models.ReactionKind]models.ReactionDisplayData 1149 + UserReacted map[models.ReactionKind]bool 1061 1150 1062 - OrderedReactionKinds []db.ReactionKind 1063 - Reactions map[db.ReactionKind]int 1064 - UserReacted map[db.ReactionKind]bool 1151 + LabelDefs map[string]*models.LabelDefinition 1065 1152 } 1066 1153 1067 1154 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1072 1159 type RepoPullPatchParams struct { 1073 1160 LoggedInUser *oauth.User 1074 1161 RepoInfo repoinfo.RepoInfo 1075 - Pull *db.Pull 1076 - Stack db.Stack 1162 + Pull *models.Pull 1163 + Stack models.Stack 1077 1164 Diff *types.NiceDiff 1078 1165 Round int 1079 - Submission *db.PullSubmission 1080 - OrderedReactionKinds []db.ReactionKind 1166 + Submission *models.PullSubmission 1167 + OrderedReactionKinds []models.ReactionKind 1081 1168 DiffOpts types.DiffOpts 1082 1169 } 1083 1170 ··· 1089 1176 type RepoPullInterdiffParams struct { 1090 1177 LoggedInUser *oauth.User 1091 1178 RepoInfo repoinfo.RepoInfo 1092 - Pull *db.Pull 1179 + Pull *models.Pull 1093 1180 Round int 1094 1181 Interdiff *patchutil.InterdiffResult 1095 - OrderedReactionKinds []db.ReactionKind 1182 + OrderedReactionKinds []models.ReactionKind 1096 1183 DiffOpts types.DiffOpts 1097 1184 } 1098 1185 ··· 1121 1208 1122 1209 type PullCompareForkParams struct { 1123 1210 RepoInfo repoinfo.RepoInfo 1124 - Forks []db.Repo 1211 + Forks []models.Repo 1125 1212 Selected string 1126 1213 } 1127 1214 ··· 1142 1229 type PullResubmitParams struct { 1143 1230 LoggedInUser *oauth.User 1144 1231 RepoInfo repoinfo.RepoInfo 1145 - Pull *db.Pull 1232 + Pull *models.Pull 1146 1233 SubmissionId int 1147 1234 } 1148 1235 ··· 1153 1240 type PullActionsParams struct { 1154 1241 LoggedInUser *oauth.User 1155 1242 RepoInfo repoinfo.RepoInfo 1156 - Pull *db.Pull 1243 + Pull *models.Pull 1157 1244 RoundNumber int 1158 1245 MergeCheck types.MergeCheckResponse 1159 1246 ResubmitCheck ResubmitResult 1160 - Stack db.Stack 1247 + Stack models.Stack 1161 1248 } 1162 1249 1163 1250 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1167 1254 type PullNewCommentParams struct { 1168 1255 LoggedInUser *oauth.User 1169 1256 RepoInfo repoinfo.RepoInfo 1170 - Pull *db.Pull 1257 + Pull *models.Pull 1171 1258 RoundNumber int 1172 1259 } 1173 1260 ··· 1178 1265 type RepoCompareParams struct { 1179 1266 LoggedInUser *oauth.User 1180 1267 RepoInfo repoinfo.RepoInfo 1181 - Forks []db.Repo 1268 + Forks []models.Repo 1182 1269 Branches []types.Branch 1183 1270 Tags []*types.TagReference 1184 1271 Base string ··· 1197 1284 type RepoCompareNewParams struct { 1198 1285 LoggedInUser *oauth.User 1199 1286 RepoInfo repoinfo.RepoInfo 1200 - Forks []db.Repo 1287 + Forks []models.Repo 1201 1288 Branches []types.Branch 1202 1289 Tags []*types.TagReference 1203 1290 Base string ··· 1235 1322 type LabelPanelParams struct { 1236 1323 LoggedInUser *oauth.User 1237 1324 RepoInfo repoinfo.RepoInfo 1238 - Defs map[string]*db.LabelDefinition 1325 + Defs map[string]*models.LabelDefinition 1239 1326 Subject string 1240 - State db.LabelState 1327 + State models.LabelState 1241 1328 } 1242 1329 1243 1330 func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { ··· 1247 1334 type EditLabelPanelParams struct { 1248 1335 LoggedInUser *oauth.User 1249 1336 RepoInfo repoinfo.RepoInfo 1250 - Defs map[string]*db.LabelDefinition 1337 + Defs map[string]*models.LabelDefinition 1251 1338 Subject string 1252 - State db.LabelState 1339 + State models.LabelState 1253 1340 } 1254 1341 1255 1342 func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { ··· 1259 1346 type PipelinesParams struct { 1260 1347 LoggedInUser *oauth.User 1261 1348 RepoInfo repoinfo.RepoInfo 1262 - Pipelines []db.Pipeline 1349 + Pipelines []models.Pipeline 1263 1350 Active string 1264 1351 } 1265 1352 ··· 1291 1378 type WorkflowParams struct { 1292 1379 LoggedInUser *oauth.User 1293 1380 RepoInfo repoinfo.RepoInfo 1294 - Pipeline db.Pipeline 1381 + Pipeline models.Pipeline 1295 1382 Workflow string 1296 1383 LogUrl string 1297 1384 Active string ··· 1307 1394 Action string 1308 1395 1309 1396 // this is supplied in the case of editing an existing string 1310 - String db.String 1397 + String models.String 1311 1398 } 1312 1399 1313 1400 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1317 1404 type StringsDashboardParams struct { 1318 1405 LoggedInUser *oauth.User 1319 1406 Card ProfileCard 1320 - Strings []db.String 1407 + Strings []models.String 1321 1408 } 1322 1409 1323 1410 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1326 1413 1327 1414 type StringTimelineParams struct { 1328 1415 LoggedInUser *oauth.User 1329 - Strings []db.String 1416 + Strings []models.String 1330 1417 } 1331 1418 1332 1419 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1338 1425 ShowRendered bool 1339 1426 RenderToggle bool 1340 1427 RenderedContents template.HTML 1341 - String db.String 1342 - Stats db.StringStats 1428 + String models.String 1429 + Stats models.StringStats 1343 1430 Owner identity.Identity 1344 1431 } 1345 1432
+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>
+18 -8
appview/pages/templates/layouts/fragments/topbar.html
··· 1 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 5 + <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 + <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 + alpha 10 + </span> 7 11 </a> 8 12 </div> 9 13 10 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 11 15 {{ with .LoggedInUser }} 12 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 13 18 {{ block "dropDown" . }} {{ end }} 14 19 {{ else }} 15 20 <a href="/login">login</a> ··· 26 31 {{ define "newButton" }} 27 32 <details class="relative inline-block text-left nav-dropdown"> 28 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 30 35 </summary> 31 36 <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 49 {{ define "dropDown" }} 45 50 <details class="relative inline-block text-left nav-dropdown"> 46 51 <summary 47 - class="cursor-pointer list-none flex items-center" 52 + class="cursor-pointer list-none flex items-center gap-1" 48 53 > 49 - {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 54 + {{ $user := .Did }} 55 + <img 56 + src="{{ tinyAvatar $user }}" 57 + alt="" 58 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 59 + /> 60 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 51 61 </summary> 52 62 <div 53 63 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+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-2 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-2 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 1 {{ define "repo/fragments/readme" }} 2 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 7 </div> 8 8 {{- end -}} 9 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+185
appview/pages/templates/repo/fragments/searchBar.html
··· 1 + {{ define "repo/fragments/searchBar" }} 2 + <div class="flex gap-2 items-center w-full"> 3 + <form class="flex-grow flex gap-2" method="get" action=""> 4 + <div class="flex-grow flex items-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800"> 5 + <input 6 + type="text" 7 + name="q" 8 + value="{{ .SearchQuery }}" 9 + placeholder="Search {{ .Placeholder }}... (e.g., 'has:enhancement fix bug')" 10 + class="flex-grow px-4 py-2 bg-transparent dark:text-white focus:outline-none" 11 + /> 12 + <button type="submit" class="px-3 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"> 13 + {{ i "search" "w-5 h-5" }} 14 + </button> 15 + </div> 16 + 17 + <!-- Keep state filter in search --> 18 + {{ if .State }} 19 + <input type="hidden" name="state" value="{{ .State }}" /> 20 + {{ end }} 21 + 22 + <!-- Sort options --> 23 + {{ $sortBy := .SortBy }} 24 + {{ $sortOrder := .SortOrder }} 25 + {{ $defaultSortBy := "created" }} 26 + {{ $defaultSortOrder := "desc" }} 27 + {{ if not $sortBy }} 28 + {{ $sortBy = $defaultSortBy }} 29 + {{ end }} 30 + {{ if not $sortOrder }} 31 + {{ $sortOrder = $defaultSortOrder }} 32 + {{ end }} 33 + <input type="hidden" name="sort_by" value="{{ $sortBy }}" id="sortByInput" /> 34 + <input type="hidden" name="sort_order" value="{{ $sortOrder }}" id="sortOrderInput" /> 35 + 36 + <details class="relative dropdown-menu" id="sortDropdown"> 37 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 38 + {{ i "arrow-down-up" "w-4 h-4" }} 39 + <span> 40 + {{ if .SortBy }} 41 + {{ if eq $sortBy "created" }}Created{{ else if eq $sortBy "comments" }}Comments{{ else if eq $sortBy "reactions" }}Reactions{{ end }} 42 + {{ else }} 43 + Sort 44 + {{ end }} 45 + </span> 46 + {{ i "chevron-down" "w-4 h-4" }} 47 + </summary> 48 + <div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10"> 49 + <div class="p-3"> 50 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Sort by</div> 51 + <div class="space-y-1 mb-3"> 52 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="created"> 53 + {{ if eq $sortBy "created" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 54 + <span class="text-sm dark:text-gray-200">Created</span> 55 + </div> 56 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="comments"> 57 + {{ if eq $sortBy "comments" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 58 + <span class="text-sm dark:text-gray-200">Comments</span> 59 + </div> 60 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-by-option" data-value="reactions"> 61 + {{ if eq $sortBy "reactions" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 62 + <span class="text-sm dark:text-gray-200">Reactions</span> 63 + </div> 64 + </div> 65 + <div class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300 pt-2 border-t border-gray-200 dark:border-gray-600">Order</div> 66 + <div class="space-y-1"> 67 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="desc"> 68 + {{ if eq $sortOrder "desc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 69 + <span class="text-sm dark:text-gray-200">Descending</span> 70 + </div> 71 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded sort-order-option" data-value="asc"> 72 + {{ if eq $sortOrder "asc" }}{{ i "check" "w-4 h-4" }}{{ else }}<span class="w-4 h-4"></span>{{ end }} 73 + <span class="text-sm dark:text-gray-200">Ascending</span> 74 + </div> 75 + </div> 76 + </div> 77 + </div> 78 + </details> 79 + 80 + <!-- Label filter dropdown --> 81 + <details class="relative dropdown-menu" id="labelDropdown"> 82 + <summary class="btn cursor-pointer list-none flex items-center gap-2"> 83 + {{ i "tag" "w-4 h-4" }} 84 + <span>label</span> 85 + {{ i "chevron-down" "w-4 h-4" }} 86 + </summary> 87 + <div class="absolute right-0 mt-2 w-64 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10 max-h-96 overflow-y-auto"> 88 + <div class="p-3"> 89 + <div class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">Filter by label</div> 90 + <div class="space-y-2"> 91 + {{ range $uri, $def := .LabelDefs }} 92 + <div class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded label-option" data-label-name="{{ $def.Name }}"> 93 + <span class="label-checkbox-icon w-4 h-4"></span> 94 + <span class="flex-grow text-sm dark:text-gray-200"> 95 + {{ template "labels/fragments/label" (dict "def" $def "val" "" "withPrefix" false) }} 96 + </span> 97 + </div> 98 + {{ end }} 99 + </div> 100 + </div> 101 + </div> 102 + </details> 103 + </form> 104 + </div> 105 + 106 + <script> 107 + (function() { 108 + // Handle label filter changes 109 + const labelOptions = document.querySelectorAll('.label-option'); 110 + const searchInput = document.querySelector('input[name="q"]'); 111 + 112 + // Initialize checkmarks based on current query 113 + const currentQuery = searchInput.value; 114 + labelOptions.forEach(option => { 115 + const labelName = option.getAttribute('data-label-name'); 116 + const hasFilter = 'has:' + labelName; 117 + const iconSpan = option.querySelector('.label-checkbox-icon'); 118 + 119 + if (currentQuery.includes(hasFilter)) { 120 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 121 + } 122 + }); 123 + 124 + labelOptions.forEach(option => { 125 + option.addEventListener('click', function() { 126 + const labelName = this.getAttribute('data-label-name'); 127 + let currentQuery = searchInput.value; 128 + const hasFilter = 'has:' + labelName; 129 + const iconSpan = this.querySelector('.label-checkbox-icon'); 130 + const isChecked = currentQuery.includes(hasFilter); 131 + 132 + if (isChecked) { 133 + // Remove has: filter 134 + currentQuery = currentQuery.replace(hasFilter, '').replace(/\s+/g, ' '); 135 + searchInput.value = currentQuery.trim(); 136 + iconSpan.innerHTML = ''; 137 + } else { 138 + // Add has: filter if not already present 139 + currentQuery = currentQuery.trim() + ' ' + hasFilter; 140 + searchInput.value = currentQuery.trim(); 141 + iconSpan.innerHTML = '{{ i "check" "w-4 h-4" }}'; 142 + } 143 + 144 + form.submit(); 145 + }); 146 + }); 147 + 148 + // Handle sort option changes 149 + const sortByOptions = document.querySelectorAll('.sort-by-option'); 150 + const sortOrderOptions = document.querySelectorAll('.sort-order-option'); 151 + const sortByInput = document.getElementById('sortByInput'); 152 + const sortOrderInput = document.getElementById('sortOrderInput'); 153 + const form = searchInput.closest('form'); 154 + 155 + sortByOptions.forEach(option => { 156 + option.addEventListener('click', function() { 157 + sortByInput.value = this.getAttribute('data-value'); 158 + form.submit(); 159 + }); 160 + }); 161 + 162 + sortOrderOptions.forEach(option => { 163 + option.addEventListener('click', function() { 164 + sortOrderInput.value = this.getAttribute('data-value'); 165 + form.submit(); 166 + }); 167 + }); 168 + 169 + // Make dropdowns mutually exclusive - close others when one opens 170 + const dropdowns = document.querySelectorAll('.dropdown-menu'); 171 + dropdowns.forEach(dropdown => { 172 + dropdown.addEventListener('toggle', function(e) { 173 + if (this.open) { 174 + // Close all other dropdowns 175 + dropdowns.forEach(other => { 176 + if (other !== this && other.open) { 177 + other.open = false; 178 + } 179 + }); 180 + } 181 + }); 182 + }); 183 + })(); 184 + </script> 185 + {{ end }}
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+65
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ if gt .ReactionCount 0 }} 46 + <span class="before:content-['·']"> 47 + {{ $s := "s" }} 48 + {{ if eq .ReactionCount 1 }} 49 + {{ $s = "" }} 50 + {{ end }} 51 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .ReactionCount }} reaction{{$s}}</a> 52 + </span> 53 + {{ end }} 54 + 55 + {{ $state := .Labels }} 56 + {{ range $k, $d := $.LabelDefs }} 57 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 58 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 59 + {{ end }} 60 + {{ end }} 61 + </div> 62 + </div> 63 + {{ end }} 64 + </div> 65 + {{ end }}
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+5 -29
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 }} ··· 110 110 <div class="flex items-center gap-2"> 111 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 113 114 {{ 114 115 template "repo/fragments/reaction" 115 116 (dict 116 117 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 + "Count" $reactionData.Count 118 119 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 120 122 }} 121 123 {{ end }} 122 124 </div> 123 125 {{ end }} 124 126 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 127 152 128 {{ define "repoAfter" }} 153 129 <div class="flex flex-col gap-4 mt-4">
+32 -55
appview/pages/templates/repo/issues/issues.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center gap-4"> 11 + <div class="flex justify-between items-center gap-4 mb-4"> 12 12 <div class="flex gap-4"> 13 13 <a 14 14 href="?state=open" ··· 33 33 <span>new</span> 34 34 </a> 35 35 </div> 36 + 37 + {{ $state := "open" }} 38 + {{ if not .FilteringByOpen }} 39 + {{ $state = "closed" }} 40 + {{ end }} 41 + 42 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "issues" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 36 43 <div class="error" id="issues"></div> 37 44 {{ end }} 38 45 39 46 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <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 }} 47 + <div class="mt-2"> 48 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 49 </div> 93 50 {{ block "pagination" . }} {{ end }} 94 51 {{ end }} ··· 102 59 103 60 {{ if gt .Page.Offset 0 }} 104 61 {{ $prev := .Page.Previous }} 62 + {{ $prevUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $prev.Offset $prev.Limit }} 63 + {{ if .SearchQuery }} 64 + {{ $prevUrl = printf "%s&q=%s" $prevUrl .SearchQuery }} 65 + {{ end }} 66 + {{ if .SortBy }} 67 + {{ $prevUrl = printf "%s&sort_by=%s" $prevUrl .SortBy }} 68 + {{ end }} 69 + {{ if .SortOrder }} 70 + {{ $prevUrl = printf "%s&sort_order=%s" $prevUrl .SortOrder }} 71 + {{ end }} 105 72 <a 106 73 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 107 74 hx-boost="true" 108 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 75 + href = "{{ $prevUrl }}" 109 76 > 110 77 {{ i "chevron-left" "w-4 h-4" }} 111 78 previous ··· 116 83 117 84 {{ if eq (len .Issues) .Page.Limit }} 118 85 {{ $next := .Page.Next }} 86 + {{ $nextUrl := printf "/%s/issues?state=%s&offset=%d&limit=%d" $.RepoInfo.FullName $currentState $next.Offset $next.Limit }} 87 + {{ if .SearchQuery }} 88 + {{ $nextUrl = printf "%s&q=%s" $nextUrl .SearchQuery }} 89 + {{ end }} 90 + {{ if .SortBy }} 91 + {{ $nextUrl = printf "%s&sort_by=%s" $nextUrl .SortBy }} 92 + {{ end }} 93 + {{ if .SortOrder }} 94 + {{ $nextUrl = printf "%s&sort_order=%s" $nextUrl .SortOrder }} 95 + {{ end }} 119 96 <a 120 97 class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 121 98 hx-boost="true" 122 - href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 99 + href = "{{ $nextUrl }}" 123 100 > 124 101 next 125 102 {{ i "chevron-right" "w-4 h-4" }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 6 13 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 14 + {{ end }} 19 15 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 29 21 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 41 52 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 71 64 </div> 72 - </form> 73 - </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 74 176 {{ end }}
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 66 <div class="flex items-center gap-2 mt-2"> 67 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 69 70 {{ 70 71 template "repo/fragments/reaction" 71 72 (dict 72 73 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 + "Count" $reactionData.Count 74 75 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 76 78 }} 77 79 {{ end }} 78 80 </div>
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+37 -15
appview/pages/templates/repo/pulls/pull.html
··· 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 - <span id="interdiff-error-{{.RoundNumber}}"></span> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 84 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }} ··· 171 189 {{ if $.LoggedInUser }} 172 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 173 191 {{ else }} 174 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 175 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 176 - <a href="/login" class="underline">login</a> to join the discussion 192 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 193 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 194 + sign up 195 + </a> 196 + <span class="text-gray-500 dark:text-gray-400">or</span> 197 + <a href="/login" class="underline">login</a> 198 + to add to the discussion 177 199 </div> 178 200 {{ end }} 179 201 </div>
+17 -1
appview/pages/templates/repo/pulls/pulls.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "repoContent" }} 11 - <div class="flex justify-between items-center"> 11 + <div class="flex justify-between items-center mb-4"> 12 12 <div class="flex gap-4"> 13 13 <a 14 14 href="?state=open" ··· 40 40 <span>new</span> 41 41 </a> 42 42 </div> 43 + 44 + {{ $state := "open" }} 45 + {{ if .FilteringBy.IsMerged }} 46 + {{ $state = "merged" }} 47 + {{ else if .FilteringBy.IsClosed }} 48 + {{ $state = "closed" }} 49 + {{ end }} 50 + 51 + {{ template "repo/fragments/searchBar" (dict "SearchQuery" .SearchQuery "Placeholder" "pulls" "State" $state "LabelDefs" .LabelDefs "SortBy" .SortBy "SortOrder" .SortOrder) }} 43 52 <div class="error" id="pulls"></div> 44 53 {{ end }} 45 54 ··· 107 116 {{ if and $pipeline $pipeline.Id }} 108 117 <span class="before:content-['·']"></span> 109 118 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 119 + {{ end }} 120 + 121 + {{ $state := .Labels }} 122 + {{ range $k, $d := $.LabelDefs }} 123 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 124 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 125 + {{ end }} 110 126 {{ end }} 111 127 </div> 112 128 </div>
+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 }}
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+7 -1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
+12 -11
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" ··· 48 48 ) *Pipelines { 49 49 logger := log.New("pipelines") 50 50 51 - return &Pipelines{oauth: oauth, 51 + return &Pipelines{ 52 + oauth: oauth, 52 53 repoResolver: repoResolver, 53 54 pages: pages, 54 55 idResolver: idResolver,
+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 - }
+159 -84
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/search" 25 + "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/idresolver" 27 + "tangled.org/core/patchutil" 28 + "tangled.org/core/tid" 29 + "tangled.org/core/types" 28 30 29 31 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 32 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 75 77 return 76 78 } 77 79 78 - pull, ok := r.Context().Value("pull").(*db.Pull) 80 + pull, ok := r.Context().Value("pull").(*models.Pull) 79 81 if !ok { 80 82 log.Println("failed to get pull") 81 83 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 83 85 } 84 86 85 87 // can be nil if this pull is not stacked 86 - stack, _ := r.Context().Value("stack").(db.Stack) 88 + stack, _ := r.Context().Value("stack").(models.Stack) 87 89 88 90 roundNumberStr := chi.URLParam(r, "round") 89 91 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 123 125 return 124 126 } 125 127 126 - pull, ok := r.Context().Value("pull").(*db.Pull) 128 + pull, ok := r.Context().Value("pull").(*models.Pull) 127 129 if !ok { 128 130 log.Println("failed to get pull") 129 131 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 131 133 } 132 134 133 135 // can be nil if this pull is not stacked 134 - stack, _ := r.Context().Value("stack").(db.Stack) 135 - abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull) 136 + stack, _ := r.Context().Value("stack").(models.Stack) 137 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 136 138 137 139 totalIdents := 1 138 140 for _, submission := range pull.Submissions { ··· 159 161 160 162 repoInfo := f.RepoInfo(user) 161 163 162 - m := make(map[string]db.Pipeline) 164 + m := make(map[string]models.Pipeline) 163 165 164 166 var shas []string 165 167 for _, s := range pull.Submissions { ··· 188 190 m[p.Sha] = p 189 191 } 190 192 191 - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 193 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 192 194 if err != nil { 193 195 log.Println("failed to get pull reactions") 194 196 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 197 } 196 198 197 - userReactions := map[db.ReactionKind]bool{} 199 + userReactions := map[models.ReactionKind]bool{} 198 200 if user != nil { 199 201 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 202 } 201 203 204 + labelDefs, err := db.GetLabelDefinitions( 205 + s.db, 206 + db.FilterIn("at_uri", f.Repo.Labels), 207 + db.FilterContains("scope", tangled.RepoPullNSID), 208 + ) 209 + if err != nil { 210 + log.Println("failed to fetch labels", err) 211 + s.pages.Error503(w) 212 + return 213 + } 214 + 215 + defs := make(map[string]*models.LabelDefinition) 216 + for _, l := range labelDefs { 217 + defs[l.AtUri().String()] = &l 218 + } 219 + 202 220 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 221 LoggedInUser: user, 204 222 RepoInfo: repoInfo, ··· 209 227 ResubmitCheck: resubmitResult, 210 228 Pipelines: m, 211 229 212 - OrderedReactionKinds: db.OrderedReactionKinds, 213 - Reactions: reactionCountMap, 230 + OrderedReactionKinds: models.OrderedReactionKinds, 231 + Reactions: reactionMap, 214 232 UserReacted: userReactions, 233 + 234 + LabelDefs: defs, 215 235 }) 216 236 } 217 237 218 - func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 219 - if pull.State == db.PullMerged { 238 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 239 + if pull.State == models.PullMerged { 220 240 return types.MergeCheckResponse{} 221 241 } 222 242 ··· 282 302 return result 283 303 } 284 304 285 - func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 286 - if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 305 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 306 + if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 287 307 return pages.Unknown 288 308 } 289 309 ··· 356 376 diffOpts.Split = true 357 377 } 358 378 359 - pull, ok := r.Context().Value("pull").(*db.Pull) 379 + pull, ok := r.Context().Value("pull").(*models.Pull) 360 380 if !ok { 361 381 log.Println("failed to get pull") 362 382 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 363 383 return 364 384 } 365 385 366 - stack, _ := r.Context().Value("stack").(db.Stack) 386 + stack, _ := r.Context().Value("stack").(models.Stack) 367 387 368 388 roundId := chi.URLParam(r, "round") 369 389 roundIdInt, err := strconv.Atoi(roundId) ··· 403 423 diffOpts.Split = true 404 424 } 405 425 406 - pull, ok := r.Context().Value("pull").(*db.Pull) 426 + pull, ok := r.Context().Value("pull").(*models.Pull) 407 427 if !ok { 408 428 log.Println("failed to get pull") 409 429 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 451 471 } 452 472 453 473 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 454 - pull, ok := r.Context().Value("pull").(*db.Pull) 474 + pull, ok := r.Context().Value("pull").(*models.Pull) 455 475 if !ok { 456 476 log.Println("failed to get pull") 457 477 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 473 493 func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 474 494 user := s.oauth.GetUser(r) 475 495 params := r.URL.Query() 496 + searchQuery := params.Get("q") 497 + sortBy := params.Get("sort_by") 498 + sortOrder := params.Get("sort_order") 476 499 477 - state := db.PullOpen 500 + templateSortBy := sortBy 501 + templateSortOrder := sortOrder 502 + 503 + if sortBy == "" { 504 + sortBy = "created" 505 + } 506 + if sortOrder == "" { 507 + sortOrder = "desc" 508 + } 509 + 510 + state := models.PullOpen 478 511 switch params.Get("state") { 479 512 case "closed": 480 - state = db.PullClosed 513 + state = models.PullClosed 481 514 case "merged": 482 - state = db.PullMerged 515 + state = models.PullMerged 483 516 } 484 517 485 518 f, err := s.repoResolver.Resolve(r) ··· 488 521 return 489 522 } 490 523 491 - pulls, err := db.GetPulls( 524 + var pulls []*models.Pull 525 + 526 + query := search.Parse(searchQuery) 527 + 528 + pulls, err = db.SearchPulls( 492 529 s.db, 530 + query.Text, 531 + query.Labels, 532 + sortBy, 533 + sortOrder, 493 534 db.FilterEq("repo_at", f.RepoAt()), 494 535 db.FilterEq("state", state), 495 536 ) 537 + 496 538 if err != nil { 497 539 log.Println("failed to get pulls", err) 498 540 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") ··· 500 542 } 501 543 502 544 for _, p := range pulls { 503 - var pullSourceRepo *db.Repo 545 + var pullSourceRepo *models.Repo 504 546 if p.PullSource != nil { 505 547 if p.PullSource.RepoAt != nil { 506 548 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 515 557 } 516 558 517 559 // we want to group all stacked PRs into just one list 518 - stacks := make(map[string]db.Stack) 560 + stacks := make(map[string]models.Stack) 519 561 var shas []string 520 562 n := 0 521 563 for _, p := range pulls { ··· 551 593 log.Printf("failed to fetch pipeline statuses: %s", err) 552 594 // non-fatal 553 595 } 554 - m := make(map[string]db.Pipeline) 596 + m := make(map[string]models.Pipeline) 555 597 for _, p := range ps { 556 598 m[p.Sha] = p 557 599 } 558 600 601 + labelDefs, err := db.GetLabelDefinitions( 602 + s.db, 603 + db.FilterIn("at_uri", f.Repo.Labels), 604 + db.FilterContains("scope", tangled.RepoPullNSID), 605 + ) 606 + if err != nil { 607 + log.Println("failed to fetch labels", err) 608 + s.pages.Error503(w) 609 + return 610 + } 611 + 612 + defs := make(map[string]*models.LabelDefinition) 613 + for _, l := range labelDefs { 614 + defs[l.AtUri().String()] = &l 615 + } 616 + 559 617 s.pages.RepoPulls(w, pages.RepoPullsParams{ 560 618 LoggedInUser: s.oauth.GetUser(r), 561 619 RepoInfo: f.RepoInfo(user), 562 620 Pulls: pulls, 621 + LabelDefs: defs, 563 622 FilteringBy: state, 564 623 Stacks: stacks, 565 624 Pipelines: m, 625 + SearchQuery: searchQuery, 626 + SortBy: templateSortBy, 627 + SortOrder: templateSortOrder, 566 628 }) 567 629 } 568 630 ··· 574 636 return 575 637 } 576 638 577 - pull, ok := r.Context().Value("pull").(*db.Pull) 639 + pull, ok := r.Context().Value("pull").(*models.Pull) 578 640 if !ok { 579 641 log.Println("failed to get pull") 580 642 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 629 691 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 630 692 return 631 693 } 632 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 694 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 633 695 Collection: tangled.RepoPullCommentNSID, 634 696 Repo: user.Did, 635 697 Rkey: tid.TID(), ··· 647 709 return 648 710 } 649 711 650 - comment := &db.PullComment{ 712 + comment := &models.PullComment{ 651 713 OwnerDid: user.Did, 652 714 RepoAt: f.RepoAt().String(), 653 715 PullId: pull.PullId, ··· 890 952 return 891 953 } 892 954 893 - pullSource := &db.PullSource{ 955 + pullSource := &models.PullSource{ 894 956 Branch: sourceBranch, 895 957 } 896 958 recordPullSource := &tangled.RepoPull_Source{ ··· 1000 1062 forkAtUri := fork.RepoAt() 1001 1063 forkAtUriStr := forkAtUri.String() 1002 1064 1003 - pullSource := &db.PullSource{ 1065 + pullSource := &models.PullSource{ 1004 1066 Branch: sourceBranch, 1005 1067 RepoAt: &forkAtUri, 1006 1068 } ··· 1021 1083 title, body, targetBranch string, 1022 1084 patch string, 1023 1085 sourceRev string, 1024 - pullSource *db.PullSource, 1086 + pullSource *models.PullSource, 1025 1087 recordPullSource *tangled.RepoPull_Source, 1026 1088 isStacked bool, 1027 1089 ) { ··· 1057 1119 1058 1120 // We've already checked earlier if it's diff-based and title is empty, 1059 1121 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1060 - if title == "" { 1122 + if title == "" || body == "" { 1061 1123 formatPatches, err := patchutil.ExtractPatches(patch) 1062 1124 if err != nil { 1063 1125 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1068 1130 return 1069 1131 } 1070 1132 1071 - title = formatPatches[0].Title 1072 - body = formatPatches[0].Body 1133 + if title == "" { 1134 + title = formatPatches[0].Title 1135 + } 1136 + if body == "" { 1137 + body = formatPatches[0].Body 1138 + } 1073 1139 } 1074 1140 1075 1141 rkey := tid.TID() 1076 - initialSubmission := db.PullSubmission{ 1142 + initialSubmission := models.PullSubmission{ 1077 1143 Patch: patch, 1078 1144 SourceRev: sourceRev, 1079 1145 } 1080 - pull := &db.Pull{ 1146 + pull := &models.Pull{ 1081 1147 Title: title, 1082 1148 Body: body, 1083 1149 TargetBranch: targetBranch, 1084 1150 OwnerDid: user.Did, 1085 1151 RepoAt: f.RepoAt(), 1086 1152 Rkey: rkey, 1087 - Submissions: []*db.PullSubmission{ 1153 + Submissions: []*models.PullSubmission{ 1088 1154 &initialSubmission, 1089 1155 }, 1090 1156 PullSource: pullSource, ··· 1102 1168 return 1103 1169 } 1104 1170 1105 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1171 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1106 1172 Collection: tangled.RepoPullNSID, 1107 1173 Repo: user.Did, 1108 1174 Rkey: rkey, ··· 1143 1209 targetBranch string, 1144 1210 patch string, 1145 1211 sourceRev string, 1146 - pullSource *db.PullSource, 1212 + pullSource *models.PullSource, 1147 1213 ) { 1148 1214 // run some necessary checks for stacked-prs first 1149 1215 ··· 1199 1265 } 1200 1266 writes = append(writes, &write) 1201 1267 } 1202 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1268 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1203 1269 Repo: user.Did, 1204 1270 Writes: writes, 1205 1271 }) ··· 1451 1517 return 1452 1518 } 1453 1519 1454 - pull, ok := r.Context().Value("pull").(*db.Pull) 1520 + pull, ok := r.Context().Value("pull").(*models.Pull) 1455 1521 if !ok { 1456 1522 log.Println("failed to get pull") 1457 1523 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1482 1548 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1483 1549 user := s.oauth.GetUser(r) 1484 1550 1485 - pull, ok := r.Context().Value("pull").(*db.Pull) 1551 + pull, ok := r.Context().Value("pull").(*models.Pull) 1486 1552 if !ok { 1487 1553 log.Println("failed to get pull") 1488 1554 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1509 1575 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1510 1576 user := s.oauth.GetUser(r) 1511 1577 1512 - pull, ok := r.Context().Value("pull").(*db.Pull) 1578 + pull, ok := r.Context().Value("pull").(*models.Pull) 1513 1579 if !ok { 1514 1580 log.Println("failed to get pull") 1515 1581 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1572 1638 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1573 1639 user := s.oauth.GetUser(r) 1574 1640 1575 - pull, ok := r.Context().Value("pull").(*db.Pull) 1641 + pull, ok := r.Context().Value("pull").(*models.Pull) 1576 1642 if !ok { 1577 1643 log.Println("failed to get pull") 1578 1644 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1665 1731 } 1666 1732 1667 1733 // validate a resubmission against a pull request 1668 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1734 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1669 1735 if patch == "" { 1670 1736 return fmt.Errorf("Patch is empty.") 1671 1737 } ··· 1686 1752 r *http.Request, 1687 1753 f *reporesolver.ResolvedRepo, 1688 1754 user *oauth.User, 1689 - pull *db.Pull, 1755 + pull *models.Pull, 1690 1756 patch string, 1691 1757 sourceRev string, 1692 1758 ) { ··· 1730 1796 return 1731 1797 } 1732 1798 1733 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1799 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1734 1800 if err != nil { 1735 1801 // failed to get record 1736 1802 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1753 1819 } 1754 1820 } 1755 1821 1756 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1822 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1757 1823 Collection: tangled.RepoPullNSID, 1758 1824 Repo: user.Did, 1759 1825 Rkey: pull.Rkey, ··· 1790 1856 r *http.Request, 1791 1857 f *reporesolver.ResolvedRepo, 1792 1858 user *oauth.User, 1793 - pull *db.Pull, 1859 + pull *models.Pull, 1794 1860 patch string, 1795 1861 stackId string, 1796 1862 ) { 1797 1863 targetBranch := pull.TargetBranch 1798 1864 1799 - origStack, _ := r.Context().Value("stack").(db.Stack) 1865 + origStack, _ := r.Context().Value("stack").(models.Stack) 1800 1866 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1801 1867 if err != nil { 1802 1868 log.Println("failed to create resubmitted stack", err) ··· 1805 1871 } 1806 1872 1807 1873 // 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) 1874 + origById := make(map[string]*models.Pull) 1875 + newById := make(map[string]*models.Pull) 1810 1876 for _, p := range origStack { 1811 1877 origById[p.ChangeId] = p 1812 1878 } ··· 1819 1885 // commits that got updated: corresponding pull is resubmitted & new round begins 1820 1886 // 1821 1887 // 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) 1888 + additions := make(map[string]*models.Pull) 1889 + deletions := make(map[string]*models.Pull) 1824 1890 unchanged := make(map[string]struct{}) 1825 1891 updated := make(map[string]struct{}) 1826 1892 ··· 1880 1946 // deleted pulls are marked as deleted in the DB 1881 1947 for _, p := range deletions { 1882 1948 // do not do delete already merged PRs 1883 - if p.State == db.PullMerged { 1949 + if p.State == models.PullMerged { 1884 1950 continue 1885 1951 } 1886 1952 ··· 1925 1991 np, _ := newById[id] 1926 1992 1927 1993 // do not update already merged PRs 1928 - if op.State == db.PullMerged { 1994 + if op.State == models.PullMerged { 1929 1995 continue 1930 1996 } 1931 1997 ··· 2025 2091 return 2026 2092 } 2027 2093 2028 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2094 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2029 2095 Repo: user.Did, 2030 2096 Writes: writes, 2031 2097 }) ··· 2046 2112 return 2047 2113 } 2048 2114 2049 - pull, ok := r.Context().Value("pull").(*db.Pull) 2115 + pull, ok := r.Context().Value("pull").(*models.Pull) 2050 2116 if !ok { 2051 2117 log.Println("failed to get pull") 2052 2118 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2053 2119 return 2054 2120 } 2055 2121 2056 - var pullsToMerge db.Stack 2122 + var pullsToMerge models.Stack 2057 2123 pullsToMerge = append(pullsToMerge, pull) 2058 2124 if pull.IsStacked() { 2059 - stack, ok := r.Context().Value("stack").(db.Stack) 2125 + stack, ok := r.Context().Value("stack").(models.Stack) 2060 2126 if !ok { 2061 2127 log.Println("failed to get stack") 2062 2128 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 2146 2212 return 2147 2213 } 2148 2214 2215 + // notify about the pull merge 2216 + for _, p := range pullsToMerge { 2217 + s.notifier.NewPullMerged(r.Context(), p) 2218 + } 2219 + 2149 2220 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2150 2221 } 2151 2222 ··· 2158 2229 return 2159 2230 } 2160 2231 2161 - pull, ok := r.Context().Value("pull").(*db.Pull) 2232 + pull, ok := r.Context().Value("pull").(*models.Pull) 2162 2233 if !ok { 2163 2234 log.Println("failed to get pull") 2164 2235 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2186 2257 } 2187 2258 defer tx.Rollback() 2188 2259 2189 - var pullsToClose []*db.Pull 2260 + var pullsToClose []*models.Pull 2190 2261 pullsToClose = append(pullsToClose, pull) 2191 2262 2192 2263 // if this PR is stacked, then we want to close all PRs below this one on the stack 2193 2264 if pull.IsStacked() { 2194 - stack := r.Context().Value("stack").(db.Stack) 2265 + stack := r.Context().Value("stack").(models.Stack) 2195 2266 subStack := stack.StrictlyBelow(pull) 2196 2267 pullsToClose = append(pullsToClose, subStack...) 2197 2268 } ··· 2213 2284 return 2214 2285 } 2215 2286 2287 + for _, p := range pullsToClose { 2288 + s.notifier.NewPullClosed(r.Context(), p) 2289 + } 2290 + 2216 2291 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2217 2292 } 2218 2293 ··· 2226 2301 return 2227 2302 } 2228 2303 2229 - pull, ok := r.Context().Value("pull").(*db.Pull) 2304 + pull, ok := r.Context().Value("pull").(*models.Pull) 2230 2305 if !ok { 2231 2306 log.Println("failed to get pull") 2232 2307 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2254 2329 } 2255 2330 defer tx.Rollback() 2256 2331 2257 - var pullsToReopen []*db.Pull 2332 + var pullsToReopen []*models.Pull 2258 2333 pullsToReopen = append(pullsToReopen, pull) 2259 2334 2260 2335 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2261 2336 if pull.IsStacked() { 2262 - stack := r.Context().Value("stack").(db.Stack) 2337 + stack := r.Context().Value("stack").(models.Stack) 2263 2338 subStack := stack.StrictlyAbove(pull) 2264 2339 pullsToReopen = append(pullsToReopen, subStack...) 2265 2340 } ··· 2284 2359 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2285 2360 } 2286 2361 2287 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2362 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2288 2363 formatPatches, err := patchutil.ExtractPatches(patch) 2289 2364 if err != nil { 2290 2365 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2296 2371 } 2297 2372 2298 2373 // the stack is identified by a UUID 2299 - var stack db.Stack 2374 + var stack models.Stack 2300 2375 parentChangeId := "" 2301 2376 for _, fp := range formatPatches { 2302 2377 // all patches must have a jj change-id ··· 2309 2384 body := fp.Body 2310 2385 rkey := tid.TID() 2311 2386 2312 - initialSubmission := db.PullSubmission{ 2387 + initialSubmission := models.PullSubmission{ 2313 2388 Patch: fp.Raw, 2314 2389 SourceRev: fp.SHA, 2315 2390 } 2316 - pull := db.Pull{ 2391 + pull := models.Pull{ 2317 2392 Title: title, 2318 2393 Body: body, 2319 2394 TargetBranch: targetBranch, 2320 2395 OwnerDid: user.Did, 2321 2396 RepoAt: f.RepoAt(), 2322 2397 Rkey: rkey, 2323 - Submissions: []*db.PullSubmission{ 2398 + Submissions: []*models.PullSubmission{ 2324 2399 &initialSubmission, 2325 2400 }, 2326 2401 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 {
+53 -25
appview/repo/artifact.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "io" 7 8 "log" 8 9 "net/http" 9 10 "net/url" 10 11 "time" 11 12 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/db" 15 + "tangled.org/core/appview/models" 16 + "tangled.org/core/appview/pages" 17 + "tangled.org/core/appview/reporesolver" 18 + "tangled.org/core/appview/xrpcclient" 19 + "tangled.org/core/tid" 20 + "tangled.org/core/types" 21 + 12 22 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 23 lexutil "github.com/bluesky-social/indigo/lex/util" 14 24 indigoxrpc "github.com/bluesky-social/indigo/xrpc" ··· 16 26 "github.com/go-chi/chi/v5" 17 27 "github.com/go-git/go-git/v5/plumbing" 18 28 "github.com/ipfs/go-cid" 19 - "tangled.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" 26 29 ) 27 30 28 31 // TODO: proper statuses here on early exit ··· 58 61 return 59 62 } 60 63 61 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 62 65 if err != nil { 63 66 log.Println("failed to upload blob", err) 64 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 70 73 rkey := tid.TID() 71 74 createdAt := time.Now() 72 75 73 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 74 77 Collection: tangled.RepoArtifactNSID, 75 78 Repo: user.Did, 76 79 Rkey: rkey, ··· 100 103 } 101 104 defer tx.Rollback() 102 105 103 - artifact := db.Artifact{ 106 + artifact := models.Artifact{ 104 107 Did: user.Did, 105 108 Rkey: rkey, 106 109 RepoAt: f.RepoAt(), ··· 133 136 }) 134 137 } 135 138 136 - // TODO: proper statuses here on early exit 137 139 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 138 - tagParam := chi.URLParam(r, "tag") 139 - filename := chi.URLParam(r, "file") 140 140 f, err := rp.repoResolver.Resolve(r) 141 141 if err != nil { 142 142 log.Println("failed to get repo and knot", err) 143 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 143 144 return 144 145 } 146 + 147 + tagParam := chi.URLParam(r, "tag") 148 + filename := chi.URLParam(r, "file") 145 149 146 150 tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 151 if err != nil { ··· 150 154 return 151 155 } 152 156 153 - client, err := rp.oauth.AuthorizedClient(r) 154 - if err != nil { 155 - log.Println("failed to get authorized client", err) 156 - return 157 - } 158 - 159 157 artifacts, err := db.GetArtifact( 160 158 rp.db, 161 159 db.FilterEq("repo_at", f.RepoAt()), ··· 164 162 ) 165 163 if err != nil { 166 164 log.Println("failed to get artifacts", err) 165 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 167 166 return 168 167 } 168 + 169 169 if len(artifacts) != 1 { 170 - log.Printf("too many or too little artifacts found") 170 + log.Printf("too many or too few artifacts found") 171 + http.Error(w, "artifact not found", http.StatusNotFound) 171 172 return 172 173 } 173 174 174 175 artifact := artifacts[0] 175 176 176 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 177 + ownerPds := f.OwnerId.PDSEndpoint() 178 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 179 + q := url.Query() 180 + q.Set("cid", artifact.BlobCid.String()) 181 + q.Set("did", artifact.Did) 182 + url.RawQuery = q.Encode() 183 + 184 + req, err := http.NewRequest(http.MethodGet, url.String(), nil) 177 185 if err != nil { 178 - log.Println("failed to get blob from pds", err) 186 + log.Println("failed to create request", err) 187 + http.Error(w, "failed to create request", http.StatusInternalServerError) 188 + return 189 + } 190 + req.Header.Set("Content-Type", "application/json") 191 + 192 + resp, err := http.DefaultClient.Do(req) 193 + if err != nil { 194 + log.Println("failed to make request", err) 195 + http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 179 196 return 180 197 } 198 + defer resp.Body.Close() 181 199 182 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 183 - w.Write(getBlobResp) 200 + // copy status code and relevant headers from upstream response 201 + w.WriteHeader(resp.StatusCode) 202 + for key, values := range resp.Header { 203 + for _, v := range values { 204 + w.Header().Add(key, v) 205 + } 206 + } 207 + 208 + // stream the body directly to the client 209 + if _, err := io.Copy(w, resp.Body); err != nil { 210 + log.Println("error streaming response to client:", err) 211 + } 184 212 } 185 213 186 214 // TODO: proper statuses here on early exit ··· 222 250 return 223 251 } 224 252 225 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 226 254 Collection: tangled.RepoArtifactNSID, 227 255 Repo: user.Did, 228 256 Rkey: artifact.Rkey,
+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{
+136 -116
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/commitverify" 22 + "tangled.org/core/appview/config" 23 + "tangled.org/core/appview/db" 24 + "tangled.org/core/appview/models" 25 + "tangled.org/core/appview/notify" 26 + "tangled.org/core/appview/oauth" 27 + "tangled.org/core/appview/pages" 28 + "tangled.org/core/appview/pages/markup" 29 + "tangled.org/core/appview/reporesolver" 30 + "tangled.org/core/appview/validator" 31 + xrpcclient "tangled.org/core/appview/xrpcclient" 32 + "tangled.org/core/eventconsumer" 33 + "tangled.org/core/idresolver" 34 + "tangled.org/core/patchutil" 35 + "tangled.org/core/rbac" 36 + "tangled.org/core/tid" 37 + "tangled.org/core/types" 38 + "tangled.org/core/xrpc/serviceauth" 39 + 20 40 comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 21 43 lexutil "github.com/bluesky-social/indigo/lex/util" 22 44 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" 41 - 42 45 securejoin "github.com/cyphar/filepath-securejoin" 43 46 "github.com/go-chi/chi/v5" 44 47 "github.com/go-git/go-git/v5/plumbing" 45 - 46 - "github.com/bluesky-social/indigo/atproto/syntax" 47 48 ) 48 49 49 50 type Repo struct { ··· 306 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 307 308 // 308 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 309 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 310 311 if err != nil { 311 312 // failed to get record 312 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 313 314 return 314 315 } 315 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 316 317 Collection: tangled.RepoNSID, 317 318 Repo: newRepo.Did, 318 319 Rkey: newRepo.Rkey, ··· 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 { ··· 882 863 user := rp.oauth.GetUser(r) 883 864 l := rp.logger.With("handler", "EditSpindle") 884 865 l = l.With("did", user.Did) 885 - l = l.With("handle", user.Handle) 886 866 887 867 errorId := "operation-error" 888 868 fail := func(msg string, err error) { ··· 935 915 return 936 916 } 937 917 938 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 939 919 if err != nil { 940 920 fail("Failed to update spindle, no record found on PDS.", err) 941 921 return 942 922 } 943 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 944 924 Collection: tangled.RepoNSID, 945 925 Repo: newRepo.Did, 946 926 Rkey: newRepo.Rkey, ··· 970 950 user := rp.oauth.GetUser(r) 971 951 l := rp.logger.With("handler", "AddLabel") 972 952 l = l.With("did", user.Did) 973 - l = l.With("handle", user.Handle) 974 953 975 954 f, err := rp.repoResolver.Resolve(r) 976 955 if err != nil { ··· 1004 983 concreteType = "null" 1005 984 } 1006 985 1007 - format := db.ValueTypeFormatAny 986 + format := models.ValueTypeFormatAny 1008 987 if valueFormat == "did" { 1009 - format = db.ValueTypeFormatDid 988 + format = models.ValueTypeFormatDid 1010 989 } 1011 990 1012 - valueType := db.ValueType{ 1013 - Type: db.ConcreteType(concreteType), 991 + valueType := models.ValueType{ 992 + Type: models.ConcreteType(concreteType), 1014 993 Format: format, 1015 994 Enum: variants, 1016 995 } 1017 996 1018 - label := db.LabelDefinition{ 997 + label := models.LabelDefinition{ 1019 998 Did: user.Did, 1020 999 Rkey: tid.TID(), 1021 1000 Name: name, ··· 1039 1018 1040 1019 // emit a labelRecord 1041 1020 labelRecord := label.AsRecord() 1042 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1043 1022 Collection: tangled.LabelDefinitionNSID, 1044 1023 Repo: label.Did, 1045 1024 Rkey: label.Rkey, ··· 1062 1041 newRepo.Labels = append(newRepo.Labels, aturi) 1063 1042 repoRecord := newRepo.AsRecord() 1064 1043 1065 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1066 1045 if err != nil { 1067 1046 fail("Failed to update labels, no record found on PDS.", err) 1068 1047 return 1069 1048 } 1070 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1071 1050 Collection: tangled.RepoNSID, 1072 1051 Repo: newRepo.Did, 1073 1052 Rkey: newRepo.Rkey, ··· 1109 1088 return 1110 1089 } 1111 1090 1112 - err = db.SubscribeLabel(tx, &db.RepoLabel{ 1091 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1113 1092 RepoAt: f.RepoAt(), 1114 1093 LabelAt: label.AtUri(), 1115 1094 }) ··· 1130 1109 user := rp.oauth.GetUser(r) 1131 1110 l := rp.logger.With("handler", "DeleteLabel") 1132 1111 l = l.With("did", user.Did) 1133 - l = l.With("handle", user.Handle) 1134 1112 1135 1113 f, err := rp.repoResolver.Resolve(r) 1136 1114 if err != nil { ··· 1160 1138 } 1161 1139 1162 1140 // delete label record from PDS 1163 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1164 1142 Collection: tangled.LabelDefinitionNSID, 1165 1143 Repo: label.Did, 1166 1144 Rkey: label.Rkey, ··· 1182 1160 newRepo.Labels = updated 1183 1161 repoRecord := newRepo.AsRecord() 1184 1162 1185 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1186 1164 if err != nil { 1187 1165 fail("Failed to update labels, no record found on PDS.", err) 1188 1166 return 1189 1167 } 1190 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1191 1169 Collection: tangled.RepoNSID, 1192 1170 Repo: newRepo.Did, 1193 1171 Rkey: newRepo.Rkey, ··· 1239 1217 user := rp.oauth.GetUser(r) 1240 1218 l := rp.logger.With("handler", "SubscribeLabel") 1241 1219 l = l.With("did", user.Did) 1242 - l = l.With("handle", user.Handle) 1243 1220 1244 1221 f, err := rp.repoResolver.Resolve(r) 1245 1222 if err != nil { 1246 1223 l.Error("failed to get repo and knot", "err", err) 1224 + return 1225 + } 1226 + 1227 + if err := r.ParseForm(); err != nil { 1228 + l.Error("invalid form", "err", err) 1247 1229 return 1248 1230 } 1249 1231 ··· 1253 1235 rp.pages.Notice(w, errorId, msg) 1254 1236 } 1255 1237 1256 - labelAt := r.FormValue("label") 1257 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1238 + labelAts := r.Form["label"] 1239 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1258 1240 if err != nil { 1259 1241 fail("Failed to subscribe to label.", err) 1260 1242 return 1261 1243 } 1262 1244 1263 1245 newRepo := f.Repo 1264 - newRepo.Labels = append(newRepo.Labels, labelAt) 1246 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1247 + 1248 + // dedup 1249 + slices.Sort(newRepo.Labels) 1250 + newRepo.Labels = slices.Compact(newRepo.Labels) 1251 + 1265 1252 repoRecord := newRepo.AsRecord() 1266 1253 1267 1254 client, err := rp.oauth.AuthorizedClient(r) ··· 1270 1257 return 1271 1258 } 1272 1259 1273 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1274 1261 if err != nil { 1275 1262 fail("Failed to update labels, no record found on PDS.", err) 1276 1263 return 1277 1264 } 1278 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1279 1266 Collection: tangled.RepoNSID, 1280 1267 Repo: newRepo.Did, 1281 1268 Rkey: newRepo.Rkey, ··· 1285 1272 }, 1286 1273 }) 1287 1274 1288 - err = db.SubscribeLabel(rp.db, &db.RepoLabel{ 1289 - RepoAt: f.RepoAt(), 1290 - LabelAt: syntax.ATURI(labelAt), 1291 - }) 1275 + tx, err := rp.db.Begin() 1292 1276 if err != nil { 1293 1277 fail("Failed to subscribe to label.", err) 1294 1278 return 1295 1279 } 1280 + defer tx.Rollback() 1281 + 1282 + for _, l := range labelAts { 1283 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1284 + RepoAt: f.RepoAt(), 1285 + LabelAt: syntax.ATURI(l), 1286 + }) 1287 + if err != nil { 1288 + fail("Failed to subscribe to label.", err) 1289 + return 1290 + } 1291 + } 1292 + 1293 + if err := tx.Commit(); err != nil { 1294 + fail("Failed to subscribe to label.", err) 1295 + return 1296 + } 1296 1297 1297 1298 // everything succeeded 1298 1299 rp.pages.HxRefresh(w) ··· 1302 1303 user := rp.oauth.GetUser(r) 1303 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1304 1305 l = l.With("did", user.Did) 1305 - l = l.With("handle", user.Handle) 1306 1306 1307 1307 f, err := rp.repoResolver.Resolve(r) 1308 1308 if err != nil { ··· 1310 1310 return 1311 1311 } 1312 1312 1313 + if err := r.ParseForm(); err != nil { 1314 + l.Error("invalid form", "err", err) 1315 + return 1316 + } 1317 + 1313 1318 errorId := "default-label-operation" 1314 1319 fail := func(msg string, err error) { 1315 1320 l.Error(msg, "err", err) 1316 1321 rp.pages.Notice(w, errorId, msg) 1317 1322 } 1318 1323 1319 - labelAt := r.FormValue("label") 1320 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1324 + labelAts := r.Form["label"] 1325 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1321 1326 if err != nil { 1322 1327 fail("Failed to unsubscribe to label.", err) 1323 1328 return ··· 1327 1332 newRepo := f.Repo 1328 1333 var updated []string 1329 1334 for _, l := range newRepo.Labels { 1330 - if l != labelAt { 1335 + if !slices.Contains(labelAts, l) { 1331 1336 updated = append(updated, l) 1332 1337 } 1333 1338 } ··· 1340 1345 return 1341 1346 } 1342 1347 1343 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1344 1349 if err != nil { 1345 1350 fail("Failed to update labels, no record found on PDS.", err) 1346 1351 return 1347 1352 } 1348 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1349 1354 Collection: tangled.RepoNSID, 1350 1355 Repo: newRepo.Did, 1351 1356 Rkey: newRepo.Rkey, ··· 1358 1363 err = db.UnsubscribeLabel( 1359 1364 rp.db, 1360 1365 db.FilterEq("repo_at", f.RepoAt()), 1361 - db.FilterEq("label_at", labelAt), 1366 + db.FilterIn("label_at", labelAts), 1362 1367 ) 1363 1368 if err != nil { 1364 1369 fail("Failed to unsubscribe label.", err) ··· 1395 1400 return 1396 1401 } 1397 1402 1398 - defs := make(map[string]*db.LabelDefinition) 1403 + defs := make(map[string]*models.LabelDefinition) 1399 1404 for _, l := range labelDefs { 1400 1405 defs[l.AtUri().String()] = &l 1401 1406 } ··· 1443 1448 return 1444 1449 } 1445 1450 1446 - defs := make(map[string]*db.LabelDefinition) 1451 + defs := make(map[string]*models.LabelDefinition) 1447 1452 for _, l := range labelDefs { 1448 1453 defs[l.AtUri().String()] = &l 1449 1454 } ··· 1469 1474 user := rp.oauth.GetUser(r) 1470 1475 l := rp.logger.With("handler", "AddCollaborator") 1471 1476 l = l.With("did", user.Did) 1472 - l = l.With("handle", user.Handle) 1473 1477 1474 1478 f, err := rp.repoResolver.Resolve(r) 1475 1479 if err != nil { ··· 1516 1520 currentUser := rp.oauth.GetUser(r) 1517 1521 rkey := tid.TID() 1518 1522 createdAt := time.Now() 1519 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1520 1524 Collection: tangled.RepoCollaboratorNSID, 1521 1525 Repo: currentUser.Did, 1522 1526 Rkey: rkey, ··· 1566 1570 return 1567 1571 } 1568 1572 1569 - err = db.AddCollaborator(tx, db.Collaborator{ 1573 + err = db.AddCollaborator(tx, models.Collaborator{ 1570 1574 Did: syntax.DID(currentUser.Did), 1571 1575 Rkey: rkey, 1572 1576 SubjectDid: collaboratorIdent.DID, ··· 1607 1611 } 1608 1612 1609 1613 // remove record from pds 1610 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1611 1615 if err != nil { 1612 1616 log.Println("failed to get authorized client", err) 1613 1617 return 1614 1618 } 1615 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1616 1620 Collection: tangled.RepoNSID, 1617 1621 Repo: user.Did, 1618 1622 Rkey: f.Rkey, ··· 1754 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1755 1759 user := rp.oauth.GetUser(r) 1756 1760 l := rp.logger.With("handler", "Secrets") 1757 - l = l.With("handle", user.Handle) 1758 1761 l = l.With("did", user.Did) 1759 1762 1760 1763 f, err := rp.repoResolver.Resolve(r) ··· 1894 1897 return 1895 1898 } 1896 1899 1897 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs())) 1900 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1898 1901 if err != nil { 1899 1902 log.Println("failed to fetch labels", err) 1900 1903 rp.pages.Error503(w) ··· 1926 1929 subscribedLabels[l] = struct{}{} 1927 1930 } 1928 1931 1932 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1933 + // if all default labels are subbed, show the "unsubscribe all" button 1934 + shouldSubscribeAll := false 1935 + for _, dl := range defaultLabels { 1936 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1937 + // one of the default labels is not subscribed to 1938 + shouldSubscribeAll = true 1939 + break 1940 + } 1941 + } 1942 + 1929 1943 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", 1944 + LoggedInUser: user, 1945 + RepoInfo: f.RepoInfo(user), 1946 + Branches: result.Branches, 1947 + Labels: labels, 1948 + DefaultLabels: defaultLabels, 1949 + SubscribedLabels: subscribedLabels, 1950 + ShouldSubscribeAll: shouldSubscribeAll, 1951 + Tabs: settingsTabs, 1952 + Tab: "general", 1938 1953 }) 1939 1954 } 1940 1955 ··· 2107 2122 } 2108 2123 2109 2124 // choose a name for a fork 2110 - forkName := f.Name 2125 + forkName := r.FormValue("repo_name") 2126 + if forkName == "" { 2127 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2128 + return 2129 + } 2130 + 2111 2131 // this check is *only* to see if the forked repo name already exists 2112 2132 // in the user's account. 2113 2133 existingRepo, err := db.GetRepo( 2114 2134 rp.db, 2115 2135 db.FilterEq("did", user.Did), 2116 - db.FilterEq("name", f.Name), 2136 + db.FilterEq("name", forkName), 2117 2137 ) 2118 2138 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 { 2139 + if !errors.Is(err, sql.ErrNoRows) { 2122 2140 log.Println("error fetching existing repo from db", "err", err) 2123 2141 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2124 2142 return 2125 2143 } 2126 2144 } else if existingRepo != nil { 2127 - // repo with this name already exists, append random string 2128 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2145 + // repo with this name already exists 2146 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2147 + return 2129 2148 } 2130 2149 l = l.With("forkName", forkName) 2131 2150 ··· 2141 2160 2142 2161 // create an atproto record for this fork 2143 2162 rkey := tid.TID() 2144 - repo := &db.Repo{ 2163 + repo := &models.Repo{ 2145 2164 Did: user.Did, 2146 2165 Name: forkName, 2147 2166 Knot: targetKnot, 2148 2167 Rkey: rkey, 2149 2168 Source: sourceAt, 2150 - Description: existingRepo.Description, 2169 + Description: f.Repo.Description, 2151 2170 Created: time.Now(), 2171 + Labels: models.DefaultLabelDefs(), 2152 2172 } 2153 2173 record := repo.AsRecord() 2154 2174 2155 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 2156 2176 if err != nil { 2157 2177 l.Error("failed to create xrpcclient", "err", err) 2158 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2159 2179 return 2160 2180 } 2161 2181 2162 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2163 2183 Collection: tangled.RepoNSID, 2164 2184 Repo: user.Did, 2165 2185 Rkey: rkey, ··· 2191 2211 rollback := func() { 2192 2212 err1 := tx.Rollback() 2193 2213 err2 := rp.enforcer.E.LoadPolicy() 2194 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2195 2215 2196 2216 // ignore txn complete errors, this is okay 2197 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 2264 2284 aturi = "" 2265 2285 2266 2286 rp.notifier.NewRepo(r.Context(), repo) 2267 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2268 2288 } 2269 2289 } 2270 2290 2271 2291 // this is used to rollback changes made to the PDS 2272 2292 // 2273 2293 // it is a no-op if the provided ATURI is empty 2274 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2275 2295 if aturi == "" { 2276 2296 return nil 2277 2297 } ··· 2282 2302 repo := parsed.Authority().String() 2283 2303 rkey := parsed.RecordKey().String() 2284 2304 2285 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2286 2306 Collection: collection, 2287 2307 Repo: repo, 2288 2308 Rkey: rkey,
+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 }
+63
appview/search/query.go
··· 1 + package search 2 + 3 + import ( 4 + "strings" 5 + ) 6 + 7 + // Query represents a parsed search query 8 + type Query struct { 9 + // Text search terms (anything that's not a has: filter) 10 + Text string 11 + // Label filters from has:labelname syntax 12 + Labels []string 13 + } 14 + 15 + // Parse parses a search query string into a Query struct 16 + // Syntax: 17 + // - "has:enhancement" adds a label filter 18 + // - Other text becomes part of the text search 19 + func Parse(queryStr string) Query { 20 + q := Query{ 21 + Labels: []string{}, 22 + } 23 + 24 + // Split query into tokens 25 + tokens := strings.Fields(queryStr) 26 + var textParts []string 27 + 28 + for _, token := range tokens { 29 + // Check if it's a has: filter 30 + if strings.HasPrefix(token, "has:") { 31 + label := strings.TrimPrefix(token, "has:") 32 + if label != "" { 33 + q.Labels = append(q.Labels, label) 34 + } 35 + } else { 36 + // It's a text search term 37 + textParts = append(textParts, token) 38 + } 39 + } 40 + 41 + q.Text = strings.Join(textParts, " ") 42 + return q 43 + } 44 + 45 + // String converts a Query back to a query string 46 + func (q Query) String() string { 47 + var parts []string 48 + 49 + if q.Text != "" { 50 + parts = append(parts, q.Text) 51 + } 52 + 53 + for _, label := range q.Labels { 54 + parts = append(parts, "has:"+label) 55 + } 56 + 57 + return strings.Join(parts, " ") 58 + } 59 + 60 + // HasFilters returns true if the query has any search filters 61 + func (q Query) HasFilters() bool { 62 + return q.Text != "" || len(q.Labels) > 0 63 + }
+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 (
+64 -12
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)) ··· 418 470 } 419 471 420 472 // store in pds too 421 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 422 474 Collection: tangled.PublicKeyNSID, 423 475 Repo: did, 424 476 Rkey: rkey, ··· 475 527 476 528 if rkey != "" { 477 529 // remove from pds too 478 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 479 531 Collection: tangled.PublicKeyNSID, 480 532 Repo: did, 481 533 Rkey: rkey,
+75 -12
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/idresolver" 21 24 ) 22 25 23 26 type Signup struct { ··· 25 28 db *db.DB 26 29 cf *dns.Cloudflare 27 30 posthog posthog.Client 28 - xrpc *xrpcclient.Client 29 31 idResolver *idresolver.Resolver 30 32 pages *pages.Pages 31 33 l *slog.Logger ··· 115 117 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 118 switch r.Method { 117 119 case http.MethodGet: 118 - s.pages.Signup(w) 120 + s.pages.Signup(w, pages.SignupParams{ 121 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 + }) 119 123 case http.MethodPost: 120 124 if s.cf == nil { 121 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 + return 122 127 } 123 128 emailId := r.FormValue("email") 129 + cfToken := r.FormValue("cf-turnstile-response") 124 130 125 131 noticeId := "signup-msg" 132 + 133 + if err := s.validateCaptcha(cfToken, r); err != nil { 134 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 135 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 136 + return 137 + } 138 + 126 139 if !email.IsValidEmail(emailId) { 127 140 s.pages.Notice(w, noticeId, "Invalid email address.") 128 141 return ··· 163 176 s.pages.Notice(w, noticeId, "Failed to send email.") 164 177 return 165 178 } 166 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 179 + err = db.AddInflightSignup(s.db, models.InflightSignup{ 167 180 Email: emailId, 168 181 InviteCode: code, 169 182 }) ··· 229 242 return 230 243 } 231 244 232 - err = db.AddEmail(s.db, db.Email{ 245 + err = db.AddEmail(s.db, models.Email{ 233 246 Did: did, 234 247 Address: email, 235 248 Verified: true, ··· 254 267 return 255 268 } 256 269 } 270 + 271 + type turnstileResponse struct { 272 + Success bool `json:"success"` 273 + ErrorCodes []string `json:"error-codes,omitempty"` 274 + ChallengeTs string `json:"challenge_ts,omitempty"` 275 + Hostname string `json:"hostname,omitempty"` 276 + } 277 + 278 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 279 + if cfToken == "" { 280 + return errors.New("captcha token is empty") 281 + } 282 + 283 + if s.config.Cloudflare.TurnstileSecretKey == "" { 284 + return errors.New("turnstile secret key not configured") 285 + } 286 + 287 + data := url.Values{} 288 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 289 + data.Set("response", cfToken) 290 + 291 + // include the client IP if we have it 292 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 293 + data.Set("remoteip", remoteIP) 294 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 295 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 296 + data.Set("remoteip", strings.TrimSpace(ips[0])) 297 + } 298 + } else { 299 + data.Set("remoteip", r.RemoteAddr) 300 + } 301 + 302 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 303 + if err != nil { 304 + return fmt.Errorf("failed to verify turnstile token: %w", err) 305 + } 306 + defer resp.Body.Close() 307 + 308 + var turnstileResp turnstileResponse 309 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 310 + return fmt.Errorf("failed to decode turnstile response: %w", err) 311 + } 312 + 313 + if !turnstileResp.Success { 314 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 315 + return errors.New("turnstile validation failed") 316 + } 317 + 318 + return nil 319 + }
+20 -19
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 }) ··· 188 189 return 189 190 } 190 191 191 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 192 193 var exCid *string 193 194 if ex != nil { 194 195 exCid = ex.Cid 195 196 } 196 197 197 198 // re-announce by registering under same rkey 198 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 199 200 Collection: tangled.SpindleNSID, 200 201 Repo: user.Did, 201 202 Rkey: instance, ··· 331 332 return 332 333 } 333 334 334 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 335 336 Collection: tangled.SpindleNSID, 336 337 Repo: user.Did, 337 338 Rkey: instance, ··· 524 525 rkey := tid.TID() 525 526 526 527 // add member to db 527 - if err = db.AddSpindleMember(tx, db.SpindleMember{ 528 + if err = db.AddSpindleMember(tx, models.SpindleMember{ 528 529 Did: syntax.DID(user.Did), 529 530 Rkey: rkey, 530 531 Instance: instance, ··· 541 542 return 542 543 } 543 544 544 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 545 546 Collection: tangled.SpindleMemberNSID, 546 547 Repo: user.Did, 547 548 Rkey: rkey, ··· 682 683 } 683 684 684 685 // remove from pds 685 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 686 687 Collection: tangled.SpindleMemberNSID, 687 688 Repo: user.Did, 688 689 Rkey: members[0].Rkey,
+10 -9
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) { ··· 42 43 case http.MethodPost: 43 44 createdAt := time.Now().Format(time.RFC3339) 44 45 rkey := tid.TID() 45 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 46 47 Collection: tangled.GraphFollowNSID, 47 48 Repo: currentUser.Did, 48 49 Rkey: rkey, ··· 59 60 60 61 log.Println("created atproto record: ", resp.Uri) 61 62 62 - follow := &db.Follow{ 63 + follow := &models.Follow{ 63 64 UserDid: currentUser.Did, 64 65 SubjectDid: subjectIdent.DID.String(), 65 66 Rkey: rkey, ··· 75 76 76 77 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 78 UserDid: subjectIdent.DID.String(), 78 - FollowStatus: db.IsFollowing, 79 + FollowStatus: models.IsFollowing, 79 80 }) 80 81 81 82 return ··· 87 88 return 88 89 } 89 90 90 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 91 92 Collection: tangled.GraphFollowNSID, 92 93 Repo: currentUser.Did, 93 94 Rkey: follow.Rkey, ··· 106 107 107 108 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 108 109 UserDid: subjectIdent.DID.String(), 109 - FollowStatus: db.IsNotFollowing, 110 + FollowStatus: models.IsNotFollowing, 110 111 }) 111 112 112 113 s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+4 -4
appview/state/git_http.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/identity" 10 10 "github.com/go-chi/chi/v5" 11 - "tangled.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),
+63
appview/state/login.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+32 -39
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 { ··· 641 634 vanityStats = append(vanityStats, string(v.Kind)) 642 635 } 643 636 644 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 645 638 var cid *string 646 639 if ex != nil { 647 640 cid = ex.Cid 648 641 } 649 642 650 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 651 644 Collection: tangled.ActorProfileNSID, 652 645 Repo: user.Did, 653 646 Rkey: "self",
+17 -14
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 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" 11 + 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 47 case http.MethodPost: 47 48 createdAt := time.Now().Format(time.RFC3339) 48 49 rkey := tid.TID() 49 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 50 51 Collection: tangled.FeedReactionNSID, 51 52 Repo: currentUser.Did, 52 53 Rkey: rkey, ··· 69 70 return 70 71 } 71 72 72 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 73 74 if err != nil { 74 - log.Println("failed to get reaction count for ", subjectUri) 75 + log.Println("failed to get reactions for ", subjectUri) 75 76 } 76 77 77 78 log.Println("created atproto record: ", resp.Uri) ··· 79 80 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 81 ThreadAt: subjectUri, 81 82 Kind: reactionKind, 82 - Count: count, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 83 85 IsReacted: true, 84 86 }) 85 87 ··· 91 93 return 92 94 } 93 95 94 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 97 Collection: tangled.FeedReactionNSID, 96 98 Repo: currentUser.Did, 97 99 Rkey: reaction.Rkey, ··· 108 110 // this is not an issue, the firehose event might have already done this 109 111 } 110 112 111 - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 112 114 if err != nil { 113 - log.Println("failed to get reaction count for ", subjectUri) 115 + log.Println("failed to get reactions for ", subjectUri) 114 116 return 115 117 } 116 118 117 119 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 120 ThreadAt: subjectUri, 119 121 Kind: reactionKind, 120 - Count: count, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 121 124 IsReacted: false, 122 125 }) 123 126
+47 -22
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 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" 8 + "tangled.org/core/appview/issues" 9 + "tangled.org/core/appview/knots" 10 + "tangled.org/core/appview/labels" 11 + "tangled.org/core/appview/middleware" 12 + "tangled.org/core/appview/notifications" 13 + "tangled.org/core/appview/pipelines" 14 + "tangled.org/core/appview/pulls" 15 + "tangled.org/core/appview/repo" 16 + "tangled.org/core/appview/settings" 17 + "tangled.org/core/appview/signup" 18 + "tangled.org/core/appview/spindles" 19 + "tangled.org/core/appview/state/userutil" 20 + avstrings "tangled.org/core/appview/strings" 21 + "tangled.org/core/log" 23 22 ) 24 23 25 24 func (s *State) Router() http.Handler { ··· 35 34 36 35 router.Get("/favicon.svg", s.Favicon) 37 36 router.Get("/favicon.ico", s.Favicon) 37 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 38 39 39 userRouter := s.UserRouter(&middleware) 40 40 standardRouter := s.StandardRouter(&middleware) ··· 115 115 116 116 r.Get("/", s.HomeOrTimeline) 117 117 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 118 + r.Get("/upgradeBanner", s.UpgradeBanner) 119 + 120 + // special-case handler for serving tangled.org/core 121 + r.Get("/core", s.Core()) 122 + 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 119 126 120 127 r.Route("/repo", func(r chi.Router) { 121 128 r.Route("/new", func(r chi.Router) { ··· 125 132 }) 126 133 // r.Post("/import", s.ImportRepo) 127 134 }) 135 + 136 + r.Get("/goodfirstissues", s.GoodFirstIssues) 128 137 129 138 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 130 139 r.Post("/", s.Follow) ··· 153 162 r.Mount("/strings", s.StringsRouter(mw)) 154 163 r.Mount("/knots", s.KnotsRouter()) 155 164 r.Mount("/spindles", s.SpindlesRouter()) 165 + r.Mount("/notifications", s.NotificationsRouter(mw)) 166 + 156 167 r.Mount("/signup", s.SignupRouter()) 157 - r.Mount("/", s.OAuthRouter()) 168 + r.Mount("/", s.oauth.Router()) 158 169 159 170 r.Get("/keys/{user}", s.Keys) 160 171 r.Get("/terms", s.TermsOfService) 161 172 r.Get("/privacy", s.PrivacyPolicy) 173 + r.Get("/brand", s.Brand) 162 174 163 175 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 164 176 s.pages.Error404(w) ··· 166 178 return r 167 179 } 168 180 169 - func (s *State) OAuthRouter() http.Handler { 170 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 171 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 172 - return oauth.Router() 181 + // Core serves tangled.org/core go-import meta tags, and redirects 182 + // to the core repository if accessed normally. 183 + func (s *State) Core() http.HandlerFunc { 184 + return func(w http.ResponseWriter, r *http.Request) { 185 + if r.URL.Query().Get("go-get") == "1" { 186 + w.Header().Set("Content-Type", "text/html") 187 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 188 + return 189 + } 190 + 191 + http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 192 + } 173 193 } 174 194 175 195 func (s *State) SettingsRouter() http.Handler { ··· 253 273 } 254 274 255 275 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 256 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 276 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 257 277 return ls.Router(mw) 278 + } 279 + 280 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 281 + notifs := notifications.New(s.db, s.oauth, s.pages) 282 + return notifs.Router(mw) 258 283 } 259 284 260 285 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:"),
+10 -9
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) { ··· 39 40 case http.MethodPost: 40 41 createdAt := time.Now().Format(time.RFC3339) 41 42 rkey := tid.TID() 42 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 43 44 Collection: tangled.FeedStarNSID, 44 45 Repo: currentUser.Did, 45 46 Rkey: rkey, ··· 55 56 } 56 57 log.Println("created atproto record: ", resp.Uri) 57 58 58 - star := &db.Star{ 59 + star := &models.Star{ 59 60 StarredByDid: currentUser.Did, 60 61 RepoAt: subjectUri, 61 62 Rkey: rkey, ··· 77 78 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 79 IsStarred: true, 79 80 RepoAt: subjectUri, 80 - Stats: db.RepoStats{ 81 + Stats: models.RepoStats{ 81 82 StarCount: starCount, 82 83 }, 83 84 }) ··· 91 92 return 92 93 } 93 94 94 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 95 96 Collection: tangled.FeedStarNSID, 96 97 Repo: currentUser.Did, 97 98 Rkey: star.Rkey, ··· 119 120 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 121 IsStarred: false, 121 122 RepoAt: subjectUri, 122 - Stats: db.RepoStats{ 123 + Stats: models.RepoStats{ 123 124 StarCount: starCount, 124 125 }, 125 126 })
+118 -36
appview/state/state.go
··· 11 11 "strings" 12 12 "time" 13 13 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview" 16 + "tangled.org/core/appview/cache" 17 + "tangled.org/core/appview/cache/session" 18 + "tangled.org/core/appview/config" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/notify" 22 + dbnotify "tangled.org/core/appview/notify/db" 23 + phnotify "tangled.org/core/appview/notify/posthog" 24 + "tangled.org/core/appview/oauth" 25 + "tangled.org/core/appview/pages" 26 + "tangled.org/core/appview/reporesolver" 27 + "tangled.org/core/appview/validator" 28 + xrpcclient "tangled.org/core/appview/xrpcclient" 29 + "tangled.org/core/eventconsumer" 30 + "tangled.org/core/idresolver" 31 + "tangled.org/core/jetstream" 32 + tlog "tangled.org/core/log" 33 + "tangled.org/core/rbac" 34 + "tangled.org/core/tid" 35 + 14 36 comatproto "github.com/bluesky-social/indigo/api/atproto" 37 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 38 "github.com/bluesky-social/indigo/atproto/syntax" 16 39 lexutil "github.com/bluesky-social/indigo/lex/util" 17 40 securejoin "github.com/cyphar/filepath-securejoin" 18 41 "github.com/go-chi/chi/v5" 19 42 "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" 40 43 ) 41 44 42 45 type State struct { ··· 74 77 res = idresolver.DefaultResolver() 75 78 } 76 79 77 - pgs := pages.NewPages(config, res) 80 + pages := pages.NewPages(config, res) 78 81 cache := cache.New(config.Redis.Addr) 79 82 sess := session.New(cache) 80 - oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 83 + oauth2, err := oauth.New(config) 84 + if err != nil { 85 + return nil, fmt.Errorf("failed to start oauth handler: %w", err) 86 + } 87 + validator := validator.New(d, res, enforcer) 82 88 83 89 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 90 if err != nil { ··· 87 93 88 94 repoResolver := reporesolver.New(config, enforcer, res, d) 89 95 90 - wrapper := db.DbWrapper{d} 96 + wrapper := db.DbWrapper{Execer: d} 91 97 jc, err := jetstream.NewJetstreamClient( 92 98 config.Jetstream.Endpoint, 93 99 "appview", ··· 103 109 tangled.RepoIssueNSID, 104 110 tangled.RepoIssueCommentNSID, 105 111 tangled.LabelDefinitionNSID, 112 + tangled.LabelOpNSID, 106 113 }, 107 114 nil, 108 115 slog.Default(), ··· 117 124 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 118 125 } 119 126 127 + if err := BackfillDefaultDefs(d, res); err != nil { 128 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 129 + } 130 + 120 131 ingester := appview.Ingester{ 121 132 Db: wrapper, 122 133 Enforcer: enforcer, ··· 143 154 spindlestream.Start(ctx) 144 155 145 156 var notifiers []notify.Notifier 157 + 158 + // Always add the database notifier 159 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 160 + 161 + // Add other notifiers in production only 146 162 if !config.Core.Dev { 147 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 163 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 148 164 } 149 165 notifier := notify.NewMergedNotifier(notifiers...) 150 166 151 167 state := &State{ 152 168 d, 153 169 notifier, 154 - oauth, 170 + oauth2, 155 171 enforcer, 156 - pgs, 172 + pages, 157 173 sess, 158 174 res, 159 175 posthog, ··· 187 203 s.pages.Favicon(w) 188 204 } 189 205 206 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 207 + const manifestJson = `{ 208 + "name": "tangled", 209 + "description": "tightly-knit social coding.", 210 + "icons": [ 211 + { 212 + "src": "/favicon.svg", 213 + "sizes": "144x144" 214 + } 215 + ], 216 + "start_url": "/", 217 + "id": "org.tangled", 218 + 219 + "display": "standalone", 220 + "background_color": "#111827", 221 + "theme_color": "#111827" 222 + }` 223 + 224 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 225 + w.Header().Set("Content-Type", "application/json") 226 + w.Write([]byte(manifestJson)) 227 + } 228 + 190 229 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 191 230 user := s.oauth.GetUser(r) 192 231 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 201 240 }) 202 241 } 203 242 243 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 244 + user := s.oauth.GetUser(r) 245 + s.pages.Brand(w, pages.BrandParams{ 246 + LoggedInUser: user, 247 + }) 248 + } 249 + 204 250 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 205 251 if s.oauth.GetUser(r) != nil { 206 252 s.Timeline(w, r) ··· 229 275 return 230 276 } 231 277 278 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 279 + if err != nil { 280 + // non-fatal 281 + } 282 + 232 283 s.pages.Timeline(w, pages.TimelineParams{ 233 284 LoggedInUser: user, 234 285 Timeline: timeline, 235 286 Repos: repos, 287 + GfiLabel: gfiLabel, 236 288 }) 237 289 } 238 290 239 291 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 240 292 user := s.oauth.GetUser(r) 293 + if user == nil { 294 + return 295 + } 296 + 241 297 l := s.logger.With("handler", "UpgradeBanner") 242 298 l = l.With("did", user.Did) 243 - l = l.With("handle", user.Handle) 244 299 245 300 regs, err := db.GetRegistrations( 246 301 s.db, ··· 380 435 381 436 user := s.oauth.GetUser(r) 382 437 l = l.With("did", user.Did) 383 - l = l.With("handle", user.Handle) 384 438 385 439 // form validation 386 440 domain := r.FormValue("domain") ··· 433 487 434 488 // create atproto record for this repo 435 489 rkey := tid.TID() 436 - repo := &db.Repo{ 490 + repo := &models.Repo{ 437 491 Did: user.Did, 438 492 Name: repoName, 439 493 Knot: domain, 440 494 Rkey: rkey, 441 495 Description: description, 442 496 Created: time.Now(), 497 + Labels: models.DefaultLabelDefs(), 443 498 } 444 499 record := repo.AsRecord() 445 500 446 - xrpcClient, err := s.oauth.AuthorizedClient(r) 501 + atpClient, err := s.oauth.AuthorizedClient(r) 447 502 if err != nil { 448 503 l.Info("PDS write failed", "err", err) 449 504 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 450 505 return 451 506 } 452 507 453 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 508 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 454 509 Collection: tangled.RepoNSID, 455 510 Repo: user.Did, 456 511 Rkey: rkey, ··· 482 537 rollback := func() { 483 538 err1 := tx.Rollback() 484 539 err2 := s.enforcer.E.LoadPolicy() 485 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 540 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 486 541 487 542 // ignore txn complete errors, this is okay 488 543 if errors.Is(err1, sql.ErrTxDone) { ··· 555 610 aturi = "" 556 611 557 612 s.notifier.NewRepo(r.Context(), repo) 558 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 613 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) 559 614 } 560 615 } 561 616 562 617 // this is used to rollback changes made to the PDS 563 618 // 564 619 // it is a no-op if the provided ATURI is empty 565 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 620 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 566 621 if aturi == "" { 567 622 return nil 568 623 } ··· 573 628 repo := parsed.Authority().String() 574 629 rkey := parsed.RecordKey().String() 575 630 576 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 631 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 577 632 Collection: collection, 578 633 Repo: repo, 579 634 Rkey: rkey, 580 635 }) 581 636 return err 582 637 } 638 + 639 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 640 + defaults := models.DefaultLabelDefs() 641 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 642 + if err != nil { 643 + return err 644 + } 645 + // already present 646 + if len(defaultLabels) == len(defaults) { 647 + return nil 648 + } 649 + 650 + labelDefs, err := models.FetchDefaultDefs(r) 651 + if err != nil { 652 + return err 653 + } 654 + 655 + // Insert each label definition to the database 656 + for _, labelDef := range labelDefs { 657 + _, err = db.AddLabelDefinition(e, &labelDef) 658 + if err != nil { 659 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 660 + } 661 + } 662 + 663 + return nil 664 + }
+21 -18
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" 23 24 "github.com/bluesky-social/indigo/atproto/syntax" 24 - lexutil "github.com/bluesky-social/indigo/lex/util" 25 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 26 29 ) 27 30 28 31 type Strings struct { ··· 235 238 description := r.FormValue("description") 236 239 237 240 // construct new string from form values 238 - entry := db.String{ 241 + entry := models.String{ 239 242 Did: first.Did, 240 243 Rkey: first.Rkey, 241 244 Filename: filename, ··· 253 256 } 254 257 255 258 // first replace the existing record in the PDS 256 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 257 260 if err != nil { 258 261 fail("Failed to updated existing record.", err) 259 262 return 260 263 } 261 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 262 265 Collection: tangled.StringNSID, 263 266 Repo: entry.Did.String(), 264 267 Rkey: entry.Rkey, ··· 283 286 s.Notifier.EditString(r.Context(), &entry) 284 287 285 288 // if that went okay, redir to the string 286 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 287 290 } 288 291 289 292 } ··· 318 321 319 322 description := r.FormValue("description") 320 323 321 - string := db.String{ 324 + string := models.String{ 322 325 Did: syntax.DID(user.Did), 323 326 Rkey: tid.TID(), 324 327 Filename: filename, ··· 335 338 return 336 339 } 337 340 338 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 339 342 Collection: tangled.StringNSID, 340 343 Repo: user.Did, 341 344 Rkey: string.Rkey, ··· 359 362 s.Notifier.NewString(r.Context(), &string) 360 363 361 364 // successful 362 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 363 366 } 364 367 } 365 368 ··· 402 405 403 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 404 407 405 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 406 409 } 407 410 408 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
+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 }
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 19 13 ErrXrpcFailed = errors.New("xrpc request failed") 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 22 - 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 16 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error {
+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/genjwks/main.go
··· 1 - // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 1 + // adapted from https://tangled.org/anirudh.fi/atproto-oauth 2 2 3 3 package main 4 4
+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 (
+5 -5
go.mod
··· 1 - module tangled.sh/tangled.sh/core 1 + module tangled.org/core 2 2 3 3 go 1.24.4 4 4 ··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 ··· 40 40 github.com/urfave/cli/v3 v3.3.3 41 41 github.com/whyrusleeping/cbor-gen v0.3.1 42 42 github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 - github.com/yuin/goldmark v1.7.12 43 + github.com/yuin/goldmark v1.7.13 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 49 50 gopkg.in/yaml.v3 v3.0.1 50 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 51 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 51 52 ) 52 53 53 54 require ( ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+6 -4
go.sum
··· 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 436 438 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 437 439 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 440 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 - github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 - github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 442 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 443 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 444 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 443 445 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= ··· 652 654 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 653 655 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 654 656 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 655 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 657 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5 h1:EpQ9MT09jSf4Zjs1+yFvB4CD/fBkFdx8UaDJDwO1Jk8= 658 + tangled.org/anirudh.fi/atproto-oauth v0.0.0-20251004062652-69f4561572b5/go.mod h1:BQFGoN2V+h5KtgKsQgWU73R55ILdDy/R5RZTrZi6wog= 657 659 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 658 660 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+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",
+1 -1
nix/gomod2nix.toml
··· 527 527 [mod."lukechampine.com/blake3"] 528 528 version = "v1.4.1" 529 529 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 - [mod."tangled.sh/icyphox.sh/atproto-oauth"] 530 + [mod."tangled.org/anirudh.fi/atproto-oauth"] 531 531 version = "v0.0.0-20250724194903-28e660378cb1" 532 532 hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+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"