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

Compare changes

Choose any two refs to compare.

Changed files
+7557 -4328
.air
.tangled
workflows
api
tangled
appview
cache
session
commitverify
config
db
dns
issues
knots
labels
middleware
models
notifications
notify
oauth
pages
pagination
pipelines
posthog
pulls
repo
reporesolver
serververify
settings
signup
spindles
state
strings
validator
cmd
appview
combinediff
interdiff
knot
spindle
verifysig
crypto
docs
eventconsumer
guard
jetstream
keyfetch
knotserver
legal
lexicons
repo
nix
patchutil
rbac
spindle
types
workflow
xrpc
serviceauth
+1 -1
.air/knotserver.toml
··· 1 [build] 2 - cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 bin = ".bin/knot server" 4 root = "." 5
··· 1 [build] 2 + cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/' 3 bin = ".bin/knot server" 4 root = "." 5
+6
.tangled/workflows/test.yml
··· 14 command: | 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 17 - name: run all tests 18 environment: 19 CGO_ENABLED: 1
··· 14 command: | 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 17 + - name: run linter 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go vet -v ./... 22 + 23 - name: run all tests 24 environment: 25 CGO_ENABLED: 1
+10
api/tangled/repotree.go
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 // ref: The git reference used 35 Ref string `json:"ref" cborgen:"ref"` 36 } 37 38 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
··· 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 // parent: The parent path in the tree 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"` 36 // ref: The git reference used 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"` 46 } 47 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+1 -1
appview/cache/session/store.go
··· 6 "fmt" 7 "time" 8 9 - "tangled.sh/tangled.sh/core/appview/cache" 10 ) 11 12 type OAuthSession struct {
··· 6 "fmt" 7 "time" 8 9 + "tangled.org/core/appview/cache" 10 ) 11 12 type OAuthSession struct {
+5 -4
appview/commitverify/verify.go
··· 4 "log" 5 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" 10 ) 11 12 type verifiedCommit struct { ··· 45 func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 46 vcs := VerifiedCommits{} 47 48 - didPubkeyCache := make(map[string][]db.PublicKey) 49 50 for _, commit := range ndCommits { 51 c := commit.Commit
··· 4 "log" 5 6 "github.com/go-git/go-git/v5/plumbing/object" 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/crypto" 10 + "tangled.org/core/types" 11 ) 12 13 type verifiedCommit struct { ··· 46 func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) { 47 vcs := VerifiedCommits{} 48 49 + didPubkeyCache := make(map[string][]models.PublicKey) 50 51 for _, commit := range ndCommits { 52 c := commit.Commit
+4 -2
appview/config/config.go
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 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"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
+5 -25
appview/db/artifact.go
··· 5 "strings" 6 "time" 7 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 "github.com/go-git/go-git/v5/plumbing" 10 "github.com/ipfs/go-cid" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 ) 13 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 { 34 _, err := e.Exec( 35 `insert or ignore into artifacts ( 36 did, ··· 57 return err 58 } 59 60 - func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 61 - var artifacts []Artifact 62 63 var conditions []string 64 var args []any ··· 94 defer rows.Close() 95 96 for rows.Next() { 97 - var artifact Artifact 98 var createdAt string 99 var tag []byte 100 var blobCid string
··· 5 "strings" 6 "time" 7 8 "github.com/go-git/go-git/v5/plumbing" 9 "github.com/ipfs/go-cid" 10 + "tangled.org/core/appview/models" 11 ) 12 13 + func AddArtifact(e Execer, artifact models.Artifact) error { 14 _, err := e.Exec( 15 `insert or ignore into artifacts ( 16 did, ··· 37 return err 38 } 39 40 + func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) { 41 + var artifacts []models.Artifact 42 43 var conditions []string 44 var args []any ··· 74 defer rows.Close() 75 76 for rows.Next() { 77 + var artifact models.Artifact 78 var createdAt string 79 var tag []byte 80 var blobCid string
+3 -18
appview/db/collaborators.go
··· 3 import ( 4 "fmt" 5 "strings" 6 - "time" 7 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 ) 10 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 { 26 _, err := e.Exec( 27 `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 c.Did, c.Rkey, c.SubjectDid, c.RepoAt, ··· 49 return err 50 } 51 52 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 if err != nil { 55 return nil, err
··· 3 import ( 4 "fmt" 5 "strings" 6 7 + "tangled.org/core/appview/models" 8 ) 9 10 + func AddCollaborator(e Execer, c models.Collaborator) error { 11 _, err := e.Exec( 12 `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 13 c.Did, c.Rkey, c.SubjectDid, c.RepoAt, ··· 34 return err 35 } 36 37 + func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) { 38 rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 39 if err != nil { 40 return nil, err
+172 -10
appview/db/db.go
··· 527 -- label to subscribe to 528 label_at text not null, 529 530 - unique (repo_at, label_at), 531 - foreign key (label_at) references label_definitions (at_uri) 532 ); 533 534 create table if not exists migrations ( ··· 536 name text unique 537 ); 538 539 - -- indexes for better star query performance 540 create index if not exists idx_stars_created on stars(created); 541 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 542 `) ··· 788 _, err := tx.Exec(` 789 alter table spindles add column needs_upgrade integer not null default 0; 790 `) 791 - if err != nil { 792 - return err 793 - } 794 - 795 - _, err = tx.Exec(` 796 - update spindles set needs_upgrade = 1; 797 - `) 798 return err 799 }) 800 ··· 931 _, err = tx.Exec(`drop table comments`) 932 return err 933 }) 934 935 return &DB{db}, nil 936 }
··· 527 -- label to subscribe to 528 label_at text not null, 529 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 559 ); 560 561 create table if not exists migrations ( ··· 563 name text unique 564 ); 565 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); 569 create index if not exists idx_stars_created on stars(created); 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 571 `) ··· 817 _, err := tx.Exec(` 818 alter table spindles add column needs_upgrade integer not null default 0; 819 `) 820 return err 821 }) 822 ··· 953 _, err = tx.Exec(`drop table comments`) 954 return err 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;") 1096 1097 return &DB{db}, nil 1098 }
+29 -34
appview/db/email.go
··· 3 import ( 4 "strings" 5 "time" 6 - ) 7 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 - } 18 19 - func GetPrimaryEmail(e Execer, did string) (Email, error) { 20 query := ` 21 select id, did, email, verified, is_primary, verification_code, last_sent, created 22 from emails 23 where did = ? and is_primary = true 24 ` 25 - var email Email 26 var createdStr string 27 var lastSent string 28 err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 29 if err != nil { 30 - return Email{}, err 31 } 32 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 33 if err != nil { 34 - return Email{}, err 35 } 36 parsedTime, err := time.Parse(time.RFC3339, lastSent) 37 if err != nil { 38 - return Email{}, err 39 } 40 email.LastSent = &parsedTime 41 return email, nil 42 } 43 44 - func GetEmail(e Execer, did string, em string) (Email, error) { 45 query := ` 46 select id, did, email, verified, is_primary, verification_code, last_sent, created 47 from emails 48 where did = ? and email = ? 49 ` 50 - var email Email 51 var createdStr string 52 var lastSent string 53 err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 54 if err != nil { 55 - return Email{}, err 56 } 57 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 58 if err != nil { 59 - return Email{}, err 60 } 61 parsedTime, err := time.Parse(time.RFC3339, lastSent) 62 if err != nil { 63 - return Email{}, err 64 } 65 email.LastSent = &parsedTime 66 return email, nil ··· 80 return did, nil 81 } 82 83 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 84 - if len(ems) == 0 { 85 return make(map[string]string), nil 86 } 87 ··· 90 verifiedFilter = 1 91 } 92 93 // Create placeholders for the IN clause 94 - placeholders := make([]string, len(ems)) 95 - args := make([]any, len(ems)+1) 96 97 args[0] = verifiedFilter 98 - for i, em := range ems { 99 - placeholders[i] = "?" 100 - args[i+1] = em 101 } 102 103 query := ` ··· 113 return nil, err 114 } 115 defer rows.Close() 116 - 117 - assoc := make(map[string]string) 118 119 for rows.Next() { 120 var email, did string ··· 187 return count > 0, nil 188 } 189 190 - func AddEmail(e Execer, email Email) error { 191 // Check if this is the first email for this DID 192 countQuery := ` 193 select count(*) ··· 254 return err 255 } 256 257 - func GetAllEmails(e Execer, did string) ([]Email, error) { 258 query := ` 259 select did, email, verified, is_primary, verification_code, last_sent, created 260 from emails ··· 266 } 267 defer rows.Close() 268 269 - var emails []Email 270 for rows.Next() { 271 - var email Email 272 var createdStr string 273 var lastSent string 274 err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
··· 3 import ( 4 "strings" 5 "time" 6 7 + "tangled.org/core/appview/models" 8 + ) 9 10 + func GetPrimaryEmail(e Execer, did string) (models.Email, error) { 11 query := ` 12 select id, did, email, verified, is_primary, verification_code, last_sent, created 13 from emails 14 where did = ? and is_primary = true 15 ` 16 + var email models.Email 17 var createdStr string 18 var lastSent string 19 err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 20 if err != nil { 21 + return models.Email{}, err 22 } 23 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 24 if err != nil { 25 + return models.Email{}, err 26 } 27 parsedTime, err := time.Parse(time.RFC3339, lastSent) 28 if err != nil { 29 + return models.Email{}, err 30 } 31 email.LastSent = &parsedTime 32 return email, nil 33 } 34 35 + func GetEmail(e Execer, did string, em string) (models.Email, error) { 36 query := ` 37 select id, did, email, verified, is_primary, verification_code, last_sent, created 38 from emails 39 where did = ? and email = ? 40 ` 41 + var email models.Email 42 var createdStr string 43 var lastSent string 44 err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr) 45 if err != nil { 46 + return models.Email{}, err 47 } 48 email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) 49 if err != nil { 50 + return models.Email{}, err 51 } 52 parsedTime, err := time.Parse(time.RFC3339, lastSent) 53 if err != nil { 54 + return models.Email{}, err 55 } 56 email.LastSent = &parsedTime 57 return email, nil ··· 71 return did, nil 72 } 73 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 81 verifiedFilter = 1 82 } 83 84 + assoc := make(map[string]string) 85 + 86 // Create placeholders for the IN clause 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 89 90 args[0] = verifiedFilter 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) 98 } 99 100 query := ` ··· 110 return nil, err 111 } 112 defer rows.Close() 113 114 for rows.Next() { 115 var email, did string ··· 182 return count > 0, nil 183 } 184 185 + func AddEmail(e Execer, email models.Email) error { 186 // Check if this is the first email for this DID 187 countQuery := ` 188 select count(*) ··· 249 return err 250 } 251 252 + func GetAllEmails(e Execer, did string) ([]models.Email, error) { 253 query := ` 254 select did, email, verified, is_primary, verification_code, last_sent, created 255 from emails ··· 261 } 262 defer rows.Close() 263 264 + var emails []models.Email 265 for rows.Next() { 266 + var email models.Email 267 var createdStr string 268 var lastSent string 269 err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+26 -57
appview/db/follow.go
··· 5 "log" 6 "strings" 7 "time" 8 ) 9 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 { 18 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 19 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 20 return err 21 } 22 23 // Get a follow record 24 - func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) { 25 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 26 row := e.QueryRow(query, userDid, subjectDid) 27 28 - var follow Follow 29 var followedAt string 30 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 31 if err != nil { ··· 55 return err 56 } 57 58 - type FollowStats struct { 59 - Followers int64 60 - Following int64 61 - } 62 - 63 - func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 var followers, following int64 65 err := e.QueryRow( 66 `SELECT ··· 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 69 FROM follows;`, did, did).Scan(&followers, &following) 70 if err != nil { 71 - return FollowStats{}, err 72 } 73 - return FollowStats{ 74 Followers: followers, 75 Following: following, 76 }, nil 77 } 78 79 - func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 if len(dids) == 0 { 81 return nil, nil 82 } ··· 112 ) g on f.did = g.did`, 113 placeholderStr, placeholderStr) 114 115 - result := make(map[string]FollowStats) 116 117 rows, err := e.Query(query, args...) 118 if err != nil { ··· 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 return nil, err 128 } 129 - result[did] = FollowStats{ 130 Followers: followers, 131 Following: following, 132 } ··· 134 135 for _, did := range dids { 136 if _, exists := result[did]; !exists { 137 - result[did] = FollowStats{ 138 Followers: 0, 139 Following: 0, 140 } ··· 144 return result, nil 145 } 146 147 - func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 148 - var follows []Follow 149 150 var conditions []string 151 var args []any ··· 177 return nil, err 178 } 179 for rows.Next() { 180 - var follow Follow 181 var followedAt string 182 err := rows.Scan( 183 &follow.UserDid, ··· 200 return follows, nil 201 } 202 203 - func GetFollowers(e Execer, did string) ([]Follow, error) { 204 return GetFollows(e, 0, FilterEq("subject_did", did)) 205 } 206 207 - func GetFollowing(e Execer, did string) ([]Follow, error) { 208 return GetFollows(e, 0, FilterEq("user_did", did)) 209 } 210 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) { 233 if len(subjectDids) == 0 || userDid == "" { 234 - return make(map[string]FollowStatus), nil 235 } 236 237 - result := make(map[string]FollowStatus) 238 239 for _, subjectDid := range subjectDids { 240 if userDid == subjectDid { 241 - result[subjectDid] = IsSelf 242 } else { 243 - result[subjectDid] = IsNotFollowing 244 } 245 } 246 ··· 281 if err := rows.Scan(&subjectDid); err != nil { 282 return nil, err 283 } 284 - result[subjectDid] = IsFollowing 285 } 286 287 return result, nil 288 } 289 290 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 291 statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 292 if err != nil { 293 - return IsNotFollowing 294 } 295 return statuses[subjectDid] 296 } 297 298 - func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) { 299 return getFollowStatuses(e, userDid, subjectDids) 300 }
··· 5 "log" 6 "strings" 7 "time" 8 + 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func AddFollow(e Execer, follow *models.Follow) error { 13 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 14 _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 15 return err 16 } 17 18 // Get a follow record 19 + func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 20 query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 21 row := e.QueryRow(query, userDid, subjectDid) 22 23 + var follow models.Follow 24 var followedAt string 25 err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 26 if err != nil { ··· 50 return err 51 } 52 53 + func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) { 54 var followers, following int64 55 err := e.QueryRow( 56 `SELECT ··· 58 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 59 FROM follows;`, did, did).Scan(&followers, &following) 60 if err != nil { 61 + return models.FollowStats{}, err 62 } 63 + return models.FollowStats{ 64 Followers: followers, 65 Following: following, 66 }, nil 67 } 68 69 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) { 70 if len(dids) == 0 { 71 return nil, nil 72 } ··· 102 ) g on f.did = g.did`, 103 placeholderStr, placeholderStr) 104 105 + result := make(map[string]models.FollowStats) 106 107 rows, err := e.Query(query, args...) 108 if err != nil { ··· 116 if err := rows.Scan(&did, &followers, &following); err != nil { 117 return nil, err 118 } 119 + result[did] = models.FollowStats{ 120 Followers: followers, 121 Following: following, 122 } ··· 124 125 for _, did := range dids { 126 if _, exists := result[did]; !exists { 127 + result[did] = models.FollowStats{ 128 Followers: 0, 129 Following: 0, 130 } ··· 134 return result, nil 135 } 136 137 + func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) { 138 + var follows []models.Follow 139 140 var conditions []string 141 var args []any ··· 167 return nil, err 168 } 169 for rows.Next() { 170 + var follow models.Follow 171 var followedAt string 172 err := rows.Scan( 173 &follow.UserDid, ··· 190 return follows, nil 191 } 192 193 + func GetFollowers(e Execer, did string) ([]models.Follow, error) { 194 return GetFollows(e, 0, FilterEq("subject_did", did)) 195 } 196 197 + func GetFollowing(e Execer, did string) ([]models.Follow, error) { 198 return GetFollows(e, 0, FilterEq("user_did", did)) 199 } 200 201 + func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 202 if len(subjectDids) == 0 || userDid == "" { 203 + return make(map[string]models.FollowStatus), nil 204 } 205 206 + result := make(map[string]models.FollowStatus) 207 208 for _, subjectDid := range subjectDids { 209 if userDid == subjectDid { 210 + result[subjectDid] = models.IsSelf 211 } else { 212 + result[subjectDid] = models.IsNotFollowing 213 } 214 } 215 ··· 250 if err := rows.Scan(&subjectDid); err != nil { 251 return nil, err 252 } 253 + result[subjectDid] = models.IsFollowing 254 } 255 256 return result, nil 257 } 258 259 + func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus { 260 statuses, err := getFollowStatuses(e, userDid, []string{subjectDid}) 261 if err != nil { 262 + return models.IsNotFollowing 263 } 264 return statuses[subjectDid] 265 } 266 267 + func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { 268 return getFollowStatuses(e, userDid, subjectDids) 269 }
+23 -212
appview/db/issues.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/appview/pagination" 15 ) 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 { 202 // ensure sequence exists 203 _, err := tx.Exec(` 204 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 233 } 234 } 235 236 - func createNewIssue(tx *sql.Tx, issue *Issue) error { 237 // get next issue_id 238 var newIssueId int 239 err := tx.QueryRow(` 240 - update repo_issue_seqs 241 - set next_issue_id = next_issue_id + 1 242 - where repo_at = ? 243 returning next_issue_id - 1 244 `, issue.RepoAt).Scan(&newIssueId) 245 if err != nil { ··· 256 return row.Scan(&issue.Id, &issue.IssueId) 257 } 258 259 - func updateIssue(tx *sql.Tx, issue *Issue) error { 260 // update existing issue 261 _, err := tx.Exec(` 262 update issues ··· 266 return err 267 } 268 269 - func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 270 - issueMap := make(map[string]*Issue) // at-uri -> issue 271 272 var conditions []string 273 var args []any ··· 322 defer rows.Close() 323 324 for rows.Next() { 325 - var issue Issue 326 var createdAt string 327 var editedAt, deletedAt sql.Null[string] 328 var rowNum int64 ··· 375 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 376 } 377 378 - repoMap := make(map[string]*Repo) 379 for i := range repos { 380 repoMap[string(repos[i].RepoAt())] = &repos[i] 381 } ··· 415 } 416 } 417 418 - var issues []Issue 419 for _, i := range issueMap { 420 issues = append(issues, *i) 421 } ··· 427 return issues, nil 428 } 429 430 - func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 431 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 432 } 433 434 - func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 435 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 436 row := e.QueryRow(query, repoAt, issueId) 437 438 - var issue Issue 439 var createdAt string 440 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 441 if err != nil { ··· 451 return &issue, nil 452 } 453 454 - func AddIssueComment(e Execer, c IssueComment) (int64, error) { 455 result, err := e.Exec( 456 `insert into issue_comments ( 457 did, ··· 513 return err 514 } 515 516 - func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 517 - var comments []IssueComment 518 519 var conditions []string 520 var args []any ··· 550 } 551 552 for rows.Next() { 553 - var comment IssueComment 554 var created string 555 var rkey, edited, deleted, replyTo sql.Null[string] 556 err := rows.Scan( ··· 657 return err 658 } 659 660 - type IssueCount struct { 661 - Open int 662 - Closed int 663 - } 664 - 665 - func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) { 666 row := e.QueryRow(` 667 select 668 count(case when open = 1 then 1 end) as open_count, ··· 672 repoAt, 673 ) 674 675 - var count IssueCount 676 if err := row.Scan(&count.Open, &count.Closed); err != nil { 677 - return IssueCount{0, 0}, err 678 } 679 680 return count, nil
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pagination" 15 ) 16 17 + func PutIssue(tx *sql.Tx, issue *models.Issue) error { 18 // ensure sequence exists 19 _, err := tx.Exec(` 20 insert or ignore into repo_issue_seqs (repo_at, next_issue_id) ··· 49 } 50 } 51 52 + func createNewIssue(tx *sql.Tx, issue *models.Issue) error { 53 // get next issue_id 54 var newIssueId int 55 err := tx.QueryRow(` 56 + update repo_issue_seqs 57 + set next_issue_id = next_issue_id + 1 58 + where repo_at = ? 59 returning next_issue_id - 1 60 `, issue.RepoAt).Scan(&newIssueId) 61 if err != nil { ··· 72 return row.Scan(&issue.Id, &issue.IssueId) 73 } 74 75 + func updateIssue(tx *sql.Tx, issue *models.Issue) error { 76 // update existing issue 77 _, err := tx.Exec(` 78 update issues ··· 82 return err 83 } 84 85 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 86 + issueMap := make(map[string]*models.Issue) // at-uri -> issue 87 88 var conditions []string 89 var args []any ··· 138 defer rows.Close() 139 140 for rows.Next() { 141 + var issue models.Issue 142 var createdAt string 143 var editedAt, deletedAt sql.Null[string] 144 var rowNum int64 ··· 191 return nil, fmt.Errorf("failed to build repo mappings: %w", err) 192 } 193 194 + repoMap := make(map[string]*models.Repo) 195 for i := range repos { 196 repoMap[string(repos[i].RepoAt())] = &repos[i] 197 } ··· 231 } 232 } 233 234 + var issues []models.Issue 235 for _, i := range issueMap { 236 issues = append(issues, *i) 237 } ··· 243 return issues, nil 244 } 245 246 + func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) { 247 return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 248 } 249 250 + func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) { 251 query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 252 row := e.QueryRow(query, repoAt, issueId) 253 254 + var issue models.Issue 255 var createdAt string 256 err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 257 if err != nil { ··· 267 return &issue, nil 268 } 269 270 + func AddIssueComment(e Execer, c models.IssueComment) (int64, error) { 271 result, err := e.Exec( 272 `insert into issue_comments ( 273 did, ··· 329 return err 330 } 331 332 + func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 333 + var comments []models.IssueComment 334 335 var conditions []string 336 var args []any ··· 366 } 367 368 for rows.Next() { 369 + var comment models.IssueComment 370 var created string 371 var rkey, edited, deleted, replyTo sql.Null[string] 372 err := rows.Scan( ··· 473 return err 474 } 475 476 + func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) { 477 row := e.QueryRow(` 478 select 479 count(case when open = 1 then 1 end) as open_count, ··· 483 repoAt, 484 ) 485 486 + var count models.IssueCount 487 if err := row.Scan(&count.Open, &count.Closed); err != nil { 488 + return models.IssueCount{}, err 489 } 490 491 return count, nil
+33 -496
appview/db/label.go
··· 1 package db 2 3 import ( 4 - "crypto/sha1" 5 "database/sql" 6 - "encoding/hex" 7 - "errors" 8 "fmt" 9 "maps" 10 "slices" ··· 12 "time" 13 14 "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" 33 ) 34 35 - // ValueType represents an atproto lexicon type definition with constraints 36 - type ValueType struct { 37 - Type ConcreteType `json:"type"` 38 - Format ValueTypeFormat `json:"format,omitempty"` 39 - Enum []string `json:"enum,omitempty"` 40 - } 41 - 42 - func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 43 - return tangled.LabelDefinition_ValueType{ 44 - Type: string(vt.Type), 45 - Format: string(vt.Format), 46 - Enum: vt.Enum, 47 - } 48 - } 49 - 50 - func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 51 - return ValueType{ 52 - Type: ConcreteType(record.Type), 53 - Format: ValueTypeFormat(record.Format), 54 - Enum: record.Enum, 55 - } 56 - } 57 - 58 - func (vt ValueType) IsConcreteType() bool { 59 - return vt.Type == ConcreteTypeNull || 60 - vt.Type == ConcreteTypeString || 61 - vt.Type == ConcreteTypeInt || 62 - vt.Type == ConcreteTypeBool 63 - } 64 - 65 - func (vt ValueType) IsNull() bool { 66 - return vt.Type == ConcreteTypeNull 67 - } 68 - 69 - func (vt ValueType) IsString() bool { 70 - return vt.Type == ConcreteTypeString 71 - } 72 - 73 - func (vt ValueType) IsInt() bool { 74 - return vt.Type == ConcreteTypeInt 75 - } 76 - 77 - func (vt ValueType) IsBool() bool { 78 - return vt.Type == ConcreteTypeBool 79 - } 80 - 81 - func (vt ValueType) 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 // no updating type for now 189 - func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) { 190 result, err := e.Exec( 191 `insert into label_definitions ( 192 did, ··· 232 return id, nil 233 } 234 235 - func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) { 236 - var labelDefinitions []LabelDefinition 237 var conditions []string 238 var args []any 239 ··· 275 defer rows.Close() 276 277 for rows.Next() { 278 - var labelDefinition LabelDefinition 279 var createdAt, enumVariants, scopes string 280 var color sql.Null[string] 281 var multiple int ··· 324 } 325 326 // helper to get exactly one label def 327 - func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) { 328 labels, err := GetLabelDefinitions(e, filters...) 329 if err != nil { 330 return nil, err ··· 341 return &labels[0], nil 342 } 343 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) { 461 now := time.Now() 462 result, err := e.Exec( 463 `insert into label_ops ( ··· 500 return id, nil 501 } 502 503 - func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) { 504 - var labelOps []LabelOp 505 var conditions []string 506 var args []any 507 ··· 541 defer rows.Close() 542 543 for rows.Next() { 544 - var labelOp LabelOp 545 var performedAt, indexedAt string 546 547 if err := rows.Scan( ··· 575 } 576 577 // get labels for a given list of subject URIs 578 - func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) { 579 ops, err := GetLabelOps(e, filters...) 580 if err != nil { 581 return nil, err 582 } 583 584 // group ops by subject 585 - opsBySubject := make(map[syntax.ATURI][]LabelOp) 586 for _, op := range ops { 587 subject := syntax.ATURI(op.Subject) 588 opsBySubject[subject] = append(opsBySubject[subject], op) ··· 601 } 602 603 // apply label ops for each subject and collect results 604 - results := make(map[syntax.ATURI]LabelState) 605 for subject, subjectOps := range opsBySubject { 606 - state := NewLabelState() 607 actx.ApplyLabelOps(state, subjectOps) 608 results[subject] = state 609 } ··· 611 return results, nil 612 } 613 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) { 669 labels, err := GetLabelDefinitions(e, filters...) 670 if err != nil { 671 return nil, err 672 } 673 674 - defs := make(map[string]*LabelDefinition) 675 for _, l := range labels { 676 defs[l.AtUri().String()] = &l 677 } 678 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 816 }
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 "maps" 7 "slices" ··· 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/appview/models" 13 ) 14 15 // no updating type for now 16 + func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) { 17 result, err := e.Exec( 18 `insert into label_definitions ( 19 did, ··· 59 return id, nil 60 } 61 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 80 var conditions []string 81 var args []any 82 ··· 118 defer rows.Close() 119 120 for rows.Next() { 121 + var labelDefinition models.LabelDefinition 122 var createdAt, enumVariants, scopes string 123 var color sql.Null[string] 124 var multiple int ··· 167 } 168 169 // helper to get exactly one label def 170 + func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) { 171 labels, err := GetLabelDefinitions(e, filters...) 172 if err != nil { 173 return nil, err ··· 184 return &labels[0], nil 185 } 186 187 + func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) { 188 now := time.Now() 189 result, err := e.Exec( 190 `insert into label_ops ( ··· 227 return id, nil 228 } 229 230 + func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) { 231 + var labelOps []models.LabelOp 232 var conditions []string 233 var args []any 234 ··· 268 defer rows.Close() 269 270 for rows.Next() { 271 + var labelOp models.LabelOp 272 var performedAt, indexedAt string 273 274 if err := rows.Scan( ··· 302 } 303 304 // get labels for a given list of subject URIs 305 + func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) { 306 ops, err := GetLabelOps(e, filters...) 307 if err != nil { 308 return nil, err 309 } 310 311 // group ops by subject 312 + opsBySubject := make(map[syntax.ATURI][]models.LabelOp) 313 for _, op := range ops { 314 subject := syntax.ATURI(op.Subject) 315 opsBySubject[subject] = append(opsBySubject[subject], op) ··· 328 } 329 330 // apply label ops for each subject and collect results 331 + results := make(map[syntax.ATURI]models.LabelState) 332 for subject, subjectOps := range opsBySubject { 333 + state := models.NewLabelState() 334 actx.ApplyLabelOps(state, subjectOps) 335 results[subject] = state 336 } ··· 338 return results, nil 339 } 340 341 + func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) { 342 labels, err := GetLabelDefinitions(e, filters...) 343 if err != nil { 344 return nil, err 345 } 346 347 + defs := make(map[string]*models.LabelDefinition) 348 for _, l := range labels { 349 defs[l.AtUri().String()] = &l 350 } 351 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 353 }
+38 -13
appview/db/language.go
··· 1 package db 2 3 import ( 4 "fmt" 5 "strings" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 ) 9 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) { 20 var conditions []string 21 var args []any 22 for _, filter := range filters { ··· 39 return nil, fmt.Errorf("failed to execute query: %w ", err) 40 } 41 42 - var langs []RepoLanguage 43 for rows.Next() { 44 - var rl RepoLanguage 45 var isDefaultRef int 46 47 err := rows.Scan( ··· 69 return langs, nil 70 } 71 72 - func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 73 stmt, err := e.Prepare( 74 "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 ) ··· 91 92 return nil 93 }
··· 1 package db 2 3 import ( 4 + "database/sql" 5 "fmt" 6 "strings" 7 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) { 13 var conditions []string 14 var args []any 15 for _, filter := range filters { ··· 32 return nil, fmt.Errorf("failed to execute query: %w ", err) 33 } 34 35 + var langs []models.RepoLanguage 36 for rows.Next() { 37 + var rl models.RepoLanguage 38 var isDefaultRef int 39 40 err := rows.Scan( ··· 62 return langs, nil 63 } 64 65 + func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error { 66 stmt, err := e.Prepare( 67 "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 68 ) ··· 84 85 return nil 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 "strings" 7 "time" 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" 13 ) 14 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 136 137 var conditions []string 138 var args []any ··· 156 defer rows.Close() 157 158 for rows.Next() { 159 - var pipeline Pipeline 160 var createdAt string 161 err = rows.Scan( 162 &pipeline.Id, ··· 185 return pipelines, nil 186 } 187 188 - func AddPipeline(e Execer, pipeline Pipeline) error { 189 args := []any{ 190 pipeline.Rkey, 191 pipeline.Knot, ··· 216 return err 217 } 218 219 - func AddTrigger(e Execer, trigger Trigger) (int64, error) { 220 args := []any{ 221 trigger.Kind, 222 trigger.PushRef, ··· 252 return res.LastInsertId() 253 } 254 255 - func AddPipelineStatus(e Execer, status PipelineStatus) error { 256 args := []any{ 257 status.Spindle, 258 status.Rkey, ··· 290 291 // this is a mega query, but the most useful one: 292 // get N pipelines, for each one get the latest status of its N workflows 293 - func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) { 294 var conditions []string 295 var args []any 296 for _, filter := range filters { ··· 335 } 336 defer rows.Close() 337 338 - pipelines := make(map[string]Pipeline) 339 for rows.Next() { 340 - var p Pipeline 341 - var t Trigger 342 var created string 343 344 err := rows.Scan( ··· 370 371 t.Id = p.TriggerId 372 p.Trigger = &t 373 - p.Statuses = make(map[string]WorkflowStatus) 374 375 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 376 pipelines[k] = p ··· 409 defer rows.Close() 410 411 for rows.Next() { 412 - var ps PipelineStatus 413 var created string 414 415 err := rows.Scan( ··· 442 } 443 statuses, _ := pipeline.Statuses[ps.Workflow] 444 if !ok { 445 - pipeline.Statuses[ps.Workflow] = WorkflowStatus{} 446 } 447 448 // append ··· 453 pipelines[key] = pipeline 454 } 455 456 - var all []Pipeline 457 for _, p := range pipelines { 458 for _, s := range p.Statuses { 459 - slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 460 if a.Created.After(b.Created) { 461 return 1 462 } ··· 476 } 477 478 // sort pipelines by date 479 - slices.SortFunc(all, func(a, b Pipeline) int { 480 if a.Created.After(b.Created) { 481 return -1 482 }
··· 6 "strings" 7 "time" 8 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) { 13 + var pipelines []models.Pipeline 14 15 var conditions []string 16 var args []any ··· 34 defer rows.Close() 35 36 for rows.Next() { 37 + var pipeline models.Pipeline 38 var createdAt string 39 err = rows.Scan( 40 &pipeline.Id, ··· 63 return pipelines, nil 64 } 65 66 + func AddPipeline(e Execer, pipeline models.Pipeline) error { 67 args := []any{ 68 pipeline.Rkey, 69 pipeline.Knot, ··· 94 return err 95 } 96 97 + func AddTrigger(e Execer, trigger models.Trigger) (int64, error) { 98 args := []any{ 99 trigger.Kind, 100 trigger.PushRef, ··· 130 return res.LastInsertId() 131 } 132 133 + func AddPipelineStatus(e Execer, status models.PipelineStatus) error { 134 args := []any{ 135 status.Spindle, 136 status.Rkey, ··· 168 169 // this is a mega query, but the most useful one: 170 // get N pipelines, for each one get the latest status of its N workflows 171 + func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) { 172 var conditions []string 173 var args []any 174 for _, filter := range filters { ··· 213 } 214 defer rows.Close() 215 216 + pipelines := make(map[string]models.Pipeline) 217 for rows.Next() { 218 + var p models.Pipeline 219 + var t models.Trigger 220 var created string 221 222 err := rows.Scan( ··· 248 249 t.Id = p.TriggerId 250 p.Trigger = &t 251 + p.Statuses = make(map[string]models.WorkflowStatus) 252 253 k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey) 254 pipelines[k] = p ··· 287 defer rows.Close() 288 289 for rows.Next() { 290 + var ps models.PipelineStatus 291 var created string 292 293 err := rows.Scan( ··· 320 } 321 statuses, _ := pipeline.Statuses[ps.Workflow] 322 if !ok { 323 + pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{} 324 } 325 326 // append ··· 331 pipelines[key] = pipeline 332 } 333 334 + var all []models.Pipeline 335 for _, p := range pipelines { 336 for _, s := range p.Statuses { 337 + slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int { 338 if a.Created.After(b.Created) { 339 return 1 340 } ··· 354 } 355 356 // sort pipelines by date 357 + slices.SortFunc(all, func(a, b models.Pipeline) int { 358 if a.Created.After(b.Created) { 359 return -1 360 }
+25 -194
appview/db/profile.go
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 ) 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 const TimeframeMonths = 7 107 108 - func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { 109 - timeline := ProfileTimeline{ 110 - ByMonth: make([]ByMonth, TimeframeMonths), 111 } 112 currentMonth := time.Now().Month() 113 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) ··· 162 163 for _, repo := range repos { 164 // TODO: get this in the original query; requires COALESCE because nullable 165 - var sourceRepo *Repo 166 if repo.Source != "" { 167 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 168 if err != nil { ··· 180 idx := currentMonth - repoMonth 181 182 items := &timeline.ByMonth[idx].RepoEvents 183 - *items = append(*items, RepoEvent{ 184 Repo: &repo, 185 Source: sourceRepo, 186 }) ··· 189 return &timeline, nil 190 } 191 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 { 272 defer tx.Rollback() 273 274 // update links ··· 366 return tx.Commit() 367 } 368 369 - func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 370 var conditions []string 371 var args []any 372 for _, filter := range filters { ··· 396 return nil, err 397 } 398 399 - profileMap := make(map[string]*Profile) 400 for rows.Next() { 401 - var profile Profile 402 var includeBluesky int 403 404 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) ··· 469 return profileMap, nil 470 } 471 472 - func GetProfile(e Execer, did string) (*Profile, error) { 473 - var profile Profile 474 profile.Did = did 475 476 includeBluesky := 0 ··· 479 did, 480 ).Scan(&profile.Description, &includeBluesky, &profile.Location) 481 if err == sql.ErrNoRows { 482 - profile := Profile{} 483 profile.Did = did 484 return &profile, nil 485 } ··· 539 return &profile, nil 540 } 541 542 - func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 543 query := "" 544 var args []any 545 switch stat { 546 - case VanityStatMergedPRCount: 547 query = `select count(id) from pulls where owner_did = ? and state = ?` 548 - args = append(args, did, PullMerged) 549 - case VanityStatClosedPRCount: 550 query = `select count(id) from pulls where owner_did = ? and state = ?` 551 - args = append(args, did, PullClosed) 552 - case VanityStatOpenPRCount: 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 554 - args = append(args, did, PullOpen) 555 - case VanityStatOpenIssueCount: 556 query = `select count(id) from issues where did = ? and open = 1` 557 args = append(args, did) 558 - case VanityStatClosedIssueCount: 559 query = `select count(id) from issues where did = ? and open = 0` 560 args = append(args, did) 561 - case VanityStatRepositoryCount: 562 query = `select count(id) from repos where did = ?` 563 args = append(args, did) 564 } ··· 572 return result, nil 573 } 574 575 - func ValidateProfile(e Execer, profile *Profile) error { 576 // ensure description is not too long 577 if len(profile.Description) > 256 { 578 return fmt.Errorf("Entered bio is too long.") ··· 620 return nil 621 } 622 623 - func validateLinks(profile *Profile) error { 624 for i, link := range profile.Links { 625 if link == "" { 626 continue
··· 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 14 ) 15 16 const TimeframeMonths = 7 17 18 + func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 19 + timeline := models.ProfileTimeline{ 20 + ByMonth: make([]models.ByMonth, TimeframeMonths), 21 } 22 currentMonth := time.Now().Month() 23 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) ··· 72 73 for _, repo := range repos { 74 // TODO: get this in the original query; requires COALESCE because nullable 75 + var sourceRepo *models.Repo 76 if repo.Source != "" { 77 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 78 if err != nil { ··· 90 idx := currentMonth - repoMonth 91 92 items := &timeline.ByMonth[idx].RepoEvents 93 + *items = append(*items, models.RepoEvent{ 94 Repo: &repo, 95 Source: sourceRepo, 96 }) ··· 99 return &timeline, nil 100 } 101 102 + func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 103 defer tx.Rollback() 104 105 // update links ··· 197 return tx.Commit() 198 } 199 200 + func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 201 var conditions []string 202 var args []any 203 for _, filter := range filters { ··· 227 return nil, err 228 } 229 230 + profileMap := make(map[string]*models.Profile) 231 for rows.Next() { 232 + var profile models.Profile 233 var includeBluesky int 234 235 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) ··· 300 return profileMap, nil 301 } 302 303 + func GetProfile(e Execer, did string) (*models.Profile, error) { 304 + var profile models.Profile 305 profile.Did = did 306 307 includeBluesky := 0 ··· 310 did, 311 ).Scan(&profile.Description, &includeBluesky, &profile.Location) 312 if err == sql.ErrNoRows { 313 + profile := models.Profile{} 314 profile.Did = did 315 return &profile, nil 316 } ··· 370 return &profile, nil 371 } 372 373 + func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 374 query := "" 375 var args []any 376 switch stat { 377 + case models.VanityStatMergedPRCount: 378 query = `select count(id) from pulls where owner_did = ? and state = ?` 379 + args = append(args, did, models.PullMerged) 380 + case models.VanityStatClosedPRCount: 381 query = `select count(id) from pulls where owner_did = ? and state = ?` 382 + args = append(args, did, models.PullClosed) 383 + case models.VanityStatOpenPRCount: 384 query = `select count(id) from pulls where owner_did = ? and state = ?` 385 + args = append(args, did, models.PullOpen) 386 + case models.VanityStatOpenIssueCount: 387 query = `select count(id) from issues where did = ? and open = 1` 388 args = append(args, did) 389 + case models.VanityStatClosedIssueCount: 390 query = `select count(id) from issues where did = ? and open = 0` 391 args = append(args, did) 392 + case models.VanityStatRepositoryCount: 393 query = `select count(id) from repos where did = ?` 394 args = append(args, did) 395 } ··· 403 return result, nil 404 } 405 406 + func ValidateProfile(e Execer, profile *models.Profile) error { 407 // ensure description is not too long 408 if len(profile.Description) > 256 { 409 return fmt.Errorf("Entered bio is too long.") ··· 451 return nil 452 } 453 454 + func validateLinks(profile *models.Profile) error { 455 for i, link := range profile.Links { 456 if link == "" { 457 continue
+7 -26
appview/db/pubkeys.go
··· 1 package db 2 3 import ( 4 - "encoding/json" 5 "time" 6 ) 7 ··· 29 return err 30 } 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 53 54 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 55 if err != nil { ··· 58 defer rows.Close() 59 60 for rows.Next() { 61 - var publicKey PublicKey 62 var createdAt string 63 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 64 return nil, err ··· 75 return keys, nil 76 } 77 78 - func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) { 79 - var keys []PublicKey 80 81 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 82 if err != nil { ··· 85 defer rows.Close() 86 87 for rows.Next() { 88 - var publicKey PublicKey 89 var createdAt string 90 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 91 return nil, err
··· 1 package db 2 3 import ( 4 + "tangled.org/core/appview/models" 5 "time" 6 ) 7 ··· 29 return err 30 } 31 32 + func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) { 33 + var keys []models.PublicKey 34 35 rows, err := e.Query(`select key, name, did, rkey, created from public_keys`) 36 if err != nil { ··· 39 defer rows.Close() 40 41 for rows.Next() { 42 + var publicKey models.PublicKey 43 var createdAt string 44 if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil { 45 return nil, err ··· 56 return keys, nil 57 } 58 59 + func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) { 60 + var keys []models.PublicKey 61 62 rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did) 63 if err != nil { ··· 66 defer rows.Close() 67 68 for rows.Next() { 69 + var publicKey models.PublicKey 70 var createdAt string 71 if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil { 72 return nil, err
+193 -572
appview/db/pulls.go
··· 1 package db 2 3 import ( 4 "database/sql" 5 "fmt" 6 - "log" 7 "slices" 8 "sort" 9 "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/patchutil" 15 - "tangled.sh/tangled.sh/core/types" 16 - ) 17 - 18 - type PullState int 19 - 20 - const ( 21 - PullClosed PullState = iota 22 - PullOpen 23 - PullMerged 24 - PullDeleted 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 { 227 _, err := tx.Exec(` 228 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 229 values (?, 1) ··· 244 } 245 246 pull.PullId = nextId 247 - pull.State = PullOpen 248 249 var sourceBranch, sourceRepoAt *string 250 if pull.PullSource != nil { ··· 266 parentChangeId = &pull.ParentChangeId 267 } 268 269 - _, err = tx.Exec( 270 ` 271 insert into pulls ( 272 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 return err 291 } 292 293 _, 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) 297 return err 298 } 299 ··· 311 return pullId - 1, err 312 } 313 314 - func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 315 - pulls := make(map[int]*Pull) 316 317 var conditions []string 318 var args []any ··· 332 333 query := fmt.Sprintf(` 334 select 335 owner_did, 336 repo_at, 337 pull_id, ··· 361 defer rows.Close() 362 363 for rows.Next() { 364 - var pull Pull 365 var createdAt string 366 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 367 err := rows.Scan( 368 &pull.OwnerDid, 369 &pull.RepoAt, 370 &pull.PullId, ··· 391 pull.Created = createdTime 392 393 if sourceBranch.Valid { 394 - pull.PullSource = &PullSource{ 395 Branch: sourceBranch.String, 396 } 397 if sourceRepoAt.Valid { ··· 413 pull.ParentChangeId = parentChangeId.String 414 } 415 416 - pulls[pull.PullId] = &pull 417 } 418 419 - // get latest round no. for each pull 420 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 421 - submissionsQuery := fmt.Sprintf(` 422 - select 423 - id, pull_id, round_number, patch, created, source_rev 424 - from 425 - pull_submissions 426 - where 427 - repo_at in (%s) and pull_id in (%s) 428 - `, inClause, inClause) 429 - 430 - args = make([]any, len(pulls)*2) 431 - idx := 0 432 - for _, p := range pulls { 433 - args[idx] = p.RepoAt 434 - idx += 1 435 - } 436 for _, p := range pulls { 437 - args[idx] = p.PullId 438 - idx += 1 439 } 440 - submissionsRows, err := e.Query(submissionsQuery, args...) 441 if err != nil { 442 - return nil, err 443 } 444 - defer submissionsRows.Close() 445 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 460 } 461 - 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 475 } 476 } 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 - 494 - args = []any{} 495 for _, p := range pulls { 496 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 497 - } 498 - commentsRows, err := e.Query(commentsQuery, args...) 499 - if err != nil { 500 - return nil, err 501 - } 502 - defer commentsRows.Close() 503 - 504 - for commentsRows.Next() { 505 - var commentCount, pullId int 506 - err := commentsRows.Scan( 507 - &commentCount, 508 - &pullId, 509 - ) 510 - if err != nil { 511 - return nil, err 512 } 513 - if p, ok := pulls[pullId]; ok { 514 - p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 515 - } 516 - } 517 - if err := rows.Err(); err != nil { 518 - return nil, err 519 } 520 521 - orderedByPullId := []*Pull{} 522 for _, p := range pulls { 523 orderedByPullId = append(orderedByPullId, p) 524 } ··· 529 return orderedByPullId, nil 530 } 531 532 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 533 return GetPullsWithLimit(e, 0, filters...) 534 } 535 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 - ) 579 if err != nil { 580 return nil, err 581 } 582 - 583 - createdTime, err := time.Parse(time.RFC3339, createdAt) 584 - if err != nil { 585 - return nil, err 586 } 587 - pull.Created = createdTime 588 589 - // populate source 590 - if sourceBranch.Valid { 591 - pull.PullSource = &PullSource{ 592 - Branch: sourceBranch.String, 593 - } 594 - if sourceRepoAt.Valid { 595 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 596 - if err != nil { 597 - return nil, err 598 - } 599 - pull.PullSource.RepoAt = &sourceRepoAtParsed 600 - } 601 - } 602 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 611 } 612 613 - submissionsQuery := ` 614 select 615 - id, pull_id, repo_at, round_number, patch, created, source_rev 616 from 617 pull_submissions 618 - where 619 - repo_at = ? and pull_id = ? 620 - ` 621 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 622 if err != nil { 623 return nil, err 624 } 625 - defer submissionsRows.Close() 626 627 - submissionsMap := make(map[int]*PullSubmission) 628 629 - for submissionsRows.Next() { 630 - var submission PullSubmission 631 - var submissionCreatedStr string 632 - var submissionSourceRev sql.NullString 633 - err := submissionsRows.Scan( 634 &submission.ID, 635 - &submission.PullId, 636 - &submission.RepoAt, 637 &submission.RoundNumber, 638 &submission.Patch, 639 - &submissionCreatedStr, 640 - &submissionSourceRev, 641 ) 642 if err != nil { 643 return nil, err 644 } 645 646 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 647 if err != nil { 648 return nil, err 649 } 650 - submission.Created = submissionCreatedTime 651 652 - if submissionSourceRev.Valid { 653 - submission.SourceRev = submissionSourceRev.String 654 } 655 656 - submissionsMap[submission.ID] = &submission 657 } 658 - if err = submissionsRows.Close(); err != nil { 659 return nil, err 660 } 661 - if len(submissionsMap) == 0 { 662 - return &pull, nil 663 } 664 665 var args []any 666 - for k := range submissionsMap { 667 - args = append(args, k) 668 } 669 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 670 - commentsQuery := fmt.Sprintf(` 671 select 672 id, 673 pull_id, ··· 679 created 680 from 681 pull_comments 682 - where 683 - submission_id IN (%s) 684 order by 685 created asc 686 - `, inClause) 687 - commentsRows, err := e.Query(commentsQuery, args...) 688 if err != nil { 689 return nil, err 690 } 691 - defer commentsRows.Close() 692 693 - for commentsRows.Next() { 694 - var comment PullComment 695 - var commentCreatedStr string 696 - err := commentsRows.Scan( 697 &comment.ID, 698 &comment.PullId, 699 &comment.SubmissionId, ··· 701 &comment.OwnerDid, 702 &comment.CommentAt, 703 &comment.Body, 704 - &commentCreatedStr, 705 ) 706 if err != nil { 707 return nil, err 708 } 709 710 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 711 - if err != nil { 712 - return nil, err 713 - } 714 - comment.Created = commentCreatedTime 715 - 716 - // Add the comment to its submission 717 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 718 - submission.Comments = append(submission.Comments, comment) 719 } 720 721 - } 722 - if err = commentsRows.Err(); err != nil { 723 - return nil, err 724 - } 725 - 726 - var pullSourceRepo *Repo 727 - if pull.PullSource != nil { 728 - if pull.PullSource.RepoAt != nil { 729 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 730 - if err != nil { 731 - log.Printf("failed to get repo by at uri: %v", err) 732 - } else { 733 - pull.PullSource.Repo = pullSourceRepo 734 - } 735 - } 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 744 } 745 746 // timeframe here is directly passed into the sql query filter, and any 747 // 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 750 751 rows, err := e.Query(` 752 select ··· 775 defer rows.Close() 776 777 for rows.Next() { 778 - var pull Pull 779 - var repo Repo 780 var pullCreatedAt, repoCreatedAt string 781 err := rows.Scan( 782 &pull.OwnerDid, ··· 819 return pulls, nil 820 } 821 822 - func NewPullComment(e Execer, comment *PullComment) (int64, error) { 823 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 824 res, err := e.Exec( 825 query, ··· 842 return i, nil 843 } 844 845 - func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error { 846 _, err := e.Exec( 847 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, 848 pullState, 849 repoAt, 850 pullId, 851 - PullDeleted, // only update state of non-deleted pulls 852 - PullMerged, // only update state of non-merged pulls 853 ) 854 return err 855 } 856 857 func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error { 858 - err := SetPullState(e, repoAt, pullId, PullClosed) 859 return err 860 } 861 862 func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error { 863 - err := SetPullState(e, repoAt, pullId, PullOpen) 864 return err 865 } 866 867 func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error { 868 - err := SetPullState(e, repoAt, pullId, PullMerged) 869 return err 870 } 871 872 func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error { 873 - err := SetPullState(e, repoAt, pullId, PullDeleted) 874 return err 875 } 876 877 - func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 878 newRoundNumber := len(pull.Submissions) 879 _, 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) 883 884 return err 885 } ··· 931 return err 932 } 933 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) { 942 row := e.QueryRow(` 943 select 944 count(case when state = ? then 1 end) as open_count, ··· 947 count(case when state = ? then 1 end) as deleted_count 948 from pulls 949 where repo_at = ?`, 950 - PullOpen, 951 - PullMerged, 952 - PullClosed, 953 - PullDeleted, 954 repoAt, 955 ) 956 957 - var count PullCount 958 if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil { 959 - return PullCount{0, 0, 0, 0}, err 960 } 961 962 return count, nil 963 } 964 - 965 - type Stack []*Pull 966 967 // change-id parent-change-id 968 // ··· 972 // 1 x <------' nil (BOT) 973 // 974 // `w` is parent of none, so it is the top of the stack 975 - func GetStack(e Execer, stackId string) (Stack, error) { 976 unorderedPulls, err := GetPulls( 977 e, 978 FilterEq("stack_id", stackId), 979 - FilterNotEq("state", PullDeleted), 980 ) 981 if err != nil { 982 return nil, err 983 } 984 // map of parent-change-id to pull 985 - changeIdMap := make(map[string]*Pull, len(unorderedPulls)) 986 - parentMap := make(map[string]*Pull, len(unorderedPulls)) 987 for _, p := range unorderedPulls { 988 changeIdMap[p.ChangeId] = p 989 if p.ParentChangeId != "" { ··· 992 } 993 994 // the top of the stack is the pull that is not a parent of any pull 995 - var topPull *Pull 996 for _, maybeTop := range unorderedPulls { 997 if _, ok := parentMap[maybeTop.ChangeId]; !ok { 998 topPull = maybeTop ··· 1000 } 1001 } 1002 1003 - pulls := []*Pull{} 1004 for { 1005 pulls = append(pulls, topPull) 1006 if topPull.ParentChangeId != "" { ··· 1017 return pulls, nil 1018 } 1019 1020 - func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) { 1021 pulls, err := GetPulls( 1022 e, 1023 FilterEq("stack_id", stackId), 1024 - FilterEq("state", PullDeleted), 1025 ) 1026 if err != nil { 1027 return nil, err ··· 1029 1030 return pulls, nil 1031 } 1032 - 1033 - // position of this pull in the stack 1034 - func (stack Stack) Position(pull *Pull) int { 1035 - return slices.IndexFunc(stack, func(p *Pull) bool { 1036 - return p.ChangeId == pull.ChangeId 1037 - }) 1038 - } 1039 - 1040 - // all pulls below this pull (including self) in this stack 1041 - // 1042 - // nil if this pull does not belong to this stack 1043 - func (stack Stack) Below(pull *Pull) Stack { 1044 - position := stack.Position(pull) 1045 - 1046 - if position < 0 { 1047 - return nil 1048 - } 1049 - 1050 - return stack[position:] 1051 - } 1052 - 1053 - // all pulls below this pull (excluding self) in this stack 1054 - func (stack Stack) StrictlyBelow(pull *Pull) Stack { 1055 - below := stack.Below(pull) 1056 - 1057 - if len(below) > 0 { 1058 - return below[1:] 1059 - } 1060 - 1061 - return nil 1062 - } 1063 - 1064 - // all pulls above this pull (including self) in this stack 1065 - func (stack Stack) Above(pull *Pull) Stack { 1066 - position := stack.Position(pull) 1067 - 1068 - if position < 0 { 1069 - return nil 1070 - } 1071 - 1072 - return stack[:position+1] 1073 - } 1074 - 1075 - // all pulls below this pull (excluding self) in this stack 1076 - func (stack Stack) StrictlyAbove(pull *Pull) Stack { 1077 - above := stack.Above(pull) 1078 - 1079 - if len(above) > 0 { 1080 - return above[:len(above)-1] 1081 - } 1082 - 1083 - return nil 1084 - } 1085 - 1086 - // the combined format-patches of all the newest submissions in this stack 1087 - func (stack Stack) CombinedPatch() string { 1088 - // go in reverse order because the bottom of the stack is the last element in the slice 1089 - var combined strings.Builder 1090 - for idx := range stack { 1091 - pull := stack[len(stack)-1-idx] 1092 - combined.WriteString(pull.LatestPatch()) 1093 - combined.WriteString("\n") 1094 - } 1095 - return combined.String() 1096 - } 1097 - 1098 - // filter out PRs that are "active" 1099 - // 1100 - // PRs that are still open are active 1101 - func (stack Stack) Mergeable() Stack { 1102 - var mergeable Stack 1103 - 1104 - for _, p := range stack { 1105 - // stop at the first merged PR 1106 - if p.State == PullMerged || p.State == PullClosed { 1107 - break 1108 - } 1109 - 1110 - // skip over deleted PRs 1111 - if p.State != PullDeleted { 1112 - mergeable = append(mergeable, p) 1113 - } 1114 - } 1115 - 1116 - return mergeable 1117 - }
··· 1 package db 2 3 import ( 4 + "cmp" 5 "database/sql" 6 + "errors" 7 "fmt" 8 + "maps" 9 "slices" 10 "sort" 11 "strings" 12 "time" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 + "tangled.org/core/appview/models" 16 ) 17 18 + func NewPull(tx *sql.Tx, pull *models.Pull) error { 19 _, err := tx.Exec(` 20 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 21 values (?, 1) ··· 36 } 37 38 pull.PullId = nextId 39 + pull.State = models.PullOpen 40 41 var sourceBranch, sourceRepoAt *string 42 if pull.PullSource != nil { ··· 58 parentChangeId = &pull.ParentChangeId 59 } 60 61 + result, err := tx.Exec( 62 ` 63 insert into pulls ( 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 ··· 82 return err 83 } 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 + 92 _, err = tx.Exec(` 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) 96 return err 97 } 98 ··· 110 return pullId - 1, err 111 } 112 113 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 114 + pulls := make(map[syntax.ATURI]*models.Pull) 115 116 var conditions []string 117 var args []any ··· 131 132 query := fmt.Sprintf(` 133 select 134 + id, 135 owner_did, 136 repo_at, 137 pull_id, ··· 161 defer rows.Close() 162 163 for rows.Next() { 164 + var pull models.Pull 165 var createdAt string 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 167 err := rows.Scan( 168 + &pull.ID, 169 &pull.OwnerDid, 170 &pull.RepoAt, 171 &pull.PullId, ··· 192 pull.Created = createdTime 193 194 if sourceBranch.Valid { 195 + pull.PullSource = &models.PullSource{ 196 Branch: sourceBranch.String, 197 } 198 if sourceRepoAt.Valid { ··· 214 pull.ParentChangeId = parentChangeId.String 215 } 216 217 + pulls[pull.PullAt()] = &pull 218 } 219 220 + var pullAts []syntax.ATURI 221 for _, p := range pulls { 222 + pullAts = append(pullAts, p.PullAt()) 223 } 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 225 if err != nil { 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 227 } 228 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 232 } 233 + } 234 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 243 } 244 + } 245 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 248 + for _, p := range pulls { 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 } 252 } 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) 256 } 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 + } 266 } 267 } 268 269 + orderedByPullId := []*models.Pull{} 270 for _, p := range pulls { 271 orderedByPullId = append(orderedByPullId, p) 272 } ··· 277 return orderedByPullId, nil 278 } 279 280 + func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) { 281 return GetPullsWithLimit(e, 0, filters...) 282 } 283 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)) 286 if err != nil { 287 return nil, err 288 } 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 291 } 292 293 + return pulls[0], nil 294 + } 295 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()...) 303 } 304 + 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 308 } 309 310 + query := fmt.Sprintf(` 311 select 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 318 from 319 pull_submissions 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 326 if err != nil { 327 return nil, err 328 } 329 + defer rows.Close() 330 331 + submissionMap := make(map[int]*models.PullSubmission) 332 333 + for rows.Next() { 334 + var submission models.PullSubmission 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 338 &submission.ID, 339 + &submission.PullAt, 340 &submission.RoundNumber, 341 &submission.Patch, 342 + &createdAt, 343 + &sourceRev, 344 ) 345 if err != nil { 346 return nil, err 347 } 348 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 350 if err != nil { 351 return nil, err 352 } 353 + submission.Created = createdTime 354 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 357 } 358 359 + submissionMap[submission.ID] = &submission 360 } 361 + 362 + if err := rows.Err(); err != nil { 363 + return nil, err 364 + } 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 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 + } 376 + } 377 + 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 + } 383 + 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 389 } 390 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 396 var args []any 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 ") 405 + } 406 + 407 + query := fmt.Sprintf(` 408 select 409 id, 410 pull_id, ··· 416 created 417 from 418 pull_comments 419 + %s 420 order by 421 created asc 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 425 if err != nil { 426 return nil, err 427 } 428 + defer rows.Close() 429 430 + var comments []models.PullComment 431 + for rows.Next() { 432 + var comment models.PullComment 433 + var createdAt string 434 + err := rows.Scan( 435 &comment.ID, 436 &comment.PullId, 437 &comment.SubmissionId, ··· 439 &comment.OwnerDid, 440 &comment.CommentAt, 441 &comment.Body, 442 + &createdAt, 443 ) 444 if err != nil { 445 return nil, err 446 } 447 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 450 } 451 452 + comments = append(comments, comment) 453 } 454 455 + if err := rows.Err(); err != nil { 456 + return nil, err 457 } 458 459 + return comments, nil 460 } 461 462 // timeframe here is directly passed into the sql query filter, and any 463 // timeframe in the past should be negative; e.g.: "-3 months" 464 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) { 465 + var pulls []models.Pull 466 467 rows, err := e.Query(` 468 select ··· 491 defer rows.Close() 492 493 for rows.Next() { 494 + var pull models.Pull 495 + var repo models.Repo 496 var pullCreatedAt, repoCreatedAt string 497 err := rows.Scan( 498 &pull.OwnerDid, ··· 535 return pulls, nil 536 } 537 538 + func NewPullComment(e Execer, comment *models.PullComment) (int64, error) { 539 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 540 res, err := e.Exec( 541 query, ··· 558 return i, nil 559 } 560 561 + func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { 562 _, err := e.Exec( 563 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`, 564 pullState, 565 repoAt, 566 pullId, 567 + models.PullDeleted, // only update state of non-deleted pulls 568 + models.PullMerged, // only update state of non-merged pulls 569 ) 570 return err 571 } 572 573 func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error { 574 + err := SetPullState(e, repoAt, pullId, models.PullClosed) 575 return err 576 } 577 578 func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error { 579 + err := SetPullState(e, repoAt, pullId, models.PullOpen) 580 return err 581 } 582 583 func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error { 584 + err := SetPullState(e, repoAt, pullId, models.PullMerged) 585 return err 586 } 587 588 func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error { 589 + err := SetPullState(e, repoAt, pullId, models.PullDeleted) 590 return err 591 } 592 593 + func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 594 newRoundNumber := len(pull.Submissions) 595 _, err := e.Exec(` 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 599 600 return err 601 } ··· 647 return err 648 } 649 650 + func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) { 651 row := e.QueryRow(` 652 select 653 count(case when state = ? then 1 end) as open_count, ··· 656 count(case when state = ? then 1 end) as deleted_count 657 from pulls 658 where repo_at = ?`, 659 + models.PullOpen, 660 + models.PullMerged, 661 + models.PullClosed, 662 + models.PullDeleted, 663 repoAt, 664 ) 665 666 + var count models.PullCount 667 if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil { 668 + return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err 669 } 670 671 return count, nil 672 } 673 674 // change-id parent-change-id 675 // ··· 679 // 1 x <------' nil (BOT) 680 // 681 // `w` is parent of none, so it is the top of the stack 682 + func GetStack(e Execer, stackId string) (models.Stack, error) { 683 unorderedPulls, err := GetPulls( 684 e, 685 FilterEq("stack_id", stackId), 686 + FilterNotEq("state", models.PullDeleted), 687 ) 688 if err != nil { 689 return nil, err 690 } 691 // map of parent-change-id to pull 692 + changeIdMap := make(map[string]*models.Pull, len(unorderedPulls)) 693 + parentMap := make(map[string]*models.Pull, len(unorderedPulls)) 694 for _, p := range unorderedPulls { 695 changeIdMap[p.ChangeId] = p 696 if p.ParentChangeId != "" { ··· 699 } 700 701 // the top of the stack is the pull that is not a parent of any pull 702 + var topPull *models.Pull 703 for _, maybeTop := range unorderedPulls { 704 if _, ok := parentMap[maybeTop.ChangeId]; !ok { 705 topPull = maybeTop ··· 707 } 708 } 709 710 + pulls := []*models.Pull{} 711 for { 712 pulls = append(pulls, topPull) 713 if topPull.ParentChangeId != "" { ··· 724 return pulls, nil 725 } 726 727 + func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) { 728 pulls, err := GetPulls( 729 e, 730 FilterEq("stack_id", stackId), 731 + FilterEq("state", models.PullDeleted), 732 ) 733 if err != nil { 734 return nil, err ··· 736 737 return pulls, nil 738 }
+7 -16
appview/db/punchcard.go
··· 5 "fmt" 6 "strings" 7 "time" 8 ) 9 10 - type Punch struct { 11 - Did string 12 - Date time.Time 13 - Count int 14 - } 15 - 16 // this adds to the existing count 17 - func AddPunch(e Execer, punch Punch) error { 18 _, err := e.Exec(` 19 insert into punchcard (did, date, count) 20 values (?, ?, ?) ··· 24 return err 25 } 26 27 - type Punchcard struct { 28 - Total int 29 - Punches []Punch 30 - } 31 - 32 - func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 - punchcard := &Punchcard{} 34 now := time.Now() 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) 37 for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) { 38 - punchcard.Punches = append(punchcard.Punches, Punch{ 39 Date: d, 40 Count: 0, 41 }) ··· 68 defer rows.Close() 69 70 for rows.Next() { 71 - var punch Punch 72 var date string 73 var count sql.NullInt64 74 if err := rows.Scan(&date, &count); err != nil {
··· 5 "fmt" 6 "strings" 7 "time" 8 + 9 + "tangled.org/core/appview/models" 10 ) 11 12 // this adds to the existing count 13 + func AddPunch(e Execer, punch models.Punch) error { 14 _, err := e.Exec(` 15 insert into punchcard (did, date, count) 16 values (?, ?, ?) ··· 20 return err 21 } 22 23 + func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) { 24 + punchcard := &models.Punchcard{} 25 now := time.Now() 26 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 27 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) 28 for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) { 29 + punchcard.Punches = append(punchcard.Punches, models.Punch{ 30 Date: d, 31 Count: 0, 32 }) ··· 59 defer rows.Close() 60 61 for rows.Next() { 62 + var punch models.Punch 63 var date string 64 var count sql.NullInt64 65 if err := rows.Scan(&date, &count); err != nil {
+14 -63
appview/db/reaction.go
··· 5 "time" 6 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 = "👀" 21 ) 22 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 { 61 query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 return err 64 } 65 66 // Get a reaction record 67 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 68 query := ` 69 select reacted_by_did, thread_at, created, rkey 70 from reactions 71 where reacted_by_did = ? and thread_at = ? and kind = ?` 72 row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 74 - var reaction Reaction 75 var created string 76 err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 if err != nil { ··· 90 } 91 92 // Remove a reaction 93 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 94 _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 return err 96 } ··· 101 return err 102 } 103 104 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 105 count := 0 106 err := e.QueryRow( 107 `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) ··· 111 return count, nil 112 } 113 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 120 } 121 countMap[kind] = count 122 } 123 return countMap, nil 124 } 125 126 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 127 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 return false 129 } else { ··· 131 } 132 } 133 134 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 - statusMap := map[ReactionKind]bool{} 136 - for _, kind := range OrderedReactionKinds { 137 count := GetReactionStatus(e, userDid, threadAt, kind) 138 statusMap[kind] = count 139 }
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/appview/models" 9 ) 10 11 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 12 query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 13 _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 14 return err 15 } 16 17 // Get a reaction record 18 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 19 query := ` 20 select reacted_by_did, thread_at, created, rkey 21 from reactions 22 where reacted_by_did = ? and thread_at = ? and kind = ?` 23 row := e.QueryRow(query, reactedByDid, threadAt, kind) 24 25 + var reaction models.Reaction 26 var created string 27 err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 28 if err != nil { ··· 41 } 42 43 // Remove a reaction 44 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 45 _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 46 return err 47 } ··· 52 return err 53 } 54 55 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 56 count := 0 57 err := e.QueryRow( 58 `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) ··· 62 return count, nil 63 } 64 65 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 + countMap := map[models.ReactionKind]int{} 67 + for _, kind := range models.OrderedReactionKinds { 68 count, err := GetReactionCount(e, threadAt, kind) 69 if err != nil { 70 + return map[models.ReactionKind]int{}, nil 71 } 72 countMap[kind] = count 73 } 74 return countMap, nil 75 } 76 77 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 78 if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 79 return false 80 } else { ··· 82 } 83 } 84 85 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 86 + statusMap := map[models.ReactionKind]bool{} 87 + for _, kind := range models.OrderedReactionKinds { 88 count := GetReactionStatus(e, userDid, threadAt, kind) 89 statusMap[kind] = count 90 }
+4 -43
appview/db/registration.go
··· 5 "fmt" 6 "strings" 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 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 49 ) 50 51 - func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 52 - var registrations []Registration 53 54 var conditions []string 55 var args []any ··· 81 var createdAt string 82 var registeredAt sql.Null[string] 83 var needsUpgrade int 84 - var reg Registration 85 86 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 87 if err != nil {
··· 5 "fmt" 6 "strings" 7 "time" 8 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) { 13 + var registrations []models.Registration 14 15 var conditions []string 16 var args []any ··· 42 var createdAt string 43 var registeredAt sql.Null[string] 44 var needsUpgrade int 45 + var reg models.Registration 46 47 err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 48 if err != nil {
+63 -87
appview/db/repos.go
··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 securejoin "github.com/cyphar/filepath-securejoin" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 ) 16 17 type Repo struct { 18 Did string 19 Name string 20 Knot string ··· 22 Created time.Time 23 Description string 24 Spindle string 25 - Labels []string 26 27 // optionally, populate this when querying for reverse mappings 28 - RepoStats *RepoStats 29 30 // optional 31 Source string 32 } 33 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 func (r Repo) RepoAt() syntax.ATURI { 61 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 62 } ··· 66 return p 67 } 68 69 - func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 70 - repoMap := make(map[syntax.ATURI]*Repo) 71 72 var conditions []string 73 var args []any ··· 88 89 repoQuery := fmt.Sprintf( 90 `select 91 did, 92 name, 93 knot, ··· 111 } 112 113 for rows.Next() { 114 - var repo Repo 115 var createdAt string 116 var description, source, spindle sql.NullString 117 118 err := rows.Scan( 119 &repo.Did, 120 &repo.Name, 121 &repo.Knot, ··· 142 repo.Spindle = spindle.String 143 } 144 145 - repo.RepoStats = &RepoStats{} 146 repoMap[repo.RepoAt()] = &repo 147 } 148 ··· 184 185 languageQuery := fmt.Sprintf( 186 ` 187 - select 188 - repo_at, language 189 - from 190 - repo_languages r1 191 - where 192 - repo_at IN (%s) 193 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 - ); 202 `, 203 inClause, 204 ) ··· 290 inClause, 291 ) 292 args = append([]any{ 293 - PullOpen, 294 - PullMerged, 295 - PullClosed, 296 - PullDeleted, 297 }, args...) 298 rows, err = e.Query( 299 pullCountQuery, ··· 320 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 321 } 322 323 - var repos []Repo 324 for _, r := range repoMap { 325 repos = append(repos, *r) 326 } 327 328 - slices.SortFunc(repos, func(a, b Repo) int { 329 if a.Created.After(b.Created) { 330 return -1 331 } ··· 336 } 337 338 // helper to get exactly one repo 339 - func GetRepo(e Execer, filters ...filter) (*Repo, error) { 340 repos, err := GetRepos(e, 0, filters...) 341 if err != nil { 342 return nil, err ··· 377 return count, nil 378 } 379 380 - func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { 381 - var repo Repo 382 var nullableDescription sql.NullString 383 384 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 385 386 var createdAt string 387 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 388 return nil, err 389 } 390 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 399 return &repo, nil 400 } 401 402 - func AddRepo(e Execer, repo *Repo) error { 403 - _, err := e.Exec( 404 `insert into repos 405 (did, name, knot, rkey, at_uri, description, source) 406 values (?, ?, ?, ?, ?, ?, ?)`, 407 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 408 ) 409 - return err 410 } 411 412 func RemoveRepo(e Execer, did, name string) error { ··· 423 return nullableSource.String, nil 424 } 425 426 - func GetForksByDid(e Execer, did string) ([]Repo, error) { 427 - var repos []Repo 428 429 rows, err := e.Query( 430 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 431 from repos r 432 left join collaborators c on r.at_uri = c.repo_at 433 where (r.did = ? or c.subject_did = ?) ··· 442 defer rows.Close() 443 444 for rows.Next() { 445 - var repo Repo 446 var createdAt string 447 var nullableDescription sql.NullString 448 var nullableSource sql.NullString 449 450 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 451 if err != nil { 452 return nil, err 453 } ··· 477 return repos, nil 478 } 479 480 - func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 481 - var repo Repo 482 var createdAt string 483 var nullableDescription sql.NullString 484 var nullableSource sql.NullString 485 486 row := e.QueryRow( 487 - `select did, name, knot, rkey, description, created, source 488 from repos 489 where did = ? and name = ? and source is not null and source != ''`, 490 did, name, 491 ) 492 493 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 494 if err != nil { 495 return nil, err 496 } ··· 525 return err 526 } 527 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 { 542 query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 543 544 _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) ··· 563 return err 564 } 565 566 - func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) { 567 var conditions []string 568 var args []any 569 for _, filter := range filters { ··· 584 } 585 defer rows.Close() 586 587 - var labels []RepoLabel 588 for rows.Next() { 589 - var label RepoLabel 590 591 err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 592 if err != nil {
··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/appview/models" 16 ) 17 18 type Repo struct { 19 + Id int64 20 Did string 21 Name string 22 Knot string ··· 24 Created time.Time 25 Description string 26 Spindle string 27 28 // optionally, populate this when querying for reverse mappings 29 + RepoStats *models.RepoStats 30 31 // optional 32 Source string 33 } 34 35 func (r Repo) RepoAt() syntax.ATURI { 36 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 } ··· 41 return p 42 } 43 44 + func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 45 + repoMap := make(map[syntax.ATURI]*models.Repo) 46 47 var conditions []string 48 var args []any ··· 63 64 repoQuery := fmt.Sprintf( 65 `select 66 + id, 67 did, 68 name, 69 knot, ··· 87 } 88 89 for rows.Next() { 90 + var repo models.Repo 91 var createdAt string 92 var description, source, spindle sql.NullString 93 94 err := rows.Scan( 95 + &repo.Id, 96 &repo.Did, 97 &repo.Name, 98 &repo.Knot, ··· 119 repo.Spindle = spindle.String 120 } 121 122 + repo.RepoStats = &models.RepoStats{} 123 repoMap[repo.RepoAt()] = &repo 124 } 125 ··· 161 162 languageQuery := fmt.Sprintf( 163 ` 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) 175 and is_default_ref = 1 176 + ) 177 + where rn = 1 178 `, 179 inClause, 180 ) ··· 266 inClause, 267 ) 268 args = append([]any{ 269 + models.PullOpen, 270 + models.PullMerged, 271 + models.PullClosed, 272 + models.PullDeleted, 273 }, args...) 274 rows, err = e.Query( 275 pullCountQuery, ··· 296 return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 297 } 298 299 + var repos []models.Repo 300 for _, r := range repoMap { 301 repos = append(repos, *r) 302 } 303 304 + slices.SortFunc(repos, func(a, b models.Repo) int { 305 if a.Created.After(b.Created) { 306 return -1 307 } ··· 312 } 313 314 // helper to get exactly one repo 315 + func GetRepo(e Execer, filters ...filter) (*models.Repo, error) { 316 repos, err := GetRepos(e, 0, filters...) 317 if err != nil { 318 return nil, err ··· 353 return count, nil 354 } 355 356 + func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) { 357 + var repo models.Repo 358 var nullableDescription sql.NullString 359 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 361 362 var createdAt string 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 364 return nil, err 365 } 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 375 return &repo, nil 376 } 377 378 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 + _, err := tx.Exec( 380 `insert into repos 381 (did, name, knot, rkey, at_uri, description, source) 382 values (?, ?, ?, ?, ?, ?, ?)`, 383 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 384 ) 385 + if err != nil { 386 + return fmt.Errorf("failed to insert repo: %w", err) 387 + } 388 + 389 + for _, dl := range repo.Labels { 390 + if err := SubscribeLabel(tx, &models.RepoLabel{ 391 + RepoAt: repo.RepoAt(), 392 + LabelAt: syntax.ATURI(dl), 393 + }); err != nil { 394 + return fmt.Errorf("failed to subscribe to label: %w", err) 395 + } 396 + } 397 + 398 + return nil 399 } 400 401 func RemoveRepo(e Execer, did, name string) error { ··· 412 return nullableSource.String, nil 413 } 414 415 + func GetForksByDid(e Execer, did string) ([]models.Repo, error) { 416 + var repos []models.Repo 417 418 rows, err := e.Query( 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 420 from repos r 421 left join collaborators c on r.at_uri = c.repo_at 422 where (r.did = ? or c.subject_did = ?) ··· 431 defer rows.Close() 432 433 for rows.Next() { 434 + var repo models.Repo 435 var createdAt string 436 var nullableDescription sql.NullString 437 var nullableSource sql.NullString 438 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 440 if err != nil { 441 return nil, err 442 } ··· 466 return repos, nil 467 } 468 469 + func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) { 470 + var repo models.Repo 471 var createdAt string 472 var nullableDescription sql.NullString 473 var nullableSource sql.NullString 474 475 row := e.QueryRow( 476 + `select id, did, name, knot, rkey, description, created, source 477 from repos 478 where did = ? and name = ? and source is not null and source != ''`, 479 did, name, 480 ) 481 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 483 if err != nil { 484 return nil, err 485 } ··· 514 return err 515 } 516 517 + func SubscribeLabel(e Execer, rl *models.RepoLabel) error { 518 query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)` 519 520 _, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String()) ··· 539 return err 540 } 541 542 + func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) { 543 var conditions []string 544 var args []any 545 for _, filter := range filters { ··· 560 } 561 defer rows.Close() 562 563 + var labels []models.RepoLabel 564 for rows.Next() { 565 + var label models.RepoLabel 566 567 err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt) 568 if err != nil {
+4 -9
appview/db/signup.go
··· 1 package db 2 3 - import "time" 4 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 { 13 query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 return err
··· 1 package db 2 3 + import ( 4 + "tangled.org/core/appview/models" 5 + ) 6 7 + func AddInflightSignup(e Execer, signup models.InflightSignup) error { 8 query := `insert into signups_inflight (email, invite_code) values (?, ?)` 9 _, err := e.Exec(query, signup.Email, signup.InviteCode) 10 return err
+9 -27
appview/db/spindle.go
··· 6 "strings" 7 "time" 8 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 ) 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 32 33 var conditions []string 34 var args []any ··· 59 defer rows.Close() 60 61 for rows.Next() { 62 - var spindle Spindle 63 var createdAt string 64 var verified sql.NullString 65 var needsUpgrade int ··· 100 } 101 102 // if there is an existing spindle with the same instance, this returns an error 103 - func AddSpindle(e Execer, spindle Spindle) error { 104 _, err := e.Exec( 105 `insert into spindles (owner, instance) values (?, ?)`, 106 spindle.Owner, ··· 151 return err 152 } 153 154 - func AddSpindleMember(e Execer, member SpindleMember) error { 155 _, err := e.Exec( 156 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 157 member.Did, ··· 181 return err 182 } 183 184 - func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) { 185 - var members []SpindleMember 186 187 var conditions []string 188 var args []any ··· 213 defer rows.Close() 214 215 for rows.Next() { 216 - var member SpindleMember 217 var createdAt string 218 219 if err := rows.Scan(
··· 6 "strings" 7 "time" 8 9 + "tangled.org/core/appview/models" 10 ) 11 12 + func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) { 13 + var spindles []models.Spindle 14 15 var conditions []string 16 var args []any ··· 41 defer rows.Close() 42 43 for rows.Next() { 44 + var spindle models.Spindle 45 var createdAt string 46 var verified sql.NullString 47 var needsUpgrade int ··· 82 } 83 84 // if there is an existing spindle with the same instance, this returns an error 85 + func AddSpindle(e Execer, spindle models.Spindle) error { 86 _, err := e.Exec( 87 `insert into spindles (owner, instance) values (?, ?)`, 88 spindle.Owner, ··· 133 return err 134 } 135 136 + func AddSpindleMember(e Execer, member models.SpindleMember) error { 137 _, err := e.Exec( 138 `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 139 member.Did, ··· 163 return err 164 } 165 166 + func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) { 167 + var members []models.SpindleMember 168 169 var conditions []string 170 var args []any ··· 195 defer rows.Close() 196 197 for rows.Next() { 198 + var member models.SpindleMember 199 var createdAt string 200 201 if err := rows.Scan(
+27 -39
appview/db/star.go
··· 5 "errors" 6 "fmt" 7 "log" 8 "strings" 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 ) 13 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 { 39 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 40 _, err := e.Exec( 41 query, ··· 47 } 48 49 // Get a star record 50 - func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 51 query := ` 52 select starred_by_did, repo_at, created, rkey 53 from stars 54 where starred_by_did = ? and repo_at = ?` 55 row := e.QueryRow(query, starredByDid, repoAt) 56 57 - var star Star 58 var created string 59 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 60 if err != nil { ··· 152 func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 153 return getStarStatuses(e, userDid, repoAts) 154 } 155 - func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 156 var conditions []string 157 var args []any 158 for _, filter := range filters { ··· 184 return nil, err 185 } 186 187 - starMap := make(map[string][]Star) 188 for rows.Next() { 189 - var star Star 190 var created string 191 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 192 if err != nil { ··· 227 } 228 } 229 230 - var stars []Star 231 for _, s := range starMap { 232 stars = append(stars, s...) 233 } 234 235 return stars, nil 236 } ··· 259 return count, nil 260 } 261 262 - func GetAllStars(e Execer, limit int) ([]Star, error) { 263 - var stars []Star 264 265 rows, err := e.Query(` 266 select ··· 283 defer rows.Close() 284 285 for rows.Next() { 286 - var star Star 287 - var repo Repo 288 var starCreatedAt, repoCreatedAt string 289 290 if err := rows.Scan( ··· 322 } 323 324 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 325 - func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 326 // first, get the top repo URIs by star count from the last week 327 query := ` 328 with recent_starred_repos as ( ··· 366 } 367 368 if len(repoUris) == 0 { 369 - return []Repo{}, nil 370 } 371 372 // get full repo data ··· 376 } 377 378 // sort repos by the original trending order 379 - repoMap := make(map[string]Repo) 380 for _, repo := range repos { 381 repoMap[repo.RepoAt().String()] = repo 382 } 383 384 - orderedRepos := make([]Repo, 0, len(repoUris)) 385 for _, uri := range repoUris { 386 if repo, exists := repoMap[uri]; exists { 387 orderedRepos = append(orderedRepos, repo)
··· 5 "errors" 6 "fmt" 7 "log" 8 + "slices" 9 "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/appview/models" 14 ) 15 16 + func AddStar(e Execer, star *models.Star) error { 17 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 18 _, err := e.Exec( 19 query, ··· 25 } 26 27 // Get a star record 28 + func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) { 29 query := ` 30 select starred_by_did, repo_at, created, rkey 31 from stars 32 where starred_by_did = ? and repo_at = ?` 33 row := e.QueryRow(query, starredByDid, repoAt) 34 35 + var star models.Star 36 var created string 37 err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 38 if err != nil { ··· 130 func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) { 131 return getStarStatuses(e, userDid, repoAts) 132 } 133 + func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) { 134 var conditions []string 135 var args []any 136 for _, filter := range filters { ··· 162 return nil, err 163 } 164 165 + starMap := make(map[string][]models.Star) 166 for rows.Next() { 167 + var star models.Star 168 var created string 169 err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 170 if err != nil { ··· 205 } 206 } 207 208 + var stars []models.Star 209 for _, s := range starMap { 210 stars = append(stars, s...) 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 + }) 222 223 return stars, nil 224 } ··· 247 return count, nil 248 } 249 250 + func GetAllStars(e Execer, limit int) ([]models.Star, error) { 251 + var stars []models.Star 252 253 rows, err := e.Query(` 254 select ··· 271 defer rows.Close() 272 273 for rows.Next() { 274 + var star models.Star 275 + var repo models.Repo 276 var starCreatedAt, repoCreatedAt string 277 278 if err := rows.Scan( ··· 310 } 311 312 // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 313 + func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) { 314 // first, get the top repo URIs by star count from the last week 315 query := ` 316 with recent_starred_repos as ( ··· 354 } 355 356 if len(repoUris) == 0 { 357 + return []models.Repo{}, nil 358 } 359 360 // get full repo data ··· 364 } 365 366 // sort repos by the original trending order 367 + repoMap := make(map[string]models.Repo) 368 for _, repo := range repos { 369 repoMap[repo.RepoAt().String()] = repo 370 } 371 372 + orderedRepos := make([]models.Repo, 0, len(repoUris)) 373 for _, uri := range repoUris { 374 if repo, exists := repoMap[uri]; exists { 375 orderedRepos = append(orderedRepos, repo)
+5 -110
appview/db/strings.go
··· 1 package db 2 3 import ( 4 - "bytes" 5 "database/sql" 6 "errors" 7 "fmt" 8 - "io" 9 "strings" 10 "time" 11 - "unicode/utf8" 12 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - "tangled.sh/tangled.sh/core/api/tangled" 15 ) 16 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 { 93 _, err := e.Exec( 94 `insert into strings ( 95 did, ··· 123 return err 124 } 125 126 - func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 - var all []String 128 129 var conditions []string 130 var args []any ··· 167 defer rows.Close() 168 169 for rows.Next() { 170 - var s String 171 var createdAt string 172 var editedAt sql.NullString 173 ··· 248 _, err := e.Exec(query, args...) 249 return err 250 } 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 - }
··· 1 package db 2 3 import ( 4 "database/sql" 5 "errors" 6 "fmt" 7 "strings" 8 "time" 9 10 + "tangled.org/core/appview/models" 11 ) 12 13 + func AddString(e Execer, s models.String) error { 14 _, err := e.Exec( 15 `insert into strings ( 16 did, ··· 44 return err 45 } 46 47 + func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) { 48 + var all []models.String 49 50 var conditions []string 51 var args []any ··· 88 defer rows.Close() 89 90 for rows.Next() { 91 + var s models.String 92 var createdAt string 93 var editedAt sql.NullString 94 ··· 169 _, err := e.Exec(query, args...) 170 return err 171 }
+20 -40
appview/db/timeline.go
··· 2 3 import ( 4 "sort" 5 - "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 ) 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 // TODO: this gathers heterogenous events from different sources and aggregates 31 // 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 34 35 repos, err := getTimelineRepos(e, limit, loggedInUserDid) 36 if err != nil { ··· 63 return events, nil 64 } 65 66 - func fetchStarStatuses(e Execer, loggedInUserDid string, repos []Repo) (map[string]bool, error) { 67 if loggedInUserDid == "" { 68 return nil, nil 69 } ··· 76 return GetStarStatuses(e, loggedInUserDid, repoAts) 77 } 78 79 - func getRepoStarInfo(repo *Repo, starStatuses map[string]bool) (bool, int64) { 80 var isStarred bool 81 if starStatuses != nil { 82 isStarred = starStatuses[repo.RepoAt().String()] ··· 90 return isStarred, starCount 91 } 92 93 - func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 94 repos, err := GetRepos(e, limit) 95 if err != nil { 96 return nil, err ··· 104 } 105 } 106 107 - var origRepos []Repo 108 if args != nil { 109 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 110 } ··· 112 return nil, err 113 } 114 115 - uriToRepo := make(map[string]Repo) 116 for _, r := range origRepos { 117 uriToRepo[r.RepoAt().String()] = r 118 } ··· 122 return nil, err 123 } 124 125 - var events []TimelineEvent 126 for _, r := range repos { 127 - var source *Repo 128 if r.Source != "" { 129 if origRepo, ok := uriToRepo[r.Source]; ok { 130 source = &origRepo ··· 133 134 isStarred, starCount := getRepoStarInfo(&r, starStatuses) 135 136 - events = append(events, TimelineEvent{ 137 Repo: &r, 138 EventAt: r.Created, 139 Source: source, ··· 145 return events, nil 146 } 147 148 - func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 149 stars, err := GetStars(e, limit) 150 if err != nil { 151 return nil, err ··· 161 } 162 stars = stars[:n] 163 164 - var repos []Repo 165 for _, s := range stars { 166 repos = append(repos, *s.Repo) 167 } ··· 171 return nil, err 172 } 173 174 - var events []TimelineEvent 175 for _, s := range stars { 176 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 177 178 - events = append(events, TimelineEvent{ 179 Star: &s, 180 EventAt: s.Created, 181 IsStarred: isStarred, ··· 186 return events, nil 187 } 188 189 - func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) { 190 follows, err := GetFollows(e, limit) 191 if err != nil { 192 return nil, err ··· 211 return nil, err 212 } 213 214 - var followStatuses map[string]FollowStatus 215 if loggedInUserDid != "" { 216 followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects) 217 if err != nil { ··· 219 } 220 } 221 222 - var events []TimelineEvent 223 for _, f := range follows { 224 profile, _ := profiles[f.SubjectDid] 225 followStatMap, _ := followStatMap[f.SubjectDid] 226 227 - followStatus := IsNotFollowing 228 if followStatuses != nil { 229 followStatus = followStatuses[f.SubjectDid] 230 } 231 232 - events = append(events, TimelineEvent{ 233 Follow: &f, 234 Profile: profile, 235 FollowStats: &followStatMap,
··· 2 3 import ( 4 "sort" 5 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/appview/models" 8 ) 9 10 // TODO: this gathers heterogenous events from different sources and aggregates 11 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 12 + func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 13 + var events []models.TimelineEvent 14 15 repos, err := getTimelineRepos(e, limit, loggedInUserDid) 16 if err != nil { ··· 43 return events, nil 44 } 45 46 + func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) { 47 if loggedInUserDid == "" { 48 return nil, nil 49 } ··· 56 return GetStarStatuses(e, loggedInUserDid, repoAts) 57 } 58 59 + func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) { 60 var isStarred bool 61 if starStatuses != nil { 62 isStarred = starStatuses[repo.RepoAt().String()] ··· 70 return isStarred, starCount 71 } 72 73 + func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 74 repos, err := GetRepos(e, limit) 75 if err != nil { 76 return nil, err ··· 84 } 85 } 86 87 + var origRepos []models.Repo 88 if args != nil { 89 origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 90 } ··· 92 return nil, err 93 } 94 95 + uriToRepo := make(map[string]models.Repo) 96 for _, r := range origRepos { 97 uriToRepo[r.RepoAt().String()] = r 98 } ··· 102 return nil, err 103 } 104 105 + var events []models.TimelineEvent 106 for _, r := range repos { 107 + var source *models.Repo 108 if r.Source != "" { 109 if origRepo, ok := uriToRepo[r.Source]; ok { 110 source = &origRepo ··· 113 114 isStarred, starCount := getRepoStarInfo(&r, starStatuses) 115 116 + events = append(events, models.TimelineEvent{ 117 Repo: &r, 118 EventAt: r.Created, 119 Source: source, ··· 125 return events, nil 126 } 127 128 + func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 129 stars, err := GetStars(e, limit) 130 if err != nil { 131 return nil, err ··· 141 } 142 stars = stars[:n] 143 144 + var repos []models.Repo 145 for _, s := range stars { 146 repos = append(repos, *s.Repo) 147 } ··· 151 return nil, err 152 } 153 154 + var events []models.TimelineEvent 155 for _, s := range stars { 156 isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses) 157 158 + events = append(events, models.TimelineEvent{ 159 Star: &s, 160 EventAt: s.Created, 161 IsStarred: isStarred, ··· 166 return events, nil 167 } 168 169 + func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { 170 follows, err := GetFollows(e, limit) 171 if err != nil { 172 return nil, err ··· 191 return nil, err 192 } 193 194 + var followStatuses map[string]models.FollowStatus 195 if loggedInUserDid != "" { 196 followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects) 197 if err != nil { ··· 199 } 200 } 201 202 + var events []models.TimelineEvent 203 for _, f := range follows { 204 profile, _ := profiles[f.SubjectDid] 205 followStatMap, _ := followStatMap[f.SubjectDid] 206 207 + followStatus := models.IsNotFollowing 208 if followStatuses != nil { 209 followStatus = followStatuses[f.SubjectDid] 210 } 211 212 + events = append(events, models.TimelineEvent{ 213 Follow: &f, 214 Profile: profile, 215 FollowStats: &followStatMap,
+1 -1
appview/dns/cloudflare.go
··· 5 "fmt" 6 7 "github.com/cloudflare/cloudflare-go" 8 - "tangled.sh/tangled.sh/core/appview/config" 9 ) 10 11 type Record struct {
··· 5 "fmt" 6 7 "github.com/cloudflare/cloudflare-go" 8 + "tangled.org/core/appview/config" 9 ) 10 11 type Record struct {
+146 -65
appview/ingester.go
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/bluesky-social/jetstream/pkg/models" 13 "github.com/go-git/go-git/v5/plumbing" 14 "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" 22 ) 23 24 type Ingester struct { ··· 30 Validator *validator.Validator 31 } 32 33 - type processFunc func(ctx context.Context, e *models.Event) error 34 35 func (i *Ingester) Ingest() processFunc { 36 - return func(ctx context.Context, e *models.Event) error { 37 var err error 38 defer func() { 39 eventTime := e.TimeUS ··· 45 46 l := i.Logger.With("kind", e.Kind) 47 switch e.Kind { 48 - case models.EventKindAccount: 49 if !e.Account.Active && *e.Account.Status == "deactivated" { 50 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 51 } 52 - case models.EventKindIdentity: 53 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 54 - case models.EventKindCommit: 55 switch e.Commit.Collection { 56 case tangled.GraphFollowNSID: 57 err = i.ingestFollow(e) ··· 79 err = i.ingestIssueComment(e) 80 case tangled.LabelDefinitionNSID: 81 err = i.ingestLabelDefinition(e) 82 } 83 l = i.Logger.With("nsid", e.Commit.Collection) 84 } ··· 91 } 92 } 93 94 - func (i *Ingester) ingestStar(e *models.Event) error { 95 var err error 96 did := e.Did 97 ··· 99 l = l.With("nsid", e.Commit.Collection) 100 101 switch e.Commit.Operation { 102 - case models.CommitOperationCreate, models.CommitOperationUpdate: 103 var subjectUri syntax.ATURI 104 105 raw := json.RawMessage(e.Commit.Record) ··· 115 l.Error("invalid record", "err", err) 116 return err 117 } 118 - err = db.AddStar(i.Db, &db.Star{ 119 StarredByDid: did, 120 RepoAt: subjectUri, 121 Rkey: e.Commit.RKey, 122 }) 123 - case models.CommitOperationDelete: 124 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 125 } 126 ··· 131 return nil 132 } 133 134 - func (i *Ingester) ingestFollow(e *models.Event) error { 135 var err error 136 did := e.Did 137 ··· 139 l = l.With("nsid", e.Commit.Collection) 140 141 switch e.Commit.Operation { 142 - case models.CommitOperationCreate, models.CommitOperationUpdate: 143 raw := json.RawMessage(e.Commit.Record) 144 record := tangled.GraphFollow{} 145 err = json.Unmarshal(raw, &record) ··· 148 return err 149 } 150 151 - err = db.AddFollow(i.Db, &db.Follow{ 152 UserDid: did, 153 SubjectDid: record.Subject, 154 Rkey: e.Commit.RKey, 155 }) 156 - case models.CommitOperationDelete: 157 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 158 } 159 ··· 164 return nil 165 } 166 167 - func (i *Ingester) ingestPublicKey(e *models.Event) error { 168 did := e.Did 169 var err error 170 ··· 172 l = l.With("nsid", e.Commit.Collection) 173 174 switch e.Commit.Operation { 175 - case models.CommitOperationCreate, models.CommitOperationUpdate: 176 l.Debug("processing add of pubkey") 177 raw := json.RawMessage(e.Commit.Record) 178 record := tangled.PublicKey{} ··· 185 name := record.Name 186 key := record.Key 187 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 188 - case models.CommitOperationDelete: 189 l.Debug("processing delete of pubkey") 190 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 191 } ··· 197 return nil 198 } 199 200 - func (i *Ingester) ingestArtifact(e *models.Event) error { 201 did := e.Did 202 var err error 203 ··· 205 l = l.With("nsid", e.Commit.Collection) 206 207 switch e.Commit.Operation { 208 - case models.CommitOperationCreate, models.CommitOperationUpdate: 209 raw := json.RawMessage(e.Commit.Record) 210 record := tangled.RepoArtifact{} 211 err = json.Unmarshal(raw, &record) ··· 234 createdAt = time.Now() 235 } 236 237 - artifact := db.Artifact{ 238 Did: did, 239 Rkey: e.Commit.RKey, 240 RepoAt: repoAt, ··· 247 } 248 249 err = db.AddArtifact(i.Db, artifact) 250 - case models.CommitOperationDelete: 251 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 252 } 253 ··· 258 return nil 259 } 260 261 - func (i *Ingester) ingestProfile(e *models.Event) error { 262 did := e.Did 263 var err error 264 ··· 270 } 271 272 switch e.Commit.Operation { 273 - case models.CommitOperationCreate, models.CommitOperationUpdate: 274 raw := json.RawMessage(e.Commit.Record) 275 record := tangled.ActorProfile{} 276 err = json.Unmarshal(raw, &record) ··· 298 } 299 } 300 301 - var stats [2]db.VanityStat 302 for i, s := range record.Stats { 303 if i < 2 { 304 - stats[i].Kind = db.VanityStatKind(s) 305 } 306 } 307 ··· 312 } 313 } 314 315 - profile := db.Profile{ 316 Did: did, 317 Description: description, 318 IncludeBluesky: includeBluesky, ··· 338 } 339 340 err = db.UpsertProfile(tx, &profile) 341 - case models.CommitOperationDelete: 342 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 343 } 344 ··· 349 return nil 350 } 351 352 - func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 353 did := e.Did 354 var err error 355 ··· 357 l = l.With("nsid", e.Commit.Collection) 358 359 switch e.Commit.Operation { 360 - case models.CommitOperationCreate: 361 raw := json.RawMessage(e.Commit.Record) 362 record := tangled.SpindleMember{} 363 err = json.Unmarshal(raw, &record) ··· 386 return fmt.Errorf("failed to index profile record, invalid db cast") 387 } 388 389 - err = db.AddSpindleMember(ddb, db.SpindleMember{ 390 Did: syntax.DID(did), 391 Rkey: e.Commit.RKey, 392 Instance: record.Instance, ··· 402 } 403 404 l.Info("added spindle member") 405 - case models.CommitOperationDelete: 406 rkey := e.Commit.RKey 407 408 ddb, ok := i.Db.Execer.(*db.DB) ··· 455 return nil 456 } 457 458 - func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 459 did := e.Did 460 var err error 461 ··· 463 l = l.With("nsid", e.Commit.Collection) 464 465 switch e.Commit.Operation { 466 - case models.CommitOperationCreate: 467 raw := json.RawMessage(e.Commit.Record) 468 record := tangled.Spindle{} 469 err = json.Unmarshal(raw, &record) ··· 479 return fmt.Errorf("failed to index profile record, invalid db cast") 480 } 481 482 - err := db.AddSpindle(ddb, db.Spindle{ 483 Owner: syntax.DID(did), 484 Instance: instance, 485 }) ··· 501 502 return nil 503 504 - case models.CommitOperationDelete: 505 instance := e.Commit.RKey 506 507 ddb, ok := i.Db.Execer.(*db.DB) ··· 569 return nil 570 } 571 572 - func (i *Ingester) ingestString(e *models.Event) error { 573 did := e.Did 574 rkey := e.Commit.RKey 575 ··· 584 } 585 586 switch e.Commit.Operation { 587 - case models.CommitOperationCreate, models.CommitOperationUpdate: 588 raw := json.RawMessage(e.Commit.Record) 589 record := tangled.String{} 590 err = json.Unmarshal(raw, &record) ··· 593 return err 594 } 595 596 - string := db.StringFromRecord(did, rkey, record) 597 598 - if err = string.Validate(); err != nil { 599 l.Error("invalid record", "err", err) 600 return err 601 } ··· 607 608 return nil 609 610 - case models.CommitOperationDelete: 611 if err := db.DeleteString( 612 ddb, 613 db.FilterEq("did", did), ··· 623 return nil 624 } 625 626 - func (i *Ingester) ingestKnotMember(e *models.Event) error { 627 did := e.Did 628 var err error 629 ··· 631 l = l.With("nsid", e.Commit.Collection) 632 633 switch e.Commit.Operation { 634 - case models.CommitOperationCreate: 635 raw := json.RawMessage(e.Commit.Record) 636 record := tangled.KnotMember{} 637 err = json.Unmarshal(raw, &record) ··· 661 } 662 663 l.Info("added knot member") 664 - case models.CommitOperationDelete: 665 // we don't store knot members in a table (like we do for spindle) 666 // and we can't remove this just yet. possibly fixed if we switch 667 // to either: ··· 675 return nil 676 } 677 678 - func (i *Ingester) ingestKnot(e *models.Event) error { 679 did := e.Did 680 var err error 681 ··· 683 l = l.With("nsid", e.Commit.Collection) 684 685 switch e.Commit.Operation { 686 - case models.CommitOperationCreate: 687 raw := json.RawMessage(e.Commit.Record) 688 record := tangled.Knot{} 689 err = json.Unmarshal(raw, &record) ··· 718 719 return nil 720 721 - case models.CommitOperationDelete: 722 domain := e.Commit.RKey 723 724 ddb, ok := i.Db.Execer.(*db.DB) ··· 778 779 return nil 780 } 781 - func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 782 did := e.Did 783 rkey := e.Commit.RKey 784 ··· 793 } 794 795 switch e.Commit.Operation { 796 - case models.CommitOperationCreate, models.CommitOperationUpdate: 797 raw := json.RawMessage(e.Commit.Record) 798 record := tangled.RepoIssue{} 799 err = json.Unmarshal(raw, &record) ··· 802 return err 803 } 804 805 - issue := db.IssueFromRecord(did, rkey, record) 806 807 if err := i.Validator.ValidateIssue(&issue); err != nil { 808 return fmt.Errorf("failed to validate issue: %w", err) ··· 829 830 return nil 831 832 - case models.CommitOperationDelete: 833 if err := db.DeleteIssues( 834 ddb, 835 db.FilterEq("did", did), ··· 845 return nil 846 } 847 848 - func (i *Ingester) ingestIssueComment(e *models.Event) error { 849 did := e.Did 850 rkey := e.Commit.RKey 851 ··· 860 } 861 862 switch e.Commit.Operation { 863 - case models.CommitOperationCreate, models.CommitOperationUpdate: 864 raw := json.RawMessage(e.Commit.Record) 865 record := tangled.RepoIssueComment{} 866 err = json.Unmarshal(raw, &record) ··· 868 return fmt.Errorf("invalid record: %w", err) 869 } 870 871 - comment, err := db.IssueCommentFromRecord(did, rkey, record) 872 if err != nil { 873 return fmt.Errorf("failed to parse comment from record: %w", err) 874 } ··· 884 885 return nil 886 887 - case models.CommitOperationDelete: 888 if err := db.DeleteIssueComments( 889 ddb, 890 db.FilterEq("did", did), ··· 899 return nil 900 } 901 902 - func (i *Ingester) ingestLabelDefinition(e *models.Event) error { 903 did := e.Did 904 rkey := e.Commit.RKey 905 ··· 914 } 915 916 switch e.Commit.Operation { 917 - case models.CommitOperationCreate, models.CommitOperationUpdate: 918 raw := json.RawMessage(e.Commit.Record) 919 record := tangled.LabelDefinition{} 920 err = json.Unmarshal(raw, &record) ··· 922 return fmt.Errorf("invalid record: %w", err) 923 } 924 925 - def, err := db.LabelDefinitionFromRecord(did, rkey, record) 926 if err != nil { 927 return fmt.Errorf("failed to parse labeldef from record: %w", err) 928 } ··· 938 939 return nil 940 941 - case models.CommitOperationDelete: 942 if err := db.DeleteLabelDefinition( 943 ddb, 944 db.FilterEq("did", did), ··· 952 953 return nil 954 }
··· 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 + "maps" 9 + "slices" 10 11 "time" 12 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 + jmodels "github.com/bluesky-social/jetstream/pkg/models" 15 "github.com/go-git/go-git/v5/plumbing" 16 "github.com/ipfs/go-cid" 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" 25 ) 26 27 type Ingester struct { ··· 33 Validator *validator.Validator 34 } 35 36 + type processFunc func(ctx context.Context, e *jmodels.Event) error 37 38 func (i *Ingester) Ingest() processFunc { 39 + return func(ctx context.Context, e *jmodels.Event) error { 40 var err error 41 defer func() { 42 eventTime := e.TimeUS ··· 48 49 l := i.Logger.With("kind", e.Kind) 50 switch e.Kind { 51 + case jmodels.EventKindAccount: 52 if !e.Account.Active && *e.Account.Status == "deactivated" { 53 err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 54 } 55 + case jmodels.EventKindIdentity: 56 err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 57 + case jmodels.EventKindCommit: 58 switch e.Commit.Collection { 59 case tangled.GraphFollowNSID: 60 err = i.ingestFollow(e) ··· 82 err = i.ingestIssueComment(e) 83 case tangled.LabelDefinitionNSID: 84 err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 87 } 88 l = i.Logger.With("nsid", e.Commit.Collection) 89 } ··· 96 } 97 } 98 99 + func (i *Ingester) ingestStar(e *jmodels.Event) error { 100 var err error 101 did := e.Did 102 ··· 104 l = l.With("nsid", e.Commit.Collection) 105 106 switch e.Commit.Operation { 107 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 108 var subjectUri syntax.ATURI 109 110 raw := json.RawMessage(e.Commit.Record) ··· 120 l.Error("invalid record", "err", err) 121 return err 122 } 123 + err = db.AddStar(i.Db, &models.Star{ 124 StarredByDid: did, 125 RepoAt: subjectUri, 126 Rkey: e.Commit.RKey, 127 }) 128 + case jmodels.CommitOperationDelete: 129 err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 130 } 131 ··· 136 return nil 137 } 138 139 + func (i *Ingester) ingestFollow(e *jmodels.Event) error { 140 var err error 141 did := e.Did 142 ··· 144 l = l.With("nsid", e.Commit.Collection) 145 146 switch e.Commit.Operation { 147 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 148 raw := json.RawMessage(e.Commit.Record) 149 record := tangled.GraphFollow{} 150 err = json.Unmarshal(raw, &record) ··· 153 return err 154 } 155 156 + err = db.AddFollow(i.Db, &models.Follow{ 157 UserDid: did, 158 SubjectDid: record.Subject, 159 Rkey: e.Commit.RKey, 160 }) 161 + case jmodels.CommitOperationDelete: 162 err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 163 } 164 ··· 169 return nil 170 } 171 172 + func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { 173 did := e.Did 174 var err error 175 ··· 177 l = l.With("nsid", e.Commit.Collection) 178 179 switch e.Commit.Operation { 180 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 181 l.Debug("processing add of pubkey") 182 raw := json.RawMessage(e.Commit.Record) 183 record := tangled.PublicKey{} ··· 190 name := record.Name 191 key := record.Key 192 err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 193 + case jmodels.CommitOperationDelete: 194 l.Debug("processing delete of pubkey") 195 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 196 } ··· 202 return nil 203 } 204 205 + func (i *Ingester) ingestArtifact(e *jmodels.Event) error { 206 did := e.Did 207 var err error 208 ··· 210 l = l.With("nsid", e.Commit.Collection) 211 212 switch e.Commit.Operation { 213 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 214 raw := json.RawMessage(e.Commit.Record) 215 record := tangled.RepoArtifact{} 216 err = json.Unmarshal(raw, &record) ··· 239 createdAt = time.Now() 240 } 241 242 + artifact := models.Artifact{ 243 Did: did, 244 Rkey: e.Commit.RKey, 245 RepoAt: repoAt, ··· 252 } 253 254 err = db.AddArtifact(i.Db, artifact) 255 + case jmodels.CommitOperationDelete: 256 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 257 } 258 ··· 263 return nil 264 } 265 266 + func (i *Ingester) ingestProfile(e *jmodels.Event) error { 267 did := e.Did 268 var err error 269 ··· 275 } 276 277 switch e.Commit.Operation { 278 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 279 raw := json.RawMessage(e.Commit.Record) 280 record := tangled.ActorProfile{} 281 err = json.Unmarshal(raw, &record) ··· 303 } 304 } 305 306 + var stats [2]models.VanityStat 307 for i, s := range record.Stats { 308 if i < 2 { 309 + stats[i].Kind = models.VanityStatKind(s) 310 } 311 } 312 ··· 317 } 318 } 319 320 + profile := models.Profile{ 321 Did: did, 322 Description: description, 323 IncludeBluesky: includeBluesky, ··· 343 } 344 345 err = db.UpsertProfile(tx, &profile) 346 + case jmodels.CommitOperationDelete: 347 err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 348 } 349 ··· 354 return nil 355 } 356 357 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error { 358 did := e.Did 359 var err error 360 ··· 362 l = l.With("nsid", e.Commit.Collection) 363 364 switch e.Commit.Operation { 365 + case jmodels.CommitOperationCreate: 366 raw := json.RawMessage(e.Commit.Record) 367 record := tangled.SpindleMember{} 368 err = json.Unmarshal(raw, &record) ··· 391 return fmt.Errorf("failed to index profile record, invalid db cast") 392 } 393 394 + err = db.AddSpindleMember(ddb, models.SpindleMember{ 395 Did: syntax.DID(did), 396 Rkey: e.Commit.RKey, 397 Instance: record.Instance, ··· 407 } 408 409 l.Info("added spindle member") 410 + case jmodels.CommitOperationDelete: 411 rkey := e.Commit.RKey 412 413 ddb, ok := i.Db.Execer.(*db.DB) ··· 460 return nil 461 } 462 463 + func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error { 464 did := e.Did 465 var err error 466 ··· 468 l = l.With("nsid", e.Commit.Collection) 469 470 switch e.Commit.Operation { 471 + case jmodels.CommitOperationCreate: 472 raw := json.RawMessage(e.Commit.Record) 473 record := tangled.Spindle{} 474 err = json.Unmarshal(raw, &record) ··· 484 return fmt.Errorf("failed to index profile record, invalid db cast") 485 } 486 487 + err := db.AddSpindle(ddb, models.Spindle{ 488 Owner: syntax.DID(did), 489 Instance: instance, 490 }) ··· 506 507 return nil 508 509 + case jmodels.CommitOperationDelete: 510 instance := e.Commit.RKey 511 512 ddb, ok := i.Db.Execer.(*db.DB) ··· 574 return nil 575 } 576 577 + func (i *Ingester) ingestString(e *jmodels.Event) error { 578 did := e.Did 579 rkey := e.Commit.RKey 580 ··· 589 } 590 591 switch e.Commit.Operation { 592 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 593 raw := json.RawMessage(e.Commit.Record) 594 record := tangled.String{} 595 err = json.Unmarshal(raw, &record) ··· 598 return err 599 } 600 601 + string := models.StringFromRecord(did, rkey, record) 602 603 + if err = i.Validator.ValidateString(&string); err != nil { 604 l.Error("invalid record", "err", err) 605 return err 606 } ··· 612 613 return nil 614 615 + case jmodels.CommitOperationDelete: 616 if err := db.DeleteString( 617 ddb, 618 db.FilterEq("did", did), ··· 628 return nil 629 } 630 631 + func (i *Ingester) ingestKnotMember(e *jmodels.Event) error { 632 did := e.Did 633 var err error 634 ··· 636 l = l.With("nsid", e.Commit.Collection) 637 638 switch e.Commit.Operation { 639 + case jmodels.CommitOperationCreate: 640 raw := json.RawMessage(e.Commit.Record) 641 record := tangled.KnotMember{} 642 err = json.Unmarshal(raw, &record) ··· 666 } 667 668 l.Info("added knot member") 669 + case jmodels.CommitOperationDelete: 670 // we don't store knot members in a table (like we do for spindle) 671 // and we can't remove this just yet. possibly fixed if we switch 672 // to either: ··· 680 return nil 681 } 682 683 + func (i *Ingester) ingestKnot(e *jmodels.Event) error { 684 did := e.Did 685 var err error 686 ··· 688 l = l.With("nsid", e.Commit.Collection) 689 690 switch e.Commit.Operation { 691 + case jmodels.CommitOperationCreate: 692 raw := json.RawMessage(e.Commit.Record) 693 record := tangled.Knot{} 694 err = json.Unmarshal(raw, &record) ··· 723 724 return nil 725 726 + case jmodels.CommitOperationDelete: 727 domain := e.Commit.RKey 728 729 ddb, ok := i.Db.Execer.(*db.DB) ··· 783 784 return nil 785 } 786 + func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error { 787 did := e.Did 788 rkey := e.Commit.RKey 789 ··· 798 } 799 800 switch e.Commit.Operation { 801 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 802 raw := json.RawMessage(e.Commit.Record) 803 record := tangled.RepoIssue{} 804 err = json.Unmarshal(raw, &record) ··· 807 return err 808 } 809 810 + issue := models.IssueFromRecord(did, rkey, record) 811 812 if err := i.Validator.ValidateIssue(&issue); err != nil { 813 return fmt.Errorf("failed to validate issue: %w", err) ··· 834 835 return nil 836 837 + case jmodels.CommitOperationDelete: 838 if err := db.DeleteIssues( 839 ddb, 840 db.FilterEq("did", did), ··· 850 return nil 851 } 852 853 + func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 854 did := e.Did 855 rkey := e.Commit.RKey 856 ··· 865 } 866 867 switch e.Commit.Operation { 868 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 869 raw := json.RawMessage(e.Commit.Record) 870 record := tangled.RepoIssueComment{} 871 err = json.Unmarshal(raw, &record) ··· 873 return fmt.Errorf("invalid record: %w", err) 874 } 875 876 + comment, err := models.IssueCommentFromRecord(did, rkey, record) 877 if err != nil { 878 return fmt.Errorf("failed to parse comment from record: %w", err) 879 } ··· 889 890 return nil 891 892 + case jmodels.CommitOperationDelete: 893 if err := db.DeleteIssueComments( 894 ddb, 895 db.FilterEq("did", did), ··· 904 return nil 905 } 906 907 + func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { 908 did := e.Did 909 rkey := e.Commit.RKey 910 ··· 919 } 920 921 switch e.Commit.Operation { 922 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 923 raw := json.RawMessage(e.Commit.Record) 924 record := tangled.LabelDefinition{} 925 err = json.Unmarshal(raw, &record) ··· 927 return fmt.Errorf("invalid record: %w", err) 928 } 929 930 + def, err := models.LabelDefinitionFromRecord(did, rkey, record) 931 if err != nil { 932 return fmt.Errorf("failed to parse labeldef from record: %w", err) 933 } ··· 943 944 return nil 945 946 + case jmodels.CommitOperationDelete: 947 if err := db.DeleteLabelDefinition( 948 ddb, 949 db.FilterEq("did", did), ··· 957 958 return nil 959 } 960 + 961 + func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { 962 + did := e.Did 963 + rkey := e.Commit.RKey 964 + 965 + var err error 966 + 967 + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 968 + l.Info("ingesting record") 969 + 970 + ddb, ok := i.Db.Execer.(*db.DB) 971 + if !ok { 972 + return fmt.Errorf("failed to index label op, invalid db cast") 973 + } 974 + 975 + switch e.Commit.Operation { 976 + case jmodels.CommitOperationCreate: 977 + raw := json.RawMessage(e.Commit.Record) 978 + record := tangled.LabelOp{} 979 + err = json.Unmarshal(raw, &record) 980 + if err != nil { 981 + return fmt.Errorf("invalid record: %w", err) 982 + } 983 + 984 + subject := syntax.ATURI(record.Subject) 985 + collection := subject.Collection() 986 + 987 + var repo *models.Repo 988 + switch collection { 989 + case tangled.RepoIssueNSID: 990 + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 991 + if err != nil || len(i) != 1 { 992 + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 + } 994 + repo = i[0].Repo 995 + default: 996 + return fmt.Errorf("unsupport label subject: %s", collection) 997 + } 998 + 999 + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1000 + if err != nil { 1001 + return fmt.Errorf("failed to build label application ctx: %w", err) 1002 + } 1003 + 1004 + ops := models.LabelOpsFromRecord(did, rkey, record) 1005 + 1006 + for _, o := range ops { 1007 + def, ok := actx.Defs[o.OperandKey] 1008 + if !ok { 1009 + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 + } 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 + return fmt.Errorf("failed to validate labelop: %w", err) 1013 + } 1014 + } 1015 + 1016 + tx, err := ddb.Begin() 1017 + if err != nil { 1018 + return err 1019 + } 1020 + defer tx.Rollback() 1021 + 1022 + for _, o := range ops { 1023 + _, err = db.AddLabelOp(tx, &o) 1024 + if err != nil { 1025 + return fmt.Errorf("failed to add labelop: %w", err) 1026 + } 1027 + } 1028 + 1029 + if err = tx.Commit(); err != nil { 1030 + return err 1031 + } 1032 + } 1033 + 1034 + return nil 1035 + }
+44 -31
appview/issues/issues.go
··· 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 19 - "tangled.sh/tangled.sh/core/api/tangled" 20 - "tangled.sh/tangled.sh/core/appview/config" 21 - "tangled.sh/tangled.sh/core/appview/db" 22 - "tangled.sh/tangled.sh/core/appview/notify" 23 - "tangled.sh/tangled.sh/core/appview/oauth" 24 - "tangled.sh/tangled.sh/core/appview/pages" 25 - "tangled.sh/tangled.sh/core/appview/pagination" 26 - "tangled.sh/tangled.sh/core/appview/reporesolver" 27 - "tangled.sh/tangled.sh/core/appview/validator" 28 - "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 - "tangled.sh/tangled.sh/core/idresolver" 30 - tlog "tangled.sh/tangled.sh/core/log" 31 - "tangled.sh/tangled.sh/core/tid" 32 ) 33 34 type Issues struct { ··· 75 return 76 } 77 78 - issue, ok := r.Context().Value("issue").(*db.Issue) 79 if !ok { 80 l.Error("failed to get issue") 81 rp.pages.Error404(w) ··· 87 l.Error("failed to get issue reactions", "err", err) 88 } 89 90 - userReactions := map[db.ReactionKind]bool{} 91 if user != nil { 92 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 93 } ··· 103 return 104 } 105 106 - defs := make(map[string]*db.LabelDefinition) 107 for _, l := range labelDefs { 108 defs[l.AtUri().String()] = &l 109 } ··· 113 RepoInfo: f.RepoInfo(user), 114 Issue: issue, 115 CommentList: issue.CommentList(), 116 - OrderedReactionKinds: db.OrderedReactionKinds, 117 Reactions: reactionCountMap, 118 UserReacted: userReactions, 119 LabelDefs: defs, ··· 129 return 130 } 131 132 - issue, ok := r.Context().Value("issue").(*db.Issue) 133 if !ok { 134 l.Error("failed to get issue") 135 rp.pages.Error404(w) ··· 225 return 226 } 227 228 - issue, ok := r.Context().Value("issue").(*db.Issue) 229 if !ok { 230 l.Error("failed to get issue") 231 rp.pages.Notice(w, noticeId, "Failed to delete issue.") ··· 272 return 273 } 274 275 - issue, ok := r.Context().Value("issue").(*db.Issue) 276 if !ok { 277 l.Error("failed to get issue") 278 rp.pages.Error404(w) ··· 300 return 301 } 302 303 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 304 return 305 } else { ··· 318 return 319 } 320 321 - issue, ok := r.Context().Value("issue").(*db.Issue) 322 if !ok { 323 l.Error("failed to get issue") 324 rp.pages.Error404(w) ··· 362 return 363 } 364 365 - issue, ok := r.Context().Value("issue").(*db.Issue) 366 if !ok { 367 l.Error("failed to get issue") 368 rp.pages.Error404(w) ··· 381 replyTo = &replyToUri 382 } 383 384 - comment := db.IssueComment{ 385 Did: user.Did, 386 Rkey: tid.TID(), 387 IssueAt: issue.AtUri().String(), ··· 433 434 // reset atUri to make rollback a no-op 435 atUri = "" 436 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 437 } 438 ··· 445 return 446 } 447 448 - issue, ok := r.Context().Value("issue").(*db.Issue) 449 if !ok { 450 l.Error("failed to get issue") 451 rp.pages.Error404(w) ··· 486 return 487 } 488 489 - issue, ok := r.Context().Value("issue").(*db.Issue) 490 if !ok { 491 l.Error("failed to get issue") 492 rp.pages.Error404(w) ··· 590 return 591 } 592 593 - issue, ok := r.Context().Value("issue").(*db.Issue) 594 if !ok { 595 l.Error("failed to get issue") 596 rp.pages.Error404(w) ··· 631 return 632 } 633 634 - issue, ok := r.Context().Value("issue").(*db.Issue) 635 if !ok { 636 l.Error("failed to get issue") 637 rp.pages.Error404(w) ··· 672 return 673 } 674 675 - issue, ok := r.Context().Value("issue").(*db.Issue) 676 if !ok { 677 l.Error("failed to get issue") 678 rp.pages.Error404(w) ··· 789 return 790 } 791 792 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 793 if err != nil { 794 log.Println("failed to fetch labels", err) 795 rp.pages.Error503(w) 796 return 797 } 798 799 - defs := make(map[string]*db.LabelDefinition) 800 for _, l := range labelDefs { 801 defs[l.AtUri().String()] = &l 802 } ··· 828 RepoInfo: f.RepoInfo(user), 829 }) 830 case http.MethodPost: 831 - issue := &db.Issue{ 832 RepoAt: f.RepoAt(), 833 Rkey: tid.TID(), 834 Title: r.FormValue("title"),
··· 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 19 + "tangled.org/core/api/tangled" 20 + "tangled.org/core/appview/config" 21 + "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/notify" 24 + "tangled.org/core/appview/oauth" 25 + "tangled.org/core/appview/pages" 26 + "tangled.org/core/appview/pagination" 27 + "tangled.org/core/appview/reporesolver" 28 + "tangled.org/core/appview/validator" 29 + "tangled.org/core/appview/xrpcclient" 30 + "tangled.org/core/idresolver" 31 + tlog "tangled.org/core/log" 32 + "tangled.org/core/tid" 33 ) 34 35 type Issues struct { ··· 76 return 77 } 78 79 + issue, ok := r.Context().Value("issue").(*models.Issue) 80 if !ok { 81 l.Error("failed to get issue") 82 rp.pages.Error404(w) ··· 88 l.Error("failed to get issue reactions", "err", err) 89 } 90 91 + userReactions := map[models.ReactionKind]bool{} 92 if user != nil { 93 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 94 } ··· 104 return 105 } 106 107 + defs := make(map[string]*models.LabelDefinition) 108 for _, l := range labelDefs { 109 defs[l.AtUri().String()] = &l 110 } ··· 114 RepoInfo: f.RepoInfo(user), 115 Issue: issue, 116 CommentList: issue.CommentList(), 117 + OrderedReactionKinds: models.OrderedReactionKinds, 118 Reactions: reactionCountMap, 119 UserReacted: userReactions, 120 LabelDefs: defs, ··· 130 return 131 } 132 133 + issue, ok := r.Context().Value("issue").(*models.Issue) 134 if !ok { 135 l.Error("failed to get issue") 136 rp.pages.Error404(w) ··· 226 return 227 } 228 229 + issue, ok := r.Context().Value("issue").(*models.Issue) 230 if !ok { 231 l.Error("failed to get issue") 232 rp.pages.Notice(w, noticeId, "Failed to delete issue.") ··· 273 return 274 } 275 276 + issue, ok := r.Context().Value("issue").(*models.Issue) 277 if !ok { 278 l.Error("failed to get issue") 279 rp.pages.Error404(w) ··· 301 return 302 } 303 304 + // notify about the issue closure 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 306 + 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 308 return 309 } else { ··· 322 return 323 } 324 325 + issue, ok := r.Context().Value("issue").(*models.Issue) 326 if !ok { 327 l.Error("failed to get issue") 328 rp.pages.Error404(w) ··· 366 return 367 } 368 369 + issue, ok := r.Context().Value("issue").(*models.Issue) 370 if !ok { 371 l.Error("failed to get issue") 372 rp.pages.Error404(w) ··· 385 replyTo = &replyToUri 386 } 387 388 + comment := models.IssueComment{ 389 Did: user.Did, 390 Rkey: tid.TID(), 391 IssueAt: issue.AtUri().String(), ··· 437 438 // reset atUri to make rollback a no-op 439 atUri = "" 440 + 441 + // notify about the new comment 442 + comment.Id = commentId 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 444 + 445 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 446 } 447 ··· 454 return 455 } 456 457 + issue, ok := r.Context().Value("issue").(*models.Issue) 458 if !ok { 459 l.Error("failed to get issue") 460 rp.pages.Error404(w) ··· 495 return 496 } 497 498 + issue, ok := r.Context().Value("issue").(*models.Issue) 499 if !ok { 500 l.Error("failed to get issue") 501 rp.pages.Error404(w) ··· 599 return 600 } 601 602 + issue, ok := r.Context().Value("issue").(*models.Issue) 603 if !ok { 604 l.Error("failed to get issue") 605 rp.pages.Error404(w) ··· 640 return 641 } 642 643 + issue, ok := r.Context().Value("issue").(*models.Issue) 644 if !ok { 645 l.Error("failed to get issue") 646 rp.pages.Error404(w) ··· 681 return 682 } 683 684 + issue, ok := r.Context().Value("issue").(*models.Issue) 685 if !ok { 686 l.Error("failed to get issue") 687 rp.pages.Error404(w) ··· 798 return 799 } 800 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 806 if err != nil { 807 log.Println("failed to fetch labels", err) 808 rp.pages.Error503(w) 809 return 810 } 811 812 + defs := make(map[string]*models.LabelDefinition) 813 for _, l := range labelDefs { 814 defs[l.AtUri().String()] = &l 815 } ··· 841 RepoInfo: f.RepoInfo(user), 842 }) 843 case http.MethodPost: 844 + issue := &models.Issue{ 845 RepoAt: f.RepoAt(), 846 Rkey: tid.TID(), 847 Title: r.FormValue("title"),
+1 -1
appview/issues/router.go
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
+14 -13
appview/knots/knots.go
··· 9 "time" 10 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" 24 25 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 119 } 120 121 // organize repos by did 122 - repoMap := make(map[string][]db.Repo) 123 for _, r := range repos { 124 repoMap[r.Did] = append(repoMap[r.Did], r) 125 }
··· 9 "time" 10 11 "github.com/go-chi/chi/v5" 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" 25 26 comatproto "github.com/bluesky-social/indigo/api/atproto" 27 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 120 } 121 122 // organize repos by did 123 + repoMap := make(map[string][]models.Repo) 124 for _, r := range repos { 125 repoMap[r.Did] = append(repoMap[r.Did], r) 126 }
+32 -24
appview/labels/labels.go
··· 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "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 ) 27 28 type Labels struct { ··· 31 db *db.DB 32 logger *slog.Logger 33 validator *validator.Validator 34 } 35 36 func New( ··· 38 pages *pages.Pages, 39 db *db.DB, 40 validator *validator.Validator, 41 ) *Labels { 42 logger := log.New("labels") 43 ··· 47 db: db, 48 logger: logger, 49 validator: validator, 50 } 51 } 52 ··· 85 repoAt := r.Form.Get("repo") 86 subjectUri := r.Form.Get("subject") 87 88 // find all the labels that this repo subscribes to 89 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 90 if err != nil { ··· 103 return 104 } 105 106 - l.logger.Info("actx", "labels", labelAts) 107 - l.logger.Info("actx", "defs", actx.Defs) 108 - 109 // calculate the start state by applying already known labels 110 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 111 if err != nil { ··· 113 return 114 } 115 116 - labelState := db.NewLabelState() 117 actx.ApplyLabelOps(labelState, existingOps) 118 119 - var labelOps []db.LabelOp 120 121 // first delete all existing state 122 for key, vals := range labelState.Inner() { 123 for val := range vals { 124 - labelOps = append(labelOps, db.LabelOp{ 125 Did: did, 126 Rkey: rkey, 127 Subject: syntax.ATURI(subjectUri), 128 - Operation: db.LabelOperationDel, 129 OperandKey: key, 130 OperandValue: val, 131 PerformedAt: performedAt, ··· 141 } 142 143 for _, val := range vals { 144 - labelOps = append(labelOps, db.LabelOp{ 145 Did: did, 146 Rkey: rkey, 147 Subject: syntax.ATURI(subjectUri), 148 - Operation: db.LabelOperationAdd, 149 OperandKey: key, 150 OperandValue: val, 151 PerformedAt: performedAt, ··· 154 } 155 } 156 157 - // reduce the opset 158 - labelOps = db.ReduceLabelOps(labelOps) 159 - 160 for i := range labelOps { 161 def := actx.Defs[labelOps[i].OperandKey] 162 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 163 fail(fmt.Sprintf("Invalid form data: %s", err), err) 164 return 165 } 166 } 167 168 // next, apply all ops introduced in this request and filter out ones that are no-ops 169 validLabelOps := labelOps[:0] 170 for _, op := range labelOps { 171 - if err = actx.ApplyLabelOp(labelState, op); err != db.LabelNoOpError { 172 validLabelOps = append(validLabelOps, op) 173 } 174 } ··· 180 } 181 182 // create an atproto record of valid ops 183 - record := db.LabelOpsAsRecord(validLabelOps) 184 185 client, err := l.oauth.AuthorizedClient(r) 186 if err != nil {
··· 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 "github.com/go-chi/chi/v5" 16 17 + "tangled.org/core/api/tangled" 18 + "tangled.org/core/appview/db" 19 + "tangled.org/core/appview/middleware" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/oauth" 22 + "tangled.org/core/appview/pages" 23 + "tangled.org/core/appview/validator" 24 + "tangled.org/core/appview/xrpcclient" 25 + "tangled.org/core/log" 26 + "tangled.org/core/rbac" 27 + "tangled.org/core/tid" 28 ) 29 30 type Labels struct { ··· 33 db *db.DB 34 logger *slog.Logger 35 validator *validator.Validator 36 + enforcer *rbac.Enforcer 37 } 38 39 func New( ··· 41 pages *pages.Pages, 42 db *db.DB, 43 validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 45 ) *Labels { 46 logger := log.New("labels") 47 ··· 51 db: db, 52 logger: logger, 53 validator: validator, 54 + enforcer: enforcer, 55 } 56 } 57 ··· 90 repoAt := r.Form.Get("repo") 91 subjectUri := r.Form.Get("subject") 92 93 + repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 94 + if err != nil { 95 + fail("Failed to get repository.", err) 96 + return 97 + } 98 + 99 // find all the labels that this repo subscribes to 100 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 101 if err != nil { ··· 114 return 115 } 116 117 // calculate the start state by applying already known labels 118 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 119 if err != nil { ··· 121 return 122 } 123 124 + labelState := models.NewLabelState() 125 actx.ApplyLabelOps(labelState, existingOps) 126 127 + var labelOps []models.LabelOp 128 129 // first delete all existing state 130 for key, vals := range labelState.Inner() { 131 for val := range vals { 132 + labelOps = append(labelOps, models.LabelOp{ 133 Did: did, 134 Rkey: rkey, 135 Subject: syntax.ATURI(subjectUri), 136 + Operation: models.LabelOperationDel, 137 OperandKey: key, 138 OperandValue: val, 139 PerformedAt: performedAt, ··· 149 } 150 151 for _, val := range vals { 152 + labelOps = append(labelOps, models.LabelOp{ 153 Did: did, 154 Rkey: rkey, 155 Subject: syntax.ATURI(subjectUri), 156 + Operation: models.LabelOperationAdd, 157 OperandKey: key, 158 OperandValue: val, 159 PerformedAt: performedAt, ··· 162 } 163 } 164 165 for i := range labelOps { 166 def := actx.Defs[labelOps[i].OperandKey] 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 168 fail(fmt.Sprintf("Invalid form data: %s", err), err) 169 return 170 } 171 } 172 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 175 + 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 177 validLabelOps := labelOps[:0] 178 for _, op := range labelOps { 179 + if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError { 180 validLabelOps = append(validLabelOps, op) 181 } 182 } ··· 188 } 189 190 // create an atproto record of valid ops 191 + record := models.LabelOpsAsRecord(validLabelOps) 192 193 client, err := l.oauth.AuthorizedClient(r) 194 if err != nil {
+16 -7
appview/middleware/middleware.go
··· 12 13 "github.com/bluesky-social/indigo/atproto/identity" 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" 22 ) 23 24 type Middleware struct { ··· 42 } 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler {
··· 12 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 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 ) 23 24 type Middleware struct { ··· 42 } 43 44 type middlewareFunc func(http.Handler) http.Handler 45 + 46 + func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 + return func(next http.Handler) http.Handler { 48 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + _, _, _ = mw.oauth.GetSession(r) 50 + next.ServeHTTP(w, r) 51 + }) 52 + } 53 + } 54 55 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 56 return func(next http.Handler) http.Handler {
+30
appview/models/artifact.go
···
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/ipfs/go-cid" 10 + "tangled.org/core/api/tangled" 11 + ) 12 + 13 + type Artifact struct { 14 + Id uint64 15 + Did string 16 + Rkey string 17 + 18 + RepoAt syntax.ATURI 19 + Tag plumbing.Hash 20 + CreatedAt time.Time 21 + 22 + BlobCid cid.Cid 23 + Name string 24 + Size uint64 25 + MimeType string 26 + } 27 + 28 + func (a *Artifact) ArtifactAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 30 + }
+21
appview/models/collaborator.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Collaborator struct { 10 + // identifiers for the record 11 + Id int64 12 + Did syntax.DID 13 + Rkey string 14 + 15 + // content 16 + SubjectDid syntax.DID 17 + RepoAt syntax.ATURI 18 + 19 + // meta 20 + Created time.Time 21 + }
+16
appview/models/email.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Email struct { 8 + ID int64 9 + Did string 10 + Address string 11 + Verified bool 12 + Primary bool 13 + VerificationCode string 14 + LastSent *time.Time 15 + CreatedAt time.Time 16 + }
+38
appview/models/follow.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Follow struct { 8 + UserDid string 9 + SubjectDid string 10 + FollowedAt time.Time 11 + Rkey string 12 + } 13 + 14 + type FollowStats struct { 15 + Followers int64 16 + Following int64 17 + } 18 + 19 + type FollowStatus int 20 + 21 + const ( 22 + IsNotFollowing FollowStatus = iota 23 + IsFollowing 24 + IsSelf 25 + ) 26 + 27 + func (s FollowStatus) String() string { 28 + switch s { 29 + case IsNotFollowing: 30 + return "IsNotFollowing" 31 + case IsFollowing: 32 + return "IsFollowing" 33 + case IsSelf: 34 + return "IsSelf" 35 + default: 36 + return "IsNotFollowing" 37 + } 38 + }
+194
appview/models/issue.go
···
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "sort" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Issue struct { 13 + Id int64 14 + Did string 15 + Rkey string 16 + RepoAt syntax.ATURI 17 + IssueId int 18 + Created time.Time 19 + Edited *time.Time 20 + Deleted *time.Time 21 + Title string 22 + Body string 23 + Open bool 24 + 25 + // optionally, populate this when querying for reverse mappings 26 + // like comment counts, parent repo etc. 27 + Comments []IssueComment 28 + Labels LabelState 29 + Repo *Repo 30 + } 31 + 32 + func (i *Issue) AtUri() syntax.ATURI { 33 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 34 + } 35 + 36 + func (i *Issue) AsRecord() tangled.RepoIssue { 37 + return tangled.RepoIssue{ 38 + Repo: i.RepoAt.String(), 39 + Title: i.Title, 40 + Body: &i.Body, 41 + CreatedAt: i.Created.Format(time.RFC3339), 42 + } 43 + } 44 + 45 + func (i *Issue) State() string { 46 + if i.Open { 47 + return "open" 48 + } 49 + return "closed" 50 + } 51 + 52 + type CommentListItem struct { 53 + Self *IssueComment 54 + Replies []*IssueComment 55 + } 56 + 57 + func (i *Issue) CommentList() []CommentListItem { 58 + // Create a map to quickly find comments by their aturi 59 + toplevel := make(map[string]*CommentListItem) 60 + var replies []*IssueComment 61 + 62 + // collect top level comments into the map 63 + for _, comment := range i.Comments { 64 + if comment.IsTopLevel() { 65 + toplevel[comment.AtUri().String()] = &CommentListItem{ 66 + Self: &comment, 67 + } 68 + } else { 69 + replies = append(replies, &comment) 70 + } 71 + } 72 + 73 + for _, r := range replies { 74 + parentAt := *r.ReplyTo 75 + if parent, exists := toplevel[parentAt]; exists { 76 + parent.Replies = append(parent.Replies, r) 77 + } 78 + } 79 + 80 + var listing []CommentListItem 81 + for _, v := range toplevel { 82 + listing = append(listing, *v) 83 + } 84 + 85 + // sort everything 86 + sortFunc := func(a, b *IssueComment) bool { 87 + return a.Created.Before(b.Created) 88 + } 89 + sort.Slice(listing, func(i, j int) bool { 90 + return sortFunc(listing[i].Self, listing[j].Self) 91 + }) 92 + for _, r := range listing { 93 + sort.Slice(r.Replies, func(i, j int) bool { 94 + return sortFunc(r.Replies[i], r.Replies[j]) 95 + }) 96 + } 97 + 98 + return listing 99 + } 100 + 101 + func (i *Issue) Participants() []string { 102 + participantSet := make(map[string]struct{}) 103 + participants := []string{} 104 + 105 + addParticipant := func(did string) { 106 + if _, exists := participantSet[did]; !exists { 107 + participantSet[did] = struct{}{} 108 + participants = append(participants, did) 109 + } 110 + } 111 + 112 + addParticipant(i.Did) 113 + 114 + for _, c := range i.Comments { 115 + addParticipant(c.Did) 116 + } 117 + 118 + return participants 119 + } 120 + 121 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 122 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 123 + if err != nil { 124 + created = time.Now() 125 + } 126 + 127 + body := "" 128 + if record.Body != nil { 129 + body = *record.Body 130 + } 131 + 132 + return Issue{ 133 + RepoAt: syntax.ATURI(record.Repo), 134 + Did: did, 135 + Rkey: rkey, 136 + Created: created, 137 + Title: record.Title, 138 + Body: body, 139 + Open: true, // new issues are open by default 140 + } 141 + } 142 + 143 + type IssueComment struct { 144 + Id int64 145 + Did string 146 + Rkey string 147 + IssueAt string 148 + ReplyTo *string 149 + Body string 150 + Created time.Time 151 + Edited *time.Time 152 + Deleted *time.Time 153 + } 154 + 155 + func (i *IssueComment) AtUri() syntax.ATURI { 156 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 157 + } 158 + 159 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 160 + return tangled.RepoIssueComment{ 161 + Body: i.Body, 162 + Issue: i.IssueAt, 163 + CreatedAt: i.Created.Format(time.RFC3339), 164 + ReplyTo: i.ReplyTo, 165 + } 166 + } 167 + 168 + func (i *IssueComment) IsTopLevel() bool { 169 + return i.ReplyTo == nil 170 + } 171 + 172 + func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 173 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 174 + if err != nil { 175 + created = time.Now() 176 + } 177 + 178 + ownerDid := did 179 + 180 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 181 + return nil, err 182 + } 183 + 184 + comment := IssueComment{ 185 + Did: ownerDid, 186 + Rkey: rkey, 187 + Body: record.Body, 188 + IssueAt: record.Issue, 189 + ReplyTo: record.ReplyTo, 190 + Created: created, 191 + } 192 + 193 + return &comment, nil 194 + }
+542
appview/models/label.go
···
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "crypto/sha1" 6 + "encoding/hex" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "slices" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + "tangled.org/core/api/tangled" 17 + "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 19 + ) 20 + 21 + type ConcreteType string 22 + 23 + const ( 24 + ConcreteTypeNull ConcreteType = "null" 25 + ConcreteTypeString ConcreteType = "string" 26 + ConcreteTypeInt ConcreteType = "integer" 27 + ConcreteTypeBool ConcreteType = "boolean" 28 + ) 29 + 30 + type ValueTypeFormat string 31 + 32 + const ( 33 + ValueTypeFormatAny ValueTypeFormat = "any" 34 + ValueTypeFormatDid ValueTypeFormat = "did" 35 + ) 36 + 37 + // ValueType represents an atproto lexicon type definition with constraints 38 + type ValueType struct { 39 + Type ConcreteType `json:"type"` 40 + Format ValueTypeFormat `json:"format,omitempty"` 41 + Enum []string `json:"enum,omitempty"` 42 + } 43 + 44 + func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType { 45 + return tangled.LabelDefinition_ValueType{ 46 + Type: string(vt.Type), 47 + Format: string(vt.Format), 48 + Enum: vt.Enum, 49 + } 50 + } 51 + 52 + func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType { 53 + return ValueType{ 54 + Type: ConcreteType(record.Type), 55 + Format: ValueTypeFormat(record.Format), 56 + Enum: record.Enum, 57 + } 58 + } 59 + 60 + func (vt ValueType) IsConcreteType() bool { 61 + return vt.Type == ConcreteTypeNull || 62 + vt.Type == ConcreteTypeString || 63 + vt.Type == ConcreteTypeInt || 64 + vt.Type == ConcreteTypeBool 65 + } 66 + 67 + func (vt ValueType) IsNull() bool { 68 + return vt.Type == ConcreteTypeNull 69 + } 70 + 71 + func (vt ValueType) IsString() bool { 72 + return vt.Type == ConcreteTypeString 73 + } 74 + 75 + func (vt ValueType) IsInt() bool { 76 + return vt.Type == ConcreteTypeInt 77 + } 78 + 79 + func (vt ValueType) IsBool() bool { 80 + return vt.Type == ConcreteTypeBool 81 + } 82 + 83 + func (vt ValueType) IsEnum() bool { 84 + return len(vt.Enum) > 0 85 + } 86 + 87 + func (vt ValueType) IsDidFormat() bool { 88 + return vt.Format == ValueTypeFormatDid 89 + } 90 + 91 + func (vt ValueType) IsAnyFormat() bool { 92 + return vt.Format == ValueTypeFormatAny 93 + } 94 + 95 + type LabelDefinition struct { 96 + Id int64 97 + Did string 98 + Rkey string 99 + 100 + Name string 101 + ValueType ValueType 102 + Scope []string 103 + Color *string 104 + Multiple bool 105 + Created time.Time 106 + } 107 + 108 + func (l *LabelDefinition) AtUri() syntax.ATURI { 109 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey)) 110 + } 111 + 112 + func (l *LabelDefinition) AsRecord() tangled.LabelDefinition { 113 + vt := l.ValueType.AsRecord() 114 + return tangled.LabelDefinition{ 115 + Name: l.Name, 116 + Color: l.Color, 117 + CreatedAt: l.Created.Format(time.RFC3339), 118 + Multiple: &l.Multiple, 119 + Scope: l.Scope, 120 + ValueType: &vt, 121 + } 122 + } 123 + 124 + // random color for a given seed 125 + func randomColor(seed string) string { 126 + hash := sha1.Sum([]byte(seed)) 127 + hexStr := hex.EncodeToString(hash[:]) 128 + r := hexStr[0:2] 129 + g := hexStr[2:4] 130 + b := hexStr[4:6] 131 + 132 + return fmt.Sprintf("#%s%s%s", r, g, b) 133 + } 134 + 135 + func (ld LabelDefinition) GetColor() string { 136 + if ld.Color == nil { 137 + seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 138 + color := randomColor(seed) 139 + return color 140 + } 141 + 142 + return *ld.Color 143 + } 144 + 145 + func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { 146 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 147 + if err != nil { 148 + created = time.Now() 149 + } 150 + 151 + multiple := false 152 + if record.Multiple != nil { 153 + multiple = *record.Multiple 154 + } 155 + 156 + var vt ValueType 157 + if record.ValueType != nil { 158 + vt = ValueTypeFromRecord(*record.ValueType) 159 + } 160 + 161 + return &LabelDefinition{ 162 + Did: did, 163 + Rkey: rkey, 164 + 165 + Name: record.Name, 166 + ValueType: vt, 167 + Scope: record.Scope, 168 + Color: record.Color, 169 + Multiple: multiple, 170 + Created: created, 171 + }, nil 172 + } 173 + 174 + type LabelOp struct { 175 + Id int64 176 + Did string 177 + Rkey string 178 + Subject syntax.ATURI 179 + Operation LabelOperation 180 + OperandKey string 181 + OperandValue string 182 + PerformedAt time.Time 183 + IndexedAt time.Time 184 + } 185 + 186 + func (l LabelOp) SortAt() time.Time { 187 + createdAt := l.PerformedAt 188 + indexedAt := l.IndexedAt 189 + 190 + // if we don't have an indexedat, fall back to now 191 + if indexedAt.IsZero() { 192 + indexedAt = time.Now() 193 + } 194 + 195 + // if createdat is invalid (before epoch), treat as null -> return zero time 196 + if createdAt.Before(time.UnixMicro(0)) { 197 + return time.Time{} 198 + } 199 + 200 + // if createdat is <= indexedat, use createdat 201 + if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) { 202 + return createdAt 203 + } 204 + 205 + // otherwise, createdat is in the future relative to indexedat -> use indexedat 206 + return indexedAt 207 + } 208 + 209 + type LabelOperation string 210 + 211 + const ( 212 + LabelOperationAdd LabelOperation = "add" 213 + LabelOperationDel LabelOperation = "del" 214 + ) 215 + 216 + // a record can create multiple label ops 217 + func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp { 218 + performed, err := time.Parse(time.RFC3339, record.PerformedAt) 219 + if err != nil { 220 + performed = time.Now() 221 + } 222 + 223 + mkOp := func(operand *tangled.LabelOp_Operand) LabelOp { 224 + return LabelOp{ 225 + Did: did, 226 + Rkey: rkey, 227 + Subject: syntax.ATURI(record.Subject), 228 + OperandKey: operand.Key, 229 + OperandValue: operand.Value, 230 + PerformedAt: performed, 231 + } 232 + } 233 + 234 + var ops []LabelOp 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 237 + if o != nil { 238 + op := mkOp(o) 239 + op.Operation = LabelOperationDel 240 + ops = append(ops, op) 241 + } 242 + } 243 + for _, o := range record.Add { 244 + if o != nil { 245 + op := mkOp(o) 246 + op.Operation = LabelOperationAdd 247 + ops = append(ops, op) 248 + } 249 + } 250 + 251 + return ops 252 + } 253 + 254 + func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp { 255 + if len(ops) == 0 { 256 + return tangled.LabelOp{} 257 + } 258 + 259 + // use the first operation to establish common fields 260 + first := ops[0] 261 + record := tangled.LabelOp{ 262 + Subject: string(first.Subject), 263 + PerformedAt: first.PerformedAt.Format(time.RFC3339), 264 + } 265 + 266 + var addOperands []*tangled.LabelOp_Operand 267 + var deleteOperands []*tangled.LabelOp_Operand 268 + 269 + for _, op := range ops { 270 + operand := &tangled.LabelOp_Operand{ 271 + Key: op.OperandKey, 272 + Value: op.OperandValue, 273 + } 274 + 275 + switch op.Operation { 276 + case LabelOperationAdd: 277 + addOperands = append(addOperands, operand) 278 + case LabelOperationDel: 279 + deleteOperands = append(deleteOperands, operand) 280 + default: 281 + return tangled.LabelOp{} 282 + } 283 + } 284 + 285 + record.Add = addOperands 286 + record.Delete = deleteOperands 287 + 288 + return record 289 + } 290 + 291 + type set = map[string]struct{} 292 + 293 + type LabelState struct { 294 + inner map[string]set 295 + } 296 + 297 + func NewLabelState() LabelState { 298 + return LabelState{ 299 + inner: make(map[string]set), 300 + } 301 + } 302 + 303 + func (s LabelState) Inner() map[string]set { 304 + return s.inner 305 + } 306 + 307 + func (s LabelState) ContainsLabel(l string) bool { 308 + if valset, exists := s.inner[l]; exists { 309 + if valset != nil { 310 + return true 311 + } 312 + } 313 + 314 + return false 315 + } 316 + 317 + // go maps behavior in templates make this necessary, 318 + // indexing a map and getting `set` in return is apparently truthy 319 + func (s LabelState) ContainsLabelAndVal(l, v string) bool { 320 + if valset, exists := s.inner[l]; exists { 321 + if _, exists := valset[v]; exists { 322 + return true 323 + } 324 + } 325 + 326 + return false 327 + } 328 + 329 + func (s LabelState) GetValSet(l string) set { 330 + if valset, exists := s.inner[l]; exists { 331 + return valset 332 + } else { 333 + return make(set) 334 + } 335 + } 336 + 337 + type LabelApplicationCtx struct { 338 + Defs map[string]*LabelDefinition // labelAt -> labelDef 339 + } 340 + 341 + var ( 342 + LabelNoOpError = errors.New("no-op") 343 + ) 344 + 345 + func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error { 346 + def, ok := c.Defs[op.OperandKey] 347 + if !ok { 348 + // this def was deleted, but an op exists, so we just skip over the op 349 + return nil 350 + } 351 + 352 + switch op.Operation { 353 + case LabelOperationAdd: 354 + // if valueset is empty, init it 355 + if state.inner[op.OperandKey] == nil { 356 + state.inner[op.OperandKey] = make(set) 357 + } 358 + 359 + // if valueset is populated & this val alr exists, this labelop is a noop 360 + if valueSet, exists := state.inner[op.OperandKey]; exists { 361 + if _, exists = valueSet[op.OperandValue]; exists { 362 + return LabelNoOpError 363 + } 364 + } 365 + 366 + if def.Multiple { 367 + // append to set 368 + state.inner[op.OperandKey][op.OperandValue] = struct{}{} 369 + } else { 370 + // reset to just this value 371 + state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}} 372 + } 373 + 374 + case LabelOperationDel: 375 + // if label DNE, then deletion is a no-op 376 + if valueSet, exists := state.inner[op.OperandKey]; !exists { 377 + return LabelNoOpError 378 + } else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op 379 + return LabelNoOpError 380 + } 381 + 382 + if def.Multiple { 383 + // remove from set 384 + delete(state.inner[op.OperandKey], op.OperandValue) 385 + } else { 386 + // reset the entire label 387 + delete(state.inner, op.OperandKey) 388 + } 389 + 390 + // if the map becomes empty, then set it to nil, this is just the inverse of add 391 + if len(state.inner[op.OperandKey]) == 0 { 392 + state.inner[op.OperandKey] = nil 393 + } 394 + 395 + } 396 + 397 + return nil 398 + } 399 + 400 + func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) { 401 + // sort label ops in sort order first 402 + slices.SortFunc(ops, func(a, b LabelOp) int { 403 + return a.SortAt().Compare(b.SortAt()) 404 + }) 405 + 406 + // apply ops in sequence 407 + for _, o := range ops { 408 + _ = c.ApplyLabelOp(state, o) 409 + } 410 + } 411 + 412 + // IsInverse checks if one label operation is the inverse of another 413 + // returns true if one is an add and the other is a delete with the same key and value 414 + func (op1 LabelOp) IsInverse(op2 LabelOp) bool { 415 + if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue { 416 + return false 417 + } 418 + 419 + return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) || 420 + (op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd) 421 + } 422 + 423 + // removes pairs of label operations that are inverses of each other 424 + // from the given slice. the function preserves the order of remaining operations. 425 + func ReduceLabelOps(ops []LabelOp) []LabelOp { 426 + if len(ops) <= 1 { 427 + return ops 428 + } 429 + 430 + keep := make([]bool, len(ops)) 431 + for i := range keep { 432 + keep[i] = true 433 + } 434 + 435 + for i := range ops { 436 + if !keep[i] { 437 + continue 438 + } 439 + 440 + for j := i + 1; j < len(ops); j++ { 441 + if !keep[j] { 442 + continue 443 + } 444 + 445 + if ops[i].IsInverse(ops[j]) { 446 + keep[i] = false 447 + keep[j] = false 448 + break // move to next i since this one is now eliminated 449 + } 450 + } 451 + } 452 + 453 + // build result slice with only kept operations 454 + var result []LabelOp 455 + for i, op := range ops { 456 + if keep[i] { 457 + result = append(result, op) 458 + } 459 + } 460 + 461 + return result 462 + } 463 + 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 472 + func DefaultLabelDefs() []string { 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 479 + } 480 + } 481 + 482 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 + if err != nil { 485 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 + } 487 + pdsEndpoint := resolved.PDSEndpoint() 488 + if pdsEndpoint == "" { 489 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 + } 491 + client := &xrpc.Client{ 492 + Host: pdsEndpoint, 493 + } 494 + 495 + var labelDefs []LabelDefinition 496 + 497 + for _, dl := range DefaultLabelDefs() { 498 + atUri := syntax.ATURI(dl) 499 + parsedUri, err := syntax.ParseATURI(string(atUri)) 500 + if err != nil { 501 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 + } 503 + record, err := atproto.RepoGetRecord( 504 + context.Background(), 505 + client, 506 + "", 507 + parsedUri.Collection().String(), 508 + parsedUri.Authority().String(), 509 + parsedUri.RecordKey().String(), 510 + ) 511 + if err != nil { 512 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 513 + } 514 + 515 + if record != nil { 516 + bytes, err := record.Value.MarshalJSON() 517 + if err != nil { 518 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 519 + } 520 + 521 + raw := json.RawMessage(bytes) 522 + labelRecord := tangled.LabelDefinition{} 523 + err = json.Unmarshal(raw, &labelRecord) 524 + if err != nil { 525 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 526 + } 527 + 528 + labelDef, err := LabelDefinitionFromRecord( 529 + parsedUri.Authority().String(), 530 + parsedUri.RecordKey().String(), 531 + labelRecord, 532 + ) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 535 + } 536 + 537 + labelDefs = append(labelDefs, *labelDef) 538 + } 539 + } 540 + 541 + return labelDefs, nil 542 + }
+14
appview/models/language.go
···
··· 1 + package models 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + type RepoLanguage struct { 8 + Id int64 9 + RepoAt syntax.ATURI 10 + Ref string 11 + IsDefaultRef bool 12 + Language string 13 + Bytes int64 14 + }
+82
appview/models/notifications.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type NotificationType string 8 + 9 + const ( 10 + NotificationTypeRepoStarred NotificationType = "repo_starred" 11 + NotificationTypeIssueCreated NotificationType = "issue_created" 12 + NotificationTypeIssueCommented NotificationType = "issue_commented" 13 + NotificationTypePullCreated NotificationType = "pull_created" 14 + NotificationTypePullCommented NotificationType = "pull_commented" 15 + NotificationTypeFollowed NotificationType = "followed" 16 + NotificationTypePullMerged NotificationType = "pull_merged" 17 + NotificationTypeIssueClosed NotificationType = "issue_closed" 18 + NotificationTypePullClosed NotificationType = "pull_closed" 19 + ) 20 + 21 + type Notification struct { 22 + ID int64 23 + RecipientDid string 24 + ActorDid string 25 + Type NotificationType 26 + EntityType string 27 + EntityId string 28 + Read bool 29 + Created time.Time 30 + 31 + // foreign key references 32 + RepoId *int64 33 + IssueId *int64 34 + PullId *int64 35 + } 36 + 37 + // lucide icon that represents this notification 38 + func (n *Notification) Icon() string { 39 + switch n.Type { 40 + case NotificationTypeRepoStarred: 41 + return "star" 42 + case NotificationTypeIssueCreated: 43 + return "circle-dot" 44 + case NotificationTypeIssueCommented: 45 + return "message-square" 46 + case NotificationTypeIssueClosed: 47 + return "ban" 48 + case NotificationTypePullCreated: 49 + return "git-pull-request-create" 50 + case NotificationTypePullCommented: 51 + return "message-square" 52 + case NotificationTypePullMerged: 53 + return "git-merge" 54 + case NotificationTypePullClosed: 55 + return "git-pull-request-closed" 56 + case NotificationTypeFollowed: 57 + return "user-plus" 58 + default: 59 + return "" 60 + } 61 + } 62 + 63 + type NotificationWithEntity struct { 64 + *Notification 65 + Repo *Repo 66 + Issue *Issue 67 + Pull *Pull 68 + } 69 + 70 + type NotificationPreferences struct { 71 + ID int64 72 + UserDid string 73 + RepoStarred bool 74 + IssueCreated bool 75 + IssueCommented bool 76 + PullCreated bool 77 + PullCommented bool 78 + Followed bool 79 + PullMerged bool 80 + IssueClosed bool 81 + EmailNotifications bool 82 + }
+130
appview/models/pipeline.go
···
··· 1 + package models 2 + 3 + import ( 4 + "slices" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/go-git/go-git/v5/plumbing" 9 + spindle "tangled.org/core/spindle/models" 10 + "tangled.org/core/workflow" 11 + ) 12 + 13 + type Pipeline struct { 14 + Id int 15 + Rkey string 16 + Knot string 17 + RepoOwner syntax.DID 18 + RepoName string 19 + TriggerId int 20 + Sha string 21 + Created time.Time 22 + 23 + // populate when querying for reverse mappings 24 + Trigger *Trigger 25 + Statuses map[string]WorkflowStatus 26 + } 27 + 28 + type WorkflowStatus struct { 29 + Data []PipelineStatus 30 + } 31 + 32 + func (w WorkflowStatus) Latest() PipelineStatus { 33 + return w.Data[len(w.Data)-1] 34 + } 35 + 36 + // time taken by this workflow to reach an "end state" 37 + func (w WorkflowStatus) TimeTaken() time.Duration { 38 + var start, end *time.Time 39 + for _, s := range w.Data { 40 + if s.Status.IsStart() { 41 + start = &s.Created 42 + } 43 + if s.Status.IsFinish() { 44 + end = &s.Created 45 + } 46 + } 47 + 48 + if start != nil && end != nil && end.After(*start) { 49 + return end.Sub(*start) 50 + } 51 + 52 + return 0 53 + } 54 + 55 + func (p Pipeline) Counts() map[string]int { 56 + m := make(map[string]int) 57 + for _, w := range p.Statuses { 58 + m[w.Latest().Status.String()] += 1 59 + } 60 + return m 61 + } 62 + 63 + func (p Pipeline) TimeTaken() time.Duration { 64 + var s time.Duration 65 + for _, w := range p.Statuses { 66 + s += w.TimeTaken() 67 + } 68 + return s 69 + } 70 + 71 + func (p Pipeline) Workflows() []string { 72 + var ws []string 73 + for v := range p.Statuses { 74 + ws = append(ws, v) 75 + } 76 + slices.Sort(ws) 77 + return ws 78 + } 79 + 80 + // if we know that a spindle has picked up this pipeline, then it is Responding 81 + func (p Pipeline) IsResponding() bool { 82 + return len(p.Statuses) != 0 83 + } 84 + 85 + type Trigger struct { 86 + Id int 87 + Kind workflow.TriggerKind 88 + 89 + // push trigger fields 90 + PushRef *string 91 + PushNewSha *string 92 + PushOldSha *string 93 + 94 + // pull request trigger fields 95 + PRSourceBranch *string 96 + PRTargetBranch *string 97 + PRSourceSha *string 98 + PRAction *string 99 + } 100 + 101 + func (t *Trigger) IsPush() bool { 102 + return t != nil && t.Kind == workflow.TriggerKindPush 103 + } 104 + 105 + func (t *Trigger) IsPullRequest() bool { 106 + return t != nil && t.Kind == workflow.TriggerKindPullRequest 107 + } 108 + 109 + func (t *Trigger) TargetRef() string { 110 + if t.IsPush() { 111 + return plumbing.ReferenceName(*t.PushRef).Short() 112 + } else if t.IsPullRequest() { 113 + return *t.PRTargetBranch 114 + } 115 + 116 + return "" 117 + } 118 + 119 + type PipelineStatus struct { 120 + ID int 121 + Spindle string 122 + Rkey string 123 + PipelineKnot string 124 + PipelineRkey string 125 + Created time.Time 126 + Workflow string 127 + Status spindle.StatusKind 128 + Error *string 129 + ExitCode int 130 + }
+177
appview/models/profile.go
···
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 8 + ) 9 + 10 + type Profile struct { 11 + // ids 12 + ID int 13 + Did string 14 + 15 + // data 16 + Description string 17 + IncludeBluesky bool 18 + Location string 19 + Links [5]string 20 + Stats [2]VanityStat 21 + PinnedRepos [6]syntax.ATURI 22 + } 23 + 24 + func (p Profile) IsLinksEmpty() bool { 25 + for _, l := range p.Links { 26 + if l != "" { 27 + return false 28 + } 29 + } 30 + return true 31 + } 32 + 33 + func (p Profile) IsStatsEmpty() bool { 34 + for _, s := range p.Stats { 35 + if s.Kind != "" { 36 + return false 37 + } 38 + } 39 + return true 40 + } 41 + 42 + func (p Profile) IsPinnedReposEmpty() bool { 43 + for _, r := range p.PinnedRepos { 44 + if r != "" { 45 + return false 46 + } 47 + } 48 + return true 49 + } 50 + 51 + type VanityStatKind string 52 + 53 + const ( 54 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 55 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 56 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 57 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 58 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 59 + VanityStatRepositoryCount VanityStatKind = "repository-count" 60 + ) 61 + 62 + func (v VanityStatKind) String() string { 63 + switch v { 64 + case VanityStatMergedPRCount: 65 + return "Merged PRs" 66 + case VanityStatClosedPRCount: 67 + return "Closed PRs" 68 + case VanityStatOpenPRCount: 69 + return "Open PRs" 70 + case VanityStatOpenIssueCount: 71 + return "Open Issues" 72 + case VanityStatClosedIssueCount: 73 + return "Closed Issues" 74 + case VanityStatRepositoryCount: 75 + return "Repositories" 76 + } 77 + return "" 78 + } 79 + 80 + type VanityStat struct { 81 + Kind VanityStatKind 82 + Value uint64 83 + } 84 + 85 + func (p *Profile) ProfileAt() syntax.ATURI { 86 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 87 + } 88 + 89 + type RepoEvent struct { 90 + Repo *Repo 91 + Source *Repo 92 + } 93 + 94 + type ProfileTimeline struct { 95 + ByMonth []ByMonth 96 + } 97 + 98 + func (p *ProfileTimeline) IsEmpty() bool { 99 + if p == nil { 100 + return true 101 + } 102 + 103 + for _, m := range p.ByMonth { 104 + if !m.IsEmpty() { 105 + return false 106 + } 107 + } 108 + 109 + return true 110 + } 111 + 112 + type ByMonth struct { 113 + RepoEvents []RepoEvent 114 + IssueEvents IssueEvents 115 + PullEvents PullEvents 116 + } 117 + 118 + func (b ByMonth) IsEmpty() bool { 119 + return len(b.RepoEvents) == 0 && 120 + len(b.IssueEvents.Items) == 0 && 121 + len(b.PullEvents.Items) == 0 122 + } 123 + 124 + type IssueEvents struct { 125 + Items []*Issue 126 + } 127 + 128 + type IssueEventStats struct { 129 + Open int 130 + Closed int 131 + } 132 + 133 + func (i IssueEvents) Stats() IssueEventStats { 134 + var open, closed int 135 + for _, issue := range i.Items { 136 + if issue.Open { 137 + open += 1 138 + } else { 139 + closed += 1 140 + } 141 + } 142 + 143 + return IssueEventStats{ 144 + Open: open, 145 + Closed: closed, 146 + } 147 + } 148 + 149 + type PullEvents struct { 150 + Items []*Pull 151 + } 152 + 153 + func (p PullEvents) Stats() PullEventStats { 154 + var open, merged, closed int 155 + for _, pull := range p.Items { 156 + switch pull.State { 157 + case PullOpen: 158 + open += 1 159 + case PullMerged: 160 + merged += 1 161 + case PullClosed: 162 + closed += 1 163 + } 164 + } 165 + 166 + return PullEventStats{ 167 + Open: open, 168 + Merged: merged, 169 + Closed: closed, 170 + } 171 + } 172 + 173 + type PullEventStats struct { 174 + Closed int 175 + Open int 176 + Merged int 177 + }
+25
appview/models/pubkey.go
···
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + ) 7 + 8 + type PublicKey struct { 9 + Did string `json:"did"` 10 + Key string `json:"key"` 11 + Name string `json:"name"` 12 + Rkey string `json:"rkey"` 13 + Created *time.Time 14 + } 15 + 16 + func (p PublicKey) MarshalJSON() ([]byte, error) { 17 + type Alias PublicKey 18 + return json.Marshal(&struct { 19 + Created string `json:"created"` 20 + *Alias 21 + }{ 22 + Created: p.Created.Format(time.RFC3339), 23 + Alias: (*Alias)(&p), 24 + }) 25 + }
+352
appview/models/pull.go
···
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "slices" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/patchutil" 13 + "tangled.org/core/types" 14 + ) 15 + 16 + type PullState int 17 + 18 + const ( 19 + PullClosed PullState = iota 20 + PullOpen 21 + PullMerged 22 + PullDeleted 23 + ) 24 + 25 + func (p PullState) String() string { 26 + switch p { 27 + case PullOpen: 28 + return "open" 29 + case PullMerged: 30 + return "merged" 31 + case PullClosed: 32 + return "closed" 33 + case PullDeleted: 34 + return "deleted" 35 + default: 36 + return "closed" 37 + } 38 + } 39 + 40 + func (p PullState) IsOpen() bool { 41 + return p == PullOpen 42 + } 43 + func (p PullState) IsMerged() bool { 44 + return p == PullMerged 45 + } 46 + func (p PullState) IsClosed() bool { 47 + return p == PullClosed 48 + } 49 + func (p PullState) IsDeleted() bool { 50 + return p == PullDeleted 51 + } 52 + 53 + type Pull struct { 54 + // ids 55 + ID int 56 + PullId int 57 + 58 + // at ids 59 + RepoAt syntax.ATURI 60 + OwnerDid string 61 + Rkey string 62 + 63 + // content 64 + Title string 65 + Body string 66 + TargetBranch string 67 + State PullState 68 + Submissions []*PullSubmission 69 + 70 + // stacking 71 + StackId string // nullable string 72 + ChangeId string // nullable string 73 + ParentChangeId string // nullable string 74 + 75 + // meta 76 + Created time.Time 77 + PullSource *PullSource 78 + 79 + // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 + } 83 + 84 + func (p Pull) AsRecord() tangled.RepoPull { 85 + var source *tangled.RepoPull_Source 86 + if p.PullSource != nil { 87 + s := p.PullSource.AsRecord() 88 + source = &s 89 + source.Sha = p.LatestSha() 90 + } 91 + 92 + record := tangled.RepoPull{ 93 + Title: p.Title, 94 + Body: &p.Body, 95 + CreatedAt: p.Created.Format(time.RFC3339), 96 + Target: &tangled.RepoPull_Target{ 97 + Repo: p.RepoAt.String(), 98 + Branch: p.TargetBranch, 99 + }, 100 + Patch: p.LatestPatch(), 101 + Source: source, 102 + } 103 + return record 104 + } 105 + 106 + type PullSource struct { 107 + Branch string 108 + RepoAt *syntax.ATURI 109 + 110 + // optionally populate this for reverse mappings 111 + Repo *Repo 112 + } 113 + 114 + func (p PullSource) AsRecord() tangled.RepoPull_Source { 115 + var repoAt *string 116 + if p.RepoAt != nil { 117 + s := p.RepoAt.String() 118 + repoAt = &s 119 + } 120 + record := tangled.RepoPull_Source{ 121 + Branch: p.Branch, 122 + Repo: repoAt, 123 + } 124 + return record 125 + } 126 + 127 + type PullSubmission struct { 128 + // ids 129 + ID int 130 + 131 + // at ids 132 + PullAt syntax.ATURI 133 + 134 + // content 135 + RoundNumber int 136 + Patch string 137 + Comments []PullComment 138 + SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 139 + 140 + // meta 141 + Created time.Time 142 + } 143 + 144 + type PullComment struct { 145 + // ids 146 + ID int 147 + PullId int 148 + SubmissionId int 149 + 150 + // at ids 151 + RepoAt string 152 + OwnerDid string 153 + CommentAt string 154 + 155 + // content 156 + Body string 157 + 158 + // meta 159 + Created time.Time 160 + } 161 + 162 + func (p *Pull) LatestPatch() string { 163 + latestSubmission := p.Submissions[p.LastRoundNumber()] 164 + return latestSubmission.Patch 165 + } 166 + 167 + func (p *Pull) LatestSha() string { 168 + latestSubmission := p.Submissions[p.LastRoundNumber()] 169 + return latestSubmission.SourceRev 170 + } 171 + 172 + func (p *Pull) PullAt() syntax.ATURI { 173 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 174 + } 175 + 176 + func (p *Pull) LastRoundNumber() int { 177 + return len(p.Submissions) - 1 178 + } 179 + 180 + func (p *Pull) IsPatchBased() bool { 181 + return p.PullSource == nil 182 + } 183 + 184 + func (p *Pull) IsBranchBased() bool { 185 + if p.PullSource != nil { 186 + if p.PullSource.RepoAt != nil { 187 + return p.PullSource.RepoAt == &p.RepoAt 188 + } else { 189 + // no repo specified 190 + return true 191 + } 192 + } 193 + return false 194 + } 195 + 196 + func (p *Pull) IsForkBased() bool { 197 + if p.PullSource != nil { 198 + if p.PullSource.RepoAt != nil { 199 + // make sure repos are different 200 + return p.PullSource.RepoAt != &p.RepoAt 201 + } 202 + } 203 + return false 204 + } 205 + 206 + func (p *Pull) IsStacked() bool { 207 + return p.StackId != "" 208 + } 209 + 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 + func (s PullSubmission) IsFormatPatch() bool { 233 + return patchutil.IsFormatPatch(s.Patch) 234 + } 235 + 236 + func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 237 + patches, err := patchutil.ExtractPatches(s.Patch) 238 + if err != nil { 239 + log.Println("error extracting patches from submission:", err) 240 + return []types.FormatPatch{} 241 + } 242 + 243 + return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 + } 265 + 266 + type Stack []*Pull 267 + 268 + // position of this pull in the stack 269 + func (stack Stack) Position(pull *Pull) int { 270 + return slices.IndexFunc(stack, func(p *Pull) bool { 271 + return p.ChangeId == pull.ChangeId 272 + }) 273 + } 274 + 275 + // all pulls below this pull (including self) in this stack 276 + // 277 + // nil if this pull does not belong to this stack 278 + func (stack Stack) Below(pull *Pull) Stack { 279 + position := stack.Position(pull) 280 + 281 + if position < 0 { 282 + return nil 283 + } 284 + 285 + return stack[position:] 286 + } 287 + 288 + // all pulls below this pull (excluding self) in this stack 289 + func (stack Stack) StrictlyBelow(pull *Pull) Stack { 290 + below := stack.Below(pull) 291 + 292 + if len(below) > 0 { 293 + return below[1:] 294 + } 295 + 296 + return nil 297 + } 298 + 299 + // all pulls above this pull (including self) in this stack 300 + func (stack Stack) Above(pull *Pull) Stack { 301 + position := stack.Position(pull) 302 + 303 + if position < 0 { 304 + return nil 305 + } 306 + 307 + return stack[:position+1] 308 + } 309 + 310 + // all pulls below this pull (excluding self) in this stack 311 + func (stack Stack) StrictlyAbove(pull *Pull) Stack { 312 + above := stack.Above(pull) 313 + 314 + if len(above) > 0 { 315 + return above[:len(above)-1] 316 + } 317 + 318 + return nil 319 + } 320 + 321 + // the combined format-patches of all the newest submissions in this stack 322 + func (stack Stack) CombinedPatch() string { 323 + // go in reverse order because the bottom of the stack is the last element in the slice 324 + var combined strings.Builder 325 + for idx := range stack { 326 + pull := stack[len(stack)-1-idx] 327 + combined.WriteString(pull.LatestPatch()) 328 + combined.WriteString("\n") 329 + } 330 + return combined.String() 331 + } 332 + 333 + // filter out PRs that are "active" 334 + // 335 + // PRs that are still open are active 336 + func (stack Stack) Mergeable() Stack { 337 + var mergeable Stack 338 + 339 + for _, p := range stack { 340 + // stop at the first merged PR 341 + if p.State == PullMerged || p.State == PullClosed { 342 + break 343 + } 344 + 345 + // skip over deleted PRs 346 + if p.State != PullDeleted { 347 + mergeable = append(mergeable, p) 348 + } 349 + } 350 + 351 + return mergeable 352 + }
+14
appview/models/punchcard.go
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type Punch struct { 6 + Did string 7 + Date time.Time 8 + Count int 9 + } 10 + 11 + type Punchcard struct { 12 + Total int 13 + Punches []Punch 14 + }
+57
appview/models/reaction.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type ReactionKind string 10 + 11 + const ( 12 + Like ReactionKind = "👍" 13 + Unlike ReactionKind = "👎" 14 + Laugh ReactionKind = "😆" 15 + Celebration ReactionKind = "🎉" 16 + Confused ReactionKind = "🫤" 17 + Heart ReactionKind = "❤️" 18 + Rocket ReactionKind = "🚀" 19 + Eyes ReactionKind = "👀" 20 + ) 21 + 22 + func (rk ReactionKind) String() string { 23 + return string(rk) 24 + } 25 + 26 + var OrderedReactionKinds = []ReactionKind{ 27 + Like, 28 + Unlike, 29 + Laugh, 30 + Celebration, 31 + Confused, 32 + Heart, 33 + Rocket, 34 + Eyes, 35 + } 36 + 37 + func ParseReactionKind(raw string) (ReactionKind, bool) { 38 + k, ok := (map[string]ReactionKind{ 39 + "👍": Like, 40 + "👎": Unlike, 41 + "😆": Laugh, 42 + "🎉": Celebration, 43 + "🫤": Confused, 44 + "❤️": Heart, 45 + "🚀": Rocket, 46 + "👀": Eyes, 47 + })[raw] 48 + return k, ok 49 + } 50 + 51 + type Reaction struct { 52 + ReactedByDid string 53 + ThreadAt syntax.ATURI 54 + Created time.Time 55 + Rkey string 56 + Kind ReactionKind 57 + }
+44
appview/models/registration.go
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Registration represents a knot registration. Knot would've been a better 6 + // name but we're stuck with this for historical reasons. 7 + type Registration struct { 8 + Id int64 9 + Domain string 10 + ByDid string 11 + Created *time.Time 12 + Registered *time.Time 13 + NeedsUpgrade bool 14 + } 15 + 16 + func (r *Registration) Status() Status { 17 + if r.NeedsUpgrade { 18 + return NeedsUpgrade 19 + } else if r.Registered != nil { 20 + return Registered 21 + } else { 22 + return Pending 23 + } 24 + } 25 + 26 + func (r *Registration) IsRegistered() bool { 27 + return r.Status() == Registered 28 + } 29 + 30 + func (r *Registration) IsNeedsUpgrade() bool { 31 + return r.Status() == NeedsUpgrade 32 + } 33 + 34 + func (r *Registration) IsPending() bool { 35 + return r.Status() == Pending 36 + } 37 + 38 + type Status uint32 39 + 40 + const ( 41 + Registered Status = iota 42 + Pending 43 + NeedsUpgrade 44 + )
+93
appview/models/repo.go
···
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + securejoin "github.com/cyphar/filepath-securejoin" 9 + "tangled.org/core/api/tangled" 10 + ) 11 + 12 + type Repo struct { 13 + Id int64 14 + Did string 15 + Name string 16 + Knot string 17 + Rkey string 18 + Created time.Time 19 + Description string 20 + Spindle string 21 + Labels []string 22 + 23 + // optionally, populate this when querying for reverse mappings 24 + RepoStats *RepoStats 25 + 26 + // optional 27 + Source string 28 + } 29 + 30 + func (r *Repo) AsRecord() tangled.Repo { 31 + var source, spindle, description *string 32 + 33 + if r.Source != "" { 34 + source = &r.Source 35 + } 36 + 37 + if r.Spindle != "" { 38 + spindle = &r.Spindle 39 + } 40 + 41 + if r.Description != "" { 42 + description = &r.Description 43 + } 44 + 45 + return tangled.Repo{ 46 + Knot: r.Knot, 47 + Name: r.Name, 48 + Description: description, 49 + CreatedAt: r.Created.Format(time.RFC3339), 50 + Source: source, 51 + Spindle: spindle, 52 + Labels: r.Labels, 53 + } 54 + } 55 + 56 + func (r Repo) RepoAt() syntax.ATURI { 57 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 58 + } 59 + 60 + func (r Repo) DidSlashRepo() string { 61 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 62 + return p 63 + } 64 + 65 + type RepoStats struct { 66 + Language string 67 + StarCount int 68 + IssueCount IssueCount 69 + PullCount PullCount 70 + } 71 + 72 + type IssueCount struct { 73 + Open int 74 + Closed int 75 + } 76 + 77 + type PullCount struct { 78 + Open int 79 + Merged int 80 + Closed int 81 + Deleted int 82 + } 83 + 84 + type RepoLabel struct { 85 + Id int64 86 + RepoAt syntax.ATURI 87 + LabelAt syntax.ATURI 88 + } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+10
appview/models/signup.go
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + }
+25
appview/models/spindle.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Spindle struct { 10 + Id int 11 + Owner syntax.DID 12 + Instance string 13 + Verified *time.Time 14 + Created time.Time 15 + NeedsUpgrade bool 16 + } 17 + 18 + type SpindleMember struct { 19 + Id int 20 + Did syntax.DID // owner of the record 21 + Rkey string // rkey of the record 22 + Instance string 23 + Subject syntax.DID // the member being added 24 + Created time.Time 25 + }
+17
appview/models/star.go
···
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type Star struct { 10 + StarredByDid string 11 + RepoAt syntax.ATURI 12 + Created time.Time 13 + Rkey string 14 + 15 + // optionally, populate this when querying for reverse mappings 16 + Repo *Repo 17 + }
+95
appview/models/string.go
···
··· 1 + package models 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "io" 7 + "strings" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + type String struct { 15 + Did syntax.DID 16 + Rkey string 17 + 18 + Filename string 19 + Description string 20 + Contents string 21 + Created time.Time 22 + Edited *time.Time 23 + } 24 + 25 + func (s *String) StringAt() syntax.ATURI { 26 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 27 + } 28 + 29 + func (s *String) AsRecord() tangled.String { 30 + return tangled.String{ 31 + Filename: s.Filename, 32 + Description: s.Description, 33 + Contents: s.Contents, 34 + CreatedAt: s.Created.Format(time.RFC3339), 35 + } 36 + } 37 + 38 + func StringFromRecord(did, rkey string, record tangled.String) String { 39 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 40 + if err != nil { 41 + created = time.Now() 42 + } 43 + return String{ 44 + Did: syntax.DID(did), 45 + Rkey: rkey, 46 + Filename: record.Filename, 47 + Description: record.Description, 48 + Contents: record.Contents, 49 + Created: created, 50 + } 51 + } 52 + 53 + type StringStats struct { 54 + LineCount uint64 55 + ByteCount uint64 56 + } 57 + 58 + func (s String) Stats() StringStats { 59 + lineCount, err := countLines(strings.NewReader(s.Contents)) 60 + if err != nil { 61 + // non-fatal 62 + // TODO: log this? 63 + } 64 + 65 + return StringStats{ 66 + LineCount: uint64(lineCount), 67 + ByteCount: uint64(len(s.Contents)), 68 + } 69 + } 70 + 71 + func countLines(r io.Reader) (int, error) { 72 + buf := make([]byte, 32*1024) 73 + bufLen := 0 74 + count := 0 75 + nl := []byte{'\n'} 76 + 77 + for { 78 + c, err := r.Read(buf) 79 + if c > 0 { 80 + bufLen += c 81 + } 82 + count += bytes.Count(buf[:c], nl) 83 + 84 + switch { 85 + case err == io.EOF: 86 + /* handle last line not having a newline at the end */ 87 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 88 + count++ 89 + } 90 + return count, nil 91 + case err != nil: 92 + return 0, err 93 + } 94 + } 95 + }
+23
appview/models/timeline.go
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type TimelineEvent struct { 6 + *Repo 7 + *Follow 8 + *Star 9 + 10 + EventAt time.Time 11 + 12 + // optional: populate only if Repo is a fork 13 + Source *Repo 14 + 15 + // optional: populate only if event is Follow 16 + *Profile 17 + *FollowStats 18 + *FollowStatus 19 + 20 + // optional: populate only if event is Repo 21 + IsStarred bool 22 + StarCount int64 23 + }
+168
appview/notifications/notifications.go
···
··· 1 + package notifications 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/middleware" 12 + "tangled.org/core/appview/oauth" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + ) 16 + 17 + type Notifications struct { 18 + db *db.DB 19 + oauth *oauth.OAuth 20 + pages *pages.Pages 21 + } 22 + 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 24 + return &Notifications{ 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + } 29 + } 30 + 31 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 + r := chi.NewRouter() 33 + 34 + r.Use(middleware.AuthMiddleware(n.oauth)) 35 + 36 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 + 38 + r.Get("/count", n.getUnreadCount) 39 + r.Post("/{id}/read", n.markRead) 40 + r.Post("/read-all", n.markAllRead) 41 + r.Delete("/{id}", n.deleteNotification) 42 + 43 + return r 44 + } 45 + 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + userDid := n.oauth.GetDid(r) 48 + 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 54 + 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", userDid), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 63 + } 64 + 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", userDid), 69 + ) 70 + if err != nil { 71 + log.Println("failed to get notifications:", err) 72 + n.pages.Error500(w) 73 + return 74 + } 75 + 76 + err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 + } 80 + 81 + unreadCount := 0 82 + 83 + user := n.oauth.GetUser(r) 84 + if user == nil { 85 + http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 + return 87 + } 88 + 89 + fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 90 + LoggedInUser: user, 91 + Notifications: notifications, 92 + UnreadCount: unreadCount, 93 + Page: page, 94 + Total: total, 95 + })) 96 + } 97 + 98 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 + user := n.oauth.GetUser(r) 100 + count, err := db.CountNotifications( 101 + n.db, 102 + db.FilterEq("recipient_did", user.Did), 103 + db.FilterEq("read", 0), 104 + ) 105 + if err != nil { 106 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 107 + return 108 + } 109 + 110 + params := pages.NotificationCountParams{ 111 + Count: count, 112 + } 113 + err = n.pages.NotificationCount(w, params) 114 + if err != nil { 115 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 116 + return 117 + } 118 + } 119 + 120 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 121 + userDid := n.oauth.GetDid(r) 122 + 123 + idStr := chi.URLParam(r, "id") 124 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 125 + if err != nil { 126 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 127 + return 128 + } 129 + 130 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 131 + if err != nil { 132 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 133 + return 134 + } 135 + 136 + w.WriteHeader(http.StatusNoContent) 137 + } 138 + 139 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 140 + userDid := n.oauth.GetDid(r) 141 + 142 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 143 + if err != nil { 144 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 145 + return 146 + } 147 + 148 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 149 + } 150 + 151 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 152 + userDid := n.oauth.GetDid(r) 153 + 154 + idStr := chi.URLParam(r, "id") 155 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 156 + if err != nil { 157 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 158 + return 159 + } 160 + 161 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 162 + if err != nil { 163 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 164 + return 165 + } 166 + 167 + w.WriteHeader(http.StatusOK) 168 + }
+429
appview/notify/db/db.go
···
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + "tangled.org/core/idresolver" 11 + ) 12 + 13 + type databaseNotifier struct { 14 + db *db.DB 15 + res *idresolver.Resolver 16 + } 17 + 18 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 19 + return &databaseNotifier{ 20 + db: database, 21 + res: resolver, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &databaseNotifier{} 26 + 27 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + // no-op for now 29 + } 30 + 31 + func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 + var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 + if err != nil { 35 + log.Printf("NewStar: failed to get repos: %v", err) 36 + return 37 + } 38 + 39 + // don't notify yourself 40 + if repo.Did == star.StarredByDid { 41 + return 42 + } 43 + 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 67 + } 68 + 69 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 + // no-op 71 + } 72 + 73 + func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 + if err != nil { 76 + log.Printf("NewIssue: failed to get repos: %v", err) 77 + return 78 + } 79 + 80 + if repo.Did == issue.Did { 81 + return 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 + if err != nil { 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 + return 88 + } 89 + if !prefs.IssueCreated { 90 + return 91 + } 92 + 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 102 + 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 108 + } 109 + 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 + if err != nil { 113 + log.Printf("NewIssueComment: failed to get issues: %v", err) 114 + return 115 + } 116 + if len(issues) == 0 { 117 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 118 + return 119 + } 120 + issue := issues[0] 121 + 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 + if err != nil { 124 + log.Printf("NewIssueComment: failed to get repos: %v", err) 125 + return 126 + } 127 + 128 + recipients := make(map[string]bool) 129 + 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 + } 138 + } 139 + 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 149 + 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 161 + 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 167 + } 168 + 169 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 186 + 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 192 + } 193 + 194 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 195 + // no-op 196 + } 197 + 198 + func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 + if err != nil { 201 + log.Printf("NewPull: failed to get repos: %v", err) 202 + return 203 + } 204 + 205 + if repo.Did == pull.OwnerDid { 206 + return 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 + if err != nil { 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 + return 213 + } 214 + if !prefs.PullCreated { 215 + return 216 + } 217 + 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 227 + 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 233 + } 234 + 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 239 + if err != nil { 240 + log.Printf("NewPullComment: failed to get pulls: %v", err) 241 + return 242 + } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + return 246 + } 247 + pull := pulls[0] 248 + 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 + if err != nil { 251 + log.Printf("NewPullComment: failed to get repos: %v", err) 252 + return 253 + } 254 + 255 + recipients := make(map[string]bool) 256 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 265 + } 266 + 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 287 + 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 293 + } 294 + 295 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 296 + // no-op 297 + } 298 + 299 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 300 + // no-op 301 + } 302 + 303 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 304 + // no-op 305 + } 306 + 307 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 308 + // no-op 309 + } 310 + 311 + func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 + // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 + if err != nil { 315 + log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 + return 317 + } 318 + 319 + // Don't notify yourself 320 + if repo.Did == issue.Did { 321 + return 322 + } 323 + 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 333 + 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 342 + } 343 + 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 349 + } 350 + 351 + func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 + // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 + if err != nil { 355 + log.Printf("NewPullMerged: failed to get repos: %v", err) 356 + return 357 + } 358 + 359 + // Don't notify yourself 360 + if repo.Did == pull.OwnerDid { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 + if err != nil { 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 + return 369 + } 370 + if !prefs.PullMerged { 371 + return 372 + } 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 + } 383 + 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + return 388 + } 389 + } 390 + 391 + func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 + // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 + if err != nil { 395 + log.Printf("NewPullClosed: failed to get repos: %v", err) 396 + return 397 + } 398 + 399 + // Don't notify yourself 400 + if repo.Did == pull.OwnerDid { 401 + return 402 + } 403 + 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 + if err != nil { 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 + return 409 + } 410 + if !prefs.PullMerged { 411 + return 412 + } 413 + 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 + } 423 + 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 427 + return 428 + } 429 + }
+35 -12
appview/notify/merged_notifier.go
··· 3 import ( 4 "context" 5 6 - "tangled.sh/tangled.sh/core/appview/db" 7 ) 8 9 type mergedNotifier struct { ··· 16 17 var _ Notifier = &mergedNotifier{} 18 19 - func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 20 for _, notifier := range m.notifiers { 21 notifier.NewRepo(ctx, repo) 22 } 23 } 24 25 - func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 26 for _, notifier := range m.notifiers { 27 notifier.NewStar(ctx, star) 28 } 29 } 30 - func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 31 for _, notifier := range m.notifiers { 32 notifier.DeleteStar(ctx, star) 33 } 34 } 35 36 - func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 37 for _, notifier := range m.notifiers { 38 notifier.NewIssue(ctx, issue) 39 } 40 } 41 42 - func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 43 for _, notifier := range m.notifiers { 44 notifier.NewFollow(ctx, follow) 45 } 46 } 47 - func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 48 for _, notifier := range m.notifiers { 49 notifier.DeleteFollow(ctx, follow) 50 } 51 } 52 53 - func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 54 for _, notifier := range m.notifiers { 55 notifier.NewPull(ctx, pull) 56 } 57 } 58 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 59 for _, notifier := range m.notifiers { 60 notifier.NewPullComment(ctx, comment) 61 } 62 } 63 64 - func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 65 for _, notifier := range m.notifiers { 66 notifier.UpdateProfile(ctx, profile) 67 } 68 } 69 70 - func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) { 71 for _, notifier := range m.notifiers { 72 notifier.NewString(ctx, string) 73 } 74 } 75 76 - func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) { 77 for _, notifier := range m.notifiers { 78 notifier.EditString(ctx, string) 79 }
··· 3 import ( 4 "context" 5 6 + "tangled.org/core/appview/models" 7 ) 8 9 type mergedNotifier struct { ··· 16 17 var _ Notifier = &mergedNotifier{} 18 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 20 for _, notifier := range m.notifiers { 21 notifier.NewRepo(ctx, repo) 22 } 23 } 24 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) { 26 for _, notifier := range m.notifiers { 27 notifier.NewStar(ctx, star) 28 } 29 } 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) { 31 for _, notifier := range m.notifiers { 32 notifier.DeleteStar(ctx, star) 33 } 34 } 35 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 37 for _, notifier := range m.notifiers { 38 notifier.NewIssue(ctx, issue) 39 } 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 + } 46 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) { 54 for _, notifier := range m.notifiers { 55 notifier.NewFollow(ctx, follow) 56 } 57 } 58 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 59 for _, notifier := range m.notifiers { 60 notifier.DeleteFollow(ctx, follow) 61 } 62 } 63 64 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) { 65 for _, notifier := range m.notifiers { 66 notifier.NewPull(ctx, pull) 67 } 68 } 69 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 70 for _, notifier := range m.notifiers { 71 notifier.NewPullComment(ctx, comment) 72 } 73 } 74 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) { 88 for _, notifier := range m.notifiers { 89 notifier.UpdateProfile(ctx, profile) 90 } 91 } 92 93 + func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) { 94 for _, notifier := range m.notifiers { 95 notifier.NewString(ctx, string) 96 } 97 } 98 99 + func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) { 100 for _, notifier := range m.notifiers { 101 notifier.EditString(ctx, string) 102 }
+31 -23
appview/notify/notifier.go
··· 3 import ( 4 "context" 5 6 - "tangled.sh/tangled.sh/core/appview/db" 7 ) 8 9 type Notifier interface { 10 - NewRepo(ctx context.Context, repo *db.Repo) 11 12 - NewStar(ctx context.Context, star *db.Star) 13 - DeleteStar(ctx context.Context, star *db.Star) 14 15 - NewIssue(ctx context.Context, issue *db.Issue) 16 17 - NewFollow(ctx context.Context, follow *db.Follow) 18 - DeleteFollow(ctx context.Context, follow *db.Follow) 19 20 - NewPull(ctx context.Context, pull *db.Pull) 21 - NewPullComment(ctx context.Context, comment *db.PullComment) 22 23 - UpdateProfile(ctx context.Context, profile *db.Profile) 24 25 - NewString(ctx context.Context, s *db.String) 26 - EditString(ctx context.Context, s *db.String) 27 DeleteString(ctx context.Context, did, rkey string) 28 } 29 ··· 32 33 var _ Notifier = &BaseNotifier{} 34 35 - func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 36 37 - func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 38 - func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 39 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 41 42 - func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 43 - func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 44 45 - func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 46 - func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 47 48 - func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 49 50 - func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {} 51 - func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {} 52 func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
··· 3 import ( 4 "context" 5 6 + "tangled.org/core/appview/models" 7 ) 8 9 type Notifier interface { 10 + NewRepo(ctx context.Context, repo *models.Repo) 11 12 + NewStar(ctx context.Context, star *models.Star) 13 + DeleteStar(ctx context.Context, star *models.Star) 14 15 + NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 18 19 + NewFollow(ctx context.Context, follow *models.Follow) 20 + DeleteFollow(ctx context.Context, follow *models.Follow) 21 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) 26 27 + UpdateProfile(ctx context.Context, profile *models.Profile) 28 29 + NewString(ctx context.Context, s *models.String) 30 + EditString(ctx context.Context, s *models.String) 31 DeleteString(ctx context.Context, did, rkey string) 32 } 33 ··· 36 37 var _ Notifier = &BaseNotifier{} 38 39 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {} 40 41 + func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 42 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 43 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) {} 47 48 + func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 49 + func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 50 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) {} 55 56 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 57 58 + func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {} 59 + func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {} 60 func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
···
··· 1 + package posthog 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 + err := n.client.Enqueue(posthog.Capture{ 117 + DistinctId: follow.UserDid, 118 + Event: "follow", 119 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 120 + }) 121 + if err != nil { 122 + log.Println("failed to enqueue posthog event:", err) 123 + } 124 + } 125 + 126 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 127 + err := n.client.Enqueue(posthog.Capture{ 128 + DistinctId: follow.UserDid, 129 + Event: "unfollow", 130 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 131 + }) 132 + if err != nil { 133 + log.Println("failed to enqueue posthog event:", err) 134 + } 135 + } 136 + 137 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 138 + err := n.client.Enqueue(posthog.Capture{ 139 + DistinctId: profile.Did, 140 + Event: "edit_profile", 141 + }) 142 + if err != nil { 143 + log.Println("failed to enqueue posthog event:", err) 144 + } 145 + } 146 + 147 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 148 + err := n.client.Enqueue(posthog.Capture{ 149 + DistinctId: did, 150 + Event: "delete_string", 151 + Properties: posthog.Properties{"rkey": rkey}, 152 + }) 153 + if err != nil { 154 + log.Println("failed to enqueue posthog event:", err) 155 + } 156 + } 157 + 158 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 159 + err := n.client.Enqueue(posthog.Capture{ 160 + DistinctId: string.Did.String(), 161 + Event: "edit_string", 162 + Properties: posthog.Properties{"rkey": string.Rkey}, 163 + }) 164 + if err != nil { 165 + log.Println("failed to enqueue posthog event:", err) 166 + } 167 + } 168 + 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 + err := n.client.Enqueue(posthog.Capture{ 171 + DistinctId: string.Did.String(), 172 + Event: "new_string", 173 + Properties: posthog.Properties{"rkey": string.Rkey}, 174 + }) 175 + if err != nil { 176 + log.Println("failed to enqueue posthog event:", err) 177 + } 178 + } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
+12 -12
appview/oauth/handler/handler.go
··· 16 "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 (
··· 16 "github.com/gorilla/sessions" 17 "github.com/lestrrat-go/jwx/v2/jwk" 18 "github.com/posthog/posthog-go" 19 + tangled "tangled.org/core/api/tangled" 20 + sessioncache "tangled.org/core/appview/cache/session" 21 + "tangled.org/core/appview/config" 22 + "tangled.org/core/appview/db" 23 + "tangled.org/core/appview/middleware" 24 + "tangled.org/core/appview/oauth" 25 + "tangled.org/core/appview/oauth/client" 26 + "tangled.org/core/appview/pages" 27 + "tangled.org/core/consts" 28 + "tangled.org/core/idresolver" 29 + "tangled.org/core/rbac" 30 + "tangled.org/core/tid" 31 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 32 ) 33 34 const (
+4 -4
appview/oauth/oauth.go
··· 9 10 indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "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" 18 ) 19 20 type OAuth struct {
··· 9 10 indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "github.com/gorilla/sessions" 12 + sessioncache "tangled.org/core/appview/cache/session" 13 + "tangled.org/core/appview/config" 14 + "tangled.org/core/appview/oauth/client" 15 + xrpc "tangled.org/core/appview/xrpcclient" 16 oauth "tangled.sh/icyphox.sh/atproto-oauth" 17 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 ) 19 20 type OAuth struct {
+18 -18
appview/pages/funcmap.go
··· 19 20 "github.com/dustin/go-humanize" 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" 25 ) 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 141 "relTimeFmt": humanize.Time, 142 "shortRelTimeFmt": func(t time.Time) string { 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}, 159 }) 160 }, 161 "longTimeFmt": func(t time.Time) string {
··· 19 20 "github.com/dustin/go-humanize" 21 "github.com/go-enry/go-enry/v2" 22 + "tangled.org/core/appview/filetree" 23 + "tangled.org/core/appview/pages/markup" 24 + "tangled.org/core/crypto" 25 ) 26 27 func (p *Pages) funcMap() template.FuncMap { ··· 141 "relTimeFmt": humanize.Time, 142 "shortRelTimeFmt": func(t time.Time) string { 143 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 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 }) 160 }, 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 package markup 2 3 - import "strings" 4 5 type Format string 6 ··· 10 ) 11 12 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 } 15 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", 26 } 27 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 - } 34 } 35 } 36 // default format
··· 1 package markup 2 3 + import ( 4 + "regexp" 5 + ) 6 7 type Format string 8 ··· 12 ) 13 14 var FileTypes map[Format][]string = map[Format][]string{ 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 16 } 17 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 } 27 28 func GetFormat(filename string) Format { 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 32 } 33 } 34 // default format
+2 -2
appview/pages/markup/markdown.go
··· 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 - "tangled.sh/tangled.sh/core/api/tangled" 26 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 ) 28 29 // RendererType defines the type of renderer to use based on context
··· 22 "github.com/yuin/goldmark/util" 23 htmlparse "golang.org/x/net/html" 24 25 + "tangled.org/core/api/tangled" 26 + "tangled.org/core/appview/pages/repoinfo" 27 ) 28 29 // RendererType defines the type of renderer to use based on context
+210 -130
appview/pages/pages.go
··· 16 "strings" 17 "sync" 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" 30 31 "github.com/alecthomas/chroma/v2" 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" ··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 - //go:embed templates/* static 42 var Files embed.FS 43 44 type Pages struct { ··· 81 } 82 83 return p 84 - } 85 - 86 - func (p *Pages) pathToName(s string) string { 87 - return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 } 89 90 // reverse of pathToName ··· 230 return p.executePlain("user/login", w, params) 231 } 232 233 - func (p *Pages) Signup(w io.Writer) error { 234 - return p.executePlain("user/signup", w, nil) 235 } 236 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 filename := "terms.md" 248 filePath := filepath.Join("legal", filename) 249 - markdownBytes, err := os.ReadFile(filePath) 250 if err != nil { 251 return fmt.Errorf("failed to read %s: %w", filename, err) 252 } ··· 267 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 filename := "privacy.md" 269 filePath := filepath.Join("legal", filename) 270 - markdownBytes, err := os.ReadFile(filePath) 271 if err != nil { 272 return fmt.Errorf("failed to read %s: %w", filename, err) 273 } ··· 280 return p.execute("legal/privacy", w, params) 281 } 282 283 type TimelineParams struct { 284 LoggedInUser *oauth.User 285 - Timeline []db.TimelineEvent 286 - Repos []db.Repo 287 } 288 289 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 290 return p.execute("timeline/timeline", w, params) 291 } 292 293 type UserProfileSettingsParams struct { 294 LoggedInUser *oauth.User 295 Tabs []map[string]any ··· 300 return p.execute("user/settings/profile", w, params) 301 } 302 303 type UserKeysSettingsParams struct { 304 LoggedInUser *oauth.User 305 - PubKeys []db.PublicKey 306 Tabs []map[string]any 307 Tab string 308 } ··· 313 314 type UserEmailsSettingsParams struct { 315 LoggedInUser *oauth.User 316 - Emails []db.Email 317 Tabs []map[string]any 318 Tab string 319 } ··· 322 return p.execute("user/settings/emails", w, params) 323 } 324 325 type UpgradeBannerParams struct { 326 - Registrations []db.Registration 327 - Spindles []db.Spindle 328 } 329 330 func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { ··· 333 334 type KnotsParams struct { 335 LoggedInUser *oauth.User 336 - Registrations []db.Registration 337 } 338 339 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 342 343 type KnotParams struct { 344 LoggedInUser *oauth.User 345 - Registration *db.Registration 346 Members []string 347 - Repos map[string][]db.Repo 348 IsOwner bool 349 } 350 ··· 353 } 354 355 type KnotListingParams struct { 356 - *db.Registration 357 } 358 359 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { ··· 362 363 type SpindlesParams struct { 364 LoggedInUser *oauth.User 365 - Spindles []db.Spindle 366 } 367 368 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 370 } 371 372 type SpindleListingParams struct { 373 - db.Spindle 374 } 375 376 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 379 380 type SpindleDashboardParams struct { 381 LoggedInUser *oauth.User 382 - Spindle db.Spindle 383 Members []string 384 - Repos map[string][]db.Repo 385 } 386 387 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 410 type ProfileCard struct { 411 UserDid string 412 UserHandle string 413 - FollowStatus db.FollowStatus 414 - Punchcard *db.Punchcard 415 - Profile *db.Profile 416 Stats ProfileStats 417 Active string 418 } ··· 438 439 type ProfileOverviewParams struct { 440 LoggedInUser *oauth.User 441 - Repos []db.Repo 442 - CollaboratingRepos []db.Repo 443 - ProfileTimeline *db.ProfileTimeline 444 Card *ProfileCard 445 Active string 446 } ··· 452 453 type ProfileReposParams struct { 454 LoggedInUser *oauth.User 455 - Repos []db.Repo 456 Card *ProfileCard 457 Active string 458 } ··· 464 465 type ProfileStarredParams struct { 466 LoggedInUser *oauth.User 467 - Repos []db.Repo 468 Card *ProfileCard 469 Active string 470 } ··· 476 477 type ProfileStringsParams struct { 478 LoggedInUser *oauth.User 479 - Strings []db.String 480 Card *ProfileCard 481 Active string 482 } ··· 488 489 type FollowCard struct { 490 UserDid string 491 - FollowStatus db.FollowStatus 492 FollowersCount int64 493 FollowingCount int64 494 - Profile *db.Profile 495 } 496 497 type ProfileFollowersParams struct { ··· 520 521 type FollowFragmentParams struct { 522 UserDid string 523 - FollowStatus db.FollowStatus 524 } 525 526 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { ··· 529 530 type EditBioParams struct { 531 LoggedInUser *oauth.User 532 - Profile *db.Profile 533 } 534 535 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { ··· 538 539 type EditPinsParams struct { 540 LoggedInUser *oauth.User 541 - Profile *db.Profile 542 AllRepos []PinnedRepo 543 } 544 545 type PinnedRepo struct { 546 IsPinned bool 547 - db.Repo 548 } 549 550 func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { ··· 554 type RepoStarFragmentParams struct { 555 IsStarred bool 556 RepoAt syntax.ATURI 557 - Stats db.RepoStats 558 } 559 560 func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { ··· 587 EmailToDidOrHandle map[string]string 588 VerifiedCommits commitverify.VerifiedCommits 589 Languages []types.RepoLanguageDetails 590 - Pipelines map[string]db.Pipeline 591 NeedsKnotUpgrade bool 592 types.RepoIndexResponse 593 } ··· 630 Active string 631 EmailToDidOrHandle map[string]string 632 VerifiedCommits commitverify.VerifiedCommits 633 - Pipelines map[string]db.Pipeline 634 } 635 636 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 643 RepoInfo repoinfo.RepoInfo 644 Active string 645 EmailToDidOrHandle map[string]string 646 - Pipeline *db.Pipeline 647 DiffOpts types.DiffOpts 648 649 // singular because it's always going to be just one ··· 658 } 659 660 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 670 types.RepoTreeResponse 671 } 672 ··· 694 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 695 params.Active = "overview" 696 697 - if params.ReadmeFileName != "" { 698 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 699 700 ext := filepath.Ext(params.ReadmeFileName) 701 switch ext { 702 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 729 RepoInfo repoinfo.RepoInfo 730 Active string 731 types.RepoTagsResponse 732 - ArtifactMap map[plumbing.Hash][]db.Artifact 733 - DanglingArtifacts []db.Artifact 734 } 735 736 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { ··· 741 type RepoArtifactParams struct { 742 LoggedInUser *oauth.User 743 RepoInfo repoinfo.RepoInfo 744 - Artifact db.Artifact 745 } 746 747 func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { ··· 838 } 839 840 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 850 } 851 852 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 888 LoggedInUser *oauth.User 889 RepoInfo repoinfo.RepoInfo 890 Active string 891 - Issues []db.Issue 892 - LabelDefs map[string]*db.LabelDefinition 893 Page pagination.Page 894 FilteringByOpen bool 895 } ··· 903 LoggedInUser *oauth.User 904 RepoInfo repoinfo.RepoInfo 905 Active string 906 - Issue *db.Issue 907 - CommentList []db.CommentListItem 908 - LabelDefs map[string]*db.LabelDefinition 909 910 - OrderedReactionKinds []db.ReactionKind 911 - Reactions map[db.ReactionKind]int 912 - UserReacted map[db.ReactionKind]bool 913 } 914 915 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 920 type EditIssueParams struct { 921 LoggedInUser *oauth.User 922 RepoInfo repoinfo.RepoInfo 923 - Issue *db.Issue 924 Action string 925 } 926 ··· 931 932 type ThreadReactionFragmentParams struct { 933 ThreadAt syntax.ATURI 934 - Kind db.ReactionKind 935 Count int 936 IsReacted bool 937 } ··· 943 type RepoNewIssueParams struct { 944 LoggedInUser *oauth.User 945 RepoInfo repoinfo.RepoInfo 946 - Issue *db.Issue // existing issue if any -- passed when editing 947 Active string 948 Action string 949 } ··· 957 type EditIssueCommentParams struct { 958 LoggedInUser *oauth.User 959 RepoInfo repoinfo.RepoInfo 960 - Issue *db.Issue 961 - Comment *db.IssueComment 962 } 963 964 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 968 type ReplyIssueCommentPlaceholderParams struct { 969 LoggedInUser *oauth.User 970 RepoInfo repoinfo.RepoInfo 971 - Issue *db.Issue 972 - Comment *db.IssueComment 973 } 974 975 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 979 type ReplyIssueCommentParams struct { 980 LoggedInUser *oauth.User 981 RepoInfo repoinfo.RepoInfo 982 - Issue *db.Issue 983 - Comment *db.IssueComment 984 } 985 986 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 990 type IssueCommentBodyParams struct { 991 LoggedInUser *oauth.User 992 RepoInfo repoinfo.RepoInfo 993 - Issue *db.Issue 994 - Comment *db.IssueComment 995 } 996 997 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1018 type RepoPullsParams struct { 1019 LoggedInUser *oauth.User 1020 RepoInfo repoinfo.RepoInfo 1021 - Pulls []*db.Pull 1022 Active string 1023 - FilteringBy db.PullState 1024 - Stacks map[string]db.Stack 1025 - Pipelines map[string]db.Pipeline 1026 } 1027 1028 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1052 LoggedInUser *oauth.User 1053 RepoInfo repoinfo.RepoInfo 1054 Active string 1055 - Pull *db.Pull 1056 - Stack db.Stack 1057 - AbandonedPulls []*db.Pull 1058 MergeCheck types.MergeCheckResponse 1059 ResubmitCheck ResubmitResult 1060 - Pipelines map[string]db.Pipeline 1061 1062 - OrderedReactionKinds []db.ReactionKind 1063 - Reactions map[db.ReactionKind]int 1064 - UserReacted map[db.ReactionKind]bool 1065 } 1066 1067 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1072 type RepoPullPatchParams struct { 1073 LoggedInUser *oauth.User 1074 RepoInfo repoinfo.RepoInfo 1075 - Pull *db.Pull 1076 - Stack db.Stack 1077 Diff *types.NiceDiff 1078 Round int 1079 - Submission *db.PullSubmission 1080 - OrderedReactionKinds []db.ReactionKind 1081 DiffOpts types.DiffOpts 1082 } 1083 ··· 1089 type RepoPullInterdiffParams struct { 1090 LoggedInUser *oauth.User 1091 RepoInfo repoinfo.RepoInfo 1092 - Pull *db.Pull 1093 Round int 1094 Interdiff *patchutil.InterdiffResult 1095 - OrderedReactionKinds []db.ReactionKind 1096 DiffOpts types.DiffOpts 1097 } 1098 ··· 1121 1122 type PullCompareForkParams struct { 1123 RepoInfo repoinfo.RepoInfo 1124 - Forks []db.Repo 1125 Selected string 1126 } 1127 ··· 1142 type PullResubmitParams struct { 1143 LoggedInUser *oauth.User 1144 RepoInfo repoinfo.RepoInfo 1145 - Pull *db.Pull 1146 SubmissionId int 1147 } 1148 ··· 1153 type PullActionsParams struct { 1154 LoggedInUser *oauth.User 1155 RepoInfo repoinfo.RepoInfo 1156 - Pull *db.Pull 1157 RoundNumber int 1158 MergeCheck types.MergeCheckResponse 1159 ResubmitCheck ResubmitResult 1160 - Stack db.Stack 1161 } 1162 1163 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1167 type PullNewCommentParams struct { 1168 LoggedInUser *oauth.User 1169 RepoInfo repoinfo.RepoInfo 1170 - Pull *db.Pull 1171 RoundNumber int 1172 } 1173 ··· 1178 type RepoCompareParams struct { 1179 LoggedInUser *oauth.User 1180 RepoInfo repoinfo.RepoInfo 1181 - Forks []db.Repo 1182 Branches []types.Branch 1183 Tags []*types.TagReference 1184 Base string ··· 1197 type RepoCompareNewParams struct { 1198 LoggedInUser *oauth.User 1199 RepoInfo repoinfo.RepoInfo 1200 - Forks []db.Repo 1201 Branches []types.Branch 1202 Tags []*types.TagReference 1203 Base string ··· 1235 type LabelPanelParams struct { 1236 LoggedInUser *oauth.User 1237 RepoInfo repoinfo.RepoInfo 1238 - Defs map[string]*db.LabelDefinition 1239 Subject string 1240 - State db.LabelState 1241 } 1242 1243 func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { ··· 1247 type EditLabelPanelParams struct { 1248 LoggedInUser *oauth.User 1249 RepoInfo repoinfo.RepoInfo 1250 - Defs map[string]*db.LabelDefinition 1251 Subject string 1252 - State db.LabelState 1253 } 1254 1255 func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { ··· 1259 type PipelinesParams struct { 1260 LoggedInUser *oauth.User 1261 RepoInfo repoinfo.RepoInfo 1262 - Pipelines []db.Pipeline 1263 Active string 1264 } 1265 ··· 1291 type WorkflowParams struct { 1292 LoggedInUser *oauth.User 1293 RepoInfo repoinfo.RepoInfo 1294 - Pipeline db.Pipeline 1295 Workflow string 1296 LogUrl string 1297 Active string ··· 1307 Action string 1308 1309 // this is supplied in the case of editing an existing string 1310 - String db.String 1311 } 1312 1313 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1317 type StringsDashboardParams struct { 1318 LoggedInUser *oauth.User 1319 Card ProfileCard 1320 - Strings []db.String 1321 } 1322 1323 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1326 1327 type StringTimelineParams struct { 1328 LoggedInUser *oauth.User 1329 - Strings []db.String 1330 } 1331 1332 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1338 ShowRendered bool 1339 RenderToggle bool 1340 RenderedContents template.HTML 1341 - String db.String 1342 - Stats db.StringStats 1343 Owner identity.Identity 1344 } 1345
··· 16 "strings" 17 "sync" 18 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 31 "github.com/alecthomas/chroma/v2" 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" ··· 38 "github.com/go-git/go-git/v5/plumbing/object" 39 ) 40 41 + //go:embed templates/* static legal 42 var Files embed.FS 43 44 type Pages struct { ··· 81 } 82 83 return p 84 } 85 86 // reverse of pathToName ··· 226 return p.executePlain("user/login", w, params) 227 } 228 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 } 236 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 filename := "terms.md" 248 filePath := filepath.Join("legal", filename) 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) 257 if err != nil { 258 return fmt.Errorf("failed to read %s: %w", filename, err) 259 } ··· 274 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 275 filename := "privacy.md" 276 filePath := filepath.Join("legal", filename) 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) 285 if err != nil { 286 return fmt.Errorf("failed to read %s: %w", filename, err) 287 } ··· 294 return p.execute("legal/privacy", w, params) 295 } 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 + 305 type TimelineParams struct { 306 LoggedInUser *oauth.User 307 + Timeline []models.TimelineEvent 308 + Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 310 } 311 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 313 return p.execute("timeline/timeline", w, params) 314 } 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 + 329 type UserProfileSettingsParams struct { 330 LoggedInUser *oauth.User 331 Tabs []map[string]any ··· 336 return p.execute("user/settings/profile", w, params) 337 } 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 + 367 type UserKeysSettingsParams struct { 368 LoggedInUser *oauth.User 369 + PubKeys []models.PublicKey 370 Tabs []map[string]any 371 Tab string 372 } ··· 377 378 type UserEmailsSettingsParams struct { 379 LoggedInUser *oauth.User 380 + Emails []models.Email 381 Tabs []map[string]any 382 Tab string 383 } ··· 386 return p.execute("user/settings/emails", w, params) 387 } 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 + 400 type UpgradeBannerParams struct { 401 + Registrations []models.Registration 402 + Spindles []models.Spindle 403 } 404 405 func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { ··· 408 409 type KnotsParams struct { 410 LoggedInUser *oauth.User 411 + Registrations []models.Registration 412 } 413 414 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 417 418 type KnotParams struct { 419 LoggedInUser *oauth.User 420 + Registration *models.Registration 421 Members []string 422 + Repos map[string][]models.Repo 423 IsOwner bool 424 } 425 ··· 428 } 429 430 type KnotListingParams struct { 431 + *models.Registration 432 } 433 434 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { ··· 437 438 type SpindlesParams struct { 439 LoggedInUser *oauth.User 440 + Spindles []models.Spindle 441 } 442 443 func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { ··· 445 } 446 447 type SpindleListingParams struct { 448 + models.Spindle 449 } 450 451 func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { ··· 454 455 type SpindleDashboardParams struct { 456 LoggedInUser *oauth.User 457 + Spindle models.Spindle 458 Members []string 459 + Repos map[string][]models.Repo 460 } 461 462 func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { ··· 485 type ProfileCard struct { 486 UserDid string 487 UserHandle string 488 + FollowStatus models.FollowStatus 489 + Punchcard *models.Punchcard 490 + Profile *models.Profile 491 Stats ProfileStats 492 Active string 493 } ··· 513 514 type ProfileOverviewParams struct { 515 LoggedInUser *oauth.User 516 + Repos []models.Repo 517 + CollaboratingRepos []models.Repo 518 + ProfileTimeline *models.ProfileTimeline 519 Card *ProfileCard 520 Active string 521 } ··· 527 528 type ProfileReposParams struct { 529 LoggedInUser *oauth.User 530 + Repos []models.Repo 531 Card *ProfileCard 532 Active string 533 } ··· 539 540 type ProfileStarredParams struct { 541 LoggedInUser *oauth.User 542 + Repos []models.Repo 543 Card *ProfileCard 544 Active string 545 } ··· 551 552 type ProfileStringsParams struct { 553 LoggedInUser *oauth.User 554 + Strings []models.String 555 Card *ProfileCard 556 Active string 557 } ··· 563 564 type FollowCard struct { 565 UserDid string 566 + LoggedInUser *oauth.User 567 + FollowStatus models.FollowStatus 568 FollowersCount int64 569 FollowingCount int64 570 + Profile *models.Profile 571 } 572 573 type ProfileFollowersParams struct { ··· 596 597 type FollowFragmentParams struct { 598 UserDid string 599 + FollowStatus models.FollowStatus 600 } 601 602 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { ··· 605 606 type EditBioParams struct { 607 LoggedInUser *oauth.User 608 + Profile *models.Profile 609 } 610 611 func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { ··· 614 615 type EditPinsParams struct { 616 LoggedInUser *oauth.User 617 + Profile *models.Profile 618 AllRepos []PinnedRepo 619 } 620 621 type PinnedRepo struct { 622 IsPinned bool 623 + models.Repo 624 } 625 626 func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { ··· 630 type RepoStarFragmentParams struct { 631 IsStarred bool 632 RepoAt syntax.ATURI 633 + Stats models.RepoStats 634 } 635 636 func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { ··· 663 EmailToDidOrHandle map[string]string 664 VerifiedCommits commitverify.VerifiedCommits 665 Languages []types.RepoLanguageDetails 666 + Pipelines map[string]models.Pipeline 667 NeedsKnotUpgrade bool 668 types.RepoIndexResponse 669 } ··· 706 Active string 707 EmailToDidOrHandle map[string]string 708 VerifiedCommits commitverify.VerifiedCommits 709 + Pipelines map[string]models.Pipeline 710 } 711 712 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { ··· 719 RepoInfo repoinfo.RepoInfo 720 Active string 721 EmailToDidOrHandle map[string]string 722 + Pipeline *models.Pipeline 723 DiffOpts types.DiffOpts 724 725 // singular because it's always going to be just one ··· 734 } 735 736 type RepoTreeParams struct { 737 + LoggedInUser *oauth.User 738 + RepoInfo repoinfo.RepoInfo 739 + Active string 740 + BreadCrumbs [][]string 741 + TreePath string 742 + Raw bool 743 + HTMLReadme template.HTML 744 types.RepoTreeResponse 745 } 746 ··· 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 769 params.Active = "overview" 770 771 + p.rctx.RepoInfo = params.RepoInfo 772 + p.rctx.RepoInfo.Ref = params.Ref 773 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 774 775 + if params.ReadmeFileName != "" { 776 ext := filepath.Ext(params.ReadmeFileName) 777 switch ext { 778 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 805 RepoInfo repoinfo.RepoInfo 806 Active string 807 types.RepoTagsResponse 808 + ArtifactMap map[plumbing.Hash][]models.Artifact 809 + DanglingArtifacts []models.Artifact 810 } 811 812 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { ··· 817 type RepoArtifactParams struct { 818 LoggedInUser *oauth.User 819 RepoInfo repoinfo.RepoInfo 820 + Artifact models.Artifact 821 } 822 823 func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { ··· 914 } 915 916 type RepoGeneralSettingsParams struct { 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 927 } 928 929 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 965 LoggedInUser *oauth.User 966 RepoInfo repoinfo.RepoInfo 967 Active string 968 + Issues []models.Issue 969 + LabelDefs map[string]*models.LabelDefinition 970 Page pagination.Page 971 FilteringByOpen bool 972 } ··· 980 LoggedInUser *oauth.User 981 RepoInfo repoinfo.RepoInfo 982 Active string 983 + Issue *models.Issue 984 + CommentList []models.CommentListItem 985 + LabelDefs map[string]*models.LabelDefinition 986 987 + OrderedReactionKinds []models.ReactionKind 988 + Reactions map[models.ReactionKind]int 989 + UserReacted map[models.ReactionKind]bool 990 } 991 992 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { ··· 997 type EditIssueParams struct { 998 LoggedInUser *oauth.User 999 RepoInfo repoinfo.RepoInfo 1000 + Issue *models.Issue 1001 Action string 1002 } 1003 ··· 1008 1009 type ThreadReactionFragmentParams struct { 1010 ThreadAt syntax.ATURI 1011 + Kind models.ReactionKind 1012 Count int 1013 IsReacted bool 1014 } ··· 1020 type RepoNewIssueParams struct { 1021 LoggedInUser *oauth.User 1022 RepoInfo repoinfo.RepoInfo 1023 + Issue *models.Issue // existing issue if any -- passed when editing 1024 Active string 1025 Action string 1026 } ··· 1034 type EditIssueCommentParams struct { 1035 LoggedInUser *oauth.User 1036 RepoInfo repoinfo.RepoInfo 1037 + Issue *models.Issue 1038 + Comment *models.IssueComment 1039 } 1040 1041 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1045 type ReplyIssueCommentPlaceholderParams struct { 1046 LoggedInUser *oauth.User 1047 RepoInfo repoinfo.RepoInfo 1048 + Issue *models.Issue 1049 + Comment *models.IssueComment 1050 } 1051 1052 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1056 type ReplyIssueCommentParams struct { 1057 LoggedInUser *oauth.User 1058 RepoInfo repoinfo.RepoInfo 1059 + Issue *models.Issue 1060 + Comment *models.IssueComment 1061 } 1062 1063 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1067 type IssueCommentBodyParams struct { 1068 LoggedInUser *oauth.User 1069 RepoInfo repoinfo.RepoInfo 1070 + Issue *models.Issue 1071 + Comment *models.IssueComment 1072 } 1073 1074 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { ··· 1095 type RepoPullsParams struct { 1096 LoggedInUser *oauth.User 1097 RepoInfo repoinfo.RepoInfo 1098 + Pulls []*models.Pull 1099 Active string 1100 + FilteringBy models.PullState 1101 + Stacks map[string]models.Stack 1102 + Pipelines map[string]models.Pipeline 1103 + LabelDefs map[string]*models.LabelDefinition 1104 } 1105 1106 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1130 LoggedInUser *oauth.User 1131 RepoInfo repoinfo.RepoInfo 1132 Active string 1133 + Pull *models.Pull 1134 + Stack models.Stack 1135 + AbandonedPulls []*models.Pull 1136 MergeCheck types.MergeCheckResponse 1137 ResubmitCheck ResubmitResult 1138 + Pipelines map[string]models.Pipeline 1139 + 1140 + OrderedReactionKinds []models.ReactionKind 1141 + Reactions map[models.ReactionKind]int 1142 + UserReacted map[models.ReactionKind]bool 1143 1144 + LabelDefs map[string]*models.LabelDefinition 1145 } 1146 1147 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 1152 type RepoPullPatchParams struct { 1153 LoggedInUser *oauth.User 1154 RepoInfo repoinfo.RepoInfo 1155 + Pull *models.Pull 1156 + Stack models.Stack 1157 Diff *types.NiceDiff 1158 Round int 1159 + Submission *models.PullSubmission 1160 + OrderedReactionKinds []models.ReactionKind 1161 DiffOpts types.DiffOpts 1162 } 1163 ··· 1169 type RepoPullInterdiffParams struct { 1170 LoggedInUser *oauth.User 1171 RepoInfo repoinfo.RepoInfo 1172 + Pull *models.Pull 1173 Round int 1174 Interdiff *patchutil.InterdiffResult 1175 + OrderedReactionKinds []models.ReactionKind 1176 DiffOpts types.DiffOpts 1177 } 1178 ··· 1201 1202 type PullCompareForkParams struct { 1203 RepoInfo repoinfo.RepoInfo 1204 + Forks []models.Repo 1205 Selected string 1206 } 1207 ··· 1222 type PullResubmitParams struct { 1223 LoggedInUser *oauth.User 1224 RepoInfo repoinfo.RepoInfo 1225 + Pull *models.Pull 1226 SubmissionId int 1227 } 1228 ··· 1233 type PullActionsParams struct { 1234 LoggedInUser *oauth.User 1235 RepoInfo repoinfo.RepoInfo 1236 + Pull *models.Pull 1237 RoundNumber int 1238 MergeCheck types.MergeCheckResponse 1239 ResubmitCheck ResubmitResult 1240 + Stack models.Stack 1241 } 1242 1243 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { ··· 1247 type PullNewCommentParams struct { 1248 LoggedInUser *oauth.User 1249 RepoInfo repoinfo.RepoInfo 1250 + Pull *models.Pull 1251 RoundNumber int 1252 } 1253 ··· 1258 type RepoCompareParams struct { 1259 LoggedInUser *oauth.User 1260 RepoInfo repoinfo.RepoInfo 1261 + Forks []models.Repo 1262 Branches []types.Branch 1263 Tags []*types.TagReference 1264 Base string ··· 1277 type RepoCompareNewParams struct { 1278 LoggedInUser *oauth.User 1279 RepoInfo repoinfo.RepoInfo 1280 + Forks []models.Repo 1281 Branches []types.Branch 1282 Tags []*types.TagReference 1283 Base string ··· 1315 type LabelPanelParams struct { 1316 LoggedInUser *oauth.User 1317 RepoInfo repoinfo.RepoInfo 1318 + Defs map[string]*models.LabelDefinition 1319 Subject string 1320 + State models.LabelState 1321 } 1322 1323 func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error { ··· 1327 type EditLabelPanelParams struct { 1328 LoggedInUser *oauth.User 1329 RepoInfo repoinfo.RepoInfo 1330 + Defs map[string]*models.LabelDefinition 1331 Subject string 1332 + State models.LabelState 1333 } 1334 1335 func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error { ··· 1339 type PipelinesParams struct { 1340 LoggedInUser *oauth.User 1341 RepoInfo repoinfo.RepoInfo 1342 + Pipelines []models.Pipeline 1343 Active string 1344 } 1345 ··· 1371 type WorkflowParams struct { 1372 LoggedInUser *oauth.User 1373 RepoInfo repoinfo.RepoInfo 1374 + Pipeline models.Pipeline 1375 Workflow string 1376 LogUrl string 1377 Active string ··· 1387 Action string 1388 1389 // this is supplied in the case of editing an existing string 1390 + String models.String 1391 } 1392 1393 func (p *Pages) PutString(w io.Writer, params PutStringParams) error { ··· 1397 type StringsDashboardParams struct { 1398 LoggedInUser *oauth.User 1399 Card ProfileCard 1400 + Strings []models.String 1401 } 1402 1403 func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { ··· 1406 1407 type StringTimelineParams struct { 1408 LoggedInUser *oauth.User 1409 + Strings []models.String 1410 } 1411 1412 func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { ··· 1418 ShowRendered bool 1419 RenderToggle bool 1420 RenderedContents template.HTML 1421 + String models.String 1422 + Stats models.StringStats 1423 Owner identity.Identity 1424 } 1425
+4 -4
appview/pages/repoinfo/repoinfo.go
··· 7 "strings" 8 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" 12 ) 13 14 func (r RepoInfo) OwnerWithAt() string { ··· 60 Spindle string 61 RepoAt syntax.ATURI 62 IsStarred bool 63 - Stats db.RepoStats 64 Roles RolesInRepo 65 - Source *db.Repo 66 SourceHandle string 67 Ref string 68 DisableFork bool
··· 7 "strings" 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/state/userutil" 12 ) 13 14 func (r RepoInfo) OwnerWithAt() string { ··· 60 Spindle string 61 RepoAt syntax.ATURI 62 IsStarred bool 63 + Stats models.RepoStats 64 Roles RolesInRepo 65 + Source *models.Repo 66 SourceHandle string 67 Ref string 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 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 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" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 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> 26 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 <button onclick="location.reload()" class="btn-create gap-2"> 28 {{ i "refresh-cw" "w-4 h-4" }} 29 try again 30 </button> 31 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 33 back to home 34 </a> 35 </div>
··· 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 <div class="mb-6"> 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 "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 </div> 10 </div> 11 ··· 14 500 &mdash; internal server error 15 </h1> 16 <p class="text-gray-600 dark:text-gray-300"> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 <button onclick="location.reload()" class="btn-create gap-2"> 21 {{ i "refresh-cw" "w-4 h-4" }} 22 try again 23 </button> 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 back to home 27 </a> 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 {{ $d := .def }} 3 {{ $v := .val }} 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"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
··· 2 {{ $d := .def }} 3 {{ $v := .val }} 4 {{ $withPrefix := .withPrefix }} 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 <!-- preload main font --> 18 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 ··· 21 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 {{ block "extrameta" . }}{{ end }} 23 </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);"> 26 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 28 29 {{ if .LoggedInUser }} 30 <div id="upgrade-banner" ··· 38 {{ end }} 39 40 {{ 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"> 44 {{ block "content" . }}{{ end }} 45 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 - {{ end }} 53 </div> 54 {{ end }} 55 56 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 58 {{ template "layouts/fragments/footer" . }} 59 </footer> 60 {{ end }}
··· 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 20 <!-- preload main font --> 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 22 ··· 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 25 {{ block "extrameta" . }}{{ end }} 26 </head> 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"> 28 {{ block "topbarLayout" . }} 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;"> 30 31 {{ if .LoggedInUser }} 32 <div id="upgrade-banner" ··· 40 {{ end }} 41 42 {{ block "mainLayout" . }} 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 47 {{ block "content" . }}{{ end }} 48 </main> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 53 {{ block "contentAfter" . }}{{ end }} 54 </main> 55 + {{ end }} 56 + </div> 57 </div> 58 {{ end }} 59 60 {{ block "footerLayout" . }} 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 62 {{ template "layouts/fragments/footer" . }} 63 </footer> 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 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> 10 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> 19 </div> 20 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> 26 </div> 27 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> 33 </div> 34 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> 39 </div> 40 - </div> 41 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> 44 </div> 45 </div> 46 </div>
··· 1 {{ define "layouts/fragments/footer" }} 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> 13 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> 46 </div> 47 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 51 </div> 52 + </div> 53 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> 64 </div> 65 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> 93 </div> 94 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 98 </div> 99 </div> 100 </div>
+17 -7
appview/pages/templates/layouts/fragments/topbar.html
··· 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"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 7 </a> 8 </div> 9 10 - <div id="right-items" class="flex items-center gap-2"> 11 {{ with .LoggedInUser }} 12 {{ block "newButton" . }} {{ end }} 13 {{ block "dropDown" . }} {{ end }} 14 {{ else }} 15 <a href="/login">login</a> ··· 26 {{ define "newButton" }} 27 <details class="relative inline-block text-left nav-dropdown"> 28 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 30 </summary> 31 <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 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 {{ define "dropDown" }} 45 <details class="relative inline-block text-left nav-dropdown"> 46 <summary 47 - class="cursor-pointer list-none flex items-center" 48 > 49 {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 51 </summary> 52 <div 53 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"
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 <div class="flex justify-between p-0 items-center"> 4 <div id="left-items"> 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> 11 </a> 12 </div> 13 14 + <div id="right-items" class="flex items-center gap-4"> 15 {{ with .LoggedInUser }} 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 18 {{ block "dropDown" . }} {{ end }} 19 {{ else }} 20 <a href="/login">login</a> ··· 31 {{ define "newButton" }} 32 <details class="relative inline-block text-left nav-dropdown"> 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 35 </summary> 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"> 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 49 {{ define "dropDown" }} 50 <details class="relative inline-block text-left nav-dropdown"> 51 <summary 52 + class="cursor-pointer list-none flex items-center gap-1" 53 > 54 {{ $user := didOrHandle .Did .Handle }} 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> 61 </summary> 62 <div 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 {{ define "title" }}privacy policy{{ end }} 2 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> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}privacy policy{{ 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">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 }} 15 </div> 16 + </main> 17 </div> 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 {{ define "title" }}terms of service{{ end }} 2 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> 9 </div> 10 </div> 11 - {{ end }}
··· 1 {{ define "title" }}terms of service{{ 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">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 }} 15 </div> 16 + </main> 17 </div> 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 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2">
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 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 + 16 <fieldset class="space-y-3"> 17 <legend class="dark:text-white">Select a knot to fork into</legend> 18 <div class="space-y-2">
+3 -3
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group"> ··· 29 <code 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 onclick="window.getSelection().selectAllChildren(this)" 32 - data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 - >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 <button 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 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 {{ define "repo/fragments/cloneDropdown" }} 2 {{ $knot := .RepoInfo.Knot }} 3 {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 {{ end }} 6 7 <details id="clone-dropdown" class="relative inline-block text-left group"> ··· 29 <code 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 onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 <button 35 onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 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 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+26
appview/pages/templates/repo/fragments/participants.html
···
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-6 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 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"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
··· 1 {{ define "repo/fragments/readme" }} 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 {{- if .ReadmeFileName -}} 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 </div> 8 {{- end -}} 9 <section 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
···
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
···
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 26 </div> 27 </div> 28 {{ end }} ··· 122 </div> 123 {{ end }} 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 152 {{ define "repoAfter" }} 153 <div class="flex flex-col gap-4 mt-4">
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 </div> 27 </div> 28 {{ end }} ··· 122 </div> 123 {{ end }} 124 125 126 {{ define "repoAfter" }} 127 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 92 </div> 93 {{ block "pagination" . }} {{ end }} 94 {{ end }}
··· 37 {{ end }} 38 39 {{ define "repoAfter" }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 42 </div> 43 {{ block "pagination" . }} {{ end }} 44 {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 6 </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> 19 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 - /> 29 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 - /> 37 </div> 38 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 41 <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> 58 </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> 61 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> 71 </div> 72 - </form> 73 - </div> 74 {{ end }}
··· 1 {{ define "title" }}new repo{{ end }} 2 3 {{ define "content" }} 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" . }} 13 </div> 14 + {{ end }} 15 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 }} 21 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> 35 </div> 36 + <div id="repo" class="error mt-2"></div> 37 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 + 52 <div class="space-y-2"> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 55 </div> 56 + </div> 57 + </div> 58 + {{ end }} 59 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 }} 64 </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> 176 {{ end }}
+30 -12
appview/pages/templates/repo/pulls/pull.html
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 13 {{ define "repoContent" }} 14 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 {{ with $item }} 40 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 43 <!-- round number --> 44 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 </div> 47 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 {{ $owner := resolve $.Pull.OwnerDid }} 51 {{ $re := "re" }} ··· 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </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> 84 {{ end }} 85 </div> 86 </summary> 87 ··· 146 147 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 {{ 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"> 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }}
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 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 }} 30 31 {{ define "repoContent" }} 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 57 {{ with $item }} 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 61 <!-- round number --> 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 64 </div> 65 <!-- round summary --> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 67 <span class="gap-1 flex items-center"> 68 {{ $owner := resolve $.Pull.OwnerDid }} 69 {{ $re := "re" }} ··· 90 <span class="hidden md:inline">diff</span> 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 92 </a> 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> 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 103 </div> 104 </summary> 105 ··· 164 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 166 {{ range $cidx, $c := .Comments }} 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 168 {{ if gt $cidx 0 }} 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 170 {{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 </div> 112 </div> 113 {{ if .StackId }}
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 118 </div> 119 </div> 120 {{ if .StackId }}
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 47 {{ define "defaultLabelSettings" }} 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> 55 <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 {{ range .DefaultLabels }} 57 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
··· 46 47 {{ define "defaultLabelSettings" }} 48 <div class="flex flex-col gap-2"> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 85 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 86 {{ range .DefaultLabels }} 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1 -1
appview/pages/templates/repo/tree.html
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
··· 91 92 {{ define "repoAfter" }} 93 {{- if or .HTMLReadme .Readme -}} 94 + {{ template "repo/fragments/readme" . }} 95 {{- end -}} 96 {{ end }}
+2 -2
appview/pages/templates/strings/put.html
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 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> 8 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
··· 3 {{ define "content" }} 4 <div class="px-6 py-2 mb-4"> 5 {{ if eq .Action "new" }} 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 {{ else }} 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 <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> 32 </div> 33 {{ with .Description }} 34 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 43 {{ define "stringCardInfo" }} 44 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 <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 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 <span class="select-none [&:before]:content-['·']"></span> 53 {{ with .Edited }}
··· 26 {{ end }} 27 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 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> 35 </div> 36 {{ with .Description }} 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 45 46 {{ define "stringCardInfo" }} 47 {{ $stat := .Stats }} 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 50 <span class="select-none [&:before]:content-['·']"></span> 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 {{ $event := index . 1 }} 83 {{ $follow := $event.Follow }} 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 86 87 {{ $userHandle := resolve $follow.UserDid }} 88 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 </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> 127 {{ end }}
··· 82 {{ $event := index . 1 }} 83 {{ $follow := $event.Follow }} 84 {{ $profile := $event.Profile }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 87 88 {{ $userHandle := resolve $follow.UserDid }} 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 95 </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) }} 104 {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 {{ template "timeline/fragments/trending" . }} 16 {{ template "timeline/fragments/timeline" . }} 17 <div class="flex justify-end">
··· 12 <div class="flex flex-col gap-4"> 13 {{ template "timeline/fragments/hero" . }} 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 {{ template "timeline/fragments/trending" . }} 17 {{ template "timeline/fragments/timeline" . }} 18 {{ end }}
··· 13 {{ template "timeline/fragments/hero" . }} 14 {{ end }} 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 17 {{ template "timeline/fragments/trending" . }} 18 {{ template "timeline/fragments/timeline" . }} 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 <link 24 rel="stylesheet" 25 href="/static/tw.css?{{ cssContentHash }}"
··· 20 content="complete your signup for tangled" 21 /> 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 24 <link 25 rel="stylesheet" 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 14 {{ else }} 15 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 {{ end }}
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Followers }} 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) }} 21 {{ else }} 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 14 {{ else }} 15 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 {{ end }}
··· 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 {{ range .Following }} 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) }} 21 {{ else }} 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 {{ define "user/fragments/follow" }} 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 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 }} 16 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 </button> 18 {{ end }}
··· 1 {{ define "user/fragments/follow" }} 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 + class="btn w-full flex gap-2 items-center group" 4 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 hx-swap="outerHTML" 14 > 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 }} 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 </button> 22 {{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
··· 1 {{ define "user/fragments/followCard" }} 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"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 </div> 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> 19 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 {{ template "user/fragments/follow" . }} 25 </div> 26 - {{ end }} 27 </div> 28 </div> 29 - {{ end }}
··· 1 {{ define "user/fragments/followCard" }} 2 {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 </div> 8 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> 23 </div> 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"> 26 {{ template "user/fragments/follow" . }} 27 </div> 28 + {{ end }} 29 + </div> 30 </div> 31 </div> 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 /> 7 - {{ . | truncateAt30 }} 8 {{ end }}
··· 2 <img 3 src="{{ tinyAvatar . }}" 4 alt="" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 /> 7 + {{ . | resolve | truncateAt30 }} 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 5 </a> 6 {{ end }}
··· 1 {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 4 </a> 5 {{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
··· 14 {{ with $repo }} 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 <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 - 24 {{ $repoOwner := resolve .Did }} 25 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 27 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 29 {{- end -}} 30 </div> 31 - 32 {{ if and $starButton $root.LoggedInUser }} 33 {{ template "repo/fragments/repoStar" $starData }} 34 {{ end }} 35 </div> 36 {{ with .Description }}
··· 14 {{ with $repo }} 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 <div class="font-medium dark:text-white flex items-center justify-between"> 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 }} 23 {{ $repoOwner := resolve .Did }} 24 {{- if $fullName -}} 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 26 {{- else -}} 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 28 {{- end -}} 29 </div> 30 {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 32 {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 34 {{ end }} 35 </div> 36 {{ with .Description }}
+2 -1
appview/pages/templates/user/login.html
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login &middot; tangled</title> 13 </head> ··· 36 placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 handle to log in. If you're unsure, this is likely 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span>
··· 8 <meta property="og:url" content="https://tangled.org/login" /> 9 <meta property="og:description" content="login to for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>login &middot; tangled</title> 14 </head> ··· 37 placeholder="akshay.tngl.sh" 38 /> 39 <span class="text-sm text-gray-500 mt-1"> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 41 handle to log in. If you're unsure, this is likely 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 43 </span>
+173
appview/pages/templates/user/settings/notifications.html
···
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 146 + <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+7 -1
appview/pages/templates/user/signup.html
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> ··· 39 invite code, desired username, and password in the next 40 page to complete your registration. 41 </span> 42 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 <span>join now</span> 44 </button> 45 </form> 46 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 </p> 49 50 <p id="signup-msg" class="error w-full"></p>
··· 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 <meta property="og:description" content="sign up for tangled" /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 16 </head> 17 <body class="flex items-center justify-center min-h-screen"> 18 <main class="max-w-md px-6 -mt-4"> ··· 42 invite code, desired username, and password in the next 43 page to complete your registration. 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 49 <span>join now</span> 50 </button> 51 </form> 52 <p class="text-sm text-gray-500"> 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 54 </p> 55 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+10 -10
appview/pipelines/pipelines.go
··· 9 "strings" 10 "time" 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" 22 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket"
··· 9 "strings" 10 "time" 11 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 23 "github.com/go-chi/chi/v5" 24 "github.com/gorilla/websocket"
+1 -1
appview/pipelines/router.go
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
-164
appview/posthog/notifier.go
··· 1 - package posthog_service 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - "github.com/posthog/posthog-go" 8 - "tangled.sh/tangled.sh/core/appview/db" 9 - "tangled.sh/tangled.sh/core/appview/notify" 10 - ) 11 - 12 - type posthogNotifier struct { 13 - client posthog.Client 14 - notify.BaseNotifier 15 - } 16 - 17 - func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 - return &posthogNotifier{ 19 - client, 20 - notify.BaseNotifier{}, 21 - } 22 - } 23 - 24 - var _ notify.Notifier = &posthogNotifier{} 25 - 26 - func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 - err := n.client.Enqueue(posthog.Capture{ 28 - DistinctId: repo.Did, 29 - Event: "new_repo", 30 - Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 - }) 32 - if err != nil { 33 - log.Println("failed to enqueue posthog event:", err) 34 - } 35 - } 36 - 37 - func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 - err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 - Event: "star", 41 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 - }) 43 - if err != nil { 44 - log.Println("failed to enqueue posthog event:", err) 45 - } 46 - } 47 - 48 - func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 - err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 - Event: "unstar", 52 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 - }) 54 - if err != nil { 55 - log.Println("failed to enqueue posthog event:", err) 56 - } 57 - } 58 - 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 - err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.Did, 62 - Event: "new_issue", 63 - Properties: posthog.Properties{ 64 - "repo_at": issue.RepoAt.String(), 65 - "issue_id": issue.IssueId, 66 - }, 67 - }) 68 - if err != nil { 69 - log.Println("failed to enqueue posthog event:", err) 70 - } 71 - } 72 - 73 - func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 - err := n.client.Enqueue(posthog.Capture{ 75 - DistinctId: pull.OwnerDid, 76 - Event: "new_pull", 77 - Properties: posthog.Properties{ 78 - "repo_at": pull.RepoAt, 79 - "pull_id": pull.PullId, 80 - }, 81 - }) 82 - if err != nil { 83 - log.Println("failed to enqueue posthog event:", err) 84 - } 85 - } 86 - 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 - err := n.client.Enqueue(posthog.Capture{ 89 - DistinctId: comment.OwnerDid, 90 - Event: "new_pull_comment", 91 - Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 - }, 95 - }) 96 - if err != nil { 97 - log.Println("failed to enqueue posthog event:", err) 98 - } 99 - } 100 - 101 - func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 - err := n.client.Enqueue(posthog.Capture{ 103 - DistinctId: follow.UserDid, 104 - Event: "follow", 105 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 - }) 107 - if err != nil { 108 - log.Println("failed to enqueue posthog event:", err) 109 - } 110 - } 111 - 112 - func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 - err := n.client.Enqueue(posthog.Capture{ 114 - DistinctId: follow.UserDid, 115 - Event: "unfollow", 116 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 - }) 118 - if err != nil { 119 - log.Println("failed to enqueue posthog event:", err) 120 - } 121 - } 122 - 123 - func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 - err := n.client.Enqueue(posthog.Capture{ 125 - DistinctId: profile.Did, 126 - Event: "edit_profile", 127 - }) 128 - if err != nil { 129 - log.Println("failed to enqueue posthog event:", err) 130 - } 131 - } 132 - 133 - func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 - err := n.client.Enqueue(posthog.Capture{ 135 - DistinctId: did, 136 - Event: "delete_string", 137 - Properties: posthog.Properties{"rkey": rkey}, 138 - }) 139 - if err != nil { 140 - log.Println("failed to enqueue posthog event:", err) 141 - } 142 - } 143 - 144 - func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) { 145 - err := n.client.Enqueue(posthog.Capture{ 146 - DistinctId: string.Did.String(), 147 - Event: "edit_string", 148 - Properties: posthog.Properties{"rkey": string.Rkey}, 149 - }) 150 - if err != nil { 151 - log.Println("failed to enqueue posthog event:", err) 152 - } 153 - } 154 - 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) { 156 - err := n.client.Enqueue(posthog.Capture{ 157 - DistinctId: string.Did.String(), 158 - Event: "create_string", 159 - Properties: posthog.Properties{"rkey": string.Rkey}, 160 - }) 161 - if err != nil { 162 - log.Println("failed to enqueue posthog event:", err) 163 - } 164 - }
···
+124 -75
appview/pulls/pulls.go
··· 12 "strings" 13 "time" 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" 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 30 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 75 return 76 } 77 78 - pull, ok := r.Context().Value("pull").(*db.Pull) 79 if !ok { 80 log.Println("failed to get pull") 81 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 83 } 84 85 // can be nil if this pull is not stacked 86 - stack, _ := r.Context().Value("stack").(db.Stack) 87 88 roundNumberStr := chi.URLParam(r, "round") 89 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 123 return 124 } 125 126 - pull, ok := r.Context().Value("pull").(*db.Pull) 127 if !ok { 128 log.Println("failed to get pull") 129 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 131 } 132 133 // 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 137 totalIdents := 1 138 for _, submission := range pull.Submissions { ··· 159 160 repoInfo := f.RepoInfo(user) 161 162 - m := make(map[string]db.Pipeline) 163 164 var shas []string 165 for _, s := range pull.Submissions { ··· 194 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 } 196 197 - userReactions := map[db.ReactionKind]bool{} 198 if user != nil { 199 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 200 } 201 202 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 203 LoggedInUser: user, 204 RepoInfo: repoInfo, ··· 209 ResubmitCheck: resubmitResult, 210 Pipelines: m, 211 212 - OrderedReactionKinds: db.OrderedReactionKinds, 213 Reactions: reactionCountMap, 214 UserReacted: userReactions, 215 }) 216 } 217 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 { 220 return types.MergeCheckResponse{} 221 } 222 ··· 282 return result 283 } 284 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 { 287 return pages.Unknown 288 } 289 ··· 356 diffOpts.Split = true 357 } 358 359 - pull, ok := r.Context().Value("pull").(*db.Pull) 360 if !ok { 361 log.Println("failed to get pull") 362 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 363 return 364 } 365 366 - stack, _ := r.Context().Value("stack").(db.Stack) 367 368 roundId := chi.URLParam(r, "round") 369 roundIdInt, err := strconv.Atoi(roundId) ··· 403 diffOpts.Split = true 404 } 405 406 - pull, ok := r.Context().Value("pull").(*db.Pull) 407 if !ok { 408 log.Println("failed to get pull") 409 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 451 } 452 453 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 454 - pull, ok := r.Context().Value("pull").(*db.Pull) 455 if !ok { 456 log.Println("failed to get pull") 457 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 474 user := s.oauth.GetUser(r) 475 params := r.URL.Query() 476 477 - state := db.PullOpen 478 switch params.Get("state") { 479 case "closed": 480 - state = db.PullClosed 481 case "merged": 482 - state = db.PullMerged 483 } 484 485 f, err := s.repoResolver.Resolve(r) ··· 500 } 501 502 for _, p := range pulls { 503 - var pullSourceRepo *db.Repo 504 if p.PullSource != nil { 505 if p.PullSource.RepoAt != nil { 506 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 515 } 516 517 // we want to group all stacked PRs into just one list 518 - stacks := make(map[string]db.Stack) 519 var shas []string 520 n := 0 521 for _, p := range pulls { ··· 551 log.Printf("failed to fetch pipeline statuses: %s", err) 552 // non-fatal 553 } 554 - m := make(map[string]db.Pipeline) 555 for _, p := range ps { 556 m[p.Sha] = p 557 } 558 559 s.pages.RepoPulls(w, pages.RepoPullsParams{ 560 LoggedInUser: s.oauth.GetUser(r), 561 RepoInfo: f.RepoInfo(user), 562 Pulls: pulls, 563 FilteringBy: state, 564 Stacks: stacks, 565 Pipelines: m, ··· 574 return 575 } 576 577 - pull, ok := r.Context().Value("pull").(*db.Pull) 578 if !ok { 579 log.Println("failed to get pull") 580 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 647 return 648 } 649 650 - comment := &db.PullComment{ 651 OwnerDid: user.Did, 652 RepoAt: f.RepoAt().String(), 653 PullId: pull.PullId, ··· 890 return 891 } 892 893 - pullSource := &db.PullSource{ 894 Branch: sourceBranch, 895 } 896 recordPullSource := &tangled.RepoPull_Source{ ··· 1000 forkAtUri := fork.RepoAt() 1001 forkAtUriStr := forkAtUri.String() 1002 1003 - pullSource := &db.PullSource{ 1004 Branch: sourceBranch, 1005 RepoAt: &forkAtUri, 1006 } ··· 1021 title, body, targetBranch string, 1022 patch string, 1023 sourceRev string, 1024 - pullSource *db.PullSource, 1025 recordPullSource *tangled.RepoPull_Source, 1026 isStacked bool, 1027 ) { ··· 1057 1058 // We've already checked earlier if it's diff-based and title is empty, 1059 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1060 - if title == "" { 1061 formatPatches, err := patchutil.ExtractPatches(patch) 1062 if err != nil { 1063 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1068 return 1069 } 1070 1071 - title = formatPatches[0].Title 1072 - body = formatPatches[0].Body 1073 } 1074 1075 rkey := tid.TID() 1076 - initialSubmission := db.PullSubmission{ 1077 Patch: patch, 1078 SourceRev: sourceRev, 1079 } 1080 - pull := &db.Pull{ 1081 Title: title, 1082 Body: body, 1083 TargetBranch: targetBranch, 1084 OwnerDid: user.Did, 1085 RepoAt: f.RepoAt(), 1086 Rkey: rkey, 1087 - Submissions: []*db.PullSubmission{ 1088 &initialSubmission, 1089 }, 1090 PullSource: pullSource, ··· 1143 targetBranch string, 1144 patch string, 1145 sourceRev string, 1146 - pullSource *db.PullSource, 1147 ) { 1148 // run some necessary checks for stacked-prs first 1149 ··· 1451 return 1452 } 1453 1454 - pull, ok := r.Context().Value("pull").(*db.Pull) 1455 if !ok { 1456 log.Println("failed to get pull") 1457 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1482 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1483 user := s.oauth.GetUser(r) 1484 1485 - pull, ok := r.Context().Value("pull").(*db.Pull) 1486 if !ok { 1487 log.Println("failed to get pull") 1488 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1509 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1510 user := s.oauth.GetUser(r) 1511 1512 - pull, ok := r.Context().Value("pull").(*db.Pull) 1513 if !ok { 1514 log.Println("failed to get pull") 1515 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1572 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1573 user := s.oauth.GetUser(r) 1574 1575 - pull, ok := r.Context().Value("pull").(*db.Pull) 1576 if !ok { 1577 log.Println("failed to get pull") 1578 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1665 } 1666 1667 // validate a resubmission against a pull request 1668 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1669 if patch == "" { 1670 return fmt.Errorf("Patch is empty.") 1671 } ··· 1686 r *http.Request, 1687 f *reporesolver.ResolvedRepo, 1688 user *oauth.User, 1689 - pull *db.Pull, 1690 patch string, 1691 sourceRev string, 1692 ) { ··· 1790 r *http.Request, 1791 f *reporesolver.ResolvedRepo, 1792 user *oauth.User, 1793 - pull *db.Pull, 1794 patch string, 1795 stackId string, 1796 ) { 1797 targetBranch := pull.TargetBranch 1798 1799 - origStack, _ := r.Context().Value("stack").(db.Stack) 1800 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1801 if err != nil { 1802 log.Println("failed to create resubmitted stack", err) ··· 1805 } 1806 1807 // 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) 1810 for _, p := range origStack { 1811 origById[p.ChangeId] = p 1812 } ··· 1819 // commits that got updated: corresponding pull is resubmitted & new round begins 1820 // 1821 // 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) 1824 unchanged := make(map[string]struct{}) 1825 updated := make(map[string]struct{}) 1826 ··· 1880 // deleted pulls are marked as deleted in the DB 1881 for _, p := range deletions { 1882 // do not do delete already merged PRs 1883 - if p.State == db.PullMerged { 1884 continue 1885 } 1886 ··· 1925 np, _ := newById[id] 1926 1927 // do not update already merged PRs 1928 - if op.State == db.PullMerged { 1929 continue 1930 } 1931 ··· 2046 return 2047 } 2048 2049 - pull, ok := r.Context().Value("pull").(*db.Pull) 2050 if !ok { 2051 log.Println("failed to get pull") 2052 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2053 return 2054 } 2055 2056 - var pullsToMerge db.Stack 2057 pullsToMerge = append(pullsToMerge, pull) 2058 if pull.IsStacked() { 2059 - stack, ok := r.Context().Value("stack").(db.Stack) 2060 if !ok { 2061 log.Println("failed to get stack") 2062 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 2146 return 2147 } 2148 2149 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2150 } 2151 ··· 2158 return 2159 } 2160 2161 - pull, ok := r.Context().Value("pull").(*db.Pull) 2162 if !ok { 2163 log.Println("failed to get pull") 2164 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2186 } 2187 defer tx.Rollback() 2188 2189 - var pullsToClose []*db.Pull 2190 pullsToClose = append(pullsToClose, pull) 2191 2192 // if this PR is stacked, then we want to close all PRs below this one on the stack 2193 if pull.IsStacked() { 2194 - stack := r.Context().Value("stack").(db.Stack) 2195 subStack := stack.StrictlyBelow(pull) 2196 pullsToClose = append(pullsToClose, subStack...) 2197 } ··· 2213 return 2214 } 2215 2216 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2217 } 2218 ··· 2226 return 2227 } 2228 2229 - pull, ok := r.Context().Value("pull").(*db.Pull) 2230 if !ok { 2231 log.Println("failed to get pull") 2232 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2254 } 2255 defer tx.Rollback() 2256 2257 - var pullsToReopen []*db.Pull 2258 pullsToReopen = append(pullsToReopen, pull) 2259 2260 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2261 if pull.IsStacked() { 2262 - stack := r.Context().Value("stack").(db.Stack) 2263 subStack := stack.StrictlyAbove(pull) 2264 pullsToReopen = append(pullsToReopen, subStack...) 2265 } ··· 2284 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2285 } 2286 2287 - func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2288 formatPatches, err := patchutil.ExtractPatches(patch) 2289 if err != nil { 2290 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2296 } 2297 2298 // the stack is identified by a UUID 2299 - var stack db.Stack 2300 parentChangeId := "" 2301 for _, fp := range formatPatches { 2302 // all patches must have a jj change-id ··· 2309 body := fp.Body 2310 rkey := tid.TID() 2311 2312 - initialSubmission := db.PullSubmission{ 2313 Patch: fp.Raw, 2314 SourceRev: fp.SHA, 2315 } 2316 - pull := db.Pull{ 2317 Title: title, 2318 Body: body, 2319 TargetBranch: targetBranch, 2320 OwnerDid: user.Did, 2321 RepoAt: f.RepoAt(), 2322 Rkey: rkey, 2323 - Submissions: []*db.PullSubmission{ 2324 &initialSubmission, 2325 }, 2326 PullSource: pullSource,
··· 12 "strings" 13 "time" 14 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/appview/config" 17 + "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/models" 19 + "tangled.org/core/appview/notify" 20 + "tangled.org/core/appview/oauth" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/pages/markup" 23 + "tangled.org/core/appview/reporesolver" 24 + "tangled.org/core/appview/xrpcclient" 25 + "tangled.org/core/idresolver" 26 + "tangled.org/core/patchutil" 27 + "tangled.org/core/tid" 28 + "tangled.org/core/types" 29 30 "github.com/bluekeyes/go-gitdiff/gitdiff" 31 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 76 return 77 } 78 79 + pull, ok := r.Context().Value("pull").(*models.Pull) 80 if !ok { 81 log.Println("failed to get pull") 82 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 84 } 85 86 // can be nil if this pull is not stacked 87 + stack, _ := r.Context().Value("stack").(models.Stack) 88 89 roundNumberStr := chi.URLParam(r, "round") 90 roundNumber, err := strconv.Atoi(roundNumberStr) ··· 124 return 125 } 126 127 + pull, ok := r.Context().Value("pull").(*models.Pull) 128 if !ok { 129 log.Println("failed to get pull") 130 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 132 } 133 134 // can be nil if this pull is not stacked 135 + stack, _ := r.Context().Value("stack").(models.Stack) 136 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 137 138 totalIdents := 1 139 for _, submission := range pull.Submissions { ··· 160 161 repoInfo := f.RepoInfo(user) 162 163 + m := make(map[string]models.Pipeline) 164 165 var shas []string 166 for _, s := range pull.Submissions { ··· 195 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 196 } 197 198 + userReactions := map[models.ReactionKind]bool{} 199 if user != nil { 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 } 202 203 + labelDefs, err := db.GetLabelDefinitions( 204 + s.db, 205 + db.FilterIn("at_uri", f.Repo.Labels), 206 + db.FilterContains("scope", tangled.RepoPullNSID), 207 + ) 208 + if err != nil { 209 + log.Println("failed to fetch labels", err) 210 + s.pages.Error503(w) 211 + return 212 + } 213 + 214 + defs := make(map[string]*models.LabelDefinition) 215 + for _, l := range labelDefs { 216 + defs[l.AtUri().String()] = &l 217 + } 218 + 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 220 LoggedInUser: user, 221 RepoInfo: repoInfo, ··· 226 ResubmitCheck: resubmitResult, 227 Pipelines: m, 228 229 + OrderedReactionKinds: models.OrderedReactionKinds, 230 Reactions: reactionCountMap, 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 234 }) 235 } 236 237 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 238 + if pull.State == models.PullMerged { 239 return types.MergeCheckResponse{} 240 } 241 ··· 301 return result 302 } 303 304 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 305 + if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 306 return pages.Unknown 307 } 308 ··· 375 diffOpts.Split = true 376 } 377 378 + pull, ok := r.Context().Value("pull").(*models.Pull) 379 if !ok { 380 log.Println("failed to get pull") 381 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 382 return 383 } 384 385 + stack, _ := r.Context().Value("stack").(models.Stack) 386 387 roundId := chi.URLParam(r, "round") 388 roundIdInt, err := strconv.Atoi(roundId) ··· 422 diffOpts.Split = true 423 } 424 425 + pull, ok := r.Context().Value("pull").(*models.Pull) 426 if !ok { 427 log.Println("failed to get pull") 428 s.pages.Notice(w, "pull-error", "Failed to get pull.") ··· 470 } 471 472 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 473 + pull, ok := r.Context().Value("pull").(*models.Pull) 474 if !ok { 475 log.Println("failed to get pull") 476 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 493 user := s.oauth.GetUser(r) 494 params := r.URL.Query() 495 496 + state := models.PullOpen 497 switch params.Get("state") { 498 case "closed": 499 + state = models.PullClosed 500 case "merged": 501 + state = models.PullMerged 502 } 503 504 f, err := s.repoResolver.Resolve(r) ··· 519 } 520 521 for _, p := range pulls { 522 + var pullSourceRepo *models.Repo 523 if p.PullSource != nil { 524 if p.PullSource.RepoAt != nil { 525 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) ··· 534 } 535 536 // we want to group all stacked PRs into just one list 537 + stacks := make(map[string]models.Stack) 538 var shas []string 539 n := 0 540 for _, p := range pulls { ··· 570 log.Printf("failed to fetch pipeline statuses: %s", err) 571 // non-fatal 572 } 573 + m := make(map[string]models.Pipeline) 574 for _, p := range ps { 575 m[p.Sha] = p 576 } 577 578 + labelDefs, err := db.GetLabelDefinitions( 579 + s.db, 580 + db.FilterIn("at_uri", f.Repo.Labels), 581 + db.FilterContains("scope", tangled.RepoPullNSID), 582 + ) 583 + if err != nil { 584 + log.Println("failed to fetch labels", err) 585 + s.pages.Error503(w) 586 + return 587 + } 588 + 589 + defs := make(map[string]*models.LabelDefinition) 590 + for _, l := range labelDefs { 591 + defs[l.AtUri().String()] = &l 592 + } 593 + 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 595 LoggedInUser: s.oauth.GetUser(r), 596 RepoInfo: f.RepoInfo(user), 597 Pulls: pulls, 598 + LabelDefs: defs, 599 FilteringBy: state, 600 Stacks: stacks, 601 Pipelines: m, ··· 610 return 611 } 612 613 + pull, ok := r.Context().Value("pull").(*models.Pull) 614 if !ok { 615 log.Println("failed to get pull") 616 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 683 return 684 } 685 686 + comment := &models.PullComment{ 687 OwnerDid: user.Did, 688 RepoAt: f.RepoAt().String(), 689 PullId: pull.PullId, ··· 926 return 927 } 928 929 + pullSource := &models.PullSource{ 930 Branch: sourceBranch, 931 } 932 recordPullSource := &tangled.RepoPull_Source{ ··· 1036 forkAtUri := fork.RepoAt() 1037 forkAtUriStr := forkAtUri.String() 1038 1039 + pullSource := &models.PullSource{ 1040 Branch: sourceBranch, 1041 RepoAt: &forkAtUri, 1042 } ··· 1057 title, body, targetBranch string, 1058 patch string, 1059 sourceRev string, 1060 + pullSource *models.PullSource, 1061 recordPullSource *tangled.RepoPull_Source, 1062 isStacked bool, 1063 ) { ··· 1093 1094 // We've already checked earlier if it's diff-based and title is empty, 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1096 + if title == "" || body == "" { 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1098 if err != nil { 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1104 return 1105 } 1106 1107 + if title == "" { 1108 + title = formatPatches[0].Title 1109 + } 1110 + if body == "" { 1111 + body = formatPatches[0].Body 1112 + } 1113 } 1114 1115 rkey := tid.TID() 1116 + initialSubmission := models.PullSubmission{ 1117 Patch: patch, 1118 SourceRev: sourceRev, 1119 } 1120 + pull := &models.Pull{ 1121 Title: title, 1122 Body: body, 1123 TargetBranch: targetBranch, 1124 OwnerDid: user.Did, 1125 RepoAt: f.RepoAt(), 1126 Rkey: rkey, 1127 + Submissions: []*models.PullSubmission{ 1128 &initialSubmission, 1129 }, 1130 PullSource: pullSource, ··· 1183 targetBranch string, 1184 patch string, 1185 sourceRev string, 1186 + pullSource *models.PullSource, 1187 ) { 1188 // run some necessary checks for stacked-prs first 1189 ··· 1491 return 1492 } 1493 1494 + pull, ok := r.Context().Value("pull").(*models.Pull) 1495 if !ok { 1496 log.Println("failed to get pull") 1497 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1522 func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1523 user := s.oauth.GetUser(r) 1524 1525 + pull, ok := r.Context().Value("pull").(*models.Pull) 1526 if !ok { 1527 log.Println("failed to get pull") 1528 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 1549 func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1550 user := s.oauth.GetUser(r) 1551 1552 + pull, ok := r.Context().Value("pull").(*models.Pull) 1553 if !ok { 1554 log.Println("failed to get pull") 1555 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1612 func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1613 user := s.oauth.GetUser(r) 1614 1615 + pull, ok := r.Context().Value("pull").(*models.Pull) 1616 if !ok { 1617 log.Println("failed to get pull") 1618 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") ··· 1705 } 1706 1707 // validate a resubmission against a pull request 1708 + func validateResubmittedPatch(pull *models.Pull, patch string) error { 1709 if patch == "" { 1710 return fmt.Errorf("Patch is empty.") 1711 } ··· 1726 r *http.Request, 1727 f *reporesolver.ResolvedRepo, 1728 user *oauth.User, 1729 + pull *models.Pull, 1730 patch string, 1731 sourceRev string, 1732 ) { ··· 1830 r *http.Request, 1831 f *reporesolver.ResolvedRepo, 1832 user *oauth.User, 1833 + pull *models.Pull, 1834 patch string, 1835 stackId string, 1836 ) { 1837 targetBranch := pull.TargetBranch 1838 1839 + origStack, _ := r.Context().Value("stack").(models.Stack) 1840 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1841 if err != nil { 1842 log.Println("failed to create resubmitted stack", err) ··· 1845 } 1846 1847 // find the diff between the stacks, first, map them by changeId 1848 + origById := make(map[string]*models.Pull) 1849 + newById := make(map[string]*models.Pull) 1850 for _, p := range origStack { 1851 origById[p.ChangeId] = p 1852 } ··· 1859 // commits that got updated: corresponding pull is resubmitted & new round begins 1860 // 1861 // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1862 + additions := make(map[string]*models.Pull) 1863 + deletions := make(map[string]*models.Pull) 1864 unchanged := make(map[string]struct{}) 1865 updated := make(map[string]struct{}) 1866 ··· 1920 // deleted pulls are marked as deleted in the DB 1921 for _, p := range deletions { 1922 // do not do delete already merged PRs 1923 + if p.State == models.PullMerged { 1924 continue 1925 } 1926 ··· 1965 np, _ := newById[id] 1966 1967 // do not update already merged PRs 1968 + if op.State == models.PullMerged { 1969 continue 1970 } 1971 ··· 2086 return 2087 } 2088 2089 + pull, ok := r.Context().Value("pull").(*models.Pull) 2090 if !ok { 2091 log.Println("failed to get pull") 2092 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2093 return 2094 } 2095 2096 + var pullsToMerge models.Stack 2097 pullsToMerge = append(pullsToMerge, pull) 2098 if pull.IsStacked() { 2099 + stack, ok := r.Context().Value("stack").(models.Stack) 2100 if !ok { 2101 log.Println("failed to get stack") 2102 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") ··· 2186 return 2187 } 2188 2189 + // notify about the pull merge 2190 + for _, p := range pullsToMerge { 2191 + s.notifier.NewPullMerged(r.Context(), p) 2192 + } 2193 + 2194 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2195 } 2196 ··· 2203 return 2204 } 2205 2206 + pull, ok := r.Context().Value("pull").(*models.Pull) 2207 if !ok { 2208 log.Println("failed to get pull") 2209 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2231 } 2232 defer tx.Rollback() 2233 2234 + var pullsToClose []*models.Pull 2235 pullsToClose = append(pullsToClose, pull) 2236 2237 // if this PR is stacked, then we want to close all PRs below this one on the stack 2238 if pull.IsStacked() { 2239 + stack := r.Context().Value("stack").(models.Stack) 2240 subStack := stack.StrictlyBelow(pull) 2241 pullsToClose = append(pullsToClose, subStack...) 2242 } ··· 2258 return 2259 } 2260 2261 + for _, p := range pullsToClose { 2262 + s.notifier.NewPullClosed(r.Context(), p) 2263 + } 2264 + 2265 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2266 } 2267 ··· 2275 return 2276 } 2277 2278 + pull, ok := r.Context().Value("pull").(*models.Pull) 2279 if !ok { 2280 log.Println("failed to get pull") 2281 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") ··· 2303 } 2304 defer tx.Rollback() 2305 2306 + var pullsToReopen []*models.Pull 2307 pullsToReopen = append(pullsToReopen, pull) 2308 2309 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2310 if pull.IsStacked() { 2311 + stack := r.Context().Value("stack").(models.Stack) 2312 subStack := stack.StrictlyAbove(pull) 2313 pullsToReopen = append(pullsToReopen, subStack...) 2314 } ··· 2333 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2334 } 2335 2336 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2337 formatPatches, err := patchutil.ExtractPatches(patch) 2338 if err != nil { 2339 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2345 } 2346 2347 // the stack is identified by a UUID 2348 + var stack models.Stack 2349 parentChangeId := "" 2350 for _, fp := range formatPatches { 2351 // all patches must have a jj change-id ··· 2358 body := fp.Body 2359 rkey := tid.TID() 2360 2361 + initialSubmission := models.PullSubmission{ 2362 Patch: fp.Raw, 2363 SourceRev: fp.SHA, 2364 } 2365 + pull := models.Pull{ 2366 Title: title, 2367 Body: body, 2368 TargetBranch: targetBranch, 2369 OwnerDid: user.Did, 2370 RepoAt: f.RepoAt(), 2371 Rkey: rkey, 2372 + Submissions: []*models.PullSubmission{ 2373 &initialSubmission, 2374 }, 2375 PullSource: pullSource,
+1 -1
appview/pulls/router.go
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (s *Pulls) Router(mw *middleware.Middleware) http.Handler {
+49 -22
appview/repo/artifact.go
··· 4 "context" 5 "encoding/json" 6 "fmt" 7 "log" 8 "net/http" 9 "net/url" ··· 16 "github.com/go-chi/chi/v5" 17 "github.com/go-git/go-git/v5/plumbing" 18 "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 ) 27 28 // TODO: proper statuses here on early exit ··· 100 } 101 defer tx.Rollback() 102 103 - artifact := db.Artifact{ 104 Did: user.Did, 105 Rkey: rkey, 106 RepoAt: f.RepoAt(), ··· 133 }) 134 } 135 136 - // TODO: proper statuses here on early exit 137 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 138 - tagParam := chi.URLParam(r, "tag") 139 - filename := chi.URLParam(r, "file") 140 f, err := rp.repoResolver.Resolve(r) 141 if err != nil { 142 log.Println("failed to get repo and knot", err) 143 return 144 } 145 146 tag, err := rp.resolveTag(r.Context(), f, tagParam) 147 if err != nil { 148 log.Println("failed to resolve tag", err) ··· 150 return 151 } 152 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 artifacts, err := db.GetArtifact( 160 rp.db, 161 db.FilterEq("repo_at", f.RepoAt()), ··· 164 ) 165 if err != nil { 166 log.Println("failed to get artifacts", err) 167 return 168 } 169 if len(artifacts) != 1 { 170 - log.Printf("too many or too little artifacts found") 171 return 172 } 173 174 artifact := artifacts[0] 175 176 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 177 if err != nil { 178 - log.Println("failed to get blob from pds", err) 179 return 180 } 181 182 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 183 - w.Write(getBlobResp) 184 } 185 186 // TODO: proper statuses here on early exit
··· 4 "context" 5 "encoding/json" 6 "fmt" 7 + "io" 8 "log" 9 "net/http" 10 "net/url" ··· 17 "github.com/go-chi/chi/v5" 18 "github.com/go-git/go-git/v5/plumbing" 19 "github.com/ipfs/go-cid" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/models" 23 + "tangled.org/core/appview/pages" 24 + "tangled.org/core/appview/reporesolver" 25 + "tangled.org/core/appview/xrpcclient" 26 + "tangled.org/core/tid" 27 + "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 102 } 103 defer tx.Rollback() 104 105 + artifact := models.Artifact{ 106 Did: user.Did, 107 Rkey: rkey, 108 RepoAt: f.RepoAt(), ··· 135 }) 136 } 137 138 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 139 f, err := rp.repoResolver.Resolve(r) 140 if err != nil { 141 log.Println("failed to get repo and knot", err) 142 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 143 return 144 } 145 146 + tagParam := chi.URLParam(r, "tag") 147 + filename := chi.URLParam(r, "file") 148 + 149 tag, err := rp.resolveTag(r.Context(), f, tagParam) 150 if err != nil { 151 log.Println("failed to resolve tag", err) ··· 153 return 154 } 155 156 artifacts, err := db.GetArtifact( 157 rp.db, 158 db.FilterEq("repo_at", f.RepoAt()), ··· 161 ) 162 if err != nil { 163 log.Println("failed to get artifacts", err) 164 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 165 return 166 } 167 + 168 if len(artifacts) != 1 { 169 + log.Printf("too many or too few artifacts found") 170 + http.Error(w, "artifact not found", http.StatusNotFound) 171 return 172 } 173 174 artifact := artifacts[0] 175 176 + ownerPds := f.OwnerId.PDSEndpoint() 177 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 178 + q := url.Query() 179 + q.Set("cid", artifact.BlobCid.String()) 180 + q.Set("did", artifact.Did) 181 + url.RawQuery = q.Encode() 182 + 183 + req, err := http.NewRequest(http.MethodGet, url.String(), nil) 184 if err != nil { 185 + log.Println("failed to create request", err) 186 + http.Error(w, "failed to create request", http.StatusInternalServerError) 187 + return 188 + } 189 + req.Header.Set("Content-Type", "application/json") 190 + 191 + resp, err := http.DefaultClient.Do(req) 192 + if err != nil { 193 + log.Println("failed to make request", err) 194 + http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 195 return 196 } 197 + defer resp.Body.Close() 198 199 + // copy status code and relevant headers from upstream response 200 + w.WriteHeader(resp.StatusCode) 201 + for key, values := range resp.Header { 202 + for _, v := range values { 203 + w.Header().Add(key, v) 204 + } 205 + } 206 + 207 + // stream the body directly to the client 208 + if _, err := io.Copy(w, resp.Body); err != nil { 209 + log.Println("error streaming response to client:", err) 210 + } 211 } 212 213 // TODO: proper statuses here on early exit
+10 -9
appview/repo/feed.go
··· 8 "slices" 9 "time" 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" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 "github.com/gorilla/feeds" ··· 70 return feed, nil 71 } 72 73 - func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 75 if err != nil { 76 return nil, err ··· 108 return items, nil 109 } 110 111 - func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 if err != nil { 114 return nil, err ··· 128 }, nil 129 } 130 131 - func (rp *Repo) getPullState(pull *db.Pull) string { 132 - if pull.State == db.PullOpen { 133 return "opened" 134 } 135 return pull.State.String() 136 } 137 138 - func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 140 141 - if pull.State == db.PullMerged { 142 return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 143 } 144
··· 8 "slices" 9 "time" 10 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pagination" 14 + "tangled.org/core/appview/reporesolver" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 "github.com/gorilla/feeds" ··· 71 return feed, nil 72 } 73 74 + func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 75 owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 76 if err != nil { 77 return nil, err ··· 109 return items, nil 110 } 111 112 + func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 113 owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 114 if err != nil { 115 return nil, err ··· 129 }, nil 130 } 131 132 + func (rp *Repo) getPullState(pull *models.Pull) string { 133 + if pull.State == models.PullOpen { 134 return "opened" 135 } 136 return pull.State.String() 137 } 138 139 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string { 140 base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 141 142 + if pull.State == models.PullMerged { 143 return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 144 } 145
+26 -30
appview/repo/index.go
··· 17 18 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 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" 28 29 "github.com/go-chi/chi/v5" 30 "github.com/go-enry/go-enry/v2" ··· 191 } 192 193 for _, lang := range ls.Languages { 194 - langs = append(langs, db.RepoLanguage{ 195 RepoAt: f.RepoAt(), 196 Ref: currentRef, 197 IsDefaultRef: isDefaultRef, ··· 200 }) 201 } 202 203 // update appview's cache 204 - err = db.InsertRepoLanguages(rp.db, langs) 205 if err != nil { 206 // non-fatal 207 log.Println("failed to cache lang results", err) 208 } 209 } 210 211 var total int64 ··· 327 } 328 }() 329 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 wg.Wait() 351 352 if errs != nil { ··· 373 } 374 files = append(files, niceFile) 375 } 376 } 377 378 result := &types.RepoIndexResponse{
··· 17 18 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 "github.com/go-git/go-git/v5/plumbing" 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 29 "github.com/go-chi/chi/v5" 30 "github.com/go-enry/go-enry/v2" ··· 191 } 192 193 for _, lang := range ls.Languages { 194 + langs = append(langs, models.RepoLanguage{ 195 RepoAt: f.RepoAt(), 196 Ref: currentRef, 197 IsDefaultRef: isDefaultRef, ··· 200 }) 201 } 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 209 // update appview's cache 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 211 if err != nil { 212 // non-fatal 213 log.Println("failed to cache lang results", err) 214 } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 219 + } 220 } 221 222 var total int64 ··· 338 } 339 }() 340 341 wg.Wait() 342 343 if errs != nil { ··· 364 } 365 files = append(files, niceFile) 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 372 } 373 374 result := &types.RepoIndexResponse{
+110 -83
appview/repo/repo.go
··· 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 - "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 securejoin "github.com/cyphar/filepath-securejoin" 43 "github.com/go-chi/chi/v5" ··· 399 log.Println(err) 400 // non-fatal 401 } 402 - var pipeline *db.Pipeline 403 if p, ok := pipelines[result.Diff.Commit.This]; ok { 404 pipeline = &p 405 } ··· 448 return 449 } 450 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 // Convert XRPC response to internal types.RepoTreeResponse 474 files := make([]types.NiceTree, len(xrpcResp.Files)) 475 for i, xrpcFile := range xrpcResp.Files { ··· 505 if xrpcResp.Dotdot != nil { 506 result.DotDot = *xrpcResp.Dotdot 507 } 508 509 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 510 // so we can safely redirect to the "parent" (which is the same file). ··· 531 BreadCrumbs: breadcrumbs, 532 TreePath: treePath, 533 RepoInfo: f.RepoInfo(user), 534 - Readme: readmeContent, 535 - ReadmeFileName: readmeFileName, 536 RepoTreeResponse: result, 537 }) 538 } ··· 575 } 576 577 // convert artifacts to map for easy UI building 578 - artifactMap := make(map[plumbing.Hash][]db.Artifact) 579 for _, a := range artifacts { 580 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 581 } 582 583 - var danglingArtifacts []db.Artifact 584 for _, a := range artifacts { 585 found := false 586 for _, t := range result.Tags { ··· 1004 concreteType = "null" 1005 } 1006 1007 - format := db.ValueTypeFormatAny 1008 if valueFormat == "did" { 1009 - format = db.ValueTypeFormatDid 1010 } 1011 1012 - valueType := db.ValueType{ 1013 - Type: db.ConcreteType(concreteType), 1014 Format: format, 1015 Enum: variants, 1016 } 1017 1018 - label := db.LabelDefinition{ 1019 Did: user.Did, 1020 Rkey: tid.TID(), 1021 Name: name, ··· 1109 return 1110 } 1111 1112 - err = db.SubscribeLabel(tx, &db.RepoLabel{ 1113 RepoAt: f.RepoAt(), 1114 LabelAt: label.AtUri(), 1115 }) ··· 1247 return 1248 } 1249 1250 errorId := "default-label-operation" 1251 fail := func(msg string, err error) { 1252 l.Error(msg, "err", err) 1253 rp.pages.Notice(w, errorId, msg) 1254 } 1255 1256 - labelAt := r.FormValue("label") 1257 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1258 if err != nil { 1259 fail("Failed to subscribe to label.", err) 1260 return 1261 } 1262 1263 newRepo := f.Repo 1264 - newRepo.Labels = append(newRepo.Labels, labelAt) 1265 repoRecord := newRepo.AsRecord() 1266 1267 client, err := rp.oauth.AuthorizedClient(r) ··· 1285 }, 1286 }) 1287 1288 - err = db.SubscribeLabel(rp.db, &db.RepoLabel{ 1289 - RepoAt: f.RepoAt(), 1290 - LabelAt: syntax.ATURI(labelAt), 1291 - }) 1292 if err != nil { 1293 fail("Failed to subscribe to label.", err) 1294 return 1295 } ··· 1310 return 1311 } 1312 1313 errorId := "default-label-operation" 1314 fail := func(msg string, err error) { 1315 l.Error(msg, "err", err) 1316 rp.pages.Notice(w, errorId, msg) 1317 } 1318 1319 - labelAt := r.FormValue("label") 1320 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1321 if err != nil { 1322 fail("Failed to unsubscribe to label.", err) 1323 return ··· 1327 newRepo := f.Repo 1328 var updated []string 1329 for _, l := range newRepo.Labels { 1330 - if l != labelAt { 1331 updated = append(updated, l) 1332 } 1333 } ··· 1358 err = db.UnsubscribeLabel( 1359 rp.db, 1360 db.FilterEq("repo_at", f.RepoAt()), 1361 - db.FilterEq("label_at", labelAt), 1362 ) 1363 if err != nil { 1364 fail("Failed to unsubscribe label.", err) ··· 1395 return 1396 } 1397 1398 - defs := make(map[string]*db.LabelDefinition) 1399 for _, l := range labelDefs { 1400 defs[l.AtUri().String()] = &l 1401 } ··· 1443 return 1444 } 1445 1446 - defs := make(map[string]*db.LabelDefinition) 1447 for _, l := range labelDefs { 1448 defs[l.AtUri().String()] = &l 1449 } ··· 1566 return 1567 } 1568 1569 - err = db.AddCollaborator(tx, db.Collaborator{ 1570 Did: syntax.DID(currentUser.Did), 1571 Rkey: rkey, 1572 SubjectDid: collaboratorIdent.DID, ··· 1894 return 1895 } 1896 1897 - defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs())) 1898 if err != nil { 1899 log.Println("failed to fetch labels", err) 1900 rp.pages.Error503(w) ··· 1926 subscribedLabels[l] = struct{}{} 1927 } 1928 1929 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", 1938 }) 1939 } 1940 ··· 2107 } 2108 2109 // choose a name for a fork 2110 - forkName := f.Name 2111 // this check is *only* to see if the forked repo name already exists 2112 // in the user's account. 2113 existingRepo, err := db.GetRepo( 2114 rp.db, 2115 db.FilterEq("did", user.Did), 2116 - db.FilterEq("name", f.Name), 2117 ) 2118 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 { 2122 log.Println("error fetching existing repo from db", "err", err) 2123 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2124 return 2125 } 2126 } else if existingRepo != nil { 2127 - // repo with this name already exists, append random string 2128 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2129 } 2130 l = l.With("forkName", forkName) 2131 ··· 2141 2142 // create an atproto record for this fork 2143 rkey := tid.TID() 2144 - repo := &db.Repo{ 2145 Did: user.Did, 2146 Name: forkName, 2147 Knot: targetKnot, 2148 Rkey: rkey, 2149 Source: sourceAt, 2150 - Description: existingRepo.Description, 2151 Created: time.Now(), 2152 } 2153 record := repo.AsRecord() 2154
··· 20 comatproto "github.com/bluesky-social/indigo/api/atproto" 21 lexutil "github.com/bluesky-social/indigo/lex/util" 22 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 + "tangled.org/core/api/tangled" 24 + "tangled.org/core/appview/commitverify" 25 + "tangled.org/core/appview/config" 26 + "tangled.org/core/appview/db" 27 + "tangled.org/core/appview/models" 28 + "tangled.org/core/appview/notify" 29 + "tangled.org/core/appview/oauth" 30 + "tangled.org/core/appview/pages" 31 + "tangled.org/core/appview/pages/markup" 32 + "tangled.org/core/appview/reporesolver" 33 + "tangled.org/core/appview/validator" 34 + xrpcclient "tangled.org/core/appview/xrpcclient" 35 + "tangled.org/core/eventconsumer" 36 + "tangled.org/core/idresolver" 37 + "tangled.org/core/patchutil" 38 + "tangled.org/core/rbac" 39 + "tangled.org/core/tid" 40 + "tangled.org/core/types" 41 + "tangled.org/core/xrpc/serviceauth" 42 43 securejoin "github.com/cyphar/filepath-securejoin" 44 "github.com/go-chi/chi/v5" ··· 400 log.Println(err) 401 // non-fatal 402 } 403 + var pipeline *models.Pipeline 404 if p, ok := pipelines[result.Diff.Commit.This]; ok { 405 pipeline = &p 406 } ··· 449 return 450 } 451 452 // Convert XRPC response to internal types.RepoTreeResponse 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 454 for i, xrpcFile := range xrpcResp.Files { ··· 484 if xrpcResp.Dotdot != nil { 485 result.DotDot = *xrpcResp.Dotdot 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 491 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 493 // so we can safely redirect to the "parent" (which is the same file). ··· 514 BreadCrumbs: breadcrumbs, 515 TreePath: treePath, 516 RepoInfo: f.RepoInfo(user), 517 RepoTreeResponse: result, 518 }) 519 } ··· 556 } 557 558 // convert artifacts to map for easy UI building 559 + artifactMap := make(map[plumbing.Hash][]models.Artifact) 560 for _, a := range artifacts { 561 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 562 } 563 564 + var danglingArtifacts []models.Artifact 565 for _, a := range artifacts { 566 found := false 567 for _, t := range result.Tags { ··· 985 concreteType = "null" 986 } 987 988 + format := models.ValueTypeFormatAny 989 if valueFormat == "did" { 990 + format = models.ValueTypeFormatDid 991 } 992 993 + valueType := models.ValueType{ 994 + Type: models.ConcreteType(concreteType), 995 Format: format, 996 Enum: variants, 997 } 998 999 + label := models.LabelDefinition{ 1000 Did: user.Did, 1001 Rkey: tid.TID(), 1002 Name: name, ··· 1090 return 1091 } 1092 1093 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1094 RepoAt: f.RepoAt(), 1095 LabelAt: label.AtUri(), 1096 }) ··· 1228 return 1229 } 1230 1231 + if err := r.ParseForm(); err != nil { 1232 + l.Error("invalid form", "err", err) 1233 + return 1234 + } 1235 + 1236 errorId := "default-label-operation" 1237 fail := func(msg string, err error) { 1238 l.Error(msg, "err", err) 1239 rp.pages.Notice(w, errorId, msg) 1240 } 1241 1242 + labelAts := r.Form["label"] 1243 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1244 if err != nil { 1245 fail("Failed to subscribe to label.", err) 1246 return 1247 } 1248 1249 newRepo := f.Repo 1250 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1251 + 1252 + // dedup 1253 + slices.Sort(newRepo.Labels) 1254 + newRepo.Labels = slices.Compact(newRepo.Labels) 1255 + 1256 repoRecord := newRepo.AsRecord() 1257 1258 client, err := rp.oauth.AuthorizedClient(r) ··· 1276 }, 1277 }) 1278 1279 + tx, err := rp.db.Begin() 1280 if err != nil { 1281 + fail("Failed to subscribe to label.", err) 1282 + return 1283 + } 1284 + defer tx.Rollback() 1285 + 1286 + for _, l := range labelAts { 1287 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1288 + RepoAt: f.RepoAt(), 1289 + LabelAt: syntax.ATURI(l), 1290 + }) 1291 + if err != nil { 1292 + fail("Failed to subscribe to label.", err) 1293 + return 1294 + } 1295 + } 1296 + 1297 + if err := tx.Commit(); err != nil { 1298 fail("Failed to subscribe to label.", err) 1299 return 1300 } ··· 1315 return 1316 } 1317 1318 + if err := r.ParseForm(); err != nil { 1319 + l.Error("invalid form", "err", err) 1320 + return 1321 + } 1322 + 1323 errorId := "default-label-operation" 1324 fail := func(msg string, err error) { 1325 l.Error(msg, "err", err) 1326 rp.pages.Notice(w, errorId, msg) 1327 } 1328 1329 + labelAts := r.Form["label"] 1330 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1331 if err != nil { 1332 fail("Failed to unsubscribe to label.", err) 1333 return ··· 1337 newRepo := f.Repo 1338 var updated []string 1339 for _, l := range newRepo.Labels { 1340 + if !slices.Contains(labelAts, l) { 1341 updated = append(updated, l) 1342 } 1343 } ··· 1368 err = db.UnsubscribeLabel( 1369 rp.db, 1370 db.FilterEq("repo_at", f.RepoAt()), 1371 + db.FilterIn("label_at", labelAts), 1372 ) 1373 if err != nil { 1374 fail("Failed to unsubscribe label.", err) ··· 1405 return 1406 } 1407 1408 + defs := make(map[string]*models.LabelDefinition) 1409 for _, l := range labelDefs { 1410 defs[l.AtUri().String()] = &l 1411 } ··· 1453 return 1454 } 1455 1456 + defs := make(map[string]*models.LabelDefinition) 1457 for _, l := range labelDefs { 1458 defs[l.AtUri().String()] = &l 1459 } ··· 1576 return 1577 } 1578 1579 + err = db.AddCollaborator(tx, models.Collaborator{ 1580 Did: syntax.DID(currentUser.Did), 1581 Rkey: rkey, 1582 SubjectDid: collaboratorIdent.DID, ··· 1904 return 1905 } 1906 1907 + defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs())) 1908 if err != nil { 1909 log.Println("failed to fetch labels", err) 1910 rp.pages.Error503(w) ··· 1936 subscribedLabels[l] = struct{}{} 1937 } 1938 1939 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1940 + // if all default labels are subbed, show the "unsubscribe all" button 1941 + shouldSubscribeAll := false 1942 + for _, dl := range defaultLabels { 1943 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1944 + // one of the default labels is not subscribed to 1945 + shouldSubscribeAll = true 1946 + break 1947 + } 1948 + } 1949 + 1950 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1951 + LoggedInUser: user, 1952 + RepoInfo: f.RepoInfo(user), 1953 + Branches: result.Branches, 1954 + Labels: labels, 1955 + DefaultLabels: defaultLabels, 1956 + SubscribedLabels: subscribedLabels, 1957 + ShouldSubscribeAll: shouldSubscribeAll, 1958 + Tabs: settingsTabs, 1959 + Tab: "general", 1960 }) 1961 } 1962 ··· 2129 } 2130 2131 // choose a name for a fork 2132 + forkName := r.FormValue("repo_name") 2133 + if forkName == "" { 2134 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2135 + return 2136 + } 2137 + 2138 // this check is *only* to see if the forked repo name already exists 2139 // in the user's account. 2140 existingRepo, err := db.GetRepo( 2141 rp.db, 2142 db.FilterEq("did", user.Did), 2143 + db.FilterEq("name", forkName), 2144 ) 2145 if err != nil { 2146 + if !errors.Is(err, sql.ErrNoRows) { 2147 log.Println("error fetching existing repo from db", "err", err) 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2149 return 2150 } 2151 } else if existingRepo != nil { 2152 + // repo with this name already exists 2153 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2154 + return 2155 } 2156 l = l.With("forkName", forkName) 2157 ··· 2167 2168 // create an atproto record for this fork 2169 rkey := tid.TID() 2170 + repo := &models.Repo{ 2171 Did: user.Did, 2172 Name: forkName, 2173 Knot: targetKnot, 2174 Rkey: rkey, 2175 Source: sourceAt, 2176 + Description: f.Repo.Description, 2177 Created: time.Now(), 2178 + Labels: models.DefaultLabelDefs(), 2179 } 2180 record := repo.AsRecord() 2181
+6 -5
appview/repo/repo_util.go
··· 9 "sort" 10 "strings" 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" 15 16 "github.com/go-git/go-git/v5/plumbing/object" 17 ) ··· 143 d *db.DB, 144 repoInfo repoinfo.RepoInfo, 145 shas []string, 146 - ) (map[string]db.Pipeline, error) { 147 - m := make(map[string]db.Pipeline) 148 149 if len(shas) == 0 { 150 return m, nil
··· 9 "sort" 10 "strings" 11 12 + "tangled.org/core/appview/db" 13 + "tangled.org/core/appview/models" 14 + "tangled.org/core/appview/pages/repoinfo" 15 + "tangled.org/core/types" 16 17 "github.com/go-git/go-git/v5/plumbing/object" 18 ) ··· 144 d *db.DB, 145 repoInfo repoinfo.RepoInfo, 146 shas []string, 147 + ) (map[string]models.Pipeline, error) { 148 + m := make(map[string]models.Pipeline) 149 150 if len(shas) == 0 { 151 return m, nil
+3 -4
appview/repo/router.go
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 - "tangled.sh/tangled.sh/core/appview/middleware" 8 ) 9 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { ··· 21 r.Route("/tags", func(r chi.Router) { 22 r.Get("/", rp.RepoTags) 23 r.Route("/{tag}", func(r chi.Router) { 24 - r.Use(middleware.AuthMiddleware(rp.oauth)) 25 - // require auth to download for now 26 r.Get("/download/{file}", rp.DownloadArtifact) 27 28 // require repo:push to upload or delete artifacts ··· 30 // additionally: only the uploader can truly delete an artifact 31 // (record+blob will live on their pds) 32 r.Group(func(r chi.Router) { 33 - r.With(mw.RepoPermissionMiddleware("repo:push")) 34 r.Post("/upload", rp.AttachArtifact) 35 r.Delete("/{file}", rp.DeleteArtifact) 36 })
··· 4 "net/http" 5 6 "github.com/go-chi/chi/v5" 7 + "tangled.org/core/appview/middleware" 8 ) 9 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { ··· 21 r.Route("/tags", func(r chi.Router) { 22 r.Get("/", rp.RepoTags) 23 r.Route("/{tag}", func(r chi.Router) { 24 r.Get("/download/{file}", rp.DownloadArtifact) 25 26 // require repo:push to upload or delete artifacts ··· 28 // additionally: only the uploader can truly delete an artifact 29 // (record+blob will live on their pds) 30 r.Group(func(r chi.Router) { 31 + r.Use(middleware.AuthMiddleware(rp.oauth)) 32 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 33 r.Post("/upload", rp.AttachArtifact) 34 r.Delete("/{file}", rp.DeleteArtifact) 35 })
+13 -12
appview/reporesolver/resolver.go
··· 14 "github.com/bluesky-social/indigo/atproto/identity" 15 securejoin "github.com/cyphar/filepath-securejoin" 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" 24 ) 25 26 type ResolvedRepo struct { 27 - db.Repo 28 OwnerId identity.Identity 29 CurrentDir string 30 Ref string ··· 44 } 45 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 47 - repo, ok := r.Context().Value("repo").(*db.Repo) 48 if !ok { 49 log.Println("malformed middleware: `repo` not exist in context") 50 return nil, fmt.Errorf("malformed middleware") ··· 162 log.Println("failed to get repo source for ", repoAt, err) 163 } 164 165 - var sourceRepo *db.Repo 166 if source != "" { 167 sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 168 if err != nil { ··· 191 Knot: knot, 192 Spindle: f.Spindle, 193 Roles: f.RolesInRepo(user), 194 - Stats: db.RepoStats{ 195 StarCount: starCount, 196 IssueCount: issueCount, 197 PullCount: pullCount, ··· 211 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 212 if u != nil { 213 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 214 - return repoinfo.RolesInRepo{r} 215 } else { 216 return repoinfo.RolesInRepo{} 217 }
··· 14 "github.com/bluesky-social/indigo/atproto/identity" 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" 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" 25 ) 26 27 type ResolvedRepo struct { 28 + models.Repo 29 OwnerId identity.Identity 30 CurrentDir string 31 Ref string ··· 45 } 46 47 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 48 + repo, ok := r.Context().Value("repo").(*models.Repo) 49 if !ok { 50 log.Println("malformed middleware: `repo` not exist in context") 51 return nil, fmt.Errorf("malformed middleware") ··· 163 log.Println("failed to get repo source for ", repoAt, err) 164 } 165 166 + var sourceRepo *models.Repo 167 if source != "" { 168 sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 169 if err != nil { ··· 192 Knot: knot, 193 Spindle: f.Spindle, 194 Roles: f.RolesInRepo(user), 195 + Stats: models.RepoStats{ 196 StarCount: starCount, 197 IssueCount: issueCount, 198 PullCount: pullCount, ··· 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 if u != nil { 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 + return repoinfo.RolesInRepo{Roles: r} 216 } else { 217 return repoinfo.RolesInRepo{} 218 }
+4 -4
appview/serververify/verify.go
··· 6 "fmt" 7 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" 13 ) 14 15 var (
··· 6 "fmt" 7 8 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/db" 11 + "tangled.org/core/appview/xrpcclient" 12 + "tangled.org/core/rbac" 13 ) 14 15 var (
+62 -10
appview/settings/settings.go
··· 11 "time" 12 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" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 40 {"Name": "profile", "Icon": "user"}, 41 {"Name": "keys", "Icon": "key"}, 42 {"Name": "emails", "Icon": "mail"}, 43 } 44 ) 45 ··· 67 r.Post("/primary", s.emailsPrimary) 68 }) 69 70 return r 71 } 72 ··· 80 }) 81 } 82 83 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 84 user := s.OAuth.GetUser(r) 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) ··· 185 } 186 defer tx.Rollback() 187 188 - if err := db.AddEmail(tx, db.Email{ 189 Did: did, 190 Address: emAddr, 191 Verified: false, ··· 246 if s.Config.Core.Dev { 247 appUrl = "http://" + s.Config.Core.ListenAddr 248 } else { 249 - appUrl = "https://tangled.sh" 250 } 251 252 return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
··· 11 "time" 12 13 "github.com/go-chi/chi/v5" 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" 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 41 {"Name": "profile", "Icon": "user"}, 42 {"Name": "keys", "Icon": "key"}, 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 45 } 46 ) 47 ··· 69 r.Post("/primary", s.emailsPrimary) 70 }) 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 77 return r 78 } 79 ··· 87 }) 88 } 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 + 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 136 user := s.OAuth.GetUser(r) 137 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) ··· 237 } 238 defer tx.Rollback() 239 240 + if err := db.AddEmail(tx, models.Email{ 241 Did: did, 242 Address: emAddr, 243 Verified: false, ··· 298 if s.Config.Core.Dev { 299 appUrl = "http://" + s.Config.Core.ListenAddr 300 } else { 301 + appUrl = s.Config.Core.AppviewHost 302 } 303 304 return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+76 -11
appview/signup/signup.go
··· 2 3 import ( 4 "bufio" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "os" 9 "strings" 10 11 "github.com/go-chi/chi/v5" 12 "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" 21 ) 22 23 type Signup struct { ··· 115 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 switch r.Method { 117 case http.MethodGet: 118 - s.pages.Signup(w) 119 case http.MethodPost: 120 if s.cf == nil { 121 http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 } 123 emailId := r.FormValue("email") 124 125 noticeId := "signup-msg" 126 if !email.IsValidEmail(emailId) { 127 s.pages.Notice(w, noticeId, "Invalid email address.") 128 return ··· 163 s.pages.Notice(w, noticeId, "Failed to send email.") 164 return 165 } 166 - err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 Email: emailId, 168 InviteCode: code, 169 }) ··· 229 return 230 } 231 232 - err = db.AddEmail(s.db, db.Email{ 233 Did: did, 234 Address: email, 235 Verified: true, ··· 254 return 255 } 256 }
··· 2 3 import ( 4 "bufio" 5 + "encoding/json" 6 + "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 + "net/url" 11 "os" 12 "strings" 13 14 "github.com/go-chi/chi/v5" 15 "github.com/posthog/posthog-go" 16 + "tangled.org/core/appview/config" 17 + "tangled.org/core/appview/db" 18 + "tangled.org/core/appview/dns" 19 + "tangled.org/core/appview/email" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 22 + "tangled.org/core/appview/state/userutil" 23 + "tangled.org/core/appview/xrpcclient" 24 + "tangled.org/core/idresolver" 25 ) 26 27 type Signup struct { ··· 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 120 switch r.Method { 121 case http.MethodGet: 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 125 case http.MethodPost: 126 if s.cf == nil { 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 129 } 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 132 133 noticeId := "signup-msg" 134 + 135 + if err := s.validateCaptcha(cfToken, r); err != nil { 136 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 + return 139 + } 140 + 141 if !email.IsValidEmail(emailId) { 142 s.pages.Notice(w, noticeId, "Invalid email address.") 143 return ··· 178 s.pages.Notice(w, noticeId, "Failed to send email.") 179 return 180 } 181 + err = db.AddInflightSignup(s.db, models.InflightSignup{ 182 Email: emailId, 183 InviteCode: code, 184 }) ··· 244 return 245 } 246 247 + err = db.AddEmail(s.db, models.Email{ 248 Did: did, 249 Address: email, 250 Verified: true, ··· 269 return 270 } 271 } 272 + 273 + type turnstileResponse struct { 274 + Success bool `json:"success"` 275 + ErrorCodes []string `json:"error-codes,omitempty"` 276 + ChallengeTs string `json:"challenge_ts,omitempty"` 277 + Hostname string `json:"hostname,omitempty"` 278 + } 279 + 280 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 281 + if cfToken == "" { 282 + return errors.New("captcha token is empty") 283 + } 284 + 285 + if s.config.Cloudflare.TurnstileSecretKey == "" { 286 + return errors.New("turnstile secret key not configured") 287 + } 288 + 289 + data := url.Values{} 290 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 291 + data.Set("response", cfToken) 292 + 293 + // include the client IP if we have it 294 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 295 + data.Set("remoteip", remoteIP) 296 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 297 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 298 + data.Set("remoteip", strings.TrimSpace(ips[0])) 299 + } 300 + } else { 301 + data.Set("remoteip", r.RemoteAddr) 302 + } 303 + 304 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 305 + if err != nil { 306 + return fmt.Errorf("failed to verify turnstile token: %w", err) 307 + } 308 + defer resp.Body.Close() 309 + 310 + var turnstileResp turnstileResponse 311 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 312 + return fmt.Errorf("failed to decode turnstile response: %w", err) 313 + } 314 + 315 + if !turnstileResp.Success { 316 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 317 + return errors.New("turnstile validation failed") 318 + } 319 + 320 + return nil 321 + }
+15 -14
appview/spindles/spindles.go
··· 9 "time" 10 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" 23 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 "github.com/bluesky-social/indigo/atproto/syntax" ··· 115 } 116 117 // organize repos by did 118 - repoMap := make(map[string][]db.Repo) 119 for _, r := range repos { 120 repoMap[r.Did] = append(repoMap[r.Did], r) 121 } ··· 163 s.Enforcer.E.LoadPolicy() 164 }() 165 166 - err = db.AddSpindle(tx, db.Spindle{ 167 Owner: syntax.DID(user.Did), 168 Instance: instance, 169 }) ··· 524 rkey := tid.TID() 525 526 // add member to db 527 - if err = db.AddSpindleMember(tx, db.SpindleMember{ 528 Did: syntax.DID(user.Did), 529 Rkey: rkey, 530 Instance: instance,
··· 9 "time" 10 11 "github.com/go-chi/chi/v5" 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" 24 25 comatproto "github.com/bluesky-social/indigo/api/atproto" 26 "github.com/bluesky-social/indigo/atproto/syntax" ··· 116 } 117 118 // organize repos by did 119 + repoMap := make(map[string][]models.Repo) 120 for _, r := range repos { 121 repoMap[r.Did] = append(repoMap[r.Did], r) 122 } ··· 164 s.Enforcer.E.LoadPolicy() 165 }() 166 167 + err = db.AddSpindle(tx, models.Spindle{ 168 Owner: syntax.DID(user.Did), 169 Instance: instance, 170 }) ··· 525 rkey := tid.TID() 526 527 // add member to db 528 + if err = db.AddSpindleMember(tx, models.SpindleMember{ 529 Did: syntax.DID(user.Did), 530 Rkey: rkey, 531 Instance: instance,
+8 -7
appview/state/follow.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 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" 14 ) 15 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 59 60 log.Println("created atproto record: ", resp.Uri) 61 62 - follow := &db.Follow{ 63 UserDid: currentUser.Did, 64 SubjectDid: subjectIdent.DID.String(), 65 Rkey: rkey, ··· 75 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 77 UserDid: subjectIdent.DID.String(), 78 - FollowStatus: db.IsFollowing, 79 }) 80 81 return ··· 106 107 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 108 UserDid: subjectIdent.DID.String(), 109 - FollowStatus: db.IsNotFollowing, 110 }) 111 112 s.notifier.DeleteFollow(r.Context(), follow)
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 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" 15 ) 16 17 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 60 61 log.Println("created atproto record: ", resp.Uri) 62 63 + follow := &models.Follow{ 64 UserDid: currentUser.Did, 65 SubjectDid: subjectIdent.DID.String(), 66 Rkey: rkey, ··· 76 77 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 78 UserDid: subjectIdent.DID.String(), 79 + FollowStatus: models.IsFollowing, 80 }) 81 82 return ··· 107 108 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 109 UserDid: subjectIdent.DID.String(), 110 + FollowStatus: models.IsNotFollowing, 111 }) 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 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/appview/db" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*db.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 - repo := r.Context().Value("repo").(*db.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 - repo := r.Context().Value("repo").(*db.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev {
··· 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/go-chi/chi/v5" 11 + "tangled.org/core/appview/models" 12 ) 13 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 user := r.Context().Value("resolvedId").(identity.Identity) 16 + repo := r.Context().Value("repo").(*models.Repo) 17 18 scheme := "https" 19 if s.config.Core.Dev { ··· 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 return 33 } 34 + repo := r.Context().Value("repo").(*models.Repo) 35 36 scheme := "https" 37 if s.config.Core.Dev { ··· 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 return 50 } 51 + repo := r.Context().Value("repo").(*models.Repo) 52 53 scheme := "https" 54 if s.config.Core.Dev {
+29 -15
appview/state/knotstream.go
··· 8 "slices" 9 "time" 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" 20 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 "github.com/go-git/go-git/v5/plumbing" ··· 124 } 125 } 126 127 - punch := db.Punch{ 128 Did: record.CommitterDid, 129 Date: time.Now(), 130 Count: count, ··· 156 return fmt.Errorf("%s is not a valid reference name", ref) 157 } 158 159 - var langs []db.RepoLanguage 160 for _, l := range record.Meta.LangBreakdown.Inputs { 161 if l == nil { 162 continue 163 } 164 165 - langs = append(langs, db.RepoLanguage{ 166 RepoAt: repo.RepoAt(), 167 Ref: ref.Short(), 168 IsDefaultRef: record.Meta.IsDefaultRef, ··· 171 }) 172 } 173 174 - return db.InsertRepoLanguages(d, langs) 175 } 176 177 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 207 } 208 209 // trigger info 210 - var trigger db.Trigger 211 var sha string 212 trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 213 switch trigger.Kind { ··· 234 return fmt.Errorf("failed to add trigger entry: %w", err) 235 } 236 237 - pipeline := db.Pipeline{ 238 Rkey: msg.Rkey, 239 Knot: source.Key(), 240 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
··· 8 "slices" 9 "time" 10 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" 21 22 "github.com/bluesky-social/indigo/atproto/syntax" 23 "github.com/go-git/go-git/v5/plumbing" ··· 125 } 126 } 127 128 + punch := models.Punch{ 129 Did: record.CommitterDid, 130 Date: time.Now(), 131 Count: count, ··· 157 return fmt.Errorf("%s is not a valid reference name", ref) 158 } 159 160 + var langs []models.RepoLanguage 161 for _, l := range record.Meta.LangBreakdown.Inputs { 162 if l == nil { 163 continue 164 } 165 166 + langs = append(langs, models.RepoLanguage{ 167 RepoAt: repo.RepoAt(), 168 Ref: ref.Short(), 169 IsDefaultRef: record.Meta.IsDefaultRef, ··· 172 }) 173 } 174 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() 189 } 190 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 221 } 222 223 // trigger info 224 + var trigger models.Trigger 225 var sha string 226 trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 227 switch trigger.Kind { ··· 248 return fmt.Errorf("failed to add trigger entry: %w", err) 249 } 250 251 + pipeline := models.Pipeline{ 252 Rkey: msg.Rkey, 253 Knot: source.Key(), 254 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+30 -37
appview/state/profile.go
··· 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 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" 21 ) 22 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 76 } 77 78 loggedInUser := s.oauth.GetUser(r) 79 - followStatus := db.IsNotFollowing 80 if loggedInUser != nil { 81 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 } ··· 130 } 131 132 // filter out ones that are pinned 133 - pinnedRepos := []db.Repo{} 134 for i, r := range repos { 135 // if this is a pinned repo, add it 136 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 148 l.Error("failed to fetch collaborating repos", "err", err) 149 } 150 151 - pinnedCollaboratingRepos := []db.Repo{} 152 for _, r := range collaboratingRepos { 153 // if this is a pinned repo, add it 154 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 216 s.pages.Error500(w) 217 return 218 } 219 - var repoAts []string 220 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 233 } 234 235 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 271 272 func (s *State) followPage( 273 r *http.Request, 274 - fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 - extractDid func(db.Follow) string, 276 ) (*FollowsPageParams, error) { 277 l := s.logger.With("handler", "reposPage") 278 ··· 329 followCards := make([]pages.FollowCard, len(follows)) 330 for i, did := range followDids { 331 followStats := followStatsMap[did] 332 - followStatus := db.IsNotFollowing 333 if _, exists := loggedInUserFollowing[did]; exists { 334 - followStatus = db.IsFollowing 335 } else if loggedInUser != nil && loggedInUser.Did == did { 336 - followStatus = db.IsSelf 337 } 338 339 - var profile *db.Profile 340 if p, exists := profiles[did]; exists { 341 profile = p 342 } else { 343 - profile = &db.Profile{} 344 profile.Did = did 345 } 346 followCards[i] = pages.FollowCard{ 347 UserDid: did, 348 FollowStatus: followStatus, 349 FollowersCount: followStats.Followers, ··· 358 } 359 360 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 }) 362 if err != nil { 363 s.pages.Notice(w, "all-followers", "Failed to load followers") 364 return ··· 372 } 373 374 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 }) 376 if err != nil { 377 s.pages.Notice(w, "all-following", "Failed to load following") 378 return ··· 453 return &feed, nil 454 } 455 456 - func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 457 for _, pull := range pulls { 458 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 459 if err != nil { ··· 466 return nil 467 } 468 469 - func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 470 for _, issue := range issues { 471 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 if err != nil { ··· 478 return nil 479 } 480 481 - func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 482 for _, repo := range repos { 483 item, err := s.createRepoItem(ctx, repo, author) 484 if err != nil { ··· 489 return nil 490 } 491 492 - func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 return &feeds.Item{ 494 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 495 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 } 499 } 500 501 - func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 return &feeds.Item{ 503 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 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 } 508 } 509 510 - func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 511 var title string 512 if repo.Source != nil { 513 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) ··· 558 stat1 := r.FormValue("stat1") 559 560 if stat0 != "" { 561 - profile.Stats[0].Kind = db.VanityStatKind(stat0) 562 } 563 564 if stat1 != "" { 565 - profile.Stats[1].Kind = db.VanityStatKind(stat1) 566 } 567 568 if err := db.ValidateProfile(s.db, profile); err != nil { ··· 613 s.updateProfile(profile, w, r) 614 } 615 616 - func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 617 user := s.oauth.GetUser(r) 618 tx, err := s.db.BeginTx(r.Context(), nil) 619 if err != nil {
··· 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 "github.com/gorilla/feeds" 18 + "tangled.org/core/api/tangled" 19 + "tangled.org/core/appview/db" 20 + "tangled.org/core/appview/models" 21 + "tangled.org/core/appview/pages" 22 ) 23 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { ··· 77 } 78 79 loggedInUser := s.oauth.GetUser(r) 80 + followStatus := models.IsNotFollowing 81 if loggedInUser != nil { 82 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 83 } ··· 131 } 132 133 // filter out ones that are pinned 134 + pinnedRepos := []models.Repo{} 135 for i, r := range repos { 136 // if this is a pinned repo, add it 137 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 149 l.Error("failed to fetch collaborating repos", "err", err) 150 } 151 152 + pinnedCollaboratingRepos := []models.Repo{} 153 for _, r := range collaboratingRepos { 154 // if this is a pinned repo, add it 155 if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { ··· 217 s.pages.Error500(w) 218 return 219 } 220 + var repos []models.Repo 221 for _, s := range stars { 222 + if s.Repo != nil { 223 + repos = append(repos, *s.Repo) 224 + } 225 } 226 227 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 263 264 func (s *State) followPage( 265 r *http.Request, 266 + fetchFollows func(db.Execer, string) ([]models.Follow, error), 267 + extractDid func(models.Follow) string, 268 ) (*FollowsPageParams, error) { 269 l := s.logger.With("handler", "reposPage") 270 ··· 321 followCards := make([]pages.FollowCard, len(follows)) 322 for i, did := range followDids { 323 followStats := followStatsMap[did] 324 + followStatus := models.IsNotFollowing 325 if _, exists := loggedInUserFollowing[did]; exists { 326 + followStatus = models.IsFollowing 327 } else if loggedInUser != nil && loggedInUser.Did == did { 328 + followStatus = models.IsSelf 329 } 330 331 + var profile *models.Profile 332 if p, exists := profiles[did]; exists { 333 profile = p 334 } else { 335 + profile = &models.Profile{} 336 profile.Did = did 337 } 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 340 UserDid: did, 341 FollowStatus: followStatus, 342 FollowersCount: followStats.Followers, ··· 351 } 352 353 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 354 + followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid }) 355 if err != nil { 356 s.pages.Notice(w, "all-followers", "Failed to load followers") 357 return ··· 365 } 366 367 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 368 + followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid }) 369 if err != nil { 370 s.pages.Notice(w, "all-following", "Failed to load following") 371 return ··· 446 return &feed, nil 447 } 448 449 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error { 450 for _, pull := range pulls { 451 owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 452 if err != nil { ··· 459 return nil 460 } 461 462 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error { 463 for _, issue := range issues { 464 owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 465 if err != nil { ··· 471 return nil 472 } 473 474 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error { 475 for _, repo := range repos { 476 item, err := s.createRepoItem(ctx, repo, author) 477 if err != nil { ··· 482 return nil 483 } 484 485 + func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 486 return &feeds.Item{ 487 Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 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"}, ··· 491 } 492 } 493 494 + func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 495 return &feeds.Item{ 496 Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 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"}, ··· 500 } 501 } 502 503 + func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 504 var title string 505 if repo.Source != nil { 506 sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) ··· 551 stat1 := r.FormValue("stat1") 552 553 if stat0 != "" { 554 + profile.Stats[0].Kind = models.VanityStatKind(stat0) 555 } 556 557 if stat1 != "" { 558 + profile.Stats[1].Kind = models.VanityStatKind(stat1) 559 } 560 561 if err := db.ValidateProfile(s.db, profile); err != nil { ··· 606 s.updateProfile(profile, w, r) 607 } 608 609 + func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 610 user := s.oauth.GetUser(r) 611 tx, err := s.db.BeginTx(r.Context(), nil) 612 if err != nil {
+6 -5
appview/state/reaction.go
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview/db" 14 - "tangled.sh/tangled.sh/core/appview/pages" 15 - "tangled.sh/tangled.sh/core/tid" 16 ) 17 18 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 30 return 31 } 32 33 - reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 if !ok { 35 log.Println("invalid reaction kind") 36 return
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 11 lexutil "github.com/bluesky-social/indigo/lex/util" 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" 17 ) 18 19 func (s *State) React(w http.ResponseWriter, r *http.Request) { ··· 31 return 32 } 33 34 + reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind")) 35 if !ok { 36 log.Println("invalid reaction kind") 37 return
+46 -16
appview/state/router.go
··· 6 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" 23 ) 24 25 func (s *State) Router() http.Handler { ··· 33 s.pages, 34 ) 35 36 router.Get("/favicon.svg", s.Favicon) 37 router.Get("/favicon.ico", s.Favicon) 38 39 userRouter := s.UserRouter(&middleware) 40 standardRouter := s.StandardRouter(&middleware) ··· 115 116 r.Get("/", s.HomeOrTimeline) 117 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 119 120 r.Route("/repo", func(r chi.Router) { 121 r.Route("/new", func(r chi.Router) { ··· 126 // r.Post("/import", s.ImportRepo) 127 }) 128 129 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 130 r.Post("/", s.Follow) 131 r.Delete("/", s.Follow) ··· 153 r.Mount("/strings", s.StringsRouter(mw)) 154 r.Mount("/knots", s.KnotsRouter()) 155 r.Mount("/spindles", s.SpindlesRouter()) 156 r.Mount("/signup", s.SignupRouter()) 157 r.Mount("/", s.OAuthRouter()) 158 159 r.Get("/keys/{user}", s.Keys) 160 r.Get("/terms", s.TermsOfService) 161 r.Get("/privacy", s.PrivacyPolicy) 162 163 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 164 s.pages.Error404(w) 165 }) 166 return r 167 } 168 169 func (s *State) OAuthRouter() http.Handler { ··· 253 } 254 255 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 256 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 257 return ls.Router(mw) 258 } 259 260 func (s *State) SignupRouter() http.Handler {
··· 6 7 "github.com/go-chi/chi/v5" 8 "github.com/gorilla/sessions" 9 + "tangled.org/core/appview/issues" 10 + "tangled.org/core/appview/knots" 11 + "tangled.org/core/appview/labels" 12 + "tangled.org/core/appview/middleware" 13 + "tangled.org/core/appview/notifications" 14 + oauthhandler "tangled.org/core/appview/oauth/handler" 15 + "tangled.org/core/appview/pipelines" 16 + "tangled.org/core/appview/pulls" 17 + "tangled.org/core/appview/repo" 18 + "tangled.org/core/appview/settings" 19 + "tangled.org/core/appview/signup" 20 + "tangled.org/core/appview/spindles" 21 + "tangled.org/core/appview/state/userutil" 22 + avstrings "tangled.org/core/appview/strings" 23 + "tangled.org/core/log" 24 ) 25 26 func (s *State) Router() http.Handler { ··· 34 s.pages, 35 ) 36 37 + router.Use(middleware.TryRefreshSession()) 38 router.Get("/favicon.svg", s.Favicon) 39 router.Get("/favicon.ico", s.Favicon) 40 + router.Get("/pwa-manifest.json", s.PWAManifest) 41 42 userRouter := s.UserRouter(&middleware) 43 standardRouter := s.StandardRouter(&middleware) ··· 118 119 r.Get("/", s.HomeOrTimeline) 120 r.Get("/timeline", s.Timeline) 121 + r.Get("/upgradeBanner", s.UpgradeBanner) 122 + 123 + // special-case handler for serving tangled.org/core 124 + r.Get("/core", s.Core()) 125 126 r.Route("/repo", func(r chi.Router) { 127 r.Route("/new", func(r chi.Router) { ··· 132 // r.Post("/import", s.ImportRepo) 133 }) 134 135 + r.Get("/goodfirstissues", s.GoodFirstIssues) 136 + 137 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 138 r.Post("/", s.Follow) 139 r.Delete("/", s.Follow) ··· 161 r.Mount("/strings", s.StringsRouter(mw)) 162 r.Mount("/knots", s.KnotsRouter()) 163 r.Mount("/spindles", s.SpindlesRouter()) 164 + r.Mount("/notifications", s.NotificationsRouter(mw)) 165 + 166 r.Mount("/signup", s.SignupRouter()) 167 r.Mount("/", s.OAuthRouter()) 168 169 r.Get("/keys/{user}", s.Keys) 170 r.Get("/terms", s.TermsOfService) 171 r.Get("/privacy", s.PrivacyPolicy) 172 + r.Get("/brand", s.Brand) 173 174 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 175 s.pages.Error404(w) 176 }) 177 return r 178 + } 179 + 180 + // Core serves tangled.org/core go-import meta tags, and redirects 181 + // to the core repository if accessed normally. 182 + func (s *State) Core() http.HandlerFunc { 183 + return func(w http.ResponseWriter, r *http.Request) { 184 + if r.URL.Query().Get("go-get") == "1" { 185 + w.Header().Set("Content-Type", "text/html") 186 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 187 + return 188 + } 189 + 190 + http.Redirect(w, r, "/@tangled.org/core", http.StatusFound) 191 + } 192 } 193 194 func (s *State) OAuthRouter() http.Handler { ··· 278 } 279 280 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 281 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 282 return ls.Router(mw) 283 + } 284 + 285 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 286 + notifs := notifications.New(s.db, s.oauth, s.pages) 287 + return notifs.Router(mw) 288 } 289 290 func (s *State) SignupRouter() http.Handler {
+11 -10
appview/state/spindlestream.go
··· 9 "time" 10 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" 21 ) 22 23 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 89 created = t 90 } 91 92 - status := db.PipelineStatus{ 93 Spindle: source.Key(), 94 Rkey: msg.Rkey, 95 PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
··· 9 "time" 10 11 "github.com/bluesky-social/indigo/atproto/syntax" 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" 22 ) 23 24 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { ··· 90 created = t 91 } 92 93 + status := models.PipelineStatus{ 94 Spindle: source.Key(), 95 Rkey: msg.Rkey, 96 PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+8 -7
appview/state/star.go
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 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" 15 ) 16 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 55 } 56 log.Println("created atproto record: ", resp.Uri) 57 58 - star := &db.Star{ 59 StarredByDid: currentUser.Did, 60 RepoAt: subjectUri, 61 Rkey: rkey, ··· 77 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 78 IsStarred: true, 79 RepoAt: subjectUri, 80 - Stats: db.RepoStats{ 81 StarCount: starCount, 82 }, 83 }) ··· 119 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 120 IsStarred: false, 121 RepoAt: subjectUri, 122 - Stats: db.RepoStats{ 123 StarCount: starCount, 124 }, 125 })
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 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" 16 ) 17 18 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 56 } 57 log.Println("created atproto record: ", resp.Uri) 58 59 + star := &models.Star{ 60 StarredByDid: currentUser.Did, 61 RepoAt: subjectUri, 62 Rkey: rkey, ··· 78 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 79 IsStarred: true, 80 RepoAt: subjectUri, 81 + Stats: models.RepoStats{ 82 StarCount: starCount, 83 }, 84 }) ··· 120 s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 121 IsStarred: false, 122 RepoAt: subjectUri, 123 + Stats: models.RepoStats{ 124 StarCount: starCount, 125 }, 126 })
+105 -26
appview/state/state.go
··· 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "github.com/go-chi/chi/v5" 19 "github.com/posthog/posthog-go" 20 - "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 - "tangled.sh/tangled.sh/core/appview/cache" 23 - "tangled.sh/tangled.sh/core/appview/cache/session" 24 - "tangled.sh/tangled.sh/core/appview/config" 25 - "tangled.sh/tangled.sh/core/appview/db" 26 - "tangled.sh/tangled.sh/core/appview/notify" 27 - "tangled.sh/tangled.sh/core/appview/oauth" 28 - "tangled.sh/tangled.sh/core/appview/pages" 29 - posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 - "tangled.sh/tangled.sh/core/appview/reporesolver" 31 - "tangled.sh/tangled.sh/core/appview/validator" 32 - xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 33 - "tangled.sh/tangled.sh/core/eventconsumer" 34 - "tangled.sh/tangled.sh/core/idresolver" 35 - "tangled.sh/tangled.sh/core/jetstream" 36 - tlog "tangled.sh/tangled.sh/core/log" 37 - "tangled.sh/tangled.sh/core/rbac" 38 - "tangled.sh/tangled.sh/core/tid" 39 - // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 40 ) 41 42 type State struct { ··· 78 cache := cache.New(config.Redis.Addr) 79 sess := session.New(cache) 80 oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 82 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 if err != nil { ··· 87 88 repoResolver := reporesolver.New(config, enforcer, res, d) 89 90 - wrapper := db.DbWrapper{d} 91 jc, err := jetstream.NewJetstreamClient( 92 config.Jetstream.Endpoint, 93 "appview", ··· 103 tangled.RepoIssueNSID, 104 tangled.RepoIssueCommentNSID, 105 tangled.LabelDefinitionNSID, 106 }, 107 nil, 108 slog.Default(), ··· 115 ) 116 if err != nil { 117 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 118 } 119 120 ingester := appview.Ingester{ ··· 143 spindlestream.Start(ctx) 144 145 var notifiers []notify.Notifier 146 if !config.Core.Dev { 147 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 148 } 149 notifier := notify.NewMergedNotifier(notifiers...) 150 ··· 187 s.pages.Favicon(w) 188 } 189 190 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 191 user := s.oauth.GetUser(r) 192 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 201 }) 202 } 203 204 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 205 if s.oauth.GetUser(r) != nil { 206 s.Timeline(w, r) ··· 229 return 230 } 231 232 - s.pages.Timeline(w, pages.TimelineParams{ 233 LoggedInUser: user, 234 Timeline: timeline, 235 Repos: repos, 236 - }) 237 } 238 239 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 240 user := s.oauth.GetUser(r) 241 l := s.logger.With("handler", "UpgradeBanner") 242 l = l.With("did", user.Did) 243 l = l.With("handle", user.Handle) ··· 433 434 // create atproto record for this repo 435 rkey := tid.TID() 436 - repo := &db.Repo{ 437 Did: user.Did, 438 Name: repoName, 439 Knot: domain, 440 Rkey: rkey, 441 Description: description, 442 Created: time.Now(), 443 } 444 record := repo.AsRecord() 445 ··· 580 }) 581 return err 582 }
··· 17 securejoin "github.com/cyphar/filepath-securejoin" 18 "github.com/go-chi/chi/v5" 19 "github.com/posthog/posthog-go" 20 + "tangled.org/core/api/tangled" 21 + "tangled.org/core/appview" 22 + "tangled.org/core/appview/cache" 23 + "tangled.org/core/appview/cache/session" 24 + "tangled.org/core/appview/config" 25 + "tangled.org/core/appview/db" 26 + "tangled.org/core/appview/models" 27 + "tangled.org/core/appview/notify" 28 + dbnotify "tangled.org/core/appview/notify/db" 29 + phnotify "tangled.org/core/appview/notify/posthog" 30 + "tangled.org/core/appview/oauth" 31 + "tangled.org/core/appview/pages" 32 + "tangled.org/core/appview/reporesolver" 33 + "tangled.org/core/appview/validator" 34 + xrpcclient "tangled.org/core/appview/xrpcclient" 35 + "tangled.org/core/eventconsumer" 36 + "tangled.org/core/idresolver" 37 + "tangled.org/core/jetstream" 38 + tlog "tangled.org/core/log" 39 + "tangled.org/core/rbac" 40 + "tangled.org/core/tid" 41 ) 42 43 type State struct { ··· 79 cache := cache.New(config.Redis.Addr) 80 sess := session.New(cache) 81 oauth := oauth.NewOAuth(config, sess) 82 + validator := validator.New(d, res, enforcer) 83 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 85 if err != nil { ··· 88 89 repoResolver := reporesolver.New(config, enforcer, res, d) 90 91 + wrapper := db.DbWrapper{Execer: d} 92 jc, err := jetstream.NewJetstreamClient( 93 config.Jetstream.Endpoint, 94 "appview", ··· 104 tangled.RepoIssueNSID, 105 tangled.RepoIssueCommentNSID, 106 tangled.LabelDefinitionNSID, 107 + tangled.LabelOpNSID, 108 }, 109 nil, 110 slog.Default(), ··· 117 ) 118 if err != nil { 119 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 120 + } 121 + 122 + if err := BackfillDefaultDefs(d, res); err != nil { 123 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 124 } 125 126 ingester := appview.Ingester{ ··· 149 spindlestream.Start(ctx) 150 151 var notifiers []notify.Notifier 152 + 153 + // Always add the database notifier 154 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 155 + 156 + // Add other notifiers in production only 157 if !config.Core.Dev { 158 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 159 } 160 notifier := notify.NewMergedNotifier(notifiers...) 161 ··· 198 s.pages.Favicon(w) 199 } 200 201 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 202 + const manifestJson = `{ 203 + "name": "tangled", 204 + "description": "tightly-knit social coding.", 205 + "icons": [ 206 + { 207 + "src": "/favicon.svg", 208 + "sizes": "144x144" 209 + } 210 + ], 211 + "start_url": "/", 212 + "id": "org.tangled", 213 + 214 + "display": "standalone", 215 + "background_color": "#111827", 216 + "theme_color": "#111827" 217 + }` 218 + 219 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 220 + w.Header().Set("Content-Type", "application/json") 221 + w.Write([]byte(manifestJson)) 222 + } 223 + 224 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 225 user := s.oauth.GetUser(r) 226 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 235 }) 236 } 237 238 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 239 + user := s.oauth.GetUser(r) 240 + s.pages.Brand(w, pages.BrandParams{ 241 + LoggedInUser: user, 242 + }) 243 + } 244 + 245 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 246 if s.oauth.GetUser(r) != nil { 247 s.Timeline(w, r) ··· 270 return 271 } 272 273 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 274 + if err != nil { 275 + // non-fatal 276 + } 277 + 278 + fmt.Println(s.pages.Timeline(w, pages.TimelineParams{ 279 LoggedInUser: user, 280 Timeline: timeline, 281 Repos: repos, 282 + GfiLabel: gfiLabel, 283 + })) 284 } 285 286 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 287 user := s.oauth.GetUser(r) 288 + if user == nil { 289 + return 290 + } 291 + 292 l := s.logger.With("handler", "UpgradeBanner") 293 l = l.With("did", user.Did) 294 l = l.With("handle", user.Handle) ··· 484 485 // create atproto record for this repo 486 rkey := tid.TID() 487 + repo := &models.Repo{ 488 Did: user.Did, 489 Name: repoName, 490 Knot: domain, 491 Rkey: rkey, 492 Description: description, 493 Created: time.Now(), 494 + Labels: models.DefaultLabelDefs(), 495 } 496 record := repo.AsRecord() 497 ··· 632 }) 633 return err 634 } 635 + 636 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 637 + defaults := models.DefaultLabelDefs() 638 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 639 + if err != nil { 640 + return err 641 + } 642 + // already present 643 + if len(defaultLabels) == len(defaults) { 644 + return nil 645 + } 646 + 647 + labelDefs, err := models.FetchDefaultDefs(r) 648 + if err != nil { 649 + return err 650 + } 651 + 652 + // Insert each label definition to the database 653 + for _, labelDef := range labelDefs { 654 + _, err = db.AddLabelDefinition(e, &labelDef) 655 + if err != nil { 656 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 657 + } 658 + } 659 + 660 + return nil 661 + }
+12 -11
appview/strings/strings.go
··· 8 "strconv" 9 "time" 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" 20 21 "github.com/bluesky-social/indigo/api/atproto" 22 "github.com/bluesky-social/indigo/atproto/identity" ··· 235 description := r.FormValue("description") 236 237 // construct new string from form values 238 - entry := db.String{ 239 Did: first.Did, 240 Rkey: first.Rkey, 241 Filename: filename, ··· 318 319 description := r.FormValue("description") 320 321 - string := db.String{ 322 Did: syntax.DID(user.Did), 323 Rkey: tid.TID(), 324 Filename: filename,
··· 8 "strconv" 9 "time" 10 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" 21 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" ··· 236 description := r.FormValue("description") 237 238 // construct new string from form values 239 + entry := models.String{ 240 Did: first.Did, 241 Rkey: first.Rkey, 242 Filename: filename, ··· 319 320 description := r.FormValue("description") 321 322 + string := models.String{ 323 Did: syntax.DID(user.Did), 324 Rkey: tid.TID(), 325 Filename: filename,
+4 -3
appview/validator/issue.go
··· 4 "fmt" 5 "strings" 6 7 - "tangled.sh/tangled.sh/core/appview/db" 8 ) 9 10 - func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 // if comments have parents, only ingest ones that are 1 level deep 12 if comment.ReplyTo != nil { 13 parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) ··· 32 return nil 33 } 34 35 - func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 if issue.Title == "" { 37 return fmt.Errorf("issue title is empty") 38 }
··· 4 "fmt" 5 "strings" 6 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 ) 10 11 + func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 12 // if comments have parents, only ingest ones that are 1 level deep 13 if comment.ReplyTo != nil { 14 parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) ··· 33 return nil 34 } 35 36 + func (v *Validator) ValidateIssue(issue *models.Issue) error { 37 if issue.Title == "" { 38 return fmt.Errorf("issue title is empty") 39 }
+27 -13
appview/validator/label.go
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "golang.org/x/exp/slices" 11 - "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 13 ) 14 15 var ( ··· 21 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 ) 23 24 - func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error { 25 if label.Name == "" { 26 return fmt.Errorf("label name is empty") 27 } ··· 95 return nil 96 } 97 98 - func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 if labelOp == nil { 103 return fmt.Errorf("label operation is required") 104 } 105 106 expectedKey := labelDef.AtUri().String() 107 if labelOp.OperandKey != expectedKey { 108 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 109 } 110 111 - if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel { 112 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 113 } 114 ··· 131 return nil 132 } 133 134 - func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error { 135 valueType := labelDef.ValueType 136 137 // this is permitted, it "unsets" a label 138 if labelOp.OperandValue == "" { 139 - labelOp.Operation = db.LabelOperationDel 140 return nil 141 } 142 143 switch valueType.Type { 144 - case db.ConcreteTypeNull: 145 // For null type, value should be empty 146 if labelOp.OperandValue != "null" { 147 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 148 } 149 150 - case db.ConcreteTypeString: 151 // For string type, validate enum constraints if present 152 if valueType.IsEnum() { 153 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { ··· 156 } 157 158 switch valueType.Format { 159 - case db.ValueTypeFormatDid: 160 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 161 if err != nil { 162 return fmt.Errorf("failed to resolve did/handle: %w", err) ··· 164 165 labelOp.OperandValue = id.DID.String() 166 167 - case db.ValueTypeFormatAny, "": 168 default: 169 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 170 } 171 172 - case db.ConcreteTypeInt: 173 if labelOp.OperandValue == "" { 174 return fmt.Errorf("integer type requires non-empty value") 175 } ··· 183 } 184 } 185 186 - case db.ConcreteTypeBool: 187 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 188 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 189 }
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "golang.org/x/exp/slices" 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/appview/models" 13 ) 14 15 var ( ··· 21 validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 ) 23 24 + func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 if label.Name == "" { 26 return fmt.Errorf("label name is empty") 27 } ··· 95 return nil 96 } 97 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 if labelDef == nil { 100 return fmt.Errorf("label definition is required") 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 105 if labelOp == nil { 106 return fmt.Errorf("label operation is required") 107 } 108 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 118 + } 119 + 120 expectedKey := labelDef.AtUri().String() 121 if labelOp.OperandKey != expectedKey { 122 return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 123 } 124 125 + if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 126 return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 127 } 128 ··· 145 return nil 146 } 147 148 + func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 149 valueType := labelDef.ValueType 150 151 // this is permitted, it "unsets" a label 152 if labelOp.OperandValue == "" { 153 + labelOp.Operation = models.LabelOperationDel 154 return nil 155 } 156 157 switch valueType.Type { 158 + case models.ConcreteTypeNull: 159 // For null type, value should be empty 160 if labelOp.OperandValue != "null" { 161 return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 162 } 163 164 + case models.ConcreteTypeString: 165 // For string type, validate enum constraints if present 166 if valueType.IsEnum() { 167 if !slices.Contains(valueType.Enum, labelOp.OperandValue) { ··· 170 } 171 172 switch valueType.Format { 173 + case models.ValueTypeFormatDid: 174 id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 175 if err != nil { 176 return fmt.Errorf("failed to resolve did/handle: %w", err) ··· 178 179 labelOp.OperandValue = id.DID.String() 180 181 + case models.ValueTypeFormatAny, "": 182 default: 183 return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 184 } 185 186 + case models.ConcreteTypeInt: 187 if labelOp.OperandValue == "" { 188 return fmt.Errorf("integer type requires non-empty value") 189 } ··· 197 } 198 } 199 200 + case models.ConcreteTypeBool: 201 if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 202 return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 203 }
+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 package validator 2 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" 7 ) 8 9 type Validator struct { 10 db *db.DB 11 sanitizer markup.Sanitizer 12 resolver *idresolver.Resolver 13 } 14 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 16 return &Validator{ 17 db: db, 18 sanitizer: markup.NewSanitizer(), 19 resolver: res, 20 } 21 }
··· 1 package validator 2 3 import ( 4 + "tangled.org/core/appview/db" 5 + "tangled.org/core/appview/pages/markup" 6 + "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 8 ) 9 10 type Validator struct { 11 db *db.DB 12 sanitizer markup.Sanitizer 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 15 } 16 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 return &Validator{ 19 db: db, 20 sanitizer: markup.NewSanitizer(), 21 resolver: res, 22 + enforcer: enforcer, 23 } 24 }
+2 -2
cmd/appview/main.go
··· 7 "net/http" 8 "os" 9 10 - "tangled.sh/tangled.sh/core/appview/config" 11 - "tangled.sh/tangled.sh/core/appview/state" 12 ) 13 14 func main() {
··· 7 "net/http" 8 "os" 9 10 + "tangled.org/core/appview/config" 11 + "tangled.org/core/appview/state" 12 ) 13 14 func main() {
+1 -1
cmd/combinediff/main.go
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 9 ) 10 11 func main() {
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/patchutil" 9 ) 10 11 func main() {
+1 -1
cmd/gen.go
··· 2 3 import ( 4 cbg "github.com/whyrusleeping/cbor-gen" 5 - "tangled.sh/tangled.sh/core/api/tangled" 6 ) 7 8 func main() {
··· 2 3 import ( 4 cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.org/core/api/tangled" 6 ) 7 8 func main() {
+1 -1
cmd/interdiff/main.go
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/patchutil" 9 ) 10 11 func main() {
··· 5 "os" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/patchutil" 9 ) 10 11 func main() {
+5 -5
cmd/knot/main.go
··· 5 "os" 6 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" 13 ) 14 15 func main() {
··· 5 "os" 6 7 "github.com/urfave/cli/v3" 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 ) 14 15 func main() {
+3 -3
cmd/spindle/main.go
··· 4 "context" 5 "os" 6 7 - "tangled.sh/tangled.sh/core/log" 8 - "tangled.sh/tangled.sh/core/spindle" 9 - _ "tangled.sh/tangled.sh/core/tid" 10 ) 11 12 func main() {
··· 4 "context" 5 "os" 6 7 + "tangled.org/core/log" 8 + "tangled.org/core/spindle" 9 + _ "tangled.org/core/tid" 10 ) 11 12 func main() {
+1 -1
cmd/verifysig/main.go
··· 7 "os" 8 "strings" 9 10 - "tangled.sh/tangled.sh/core/crypto" 11 ) 12 13 func parseCommitObject(commitData string) (string, string, error) {
··· 7 "os" 8 "strings" 9 10 + "tangled.org/core/crypto" 11 ) 12 13 func parseCommitObject(commitData string) (string, string, error) {
+1 -1
crypto/verify.go
··· 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 - "tangled.sh/tangled.sh/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
··· 9 10 "github.com/hiddeco/sshsig" 11 "golang.org/x/crypto/ssh" 12 + "tangled.org/core/types" 13 ) 14 15 func VerifySignature(pubKey, signature, payload []byte) (error, bool) {
+2 -2
docs/knot-hosting.md
··· 19 First, clone this repository: 20 21 ``` 22 - git clone https://tangled.sh/@tangled.sh/core 23 ``` 24 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 131 You should now have a running knot server! You can finalize 132 your registration by hitting the `verify` button on the 133 - [/knots](https://tangled.sh/knots) page. This simply creates 134 a record on your PDS to announce the existence of the knot. 135 136 ### custom paths
··· 19 First, clone this repository: 20 21 ``` 22 + git clone https://tangled.org/@tangled.org/core 23 ``` 24 25 Then, build the `knot` CLI. This is the knot administration and operation tool. ··· 130 131 You should now have a running knot server! You can finalize 132 your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.org/knots) page. This simply creates 134 a record on your PDS to announce the existence of the knot. 135 136 ### custom paths
+4 -5
docs/migrations.md
··· 14 For knots: 15 16 - Upgrade to latest tag (v1.9.0 or above) 17 - - Head to the [knot dashboard](https://tangled.sh/knots) and 18 hit the "retry" button to verify your knot 19 20 For spindles: 21 22 - Upgrade to latest tag (v1.9.0 or above) 23 - Head to the [spindle 24 - dashboard](https://tangled.sh/spindles) and hit the 25 "retry" button to verify your spindle 26 27 ## Upgrading from v1.7.x ··· 38 environment variable entirely 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 your DID. You can find your DID in the 41 - [settings](https://tangled.sh/settings) page. 42 - Restart your knot once you have replaced the environment 43 variable 44 - - Head to the [knot dashboard](https://tangled.sh/knots) and 45 hit the "retry" button to verify your knot. This simply 46 writes a `sh.tangled.knot` record to your PDS. 47 ··· 57 }; 58 }; 59 ``` 60 -
··· 14 For knots: 15 16 - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.org/knots) and 18 hit the "retry" button to verify your knot 19 20 For spindles: 21 22 - Upgrade to latest tag (v1.9.0 or above) 23 - Head to the [spindle 24 + dashboard](https://tangled.org/spindles) and hit the 25 "retry" button to verify your spindle 26 27 ## Upgrading from v1.7.x ··· 38 environment variable entirely 39 - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 your DID. You can find your DID in the 41 + [settings](https://tangled.org/settings) page. 42 - Restart your knot once you have replaced the environment 43 variable 44 + - Head to the [knot dashboard](https://tangled.org/knots) and 45 hit the "retry" button to verify your knot. This simply 46 writes a `sh.tangled.knot` record to your PDS. 47 ··· 57 }; 58 }; 59 ```
+1 -1
docs/spindle/openbao.md
··· 44 ### production 45 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 48 achieved using Nix. 49 50 Then, initialize the bao server:
··· 44 ### production 45 46 You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be 48 achieved using Nix. 49 50 Then, initialize the bao server:
+3 -3
docs/spindle/pipeline.md
··· 21 - `manual`: The workflow can be triggered manually. 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 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: 25 26 ```yaml 27 when: ··· 73 - nodejs 74 - go 75 # custom registry 76 - git+https://tangled.sh/@example.com/my_pkg: 77 - my_pkg 78 ``` 79 ··· 141 - nodejs 142 - go 143 # custom registry 144 - git+https://tangled.sh/@example.com/my_pkg: 145 - my_pkg 146 147 environment:
··· 21 - `manual`: The workflow can be triggered manually. 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 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 26 ```yaml 27 when: ··· 73 - nodejs 74 - go 75 # custom registry 76 + git+https://tangled.org/@example.com/my_pkg: 77 - my_pkg 78 ``` 79 ··· 141 - nodejs 142 - go 143 # custom registry 144 + git+https://tangled.org/@example.com/my_pkg: 145 - my_pkg 146 147 environment:
+2 -2
eventconsumer/consumer.go
··· 9 "sync" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 - "tangled.sh/tangled.sh/core/log" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/gorilla/websocket"
··· 9 "sync" 10 "time" 11 12 + "tangled.org/core/eventconsumer/cursor" 13 + "tangled.org/core/log" 14 15 "github.com/avast/retry-go/v4" 16 "github.com/gorilla/websocket"
+1 -1
eventconsumer/cursor/redis.go
··· 5 "fmt" 6 "strconv" 7 8 - "tangled.sh/tangled.sh/core/appview/cache" 9 ) 10 11 const (
··· 5 "fmt" 6 "strconv" 7 8 + "tangled.org/core/appview/cache" 9 ) 10 11 const (
+2 -2
go.mod
··· 1 - module tangled.sh/tangled.sh/core 2 3 go 1.24.4 4 ··· 43 github.com/yuin/goldmark v1.7.12 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 golang.org/x/net v0.42.0 47 golang.org/x/sync v0.16.0 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 168 go.uber.org/atomic v1.11.0 // indirect 169 go.uber.org/multierr v1.11.0 // indirect 170 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect
··· 1 + module tangled.org/core 2 3 go 1.24.4 4 ··· 43 github.com/yuin/goldmark v1.7.12 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 47 golang.org/x/net v0.42.0 48 golang.org/x/sync v0.16.0 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 169 go.uber.org/atomic v1.11.0 // indirect 170 go.uber.org/multierr v1.11.0 // indirect 171 go.uber.org/zap v1.27.0 // indirect 172 golang.org/x/sys v0.34.0 // indirect 173 golang.org/x/text v0.27.0 // indirect 174 golang.org/x/time v0.12.0 // indirect
+2 -2
guard/guard.go
··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 - "tangled.sh/tangled.sh/core/idresolver" 19 - "tangled.sh/tangled.sh/core/log" 20 ) 21 22 func Command() *cli.Command {
··· 15 "github.com/bluesky-social/indigo/atproto/identity" 16 securejoin "github.com/cyphar/filepath-securejoin" 17 "github.com/urfave/cli/v3" 18 + "tangled.org/core/idresolver" 19 + "tangled.org/core/log" 20 ) 21 22 func Command() *cli.Command {
+1 -1
jetstream/jetstream.go
··· 13 "github.com/bluesky-social/jetstream/pkg/client" 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 "github.com/bluesky-social/jetstream/pkg/models" 16 - "tangled.sh/tangled.sh/core/log" 17 ) 18 19 type DB interface {
··· 13 "github.com/bluesky-social/jetstream/pkg/client" 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 "github.com/bluesky-social/jetstream/pkg/models" 16 + "tangled.org/core/log" 17 ) 18 19 type DB interface {
+1 -1
keyfetch/keyfetch.go
··· 10 "strings" 11 12 "github.com/urfave/cli/v3" 13 - "tangled.sh/tangled.sh/core/log" 14 ) 15 16 func Command() *cli.Command {
··· 10 "strings" 11 12 "github.com/urfave/cli/v3" 13 + "tangled.org/core/log" 14 ) 15 16 func Command() *cli.Command {
+1 -1
knotserver/config/config.go
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
··· 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 Git Git `env:",prefix=KNOT_GIT_"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 } 46 47 func Load(ctx context.Context) (*Config, error) {
+1 -1
knotserver/db/events.go
··· 4 "fmt" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/notifier" 8 ) 9 10 type Event struct {
··· 4 "fmt" 5 "time" 6 7 + "tangled.org/core/notifier" 8 ) 9 10 type Event struct {
+1 -1
knotserver/db/pubkeys.go
··· 4 "strconv" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 ) 9 10 type PublicKey struct {
··· 4 "strconv" 5 "time" 6 7 + "tangled.org/core/api/tangled" 8 ) 9 10 type PublicKey struct {
+1 -1
knotserver/git/branch.go
··· 9 10 "github.com/go-git/go-git/v5/plumbing" 11 "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.sh/tangled.sh/core/types" 13 ) 14 15 func (g *GitRepo) Branches() ([]types.Branch, error) {
··· 9 10 "github.com/go-git/go-git/v5/plumbing" 11 "github.com/go-git/go-git/v5/plumbing/object" 12 + "tangled.org/core/types" 13 ) 14 15 func (g *GitRepo) Branches() ([]types.Branch, error) {
+2 -2
knotserver/git/diff.go
··· 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 - "tangled.sh/tangled.sh/core/types" 17 ) 18 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
··· 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 "github.com/go-git/go-git/v5/plumbing" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 + "tangled.org/core/patchutil" 16 + "tangled.org/core/types" 17 ) 18 19 func (g *GitRepo) Diff() (*types.NiceDiff, error) {
-103
knotserver/git/git.go
··· 27 h plumbing.Hash 28 } 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 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 // to tar WriteHeader 45 type infoWrapper struct { ··· 48 mode fs.FileMode 49 modTime time.Time 50 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 } 90 91 func Open(path string, ref string) (*GitRepo, error) { ··· 171 return g.r.CommitObject(h) 172 } 173 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 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 c, err := g.r.CommitObject(g.h) 184 if err != nil { ··· 211 } 212 213 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 } 240 241 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 func (i *infoWrapper) Sys() any { 411 return nil 412 } 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 - }
··· 27 h plumbing.Hash 28 } 29 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 31 // to tar WriteHeader 32 type infoWrapper struct { ··· 35 mode fs.FileMode 36 modTime time.Time 37 isDir bool 38 } 39 40 func Open(path string, ref string) (*GitRepo, error) { ··· 120 return g.r.CommitObject(h) 121 } 122 123 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 124 c, err := g.r.CommitObject(g.h) 125 if err != nil { ··· 152 } 153 154 return buf.Bytes(), nil 155 } 156 157 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 326 func (i *infoWrapper) Sys() any { 327 return nil 328 }
+1 -1
knotserver/git/post_receive.go
··· 9 "strings" 10 "time" 11 12 - "tangled.sh/tangled.sh/core/api/tangled" 13 14 "github.com/go-git/go-git/v5/plumbing" 15 )
··· 9 "strings" 10 "time" 11 12 + "tangled.org/core/api/tangled" 13 14 "github.com/go-git/go-git/v5/plumbing" 15 )
+1 -3
knotserver/git/tag.go
··· 2 3 import ( 4 "fmt" 5 - "slices" 6 "strconv" 7 "strings" 8 "time" ··· 35 outFormat.WriteString("") 36 outFormat.WriteString(recordSeparator) 37 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 if err != nil { 40 return nil, fmt.Errorf("failed to get tags: %w", err) 41 } ··· 94 tags = append(tags, tag) 95 } 96 97 - slices.Reverse(tags) 98 return tags, nil 99 }
··· 2 3 import ( 4 "fmt" 5 "strconv" 6 "strings" 7 "time" ··· 34 outFormat.WriteString("") 35 outFormat.WriteString(recordSeparator) 36 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 38 if err != nil { 39 return nil, fmt.Errorf("failed to get tags: %w", err) 40 } ··· 93 tags = append(tags, tag) 94 } 95 96 return tags, nil 97 }
+1 -1
knotserver/git/tree.go
··· 8 "time" 9 10 "github.com/go-git/go-git/v5/plumbing/object" 11 - "tangled.sh/tangled.sh/core/types" 12 ) 13 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
··· 8 "time" 9 10 "github.com/go-git/go-git/v5/plumbing/object" 11 + "tangled.org/core/types" 12 ) 13 14 func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) {
+1 -1
knotserver/git.go
··· 10 11 securejoin "github.com/cyphar/filepath-securejoin" 12 "github.com/go-chi/chi/v5" 13 - "tangled.sh/tangled.sh/core/knotserver/git/service" 14 ) 15 16 func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
··· 10 11 securejoin "github.com/cyphar/filepath-securejoin" 12 "github.com/go-chi/chi/v5" 13 + "tangled.org/core/knotserver/git/service" 14 ) 15 16 func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
-4
knotserver/http_util.go
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
··· 16 w.WriteHeader(status) 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 }
+8 -8
knotserver/ingester.go
··· 15 "github.com/bluesky-social/indigo/xrpc" 16 "github.com/bluesky-social/jetstream/pkg/models" 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" 25 ) 26 27 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 151 return fmt.Errorf("failed to construct absolute repo path: %w", err) 152 } 153 154 - gr, err := git.Open(repoPath, record.Source.Branch) 155 if err != nil { 156 return fmt.Errorf("failed to open git repository: %w", err) 157 }
··· 15 "github.com/bluesky-social/indigo/xrpc" 16 "github.com/bluesky-social/jetstream/pkg/models" 17 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 26 27 func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { ··· 151 return fmt.Errorf("failed to construct absolute repo path: %w", err) 152 } 153 154 + gr, err := git.Open(repoPath, record.Source.Sha) 155 if err != nil { 156 return fmt.Errorf("failed to open git repository: %w", err) 157 }
+8 -8
knotserver/internal.go
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 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" 24 ) 25 26 type InternalHandle struct {
··· 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 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 ) 25 26 type InternalHandle struct {
+9 -9
knotserver/router.go
··· 7 "net/http" 8 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" 19 ) 20 21 type Knot struct {
··· 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 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 ) 20 21 type Knot struct {
+8 -8
knotserver/server.go
··· 6 "net/http" 7 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" 17 ) 18 19 func Command() *cli.Command {
··· 6 "net/http" 7 8 "github.com/urfave/cli/v3" 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 ) 18 19 func Command() *cli.Command {
+5 -5
knotserver/xrpc/create_repo.go
··· 13 "github.com/bluesky-social/indigo/xrpc" 14 securejoin "github.com/cyphar/filepath-securejoin" 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" 21 ) 22 23 func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
··· 13 "github.com/bluesky-social/indigo/xrpc" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 gogit "github.com/go-git/go-git/v5" 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 ) 22 23 func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/delete_repo.go
··· 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/bluesky-social/indigo/xrpc" 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" 17 ) 18 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
··· 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "github.com/bluesky-social/indigo/xrpc" 13 securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/rbac" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 ) 18 19 func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5 -5
knotserver/xrpc/fork_status.go
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 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" 16 ) 17 18 func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 17 18 func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/fork_sync.go
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 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" 15 ) 16 17 func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 16 17 func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/hidden_ref.go
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/bluesky-social/indigo/xrpc" 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" 16 ) 17 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/bluesky-social/indigo/xrpc" 11 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 17 18 func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/list_keys.go
··· 4 "net/http" 5 "strconv" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
··· 4 "net/http" 5 "strconv" 6 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) {
+6 -6
knotserver/xrpc/merge.go
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 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" 17 ) 18 19 func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
··· 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 18 19 func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/merge_check.go
··· 7 "net/http" 8 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" 13 ) 14 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
··· 7 "net/http" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/owner.go
··· 3 import ( 4 "net/http" 5 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 ) 9 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
··· 3 import ( 4 "net/http" 5 6 + "tangled.org/core/api/tangled" 7 + xrpcerr "tangled.org/core/xrpc/errors" 8 ) 9 10 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+2 -2
knotserver/xrpc/repo_archive.go
··· 8 9 "github.com/go-git/go-git/v5/plumbing" 10 11 - "tangled.sh/tangled.sh/core/knotserver/git" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
··· 8 9 "github.com/go-git/go-git/v5/plumbing" 10 11 + "tangled.org/core/knotserver/git" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+4 -4
knotserver/xrpc/repo_blob.go
··· 9 "slices" 10 "strings" 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" 15 ) 16 17 func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
··· 9 "slices" 10 "strings" 11 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + xrpcerr "tangled.org/core/xrpc/errors" 15 ) 16 17 func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 44 45 contents, err := gr.RawContent(treePath) 46 if err != nil { 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 writeError(w, xrpcerr.NewXrpcError( 49 xrpcerr.WithTag("FileNotFound"), 50 xrpcerr.WithMessage("file not found at the specified path"),
+3 -3
knotserver/xrpc/repo_branch.go
··· 5 "net/url" 6 "time" 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" 11 ) 12 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
··· 5 "net/url" 6 "time" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 ) 12 13 func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_branches.go
··· 4 "net/http" 5 "strconv" 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" 10 ) 11 12 func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
··· 4 "net/http" 5 "strconv" 6 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 ) 11 12 func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_compare.go
··· 4 "fmt" 5 "net/http" 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" 10 ) 11 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
··· 4 "fmt" 5 "net/http" 6 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 ) 11 12 func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_diff.go
··· 3 import ( 4 "net/http" 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" 9 ) 10 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
··· 3 import ( 4 "net/http" 5 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_get_default_branch.go
··· 4 "net/http" 5 "time" 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" 10 ) 11 12 func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
··· 4 "net/http" 5 "time" 6 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/knotserver/git" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 ) 11 12 func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_languages.go
··· 6 "net/http" 7 "time" 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" 12 ) 13 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
··· 6 "net/http" 7 "time" 8 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/knotserver/git" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 ) 13 14 func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_log.go
··· 4 "net/http" 5 "strconv" 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" 10 ) 11 12 func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
··· 4 "net/http" 5 "strconv" 6 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 ) 11 12 func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+3 -3
knotserver/xrpc/repo_tags.go
··· 7 "github.com/go-git/go-git/v5/plumbing" 8 "github.com/go-git/go-git/v5/plumbing/object" 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" 13 ) 14 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
··· 7 "github.com/go-git/go-git/v5/plumbing" 8 "github.com/go-git/go-git/v5/plumbing/object" 9 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) {
+27 -3
knotserver/xrpc/repo_tree.go
··· 4 "net/http" 5 "path/filepath" 6 "time" 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" 11 ) 12 13 func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 43 return 44 } 45 46 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 for i, file := range files { ··· 83 Parent: parentPtr, 84 Dotdot: dotdotPtr, 85 Files: treeEntries, 86 } 87 88 writeJson(w, response)
··· 4 "net/http" 5 "path/filepath" 6 "time" 7 + "unicode/utf8" 8 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" 13 ) 14 15 func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 45 return 46 } 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 + 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 68 for i, file := range files { ··· 103 Parent: parentPtr, 104 Dotdot: dotdotPtr, 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 110 } 111 112 writeJson(w, response)
+4 -4
knotserver/xrpc/set_default_branch.go
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/bluesky-social/indigo/xrpc" 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 16 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 ) 18 19 const ActorDid string = "ActorDid"
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/bluesky-social/indigo/xrpc" 11 securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/knotserver/git" 14 + "tangled.org/core/rbac" 15 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 ) 18 19 const ActorDid string = "ActorDid"
+2 -2
knotserver/xrpc/version.go
··· 5 "net/http" 6 "runtime/debug" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 ) 10 11 // version is set during build time. ··· 24 var modified bool 25 26 for _, mod := range info.Deps { 27 - if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 28 modVer = mod.Version 29 break 30 }
··· 5 "net/http" 6 "runtime/debug" 7 8 + "tangled.org/core/api/tangled" 9 ) 10 11 // version is set during build time. ··· 24 var modified bool 25 26 for _, mod := range info.Deps { 27 + if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" { 28 modVer = mod.Version 29 break 30 }
+9 -9
knotserver/xrpc/xrpc.go
··· 7 "strings" 8 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" 19 20 "github.com/go-chi/chi/v5" 21 )
··· 7 "strings" 8 9 securejoin "github.com/cyphar/filepath-securejoin" 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 20 "github.com/go-chi/chi/v5" 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 "package": "tangled", 4 "prefix": "sh.tangled", 5 "outdir": "api/tangled", 6 - "import": "tangled.sh/tangled.sh/core/api/tangled", 7 "gen-server": true 8 } 9 ]
··· 3 "package": "tangled", 4 "prefix": "sh.tangled", 5 "outdir": "api/tangled", 6 + "import": "tangled.org/core/api/tangled", 7 "gen-server": true 8 } 9 ]
+19
lexicons/repo/tree.json
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 "files": { 45 "type": "array", 46 "items": { ··· 69 "description": "Invalid request parameters" 70 } 71 ] 72 }, 73 "treeEntry": { 74 "type": "object",
··· 41 "type": "string", 42 "description": "Parent directory path" 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 49 "files": { 50 "type": "array", 51 "items": { ··· 74 "description": "Invalid request parameters" 75 } 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 + } 91 }, 92 "treeEntry": { 93 "type": "object",
+2 -2
nix/pkgs/knot-unwrapped.nix
··· 4 sqlite-lib, 5 src, 6 }: let 7 - version = "1.9.0-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot"; ··· 16 tags = ["libsqlite3"]; 17 18 ldflags = [ 19 - "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 ]; 21 22 env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
··· 4 sqlite-lib, 5 src, 6 }: let 7 + version = "1.9.1-alpha"; 8 in 9 buildGoApplication { 10 pname = "knot"; ··· 16 tags = ["libsqlite3"]; 17 18 ldflags = [ 19 + "-X tangled.org/core/knotserver/xrpc.version=${version}" 20 ]; 21 22 env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+1 -1
patchutil/interdiff.go
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 - "tangled.sh/tangled.sh/core/types" 9 ) 10 11 type InterdiffResult struct {
··· 5 "strings" 6 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/types" 9 ) 10 11 type InterdiffResult struct {
+1 -1
patchutil/patchutil.go
··· 10 "strings" 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 - "tangled.sh/tangled.sh/core/types" 14 ) 15 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
··· 10 "strings" 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 + "tangled.org/core/types" 14 ) 15 16 func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) {
+1 -1
rbac/rbac_test.go
··· 4 "database/sql" 5 "testing" 6 7 - "tangled.sh/tangled.sh/core/rbac" 8 9 adapter "github.com/Blank-Xu/sql-adapter" 10 "github.com/casbin/casbin/v2"
··· 4 "database/sql" 5 "testing" 6 7 + "tangled.org/core/rbac" 8 9 adapter "github.com/Blank-Xu/sql-adapter" 10 "github.com/casbin/casbin/v2"
+4 -4
spindle/db/events.go
··· 5 "fmt" 6 "time" 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" 12 ) 13 14 type Event struct {
··· 5 "fmt" 6 "time" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/notifier" 10 + "tangled.org/core/spindle/models" 11 + "tangled.org/core/tid" 12 ) 13 14 type Event struct {
+5 -5
spindle/engine/engine.go
··· 8 9 securejoin "github.com/cyphar/filepath-securejoin" 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" 16 ) 17 18 var (
··· 8 9 securejoin "github.com/cyphar/filepath-securejoin" 10 "golang.org/x/sync/errgroup" 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 ) 17 18 var (
+6 -6
spindle/engines/nixery/engine.go
··· 19 "github.com/docker/docker/client" 20 "github.com/docker/docker/pkg/stdcopy" 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" 28 ) 29 30 const (
··· 19 "github.com/docker/docker/client" 20 "github.com/docker/docker/pkg/stdcopy" 21 "gopkg.in/yaml.v3" 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 ) 29 30 const (
+2 -2
spindle/engines/nixery/setup_steps.go
··· 5 "path" 6 "strings" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - "tangled.sh/tangled.sh/core/workflow" 10 ) 11 12 func nixConfStep() Step {
··· 5 "path" 6 "strings" 7 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/workflow" 10 ) 11 12 func nixConfStep() Step {
+5 -5
spindle/ingester.go
··· 7 "fmt" 8 "time" 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" 15 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 17 "github.com/bluesky-social/indigo/atproto/identity"
··· 7 "fmt" 8 "time" 9 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 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 17 "github.com/bluesky-social/indigo/atproto/identity"
+2 -2
spindle/models/engine.go
··· 4 "context" 5 "time" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - "tangled.sh/tangled.sh/core/spindle/secrets" 9 ) 10 11 type Engine interface {
··· 4 "context" 5 "time" 6 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/spindle/secrets" 9 ) 10 11 type Engine interface {
+1 -1
spindle/models/models.go
··· 5 "regexp" 6 "slices" 7 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 )
··· 5 "regexp" 6 "slices" 7 8 + "tangled.org/core/api/tangled" 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 )
+17 -17
spindle/server.go
··· 9 "net/http" 10 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" 29 ) 30 31 //go:embed motd
··· 9 "net/http" 10 11 "github.com/go-chi/chi/v5" 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 ) 30 31 //go:embed motd
+1 -1
spindle/stream.go
··· 10 "strconv" 11 "time" 12 13 - "tangled.sh/tangled.sh/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" 16 "github.com/gorilla/websocket"
··· 10 "strconv" 11 "time" 12 13 + "tangled.org/core/spindle/models" 14 15 "github.com/go-chi/chi/v5" 16 "github.com/gorilla/websocket"
+4 -4
spindle/xrpc/add_secret.go
··· 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "github.com/bluesky-social/indigo/xrpc" 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" 17 ) 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
··· 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "github.com/bluesky-social/indigo/xrpc" 12 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 18 19 func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
+4 -4
spindle/xrpc/list_secrets.go
··· 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "github.com/bluesky-social/indigo/xrpc" 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" 17 ) 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
··· 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "github.com/bluesky-social/indigo/xrpc" 12 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 18 19 func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
+2 -2
spindle/xrpc/owner.go
··· 4 "encoding/json" 5 "net/http" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
··· 4 "encoding/json" 5 "net/http" 6 7 + "tangled.org/core/api/tangled" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 ) 10 11 func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) {
+4 -4
spindle/xrpc/remove_secret.go
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/bluesky-social/indigo/xrpc" 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" 16 ) 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 "github.com/bluesky-social/indigo/xrpc" 11 securejoin "github.com/cyphar/filepath-securejoin" 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 ) 17 18 func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
+9 -9
spindle/xrpc/xrpc.go
··· 8 9 "github.com/go-chi/chi/v5" 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" 20 ) 21 22 const ActorDid string = "ActorDid"
··· 8 9 "github.com/go-chi/chi/v5" 10 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 ) 21 22 const ActorDid string = "ActorDid"
+7 -5
types/repo.go
··· 41 } 42 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"` 49 } 50 51 type TagReference struct {
··· 41 } 42 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"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 51 } 52 53 type TagReference struct {
+1 -1
workflow/compile.go
··· 4 "errors" 5 "fmt" 6 7 - "tangled.sh/tangled.sh/core/api/tangled" 8 ) 9 10 type RawWorkflow struct {
··· 4 "errors" 5 "fmt" 6 7 + "tangled.org/core/api/tangled" 8 ) 9 10 type RawWorkflow struct {
+1 -1
workflow/compile_test.go
··· 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 ) 10 11 var trigger = tangled.Pipeline_TriggerMetadata{
··· 5 "testing" 6 7 "github.com/stretchr/testify/assert" 8 + "tangled.org/core/api/tangled" 9 ) 10 11 var trigger = tangled.Pipeline_TriggerMetadata{
+1 -1
workflow/def.go
··· 6 "slices" 7 "strings" 8 9 - "tangled.sh/tangled.sh/core/api/tangled" 10 11 "github.com/go-git/go-git/v5/plumbing" 12 "gopkg.in/yaml.v3"
··· 6 "slices" 7 "strings" 8 9 + "tangled.org/core/api/tangled" 10 11 "github.com/go-git/go-git/v5/plumbing" 12 "gopkg.in/yaml.v3"
+2 -2
xrpc/serviceauth/service_auth.go
··· 8 "strings" 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 - "tangled.sh/tangled.sh/core/idresolver" 12 - xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 ) 14 15 const ActorDid string = "ActorDid"
··· 8 "strings" 9 10 "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.org/core/idresolver" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 ) 14 15 const ActorDid string = "ActorDid"