+1
-1
.air/knotserver.toml
+1
-1
.air/knotserver.toml
···
1
1
[build]
2
-
cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/'
2
+
cmd = 'go build -ldflags "-X tangled.org/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knot/'
3
3
bin = ".bin/knot server"
4
4
root = "."
5
5
+6
.tangled/workflows/test.yml
+6
.tangled/workflows/test.yml
+10
api/tangled/repotree.go
+10
api/tangled/repotree.go
···
31
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
32
// parent: The parent path in the tree
33
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// readme: Readme for this file tree
35
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
34
36
// ref: The git reference used
35
37
Ref string `json:"ref" cborgen:"ref"`
38
+
}
39
+
40
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41
+
type RepoTree_Readme struct {
42
+
// contents: Contents of the readme file
43
+
Contents string `json:"contents" cborgen:"contents"`
44
+
// filename: Name of the readme file
45
+
Filename string `json:"filename" cborgen:"filename"`
36
46
}
37
47
38
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+1
-1
appview/cache/session/store.go
+1
-1
appview/cache/session/store.go
+5
-4
appview/commitverify/verify.go
+5
-4
appview/commitverify/verify.go
···
4
4
"log"
5
5
6
6
"github.com/go-git/go-git/v5/plumbing/object"
7
-
"tangled.sh/tangled.sh/core/appview/db"
8
-
"tangled.sh/tangled.sh/core/crypto"
9
-
"tangled.sh/tangled.sh/core/types"
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/crypto"
10
+
"tangled.org/core/types"
10
11
)
11
12
12
13
type verifiedCommit struct {
···
45
46
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
46
47
vcs := VerifiedCommits{}
47
48
48
-
didPubkeyCache := make(map[string][]db.PublicKey)
49
+
didPubkeyCache := make(map[string][]models.PublicKey)
49
50
50
51
for _, commit := range ndCommits {
51
52
c := commit.Commit
+4
-2
appview/config/config.go
+4
-2
appview/config/config.go
···
72
72
}
73
73
74
74
type Cloudflare struct {
75
-
ApiToken string `env:"API_TOKEN"`
76
-
ZoneId string `env:"ZONE_ID"`
75
+
ApiToken string `env:"API_TOKEN"`
76
+
ZoneId string `env:"ZONE_ID"`
77
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
78
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
77
79
}
78
80
79
81
func (cfg RedisConfig) ToURL() string {
+5
-25
appview/db/artifact.go
+5
-25
appview/db/artifact.go
···
5
5
"strings"
6
6
"time"
7
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
8
"github.com/go-git/go-git/v5/plumbing"
10
9
"github.com/ipfs/go-cid"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
10
+
"tangled.org/core/appview/models"
12
11
)
13
12
14
-
type Artifact struct {
15
-
Id uint64
16
-
Did string
17
-
Rkey string
18
-
19
-
RepoAt syntax.ATURI
20
-
Tag plumbing.Hash
21
-
CreatedAt time.Time
22
-
23
-
BlobCid cid.Cid
24
-
Name string
25
-
Size uint64
26
-
MimeType string
27
-
}
28
-
29
-
func (a *Artifact) ArtifactAt() syntax.ATURI {
30
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
31
-
}
32
-
33
-
func AddArtifact(e Execer, artifact Artifact) error {
13
+
func AddArtifact(e Execer, artifact models.Artifact) error {
34
14
_, err := e.Exec(
35
15
`insert or ignore into artifacts (
36
16
did,
···
57
37
return err
58
38
}
59
39
60
-
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
61
-
var artifacts []Artifact
40
+
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
+
var artifacts []models.Artifact
62
42
63
43
var conditions []string
64
44
var args []any
···
94
74
defer rows.Close()
95
75
96
76
for rows.Next() {
97
-
var artifact Artifact
77
+
var artifact models.Artifact
98
78
var createdAt string
99
79
var tag []byte
100
80
var blobCid string
+3
-18
appview/db/collaborators.go
+3
-18
appview/db/collaborators.go
···
3
3
import (
4
4
"fmt"
5
5
"strings"
6
-
"time"
7
6
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
9
8
)
10
9
11
-
type Collaborator struct {
12
-
// identifiers for the record
13
-
Id int64
14
-
Did syntax.DID
15
-
Rkey string
16
-
17
-
// content
18
-
SubjectDid syntax.DID
19
-
RepoAt syntax.ATURI
20
-
21
-
// meta
22
-
Created time.Time
23
-
}
24
-
25
-
func AddCollaborator(e Execer, c Collaborator) error {
10
+
func AddCollaborator(e Execer, c models.Collaborator) error {
26
11
_, err := e.Exec(
27
12
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
13
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
···
49
34
return err
50
35
}
51
36
52
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
37
+
func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) {
53
38
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
39
if err != nil {
55
40
return nil, err
+172
-10
appview/db/db.go
+172
-10
appview/db/db.go
···
527
527
-- label to subscribe to
528
528
label_at text not null,
529
529
530
-
unique (repo_at, label_at),
531
-
foreign key (label_at) references label_definitions (at_uri)
530
+
unique (repo_at, label_at)
531
+
);
532
+
533
+
create table if not exists notifications (
534
+
id integer primary key autoincrement,
535
+
recipient_did text not null,
536
+
actor_did text not null,
537
+
type text not null,
538
+
entity_type text not null,
539
+
entity_id text not null,
540
+
read integer not null default 0,
541
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542
+
repo_id integer references repos(id),
543
+
issue_id integer references issues(id),
544
+
pull_id integer references pulls(id)
545
+
);
546
+
547
+
create table if not exists notification_preferences (
548
+
id integer primary key autoincrement,
549
+
user_did text not null unique,
550
+
repo_starred integer not null default 1,
551
+
issue_created integer not null default 1,
552
+
issue_commented integer not null default 1,
553
+
pull_created integer not null default 1,
554
+
pull_commented integer not null default 1,
555
+
followed integer not null default 1,
556
+
pull_merged integer not null default 1,
557
+
issue_closed integer not null default 1,
558
+
email_notifications integer not null default 0
532
559
);
533
560
534
561
create table if not exists migrations (
···
536
563
name text unique
537
564
);
538
565
539
-
-- indexes for better star query performance
566
+
-- indexes for better performance
567
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
540
569
create index if not exists idx_stars_created on stars(created);
541
570
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
542
571
`)
···
788
817
_, err := tx.Exec(`
789
818
alter table spindles add column needs_upgrade integer not null default 0;
790
819
`)
791
-
if err != nil {
792
-
return err
793
-
}
794
-
795
-
_, err = tx.Exec(`
796
-
update spindles set needs_upgrade = 1;
797
-
`)
798
820
return err
799
821
})
800
822
···
931
953
_, err = tx.Exec(`drop table comments`)
932
954
return err
933
955
})
956
+
957
+
// add generated at_uri column to pulls table
958
+
//
959
+
// this requires a full table recreation because stored columns
960
+
// cannot be added via alter
961
+
//
962
+
// disable foreign-keys for the next migration
963
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
+
_, err := tx.Exec(`
966
+
create table if not exists pulls_new (
967
+
-- identifiers
968
+
id integer primary key autoincrement,
969
+
pull_id integer not null,
970
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
971
+
972
+
-- at identifiers
973
+
repo_at text not null,
974
+
owner_did text not null,
975
+
rkey text not null,
976
+
977
+
-- content
978
+
title text not null,
979
+
body text not null,
980
+
target_branch text not null,
981
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
982
+
983
+
-- source info
984
+
source_branch text,
985
+
source_repo_at text,
986
+
987
+
-- stacking
988
+
stack_id text,
989
+
change_id text,
990
+
parent_change_id text,
991
+
992
+
-- meta
993
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
994
+
995
+
-- constraints
996
+
unique(repo_at, pull_id),
997
+
unique(at_uri),
998
+
foreign key (repo_at) references repos(at_uri) on delete cascade
999
+
);
1000
+
`)
1001
+
if err != nil {
1002
+
return err
1003
+
}
1004
+
1005
+
// transfer data
1006
+
_, err = tx.Exec(`
1007
+
insert into pulls_new (
1008
+
id, pull_id, repo_at, owner_did, rkey,
1009
+
title, body, target_branch, state,
1010
+
source_branch, source_repo_at,
1011
+
stack_id, change_id, parent_change_id,
1012
+
created
1013
+
)
1014
+
select
1015
+
id, pull_id, repo_at, owner_did, rkey,
1016
+
title, body, target_branch, state,
1017
+
source_branch, source_repo_at,
1018
+
stack_id, change_id, parent_change_id,
1019
+
created
1020
+
from pulls;
1021
+
`)
1022
+
if err != nil {
1023
+
return err
1024
+
}
1025
+
1026
+
// drop old table
1027
+
_, err = tx.Exec(`drop table pulls`)
1028
+
if err != nil {
1029
+
return err
1030
+
}
1031
+
1032
+
// rename new table
1033
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1034
+
return err
1035
+
})
1036
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1037
+
1038
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1039
+
//
1040
+
// this requires a full table recreation because stored columns
1041
+
// cannot be added via alter
1042
+
//
1043
+
// disable foreign-keys for the next migration
1044
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
+
_, err := tx.Exec(`
1047
+
create table if not exists pull_submissions_new (
1048
+
-- identifiers
1049
+
id integer primary key autoincrement,
1050
+
pull_at text not null,
1051
+
1052
+
-- content, these are immutable, and require a resubmission to update
1053
+
round_number integer not null default 0,
1054
+
patch text,
1055
+
source_rev text,
1056
+
1057
+
-- meta
1058
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1059
+
1060
+
-- constraints
1061
+
unique(pull_at, round_number),
1062
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1063
+
);
1064
+
`)
1065
+
if err != nil {
1066
+
return err
1067
+
}
1068
+
1069
+
// transfer data, constructing pull_at from pulls table
1070
+
_, err = tx.Exec(`
1071
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1072
+
select
1073
+
ps.id,
1074
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1075
+
ps.round_number,
1076
+
ps.patch,
1077
+
ps.created
1078
+
from pull_submissions ps
1079
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1080
+
`)
1081
+
if err != nil {
1082
+
return err
1083
+
}
1084
+
1085
+
// drop old table
1086
+
_, err = tx.Exec(`drop table pull_submissions`)
1087
+
if err != nil {
1088
+
return err
1089
+
}
1090
+
1091
+
// rename new table
1092
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1093
+
return err
1094
+
})
1095
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
934
1096
935
1097
return &DB{db}, nil
936
1098
}
+29
-34
appview/db/email.go
+29
-34
appview/db/email.go
···
3
3
import (
4
4
"strings"
5
5
"time"
6
-
)
7
6
8
-
type Email struct {
9
-
ID int64
10
-
Did string
11
-
Address string
12
-
Verified bool
13
-
Primary bool
14
-
VerificationCode string
15
-
LastSent *time.Time
16
-
CreatedAt time.Time
17
-
}
7
+
"tangled.org/core/appview/models"
8
+
)
18
9
19
-
func GetPrimaryEmail(e Execer, did string) (Email, error) {
10
+
func GetPrimaryEmail(e Execer, did string) (models.Email, error) {
20
11
query := `
21
12
select id, did, email, verified, is_primary, verification_code, last_sent, created
22
13
from emails
23
14
where did = ? and is_primary = true
24
15
`
25
-
var email Email
16
+
var email models.Email
26
17
var createdStr string
27
18
var lastSent string
28
19
err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
29
20
if err != nil {
30
-
return Email{}, err
21
+
return models.Email{}, err
31
22
}
32
23
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
33
24
if err != nil {
34
-
return Email{}, err
25
+
return models.Email{}, err
35
26
}
36
27
parsedTime, err := time.Parse(time.RFC3339, lastSent)
37
28
if err != nil {
38
-
return Email{}, err
29
+
return models.Email{}, err
39
30
}
40
31
email.LastSent = &parsedTime
41
32
return email, nil
42
33
}
43
34
44
-
func GetEmail(e Execer, did string, em string) (Email, error) {
35
+
func GetEmail(e Execer, did string, em string) (models.Email, error) {
45
36
query := `
46
37
select id, did, email, verified, is_primary, verification_code, last_sent, created
47
38
from emails
48
39
where did = ? and email = ?
49
40
`
50
-
var email Email
41
+
var email models.Email
51
42
var createdStr string
52
43
var lastSent string
53
44
err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
54
45
if err != nil {
55
-
return Email{}, err
46
+
return models.Email{}, err
56
47
}
57
48
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
58
49
if err != nil {
59
-
return Email{}, err
50
+
return models.Email{}, err
60
51
}
61
52
parsedTime, err := time.Parse(time.RFC3339, lastSent)
62
53
if err != nil {
63
-
return Email{}, err
54
+
return models.Email{}, err
64
55
}
65
56
email.LastSent = &parsedTime
66
57
return email, nil
···
80
71
return did, nil
81
72
}
82
73
83
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
84
-
if len(ems) == 0 {
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
85
76
return make(map[string]string), nil
86
77
}
87
78
···
90
81
verifiedFilter = 1
91
82
}
92
83
84
+
assoc := make(map[string]string)
85
+
93
86
// Create placeholders for the IN clause
94
-
placeholders := make([]string, len(ems))
95
-
args := make([]any, len(ems)+1)
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
96
89
97
90
args[0] = verifiedFilter
98
-
for i, em := range ems {
99
-
placeholders[i] = "?"
100
-
args[i+1] = em
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
101
98
}
102
99
103
100
query := `
···
113
110
return nil, err
114
111
}
115
112
defer rows.Close()
116
-
117
-
assoc := make(map[string]string)
118
113
119
114
for rows.Next() {
120
115
var email, did string
···
187
182
return count > 0, nil
188
183
}
189
184
190
-
func AddEmail(e Execer, email Email) error {
185
+
func AddEmail(e Execer, email models.Email) error {
191
186
// Check if this is the first email for this DID
192
187
countQuery := `
193
188
select count(*)
···
254
249
return err
255
250
}
256
251
257
-
func GetAllEmails(e Execer, did string) ([]Email, error) {
252
+
func GetAllEmails(e Execer, did string) ([]models.Email, error) {
258
253
query := `
259
254
select did, email, verified, is_primary, verification_code, last_sent, created
260
255
from emails
···
266
261
}
267
262
defer rows.Close()
268
263
269
-
var emails []Email
264
+
var emails []models.Email
270
265
for rows.Next() {
271
-
var email Email
266
+
var email models.Email
272
267
var createdStr string
273
268
var lastSent string
274
269
err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+26
-57
appview/db/follow.go
+26
-57
appview/db/follow.go
···
5
5
"log"
6
6
"strings"
7
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
8
10
)
9
11
10
-
type Follow struct {
11
-
UserDid string
12
-
SubjectDid string
13
-
FollowedAt time.Time
14
-
Rkey string
15
-
}
16
-
17
-
func AddFollow(e Execer, follow *Follow) error {
12
+
func AddFollow(e Execer, follow *models.Follow) error {
18
13
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
19
14
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
20
15
return err
21
16
}
22
17
23
18
// Get a follow record
24
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
19
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
25
20
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
26
21
row := e.QueryRow(query, userDid, subjectDid)
27
22
28
-
var follow Follow
23
+
var follow models.Follow
29
24
var followedAt string
30
25
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
31
26
if err != nil {
···
55
50
return err
56
51
}
57
52
58
-
type FollowStats struct {
59
-
Followers int64
60
-
Following int64
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
53
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
64
54
var followers, following int64
65
55
err := e.QueryRow(
66
56
`SELECT
···
68
58
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
59
FROM follows;`, did, did).Scan(&followers, &following)
70
60
if err != nil {
71
-
return FollowStats{}, err
61
+
return models.FollowStats{}, err
72
62
}
73
-
return FollowStats{
63
+
return models.FollowStats{
74
64
Followers: followers,
75
65
Following: following,
76
66
}, nil
77
67
}
78
68
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
69
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
80
70
if len(dids) == 0 {
81
71
return nil, nil
82
72
}
···
112
102
) g on f.did = g.did`,
113
103
placeholderStr, placeholderStr)
114
104
115
-
result := make(map[string]FollowStats)
105
+
result := make(map[string]models.FollowStats)
116
106
117
107
rows, err := e.Query(query, args...)
118
108
if err != nil {
···
126
116
if err := rows.Scan(&did, &followers, &following); err != nil {
127
117
return nil, err
128
118
}
129
-
result[did] = FollowStats{
119
+
result[did] = models.FollowStats{
130
120
Followers: followers,
131
121
Following: following,
132
122
}
···
134
124
135
125
for _, did := range dids {
136
126
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
127
+
result[did] = models.FollowStats{
138
128
Followers: 0,
139
129
Following: 0,
140
130
}
···
144
134
return result, nil
145
135
}
146
136
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
148
-
var follows []Follow
137
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
var follows []models.Follow
149
139
150
140
var conditions []string
151
141
var args []any
···
177
167
return nil, err
178
168
}
179
169
for rows.Next() {
180
-
var follow Follow
170
+
var follow models.Follow
181
171
var followedAt string
182
172
err := rows.Scan(
183
173
&follow.UserDid,
···
200
190
return follows, nil
201
191
}
202
192
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
193
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
204
194
return GetFollows(e, 0, FilterEq("subject_did", did))
205
195
}
206
196
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
197
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
208
198
return GetFollows(e, 0, FilterEq("user_did", did))
209
199
}
210
200
211
-
type FollowStatus int
212
-
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
218
-
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
229
-
}
230
-
}
231
-
232
-
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
201
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
233
202
if len(subjectDids) == 0 || userDid == "" {
234
-
return make(map[string]FollowStatus), nil
203
+
return make(map[string]models.FollowStatus), nil
235
204
}
236
205
237
-
result := make(map[string]FollowStatus)
206
+
result := make(map[string]models.FollowStatus)
238
207
239
208
for _, subjectDid := range subjectDids {
240
209
if userDid == subjectDid {
241
-
result[subjectDid] = IsSelf
210
+
result[subjectDid] = models.IsSelf
242
211
} else {
243
-
result[subjectDid] = IsNotFollowing
212
+
result[subjectDid] = models.IsNotFollowing
244
213
}
245
214
}
246
215
···
281
250
if err := rows.Scan(&subjectDid); err != nil {
282
251
return nil, err
283
252
}
284
-
result[subjectDid] = IsFollowing
253
+
result[subjectDid] = models.IsFollowing
285
254
}
286
255
287
256
return result, nil
288
257
}
289
258
290
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
259
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
291
260
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
292
261
if err != nil {
293
-
return IsNotFollowing
262
+
return models.IsNotFollowing
294
263
}
295
264
return statuses[subjectDid]
296
265
}
297
266
298
-
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
267
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
299
268
return getFollowStatuses(e, userDid, subjectDids)
300
269
}
+23
-212
appview/db/issues.go
+23
-212
appview/db/issues.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/appview/pagination"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pagination"
15
15
)
16
16
17
-
type Issue struct {
18
-
Id int64
19
-
Did string
20
-
Rkey string
21
-
RepoAt syntax.ATURI
22
-
IssueId int
23
-
Created time.Time
24
-
Edited *time.Time
25
-
Deleted *time.Time
26
-
Title string
27
-
Body string
28
-
Open bool
29
-
30
-
// optionally, populate this when querying for reverse mappings
31
-
// like comment counts, parent repo etc.
32
-
Comments []IssueComment
33
-
Labels LabelState
34
-
Repo *Repo
35
-
}
36
-
37
-
func (i *Issue) AtUri() syntax.ATURI {
38
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
39
-
}
40
-
41
-
func (i *Issue) AsRecord() tangled.RepoIssue {
42
-
return tangled.RepoIssue{
43
-
Repo: i.RepoAt.String(),
44
-
Title: i.Title,
45
-
Body: &i.Body,
46
-
CreatedAt: i.Created.Format(time.RFC3339),
47
-
}
48
-
}
49
-
50
-
func (i *Issue) State() string {
51
-
if i.Open {
52
-
return "open"
53
-
}
54
-
return "closed"
55
-
}
56
-
57
-
type CommentListItem struct {
58
-
Self *IssueComment
59
-
Replies []*IssueComment
60
-
}
61
-
62
-
func (i *Issue) CommentList() []CommentListItem {
63
-
// Create a map to quickly find comments by their aturi
64
-
toplevel := make(map[string]*CommentListItem)
65
-
var replies []*IssueComment
66
-
67
-
// collect top level comments into the map
68
-
for _, comment := range i.Comments {
69
-
if comment.IsTopLevel() {
70
-
toplevel[comment.AtUri().String()] = &CommentListItem{
71
-
Self: &comment,
72
-
}
73
-
} else {
74
-
replies = append(replies, &comment)
75
-
}
76
-
}
77
-
78
-
for _, r := range replies {
79
-
parentAt := *r.ReplyTo
80
-
if parent, exists := toplevel[parentAt]; exists {
81
-
parent.Replies = append(parent.Replies, r)
82
-
}
83
-
}
84
-
85
-
var listing []CommentListItem
86
-
for _, v := range toplevel {
87
-
listing = append(listing, *v)
88
-
}
89
-
90
-
// sort everything
91
-
sortFunc := func(a, b *IssueComment) bool {
92
-
return a.Created.Before(b.Created)
93
-
}
94
-
sort.Slice(listing, func(i, j int) bool {
95
-
return sortFunc(listing[i].Self, listing[j].Self)
96
-
})
97
-
for _, r := range listing {
98
-
sort.Slice(r.Replies, func(i, j int) bool {
99
-
return sortFunc(r.Replies[i], r.Replies[j])
100
-
})
101
-
}
102
-
103
-
return listing
104
-
}
105
-
106
-
func (i *Issue) Participants() []string {
107
-
participantSet := make(map[string]struct{})
108
-
participants := []string{}
109
-
110
-
addParticipant := func(did string) {
111
-
if _, exists := participantSet[did]; !exists {
112
-
participantSet[did] = struct{}{}
113
-
participants = append(participants, did)
114
-
}
115
-
}
116
-
117
-
addParticipant(i.Did)
118
-
119
-
for _, c := range i.Comments {
120
-
addParticipant(c.Did)
121
-
}
122
-
123
-
return participants
124
-
}
125
-
126
-
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
127
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
128
-
if err != nil {
129
-
created = time.Now()
130
-
}
131
-
132
-
body := ""
133
-
if record.Body != nil {
134
-
body = *record.Body
135
-
}
136
-
137
-
return Issue{
138
-
RepoAt: syntax.ATURI(record.Repo),
139
-
Did: did,
140
-
Rkey: rkey,
141
-
Created: created,
142
-
Title: record.Title,
143
-
Body: body,
144
-
Open: true, // new issues are open by default
145
-
}
146
-
}
147
-
148
-
type IssueComment struct {
149
-
Id int64
150
-
Did string
151
-
Rkey string
152
-
IssueAt string
153
-
ReplyTo *string
154
-
Body string
155
-
Created time.Time
156
-
Edited *time.Time
157
-
Deleted *time.Time
158
-
}
159
-
160
-
func (i *IssueComment) AtUri() syntax.ATURI {
161
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
162
-
}
163
-
164
-
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
165
-
return tangled.RepoIssueComment{
166
-
Body: i.Body,
167
-
Issue: i.IssueAt,
168
-
CreatedAt: i.Created.Format(time.RFC3339),
169
-
ReplyTo: i.ReplyTo,
170
-
}
171
-
}
172
-
173
-
func (i *IssueComment) IsTopLevel() bool {
174
-
return i.ReplyTo == nil
175
-
}
176
-
177
-
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
178
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
179
-
if err != nil {
180
-
created = time.Now()
181
-
}
182
-
183
-
ownerDid := did
184
-
185
-
if _, err = syntax.ParseATURI(record.Issue); err != nil {
186
-
return nil, err
187
-
}
188
-
189
-
comment := IssueComment{
190
-
Did: ownerDid,
191
-
Rkey: rkey,
192
-
Body: record.Body,
193
-
IssueAt: record.Issue,
194
-
ReplyTo: record.ReplyTo,
195
-
Created: created,
196
-
}
197
-
198
-
return &comment, nil
199
-
}
200
-
201
-
func PutIssue(tx *sql.Tx, issue *Issue) error {
17
+
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
202
18
// ensure sequence exists
203
19
_, err := tx.Exec(`
204
20
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
233
49
}
234
50
}
235
51
236
-
func createNewIssue(tx *sql.Tx, issue *Issue) error {
52
+
func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
237
53
// get next issue_id
238
54
var newIssueId int
239
55
err := tx.QueryRow(`
240
-
update repo_issue_seqs
241
-
set next_issue_id = next_issue_id + 1
242
-
where repo_at = ?
56
+
update repo_issue_seqs
57
+
set next_issue_id = next_issue_id + 1
58
+
where repo_at = ?
243
59
returning next_issue_id - 1
244
60
`, issue.RepoAt).Scan(&newIssueId)
245
61
if err != nil {
···
256
72
return row.Scan(&issue.Id, &issue.IssueId)
257
73
}
258
74
259
-
func updateIssue(tx *sql.Tx, issue *Issue) error {
75
+
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
260
76
// update existing issue
261
77
_, err := tx.Exec(`
262
78
update issues
···
266
82
return err
267
83
}
268
84
269
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
270
-
issueMap := make(map[string]*Issue) // at-uri -> issue
85
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
271
87
272
88
var conditions []string
273
89
var args []any
···
322
138
defer rows.Close()
323
139
324
140
for rows.Next() {
325
-
var issue Issue
141
+
var issue models.Issue
326
142
var createdAt string
327
143
var editedAt, deletedAt sql.Null[string]
328
144
var rowNum int64
···
375
191
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
376
192
}
377
193
378
-
repoMap := make(map[string]*Repo)
194
+
repoMap := make(map[string]*models.Repo)
379
195
for i := range repos {
380
196
repoMap[string(repos[i].RepoAt())] = &repos[i]
381
197
}
···
415
231
}
416
232
}
417
233
418
-
var issues []Issue
234
+
var issues []models.Issue
419
235
for _, i := range issueMap {
420
236
issues = append(issues, *i)
421
237
}
···
427
243
return issues, nil
428
244
}
429
245
430
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
246
+
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
431
247
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
432
248
}
433
249
434
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
250
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
435
251
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
436
252
row := e.QueryRow(query, repoAt, issueId)
437
253
438
-
var issue Issue
254
+
var issue models.Issue
439
255
var createdAt string
440
256
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
441
257
if err != nil {
···
451
267
return &issue, nil
452
268
}
453
269
454
-
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
270
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
455
271
result, err := e.Exec(
456
272
`insert into issue_comments (
457
273
did,
···
513
329
return err
514
330
}
515
331
516
-
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
517
-
var comments []IssueComment
332
+
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
333
+
var comments []models.IssueComment
518
334
519
335
var conditions []string
520
336
var args []any
···
550
366
}
551
367
552
368
for rows.Next() {
553
-
var comment IssueComment
369
+
var comment models.IssueComment
554
370
var created string
555
371
var rkey, edited, deleted, replyTo sql.Null[string]
556
372
err := rows.Scan(
···
657
473
return err
658
474
}
659
475
660
-
type IssueCount struct {
661
-
Open int
662
-
Closed int
663
-
}
664
-
665
-
func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
476
+
func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) {
666
477
row := e.QueryRow(`
667
478
select
668
479
count(case when open = 1 then 1 end) as open_count,
···
672
483
repoAt,
673
484
)
674
485
675
-
var count IssueCount
486
+
var count models.IssueCount
676
487
if err := row.Scan(&count.Open, &count.Closed); err != nil {
677
-
return IssueCount{0, 0}, err
488
+
return models.IssueCount{}, err
678
489
}
679
490
680
491
return count, nil
+33
-496
appview/db/label.go
+33
-496
appview/db/label.go
···
1
1
package db
2
2
3
3
import (
4
-
"crypto/sha1"
5
4
"database/sql"
6
-
"encoding/hex"
7
-
"errors"
8
5
"fmt"
9
6
"maps"
10
7
"slices"
···
12
9
"time"
13
10
14
11
"github.com/bluesky-social/indigo/atproto/syntax"
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/consts"
17
-
)
18
-
19
-
type ConcreteType string
20
-
21
-
const (
22
-
ConcreteTypeNull ConcreteType = "null"
23
-
ConcreteTypeString ConcreteType = "string"
24
-
ConcreteTypeInt ConcreteType = "integer"
25
-
ConcreteTypeBool ConcreteType = "boolean"
26
-
)
27
-
28
-
type ValueTypeFormat string
29
-
30
-
const (
31
-
ValueTypeFormatAny ValueTypeFormat = "any"
32
-
ValueTypeFormatDid ValueTypeFormat = "did"
12
+
"tangled.org/core/appview/models"
33
13
)
34
14
35
-
// ValueType represents an atproto lexicon type definition with constraints
36
-
type ValueType struct {
37
-
Type ConcreteType `json:"type"`
38
-
Format ValueTypeFormat `json:"format,omitempty"`
39
-
Enum []string `json:"enum,omitempty"`
40
-
}
41
-
42
-
func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType {
43
-
return tangled.LabelDefinition_ValueType{
44
-
Type: string(vt.Type),
45
-
Format: string(vt.Format),
46
-
Enum: vt.Enum,
47
-
}
48
-
}
49
-
50
-
func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType {
51
-
return ValueType{
52
-
Type: ConcreteType(record.Type),
53
-
Format: ValueTypeFormat(record.Format),
54
-
Enum: record.Enum,
55
-
}
56
-
}
57
-
58
-
func (vt ValueType) IsConcreteType() bool {
59
-
return vt.Type == ConcreteTypeNull ||
60
-
vt.Type == ConcreteTypeString ||
61
-
vt.Type == ConcreteTypeInt ||
62
-
vt.Type == ConcreteTypeBool
63
-
}
64
-
65
-
func (vt ValueType) IsNull() bool {
66
-
return vt.Type == ConcreteTypeNull
67
-
}
68
-
69
-
func (vt ValueType) IsString() bool {
70
-
return vt.Type == ConcreteTypeString
71
-
}
72
-
73
-
func (vt ValueType) IsInt() bool {
74
-
return vt.Type == ConcreteTypeInt
75
-
}
76
-
77
-
func (vt ValueType) IsBool() bool {
78
-
return vt.Type == ConcreteTypeBool
79
-
}
80
-
81
-
func (vt ValueType) IsEnum() bool {
82
-
return len(vt.Enum) > 0
83
-
}
84
-
85
-
func (vt ValueType) IsDidFormat() bool {
86
-
return vt.Format == ValueTypeFormatDid
87
-
}
88
-
89
-
func (vt ValueType) IsAnyFormat() bool {
90
-
return vt.Format == ValueTypeFormatAny
91
-
}
92
-
93
-
type LabelDefinition struct {
94
-
Id int64
95
-
Did string
96
-
Rkey string
97
-
98
-
Name string
99
-
ValueType ValueType
100
-
Scope []string
101
-
Color *string
102
-
Multiple bool
103
-
Created time.Time
104
-
}
105
-
106
-
func (l *LabelDefinition) AtUri() syntax.ATURI {
107
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey))
108
-
}
109
-
110
-
func (l *LabelDefinition) AsRecord() tangled.LabelDefinition {
111
-
vt := l.ValueType.AsRecord()
112
-
return tangled.LabelDefinition{
113
-
Name: l.Name,
114
-
Color: l.Color,
115
-
CreatedAt: l.Created.Format(time.RFC3339),
116
-
Multiple: &l.Multiple,
117
-
Scope: l.Scope,
118
-
ValueType: &vt,
119
-
}
120
-
}
121
-
122
-
// random color for a given seed
123
-
func randomColor(seed string) string {
124
-
hash := sha1.Sum([]byte(seed))
125
-
hexStr := hex.EncodeToString(hash[:])
126
-
r := hexStr[0:2]
127
-
g := hexStr[2:4]
128
-
b := hexStr[4:6]
129
-
130
-
return fmt.Sprintf("#%s%s%s", r, g, b)
131
-
}
132
-
133
-
func (ld LabelDefinition) GetColor() string {
134
-
if ld.Color == nil {
135
-
seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey)
136
-
color := randomColor(seed)
137
-
return color
138
-
}
139
-
140
-
return *ld.Color
141
-
}
142
-
143
-
func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) {
144
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
145
-
if err != nil {
146
-
created = time.Now()
147
-
}
148
-
149
-
multiple := false
150
-
if record.Multiple != nil {
151
-
multiple = *record.Multiple
152
-
}
153
-
154
-
var vt ValueType
155
-
if record.ValueType != nil {
156
-
vt = ValueTypeFromRecord(*record.ValueType)
157
-
}
158
-
159
-
return &LabelDefinition{
160
-
Did: did,
161
-
Rkey: rkey,
162
-
163
-
Name: record.Name,
164
-
ValueType: vt,
165
-
Scope: record.Scope,
166
-
Color: record.Color,
167
-
Multiple: multiple,
168
-
Created: created,
169
-
}, nil
170
-
}
171
-
172
-
func DeleteLabelDefinition(e Execer, filters ...filter) error {
173
-
var conditions []string
174
-
var args []any
175
-
for _, filter := range filters {
176
-
conditions = append(conditions, filter.Condition())
177
-
args = append(args, filter.Arg()...)
178
-
}
179
-
whereClause := ""
180
-
if conditions != nil {
181
-
whereClause = " where " + strings.Join(conditions, " and ")
182
-
}
183
-
query := fmt.Sprintf(`delete from label_definitions %s`, whereClause)
184
-
_, err := e.Exec(query, args...)
185
-
return err
186
-
}
187
-
188
15
// no updating type for now
189
-
func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) {
16
+
func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) {
190
17
result, err := e.Exec(
191
18
`insert into label_definitions (
192
19
did,
···
232
59
return id, nil
233
60
}
234
61
235
-
func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) {
236
-
var labelDefinitions []LabelDefinition
62
+
func DeleteLabelDefinition(e Execer, filters ...filter) error {
63
+
var conditions []string
64
+
var args []any
65
+
for _, filter := range filters {
66
+
conditions = append(conditions, filter.Condition())
67
+
args = append(args, filter.Arg()...)
68
+
}
69
+
whereClause := ""
70
+
if conditions != nil {
71
+
whereClause = " where " + strings.Join(conditions, " and ")
72
+
}
73
+
query := fmt.Sprintf(`delete from label_definitions %s`, whereClause)
74
+
_, err := e.Exec(query, args...)
75
+
return err
76
+
}
77
+
78
+
func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) {
79
+
var labelDefinitions []models.LabelDefinition
237
80
var conditions []string
238
81
var args []any
239
82
···
275
118
defer rows.Close()
276
119
277
120
for rows.Next() {
278
-
var labelDefinition LabelDefinition
121
+
var labelDefinition models.LabelDefinition
279
122
var createdAt, enumVariants, scopes string
280
123
var color sql.Null[string]
281
124
var multiple int
···
324
167
}
325
168
326
169
// helper to get exactly one label def
327
-
func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) {
170
+
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
328
171
labels, err := GetLabelDefinitions(e, filters...)
329
172
if err != nil {
330
173
return nil, err
···
341
184
return &labels[0], nil
342
185
}
343
186
344
-
type LabelOp struct {
345
-
Id int64
346
-
Did string
347
-
Rkey string
348
-
Subject syntax.ATURI
349
-
Operation LabelOperation
350
-
OperandKey string
351
-
OperandValue string
352
-
PerformedAt time.Time
353
-
IndexedAt time.Time
354
-
}
355
-
356
-
func (l LabelOp) SortAt() time.Time {
357
-
createdAt := l.PerformedAt
358
-
indexedAt := l.IndexedAt
359
-
360
-
// if we don't have an indexedat, fall back to now
361
-
if indexedAt.IsZero() {
362
-
indexedAt = time.Now()
363
-
}
364
-
365
-
// if createdat is invalid (before epoch), treat as null -> return zero time
366
-
if createdAt.Before(time.UnixMicro(0)) {
367
-
return time.Time{}
368
-
}
369
-
370
-
// if createdat is <= indexedat, use createdat
371
-
if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) {
372
-
return createdAt
373
-
}
374
-
375
-
// otherwise, createdat is in the future relative to indexedat -> use indexedat
376
-
return indexedAt
377
-
}
378
-
379
-
type LabelOperation string
380
-
381
-
const (
382
-
LabelOperationAdd LabelOperation = "add"
383
-
LabelOperationDel LabelOperation = "del"
384
-
)
385
-
386
-
// a record can create multiple label ops
387
-
func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp {
388
-
performed, err := time.Parse(time.RFC3339, record.PerformedAt)
389
-
if err != nil {
390
-
performed = time.Now()
391
-
}
392
-
393
-
mkOp := func(operand *tangled.LabelOp_Operand) LabelOp {
394
-
return LabelOp{
395
-
Did: did,
396
-
Rkey: rkey,
397
-
Subject: syntax.ATURI(record.Subject),
398
-
OperandKey: operand.Key,
399
-
OperandValue: operand.Value,
400
-
PerformedAt: performed,
401
-
}
402
-
}
403
-
404
-
var ops []LabelOp
405
-
for _, o := range record.Add {
406
-
if o != nil {
407
-
op := mkOp(o)
408
-
op.Operation = LabelOperationAdd
409
-
ops = append(ops, op)
410
-
}
411
-
}
412
-
for _, o := range record.Delete {
413
-
if o != nil {
414
-
op := mkOp(o)
415
-
op.Operation = LabelOperationDel
416
-
ops = append(ops, op)
417
-
}
418
-
}
419
-
420
-
return ops
421
-
}
422
-
423
-
func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp {
424
-
if len(ops) == 0 {
425
-
return tangled.LabelOp{}
426
-
}
427
-
428
-
// use the first operation to establish common fields
429
-
first := ops[0]
430
-
record := tangled.LabelOp{
431
-
Subject: string(first.Subject),
432
-
PerformedAt: first.PerformedAt.Format(time.RFC3339),
433
-
}
434
-
435
-
var addOperands []*tangled.LabelOp_Operand
436
-
var deleteOperands []*tangled.LabelOp_Operand
437
-
438
-
for _, op := range ops {
439
-
operand := &tangled.LabelOp_Operand{
440
-
Key: op.OperandKey,
441
-
Value: op.OperandValue,
442
-
}
443
-
444
-
switch op.Operation {
445
-
case LabelOperationAdd:
446
-
addOperands = append(addOperands, operand)
447
-
case LabelOperationDel:
448
-
deleteOperands = append(deleteOperands, operand)
449
-
default:
450
-
return tangled.LabelOp{}
451
-
}
452
-
}
453
-
454
-
record.Add = addOperands
455
-
record.Delete = deleteOperands
456
-
457
-
return record
458
-
}
459
-
460
-
func AddLabelOp(e Execer, l *LabelOp) (int64, error) {
187
+
func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) {
461
188
now := time.Now()
462
189
result, err := e.Exec(
463
190
`insert into label_ops (
···
500
227
return id, nil
501
228
}
502
229
503
-
func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) {
504
-
var labelOps []LabelOp
230
+
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
+
var labelOps []models.LabelOp
505
232
var conditions []string
506
233
var args []any
507
234
···
541
268
defer rows.Close()
542
269
543
270
for rows.Next() {
544
-
var labelOp LabelOp
271
+
var labelOp models.LabelOp
545
272
var performedAt, indexedAt string
546
273
547
274
if err := rows.Scan(
···
575
302
}
576
303
577
304
// get labels for a given list of subject URIs
578
-
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) {
305
+
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
579
306
ops, err := GetLabelOps(e, filters...)
580
307
if err != nil {
581
308
return nil, err
582
309
}
583
310
584
311
// group ops by subject
585
-
opsBySubject := make(map[syntax.ATURI][]LabelOp)
312
+
opsBySubject := make(map[syntax.ATURI][]models.LabelOp)
586
313
for _, op := range ops {
587
314
subject := syntax.ATURI(op.Subject)
588
315
opsBySubject[subject] = append(opsBySubject[subject], op)
···
601
328
}
602
329
603
330
// apply label ops for each subject and collect results
604
-
results := make(map[syntax.ATURI]LabelState)
331
+
results := make(map[syntax.ATURI]models.LabelState)
605
332
for subject, subjectOps := range opsBySubject {
606
-
state := NewLabelState()
333
+
state := models.NewLabelState()
607
334
actx.ApplyLabelOps(state, subjectOps)
608
335
results[subject] = state
609
336
}
···
611
338
return results, nil
612
339
}
613
340
614
-
type set = map[string]struct{}
615
-
616
-
type LabelState struct {
617
-
inner map[string]set
618
-
}
619
-
620
-
func NewLabelState() LabelState {
621
-
return LabelState{
622
-
inner: make(map[string]set),
623
-
}
624
-
}
625
-
626
-
func (s LabelState) Inner() map[string]set {
627
-
return s.inner
628
-
}
629
-
630
-
func (s LabelState) ContainsLabel(l string) bool {
631
-
if valset, exists := s.inner[l]; exists {
632
-
if valset != nil {
633
-
return true
634
-
}
635
-
}
636
-
637
-
return false
638
-
}
639
-
640
-
// go maps behavior in templates make this necessary,
641
-
// indexing a map and getting `set` in return is apparently truthy
642
-
func (s LabelState) ContainsLabelAndVal(l, v string) bool {
643
-
if valset, exists := s.inner[l]; exists {
644
-
if _, exists := valset[v]; exists {
645
-
return true
646
-
}
647
-
}
648
-
649
-
return false
650
-
}
651
-
652
-
func (s LabelState) GetValSet(l string) set {
653
-
if valset, exists := s.inner[l]; exists {
654
-
return valset
655
-
} else {
656
-
return make(set)
657
-
}
658
-
}
659
-
660
-
type LabelApplicationCtx struct {
661
-
Defs map[string]*LabelDefinition // labelAt -> labelDef
662
-
}
663
-
664
-
var (
665
-
LabelNoOpError = errors.New("no-op")
666
-
)
667
-
668
-
func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) {
341
+
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
669
342
labels, err := GetLabelDefinitions(e, filters...)
670
343
if err != nil {
671
344
return nil, err
672
345
}
673
346
674
-
defs := make(map[string]*LabelDefinition)
347
+
defs := make(map[string]*models.LabelDefinition)
675
348
for _, l := range labels {
676
349
defs[l.AtUri().String()] = &l
677
350
}
678
351
679
-
return &LabelApplicationCtx{defs}, nil
680
-
}
681
-
682
-
func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error {
683
-
def, ok := c.Defs[op.OperandKey]
684
-
if !ok {
685
-
// this def was deleted, but an op exists, so we just skip over the op
686
-
return nil
687
-
}
688
-
689
-
switch op.Operation {
690
-
case LabelOperationAdd:
691
-
// if valueset is empty, init it
692
-
if state.inner[op.OperandKey] == nil {
693
-
state.inner[op.OperandKey] = make(set)
694
-
}
695
-
696
-
// if valueset is populated & this val alr exists, this labelop is a noop
697
-
if valueSet, exists := state.inner[op.OperandKey]; exists {
698
-
if _, exists = valueSet[op.OperandValue]; exists {
699
-
return LabelNoOpError
700
-
}
701
-
}
702
-
703
-
if def.Multiple {
704
-
// append to set
705
-
state.inner[op.OperandKey][op.OperandValue] = struct{}{}
706
-
} else {
707
-
// reset to just this value
708
-
state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}}
709
-
}
710
-
711
-
case LabelOperationDel:
712
-
// if label DNE, then deletion is a no-op
713
-
if valueSet, exists := state.inner[op.OperandKey]; !exists {
714
-
return LabelNoOpError
715
-
} else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op
716
-
return LabelNoOpError
717
-
}
718
-
719
-
if def.Multiple {
720
-
// remove from set
721
-
delete(state.inner[op.OperandKey], op.OperandValue)
722
-
} else {
723
-
// reset the entire label
724
-
delete(state.inner, op.OperandKey)
725
-
}
726
-
727
-
// if the map becomes empty, then set it to nil, this is just the inverse of add
728
-
if len(state.inner[op.OperandKey]) == 0 {
729
-
state.inner[op.OperandKey] = nil
730
-
}
731
-
732
-
}
733
-
734
-
return nil
735
-
}
736
-
737
-
func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) {
738
-
// sort label ops in sort order first
739
-
slices.SortFunc(ops, func(a, b LabelOp) int {
740
-
return a.SortAt().Compare(b.SortAt())
741
-
})
742
-
743
-
// apply ops in sequence
744
-
for _, o := range ops {
745
-
_ = c.ApplyLabelOp(state, o)
746
-
}
747
-
}
748
-
749
-
// IsInverse checks if one label operation is the inverse of another
750
-
// returns true if one is an add and the other is a delete with the same key and value
751
-
func (op1 LabelOp) IsInverse(op2 LabelOp) bool {
752
-
if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue {
753
-
return false
754
-
}
755
-
756
-
return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) ||
757
-
(op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd)
758
-
}
759
-
760
-
// removes pairs of label operations that are inverses of each other
761
-
// from the given slice. the function preserves the order of remaining operations.
762
-
func ReduceLabelOps(ops []LabelOp) []LabelOp {
763
-
if len(ops) <= 1 {
764
-
return ops
765
-
}
766
-
767
-
keep := make([]bool, len(ops))
768
-
for i := range keep {
769
-
keep[i] = true
770
-
}
771
-
772
-
for i := range ops {
773
-
if !keep[i] {
774
-
continue
775
-
}
776
-
777
-
for j := i + 1; j < len(ops); j++ {
778
-
if !keep[j] {
779
-
continue
780
-
}
781
-
782
-
if ops[i].IsInverse(ops[j]) {
783
-
keep[i] = false
784
-
keep[j] = false
785
-
break // move to next i since this one is now eliminated
786
-
}
787
-
}
788
-
}
789
-
790
-
// build result slice with only kept operations
791
-
var result []LabelOp
792
-
for i, op := range ops {
793
-
if keep[i] {
794
-
result = append(result, op)
795
-
}
796
-
}
797
-
798
-
return result
799
-
}
800
-
801
-
func DefaultLabelDefs() []string {
802
-
rkeys := []string{
803
-
"wontfix",
804
-
"duplicate",
805
-
"assignee",
806
-
"good-first-issue",
807
-
"documentation",
808
-
}
809
-
810
-
defs := make([]string, len(rkeys))
811
-
for i, r := range rkeys {
812
-
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
813
-
}
814
-
815
-
return defs
352
+
return &models.LabelApplicationCtx{Defs: defs}, nil
816
353
}
+38
-13
appview/db/language.go
+38
-13
appview/db/language.go
···
1
1
package db
2
2
3
3
import (
4
+
"database/sql"
4
5
"fmt"
5
6
"strings"
6
7
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/appview/models"
8
10
)
9
11
10
-
type RepoLanguage struct {
11
-
Id int64
12
-
RepoAt syntax.ATURI
13
-
Ref string
14
-
IsDefaultRef bool
15
-
Language string
16
-
Bytes int64
17
-
}
18
-
19
-
func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) {
12
+
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
20
13
var conditions []string
21
14
var args []any
22
15
for _, filter := range filters {
···
39
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
40
33
}
41
34
42
-
var langs []RepoLanguage
35
+
var langs []models.RepoLanguage
43
36
for rows.Next() {
44
-
var rl RepoLanguage
37
+
var rl models.RepoLanguage
45
38
var isDefaultRef int
46
39
47
40
err := rows.Scan(
···
69
62
return langs, nil
70
63
}
71
64
72
-
func InsertRepoLanguages(e Execer, langs []RepoLanguage) error {
65
+
func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error {
73
66
stmt, err := e.Prepare(
74
67
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
75
68
)
···
91
84
92
85
return nil
93
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+450
appview/db/notifications.go
+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
-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
+17
-139
appview/db/pipeline.go
···
6
6
"strings"
7
7
"time"
8
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/go-git/go-git/v5/plumbing"
11
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
-
"tangled.sh/tangled.sh/core/workflow"
9
+
"tangled.org/core/appview/models"
13
10
)
14
11
15
-
type Pipeline struct {
16
-
Id int
17
-
Rkey string
18
-
Knot string
19
-
RepoOwner syntax.DID
20
-
RepoName string
21
-
TriggerId int
22
-
Sha string
23
-
Created time.Time
24
-
25
-
// populate when querying for reverse mappings
26
-
Trigger *Trigger
27
-
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
type WorkflowStatus struct {
31
-
Data []PipelineStatus
32
-
}
33
-
34
-
func (w WorkflowStatus) Latest() PipelineStatus {
35
-
return w.Data[len(w.Data)-1]
36
-
}
37
-
38
-
// time taken by this workflow to reach an "end state"
39
-
func (w WorkflowStatus) TimeTaken() time.Duration {
40
-
var start, end *time.Time
41
-
for _, s := range w.Data {
42
-
if s.Status.IsStart() {
43
-
start = &s.Created
44
-
}
45
-
if s.Status.IsFinish() {
46
-
end = &s.Created
47
-
}
48
-
}
49
-
50
-
if start != nil && end != nil && end.After(*start) {
51
-
return end.Sub(*start)
52
-
}
53
-
54
-
return 0
55
-
}
56
-
57
-
func (p Pipeline) Counts() map[string]int {
58
-
m := make(map[string]int)
59
-
for _, w := range p.Statuses {
60
-
m[w.Latest().Status.String()] += 1
61
-
}
62
-
return m
63
-
}
64
-
65
-
func (p Pipeline) TimeTaken() time.Duration {
66
-
var s time.Duration
67
-
for _, w := range p.Statuses {
68
-
s += w.TimeTaken()
69
-
}
70
-
return s
71
-
}
72
-
73
-
func (p Pipeline) Workflows() []string {
74
-
var ws []string
75
-
for v := range p.Statuses {
76
-
ws = append(ws, v)
77
-
}
78
-
slices.Sort(ws)
79
-
return ws
80
-
}
81
-
82
-
// if we know that a spindle has picked up this pipeline, then it is Responding
83
-
func (p Pipeline) IsResponding() bool {
84
-
return len(p.Statuses) != 0
85
-
}
86
-
87
-
type Trigger struct {
88
-
Id int
89
-
Kind workflow.TriggerKind
90
-
91
-
// push trigger fields
92
-
PushRef *string
93
-
PushNewSha *string
94
-
PushOldSha *string
95
-
96
-
// pull request trigger fields
97
-
PRSourceBranch *string
98
-
PRTargetBranch *string
99
-
PRSourceSha *string
100
-
PRAction *string
101
-
}
102
-
103
-
func (t *Trigger) IsPush() bool {
104
-
return t != nil && t.Kind == workflow.TriggerKindPush
105
-
}
106
-
107
-
func (t *Trigger) IsPullRequest() bool {
108
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
109
-
}
110
-
111
-
func (t *Trigger) TargetRef() string {
112
-
if t.IsPush() {
113
-
return plumbing.ReferenceName(*t.PushRef).Short()
114
-
} else if t.IsPullRequest() {
115
-
return *t.PRTargetBranch
116
-
}
117
-
118
-
return ""
119
-
}
120
-
121
-
type PipelineStatus struct {
122
-
ID int
123
-
Spindle string
124
-
Rkey string
125
-
PipelineKnot string
126
-
PipelineRkey string
127
-
Created time.Time
128
-
Workflow string
129
-
Status spindle.StatusKind
130
-
Error *string
131
-
ExitCode int
132
-
}
133
-
134
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
135
-
var pipelines []Pipeline
12
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
+
var pipelines []models.Pipeline
136
14
137
15
var conditions []string
138
16
var args []any
···
156
34
defer rows.Close()
157
35
158
36
for rows.Next() {
159
-
var pipeline Pipeline
37
+
var pipeline models.Pipeline
160
38
var createdAt string
161
39
err = rows.Scan(
162
40
&pipeline.Id,
···
185
63
return pipelines, nil
186
64
}
187
65
188
-
func AddPipeline(e Execer, pipeline Pipeline) error {
66
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
189
67
args := []any{
190
68
pipeline.Rkey,
191
69
pipeline.Knot,
···
216
94
return err
217
95
}
218
96
219
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
97
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
220
98
args := []any{
221
99
trigger.Kind,
222
100
trigger.PushRef,
···
252
130
return res.LastInsertId()
253
131
}
254
132
255
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
133
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
256
134
args := []any{
257
135
status.Spindle,
258
136
status.Rkey,
···
290
168
291
169
// this is a mega query, but the most useful one:
292
170
// get N pipelines, for each one get the latest status of its N workflows
293
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
171
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
294
172
var conditions []string
295
173
var args []any
296
174
for _, filter := range filters {
···
335
213
}
336
214
defer rows.Close()
337
215
338
-
pipelines := make(map[string]Pipeline)
216
+
pipelines := make(map[string]models.Pipeline)
339
217
for rows.Next() {
340
-
var p Pipeline
341
-
var t Trigger
218
+
var p models.Pipeline
219
+
var t models.Trigger
342
220
var created string
343
221
344
222
err := rows.Scan(
···
370
248
371
249
t.Id = p.TriggerId
372
250
p.Trigger = &t
373
-
p.Statuses = make(map[string]WorkflowStatus)
251
+
p.Statuses = make(map[string]models.WorkflowStatus)
374
252
375
253
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
376
254
pipelines[k] = p
···
409
287
defer rows.Close()
410
288
411
289
for rows.Next() {
412
-
var ps PipelineStatus
290
+
var ps models.PipelineStatus
413
291
var created string
414
292
415
293
err := rows.Scan(
···
442
320
}
443
321
statuses, _ := pipeline.Statuses[ps.Workflow]
444
322
if !ok {
445
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
323
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
446
324
}
447
325
448
326
// append
···
453
331
pipelines[key] = pipeline
454
332
}
455
333
456
-
var all []Pipeline
334
+
var all []models.Pipeline
457
335
for _, p := range pipelines {
458
336
for _, s := range p.Statuses {
459
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
337
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
460
338
if a.Created.After(b.Created) {
461
339
return 1
462
340
}
···
476
354
}
477
355
478
356
// sort pipelines by date
479
-
slices.SortFunc(all, func(a, b Pipeline) int {
357
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
480
358
if a.Created.After(b.Created) {
481
359
return -1
482
360
}
+25
-194
appview/db/profile.go
+25
-194
appview/db/profile.go
···
10
10
"time"
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
13
+
"tangled.org/core/appview/models"
14
14
)
15
15
16
-
type RepoEvent struct {
17
-
Repo *Repo
18
-
Source *Repo
19
-
}
20
-
21
-
type ProfileTimeline struct {
22
-
ByMonth []ByMonth
23
-
}
24
-
25
-
func (p *ProfileTimeline) IsEmpty() bool {
26
-
if p == nil {
27
-
return true
28
-
}
29
-
30
-
for _, m := range p.ByMonth {
31
-
if !m.IsEmpty() {
32
-
return false
33
-
}
34
-
}
35
-
36
-
return true
37
-
}
38
-
39
-
type ByMonth struct {
40
-
RepoEvents []RepoEvent
41
-
IssueEvents IssueEvents
42
-
PullEvents PullEvents
43
-
}
44
-
45
-
func (b ByMonth) IsEmpty() bool {
46
-
return len(b.RepoEvents) == 0 &&
47
-
len(b.IssueEvents.Items) == 0 &&
48
-
len(b.PullEvents.Items) == 0
49
-
}
50
-
51
-
type IssueEvents struct {
52
-
Items []*Issue
53
-
}
54
-
55
-
type IssueEventStats struct {
56
-
Open int
57
-
Closed int
58
-
}
59
-
60
-
func (i IssueEvents) Stats() IssueEventStats {
61
-
var open, closed int
62
-
for _, issue := range i.Items {
63
-
if issue.Open {
64
-
open += 1
65
-
} else {
66
-
closed += 1
67
-
}
68
-
}
69
-
70
-
return IssueEventStats{
71
-
Open: open,
72
-
Closed: closed,
73
-
}
74
-
}
75
-
76
-
type PullEvents struct {
77
-
Items []*Pull
78
-
}
79
-
80
-
func (p PullEvents) Stats() PullEventStats {
81
-
var open, merged, closed int
82
-
for _, pull := range p.Items {
83
-
switch pull.State {
84
-
case PullOpen:
85
-
open += 1
86
-
case PullMerged:
87
-
merged += 1
88
-
case PullClosed:
89
-
closed += 1
90
-
}
91
-
}
92
-
93
-
return PullEventStats{
94
-
Open: open,
95
-
Merged: merged,
96
-
Closed: closed,
97
-
}
98
-
}
99
-
100
-
type PullEventStats struct {
101
-
Closed int
102
-
Open int
103
-
Merged int
104
-
}
105
-
106
16
const TimeframeMonths = 7
107
17
108
-
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
109
-
timeline := ProfileTimeline{
110
-
ByMonth: make([]ByMonth, TimeframeMonths),
18
+
func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) {
19
+
timeline := models.ProfileTimeline{
20
+
ByMonth: make([]models.ByMonth, TimeframeMonths),
111
21
}
112
22
currentMonth := time.Now().Month()
113
23
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
···
162
72
163
73
for _, repo := range repos {
164
74
// TODO: get this in the original query; requires COALESCE because nullable
165
-
var sourceRepo *Repo
75
+
var sourceRepo *models.Repo
166
76
if repo.Source != "" {
167
77
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
168
78
if err != nil {
···
180
90
idx := currentMonth - repoMonth
181
91
182
92
items := &timeline.ByMonth[idx].RepoEvents
183
-
*items = append(*items, RepoEvent{
93
+
*items = append(*items, models.RepoEvent{
184
94
Repo: &repo,
185
95
Source: sourceRepo,
186
96
})
···
189
99
return &timeline, nil
190
100
}
191
101
192
-
type Profile struct {
193
-
// ids
194
-
ID int
195
-
Did string
196
-
197
-
// data
198
-
Description string
199
-
IncludeBluesky bool
200
-
Location string
201
-
Links [5]string
202
-
Stats [2]VanityStat
203
-
PinnedRepos [6]syntax.ATURI
204
-
}
205
-
206
-
func (p Profile) IsLinksEmpty() bool {
207
-
for _, l := range p.Links {
208
-
if l != "" {
209
-
return false
210
-
}
211
-
}
212
-
return true
213
-
}
214
-
215
-
func (p Profile) IsStatsEmpty() bool {
216
-
for _, s := range p.Stats {
217
-
if s.Kind != "" {
218
-
return false
219
-
}
220
-
}
221
-
return true
222
-
}
223
-
224
-
func (p Profile) IsPinnedReposEmpty() bool {
225
-
for _, r := range p.PinnedRepos {
226
-
if r != "" {
227
-
return false
228
-
}
229
-
}
230
-
return true
231
-
}
232
-
233
-
type VanityStatKind string
234
-
235
-
const (
236
-
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
237
-
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
238
-
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
239
-
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
240
-
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
241
-
VanityStatRepositoryCount VanityStatKind = "repository-count"
242
-
)
243
-
244
-
func (v VanityStatKind) String() string {
245
-
switch v {
246
-
case VanityStatMergedPRCount:
247
-
return "Merged PRs"
248
-
case VanityStatClosedPRCount:
249
-
return "Closed PRs"
250
-
case VanityStatOpenPRCount:
251
-
return "Open PRs"
252
-
case VanityStatOpenIssueCount:
253
-
return "Open Issues"
254
-
case VanityStatClosedIssueCount:
255
-
return "Closed Issues"
256
-
case VanityStatRepositoryCount:
257
-
return "Repositories"
258
-
}
259
-
return ""
260
-
}
261
-
262
-
type VanityStat struct {
263
-
Kind VanityStatKind
264
-
Value uint64
265
-
}
266
-
267
-
func (p *Profile) ProfileAt() syntax.ATURI {
268
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
269
-
}
270
-
271
-
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
102
+
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
272
103
defer tx.Rollback()
273
104
274
105
// update links
···
366
197
return tx.Commit()
367
198
}
368
199
369
-
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
200
+
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
370
201
var conditions []string
371
202
var args []any
372
203
for _, filter := range filters {
···
396
227
return nil, err
397
228
}
398
229
399
-
profileMap := make(map[string]*Profile)
230
+
profileMap := make(map[string]*models.Profile)
400
231
for rows.Next() {
401
-
var profile Profile
232
+
var profile models.Profile
402
233
var includeBluesky int
403
234
404
235
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
···
469
300
return profileMap, nil
470
301
}
471
302
472
-
func GetProfile(e Execer, did string) (*Profile, error) {
473
-
var profile Profile
303
+
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
+
var profile models.Profile
474
305
profile.Did = did
475
306
476
307
includeBluesky := 0
···
479
310
did,
480
311
).Scan(&profile.Description, &includeBluesky, &profile.Location)
481
312
if err == sql.ErrNoRows {
482
-
profile := Profile{}
313
+
profile := models.Profile{}
483
314
profile.Did = did
484
315
return &profile, nil
485
316
}
···
539
370
return &profile, nil
540
371
}
541
372
542
-
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
373
+
func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) {
543
374
query := ""
544
375
var args []any
545
376
switch stat {
546
-
case VanityStatMergedPRCount:
377
+
case models.VanityStatMergedPRCount:
547
378
query = `select count(id) from pulls where owner_did = ? and state = ?`
548
-
args = append(args, did, PullMerged)
549
-
case VanityStatClosedPRCount:
379
+
args = append(args, did, models.PullMerged)
380
+
case models.VanityStatClosedPRCount:
550
381
query = `select count(id) from pulls where owner_did = ? and state = ?`
551
-
args = append(args, did, PullClosed)
552
-
case VanityStatOpenPRCount:
382
+
args = append(args, did, models.PullClosed)
383
+
case models.VanityStatOpenPRCount:
553
384
query = `select count(id) from pulls where owner_did = ? and state = ?`
554
-
args = append(args, did, PullOpen)
555
-
case VanityStatOpenIssueCount:
385
+
args = append(args, did, models.PullOpen)
386
+
case models.VanityStatOpenIssueCount:
556
387
query = `select count(id) from issues where did = ? and open = 1`
557
388
args = append(args, did)
558
-
case VanityStatClosedIssueCount:
389
+
case models.VanityStatClosedIssueCount:
559
390
query = `select count(id) from issues where did = ? and open = 0`
560
391
args = append(args, did)
561
-
case VanityStatRepositoryCount:
392
+
case models.VanityStatRepositoryCount:
562
393
query = `select count(id) from repos where did = ?`
563
394
args = append(args, did)
564
395
}
···
572
403
return result, nil
573
404
}
574
405
575
-
func ValidateProfile(e Execer, profile *Profile) error {
406
+
func ValidateProfile(e Execer, profile *models.Profile) error {
576
407
// ensure description is not too long
577
408
if len(profile.Description) > 256 {
578
409
return fmt.Errorf("Entered bio is too long.")
···
620
451
return nil
621
452
}
622
453
623
-
func validateLinks(profile *Profile) error {
454
+
func validateLinks(profile *models.Profile) error {
624
455
for i, link := range profile.Links {
625
456
if link == "" {
626
457
continue
+7
-26
appview/db/pubkeys.go
+7
-26
appview/db/pubkeys.go
···
1
1
package db
2
2
3
3
import (
4
-
"encoding/json"
4
+
"tangled.org/core/appview/models"
5
5
"time"
6
6
)
7
7
···
29
29
return err
30
30
}
31
31
32
-
type PublicKey struct {
33
-
Did string `json:"did"`
34
-
Key string `json:"key"`
35
-
Name string `json:"name"`
36
-
Rkey string `json:"rkey"`
37
-
Created *time.Time
38
-
}
39
-
40
-
func (p PublicKey) MarshalJSON() ([]byte, error) {
41
-
type Alias PublicKey
42
-
return json.Marshal(&struct {
43
-
Created string `json:"created"`
44
-
*Alias
45
-
}{
46
-
Created: p.Created.Format(time.RFC3339),
47
-
Alias: (*Alias)(&p),
48
-
})
49
-
}
50
-
51
-
func GetAllPublicKeys(e Execer) ([]PublicKey, error) {
52
-
var keys []PublicKey
32
+
func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) {
33
+
var keys []models.PublicKey
53
34
54
35
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
55
36
if err != nil {
···
58
39
defer rows.Close()
59
40
60
41
for rows.Next() {
61
-
var publicKey PublicKey
42
+
var publicKey models.PublicKey
62
43
var createdAt string
63
44
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
64
45
return nil, err
···
75
56
return keys, nil
76
57
}
77
58
78
-
func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) {
79
-
var keys []PublicKey
59
+
func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) {
60
+
var keys []models.PublicKey
80
61
81
62
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
82
63
if err != nil {
···
85
66
defer rows.Close()
86
67
87
68
for rows.Next() {
88
-
var publicKey PublicKey
69
+
var publicKey models.PublicKey
89
70
var createdAt string
90
71
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
91
72
return nil, err
+193
-572
appview/db/pulls.go
+193
-572
appview/db/pulls.go
···
1
1
package db
2
2
3
3
import (
4
+
"cmp"
4
5
"database/sql"
6
+
"errors"
5
7
"fmt"
6
-
"log"
8
+
"maps"
7
9
"slices"
8
10
"sort"
9
11
"strings"
10
12
"time"
11
13
12
14
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/patchutil"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
)
17
-
18
-
type PullState int
19
-
20
-
const (
21
-
PullClosed PullState = iota
22
-
PullOpen
23
-
PullMerged
24
-
PullDeleted
15
+
"tangled.org/core/appview/models"
25
16
)
26
17
27
-
func (p PullState) String() string {
28
-
switch p {
29
-
case PullOpen:
30
-
return "open"
31
-
case PullMerged:
32
-
return "merged"
33
-
case PullClosed:
34
-
return "closed"
35
-
case PullDeleted:
36
-
return "deleted"
37
-
default:
38
-
return "closed"
39
-
}
40
-
}
41
-
42
-
func (p PullState) IsOpen() bool {
43
-
return p == PullOpen
44
-
}
45
-
func (p PullState) IsMerged() bool {
46
-
return p == PullMerged
47
-
}
48
-
func (p PullState) IsClosed() bool {
49
-
return p == PullClosed
50
-
}
51
-
func (p PullState) IsDeleted() bool {
52
-
return p == PullDeleted
53
-
}
54
-
55
-
type Pull struct {
56
-
// ids
57
-
ID int
58
-
PullId int
59
-
60
-
// at ids
61
-
RepoAt syntax.ATURI
62
-
OwnerDid string
63
-
Rkey string
64
-
65
-
// content
66
-
Title string
67
-
Body string
68
-
TargetBranch string
69
-
State PullState
70
-
Submissions []*PullSubmission
71
-
72
-
// stacking
73
-
StackId string // nullable string
74
-
ChangeId string // nullable string
75
-
ParentChangeId string // nullable string
76
-
77
-
// meta
78
-
Created time.Time
79
-
PullSource *PullSource
80
-
81
-
// optionally, populate this when querying for reverse mappings
82
-
Repo *Repo
83
-
}
84
-
85
-
func (p Pull) AsRecord() tangled.RepoPull {
86
-
var source *tangled.RepoPull_Source
87
-
if p.PullSource != nil {
88
-
s := p.PullSource.AsRecord()
89
-
source = &s
90
-
source.Sha = p.LatestSha()
91
-
}
92
-
93
-
record := tangled.RepoPull{
94
-
Title: p.Title,
95
-
Body: &p.Body,
96
-
CreatedAt: p.Created.Format(time.RFC3339),
97
-
Target: &tangled.RepoPull_Target{
98
-
Repo: p.RepoAt.String(),
99
-
Branch: p.TargetBranch,
100
-
},
101
-
Patch: p.LatestPatch(),
102
-
Source: source,
103
-
}
104
-
return record
105
-
}
106
-
107
-
type PullSource struct {
108
-
Branch string
109
-
RepoAt *syntax.ATURI
110
-
111
-
// optionally populate this for reverse mappings
112
-
Repo *Repo
113
-
}
114
-
115
-
func (p PullSource) AsRecord() tangled.RepoPull_Source {
116
-
var repoAt *string
117
-
if p.RepoAt != nil {
118
-
s := p.RepoAt.String()
119
-
repoAt = &s
120
-
}
121
-
record := tangled.RepoPull_Source{
122
-
Branch: p.Branch,
123
-
Repo: repoAt,
124
-
}
125
-
return record
126
-
}
127
-
128
-
type PullSubmission struct {
129
-
// ids
130
-
ID int
131
-
PullId int
132
-
133
-
// at ids
134
-
RepoAt syntax.ATURI
135
-
136
-
// content
137
-
RoundNumber int
138
-
Patch string
139
-
Comments []PullComment
140
-
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
141
-
142
-
// meta
143
-
Created time.Time
144
-
}
145
-
146
-
type PullComment struct {
147
-
// ids
148
-
ID int
149
-
PullId int
150
-
SubmissionId int
151
-
152
-
// at ids
153
-
RepoAt string
154
-
OwnerDid string
155
-
CommentAt string
156
-
157
-
// content
158
-
Body string
159
-
160
-
// meta
161
-
Created time.Time
162
-
}
163
-
164
-
func (p *Pull) LatestPatch() string {
165
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
166
-
return latestSubmission.Patch
167
-
}
168
-
169
-
func (p *Pull) LatestSha() string {
170
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
171
-
return latestSubmission.SourceRev
172
-
}
173
-
174
-
func (p *Pull) PullAt() syntax.ATURI {
175
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
176
-
}
177
-
178
-
func (p *Pull) LastRoundNumber() int {
179
-
return len(p.Submissions) - 1
180
-
}
181
-
182
-
func (p *Pull) IsPatchBased() bool {
183
-
return p.PullSource == nil
184
-
}
185
-
186
-
func (p *Pull) IsBranchBased() bool {
187
-
if p.PullSource != nil {
188
-
if p.PullSource.RepoAt != nil {
189
-
return p.PullSource.RepoAt == &p.RepoAt
190
-
} else {
191
-
// no repo specified
192
-
return true
193
-
}
194
-
}
195
-
return false
196
-
}
197
-
198
-
func (p *Pull) IsForkBased() bool {
199
-
if p.PullSource != nil {
200
-
if p.PullSource.RepoAt != nil {
201
-
// make sure repos are different
202
-
return p.PullSource.RepoAt != &p.RepoAt
203
-
}
204
-
}
205
-
return false
206
-
}
207
-
208
-
func (p *Pull) IsStacked() bool {
209
-
return p.StackId != ""
210
-
}
211
-
212
-
func (s PullSubmission) IsFormatPatch() bool {
213
-
return patchutil.IsFormatPatch(s.Patch)
214
-
}
215
-
216
-
func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
217
-
patches, err := patchutil.ExtractPatches(s.Patch)
218
-
if err != nil {
219
-
log.Println("error extracting patches from submission:", err)
220
-
return []types.FormatPatch{}
221
-
}
222
-
223
-
return patches
224
-
}
225
-
226
-
func NewPull(tx *sql.Tx, pull *Pull) error {
18
+
func NewPull(tx *sql.Tx, pull *models.Pull) error {
227
19
_, err := tx.Exec(`
228
20
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
229
21
values (?, 1)
···
244
36
}
245
37
246
38
pull.PullId = nextId
247
-
pull.State = PullOpen
39
+
pull.State = models.PullOpen
248
40
249
41
var sourceBranch, sourceRepoAt *string
250
42
if pull.PullSource != nil {
···
266
58
parentChangeId = &pull.ParentChangeId
267
59
}
268
60
269
-
_, err = tx.Exec(
61
+
result, err := tx.Exec(
270
62
`
271
63
insert into pulls (
272
64
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
290
82
return err
291
83
}
292
84
85
+
// Set the database primary key ID
86
+
id, err := result.LastInsertId()
87
+
if err != nil {
88
+
return err
89
+
}
90
+
pull.ID = int(id)
91
+
293
92
_, err = tx.Exec(`
294
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
295
-
values (?, ?, ?, ?, ?)
296
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
93
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
+
values (?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
297
96
return err
298
97
}
299
98
···
311
110
return pullId - 1, err
312
111
}
313
112
314
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
315
-
pulls := make(map[int]*Pull)
113
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
316
115
317
116
var conditions []string
318
117
var args []any
···
332
131
333
132
query := fmt.Sprintf(`
334
133
select
134
+
id,
335
135
owner_did,
336
136
repo_at,
337
137
pull_id,
···
361
161
defer rows.Close()
362
162
363
163
for rows.Next() {
364
-
var pull Pull
164
+
var pull models.Pull
365
165
var createdAt string
366
166
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
367
167
err := rows.Scan(
168
+
&pull.ID,
368
169
&pull.OwnerDid,
369
170
&pull.RepoAt,
370
171
&pull.PullId,
···
391
192
pull.Created = createdTime
392
193
393
194
if sourceBranch.Valid {
394
-
pull.PullSource = &PullSource{
195
+
pull.PullSource = &models.PullSource{
395
196
Branch: sourceBranch.String,
396
197
}
397
198
if sourceRepoAt.Valid {
···
413
214
pull.ParentChangeId = parentChangeId.String
414
215
}
415
216
416
-
pulls[pull.PullId] = &pull
217
+
pulls[pull.PullAt()] = &pull
417
218
}
418
219
419
-
// get latest round no. for each pull
420
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
421
-
submissionsQuery := fmt.Sprintf(`
422
-
select
423
-
id, pull_id, round_number, patch, created, source_rev
424
-
from
425
-
pull_submissions
426
-
where
427
-
repo_at in (%s) and pull_id in (%s)
428
-
`, inClause, inClause)
429
-
430
-
args = make([]any, len(pulls)*2)
431
-
idx := 0
432
-
for _, p := range pulls {
433
-
args[idx] = p.RepoAt
434
-
idx += 1
435
-
}
220
+
var pullAts []syntax.ATURI
436
221
for _, p := range pulls {
437
-
args[idx] = p.PullId
438
-
idx += 1
222
+
pullAts = append(pullAts, p.PullAt())
439
223
}
440
-
submissionsRows, err := e.Query(submissionsQuery, args...)
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
441
225
if err != nil {
442
-
return nil, err
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
443
227
}
444
-
defer submissionsRows.Close()
445
228
446
-
for submissionsRows.Next() {
447
-
var s PullSubmission
448
-
var sourceRev sql.NullString
449
-
var createdAt string
450
-
err := submissionsRows.Scan(
451
-
&s.ID,
452
-
&s.PullId,
453
-
&s.RoundNumber,
454
-
&s.Patch,
455
-
&createdAt,
456
-
&sourceRev,
457
-
)
458
-
if err != nil {
459
-
return nil, err
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
460
232
}
461
-
462
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
463
-
if err != nil {
464
-
return nil, err
465
-
}
466
-
s.Created = createdTime
233
+
}
467
234
468
-
if sourceRev.Valid {
469
-
s.SourceRev = sourceRev.String
235
+
// collect allLabels for each issue
236
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
+
if err != nil {
238
+
return nil, fmt.Errorf("failed to query labels: %w", err)
239
+
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
470
243
}
244
+
}
471
245
472
-
if p, ok := pulls[s.PullId]; ok {
473
-
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
474
-
p.Submissions[s.RoundNumber] = &s
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
+
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
475
251
}
476
252
}
477
-
if err := rows.Err(); err != nil {
478
-
return nil, err
253
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
479
256
}
480
-
481
-
// get comment count on latest submission on each pull
482
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
483
-
commentsQuery := fmt.Sprintf(`
484
-
select
485
-
count(id), pull_id
486
-
from
487
-
pull_comments
488
-
where
489
-
submission_id in (%s)
490
-
group by
491
-
submission_id
492
-
`, inClause)
493
-
494
-
args = []any{}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
260
+
}
495
261
for _, p := range pulls {
496
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
497
-
}
498
-
commentsRows, err := e.Query(commentsQuery, args...)
499
-
if err != nil {
500
-
return nil, err
501
-
}
502
-
defer commentsRows.Close()
503
-
504
-
for commentsRows.Next() {
505
-
var commentCount, pullId int
506
-
err := commentsRows.Scan(
507
-
&commentCount,
508
-
&pullId,
509
-
)
510
-
if err != nil {
511
-
return nil, err
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
512
266
}
513
-
if p, ok := pulls[pullId]; ok {
514
-
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
515
-
}
516
-
}
517
-
if err := rows.Err(); err != nil {
518
-
return nil, err
519
267
}
520
268
521
-
orderedByPullId := []*Pull{}
269
+
orderedByPullId := []*models.Pull{}
522
270
for _, p := range pulls {
523
271
orderedByPullId = append(orderedByPullId, p)
524
272
}
···
529
277
return orderedByPullId, nil
530
278
}
531
279
532
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
280
+
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
533
281
return GetPullsWithLimit(e, 0, filters...)
534
282
}
535
283
536
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
537
-
query := `
538
-
select
539
-
owner_did,
540
-
pull_id,
541
-
created,
542
-
title,
543
-
state,
544
-
target_branch,
545
-
repo_at,
546
-
body,
547
-
rkey,
548
-
source_branch,
549
-
source_repo_at,
550
-
stack_id,
551
-
change_id,
552
-
parent_change_id
553
-
from
554
-
pulls
555
-
where
556
-
repo_at = ? and pull_id = ?
557
-
`
558
-
row := e.QueryRow(query, repoAt, pullId)
559
-
560
-
var pull Pull
561
-
var createdAt string
562
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
563
-
err := row.Scan(
564
-
&pull.OwnerDid,
565
-
&pull.PullId,
566
-
&createdAt,
567
-
&pull.Title,
568
-
&pull.State,
569
-
&pull.TargetBranch,
570
-
&pull.RepoAt,
571
-
&pull.Body,
572
-
&pull.Rkey,
573
-
&sourceBranch,
574
-
&sourceRepoAt,
575
-
&stackId,
576
-
&changeId,
577
-
&parentChangeId,
578
-
)
284
+
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
579
286
if err != nil {
580
287
return nil, err
581
288
}
582
-
583
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
584
-
if err != nil {
585
-
return nil, err
289
+
if pulls == nil {
290
+
return nil, sql.ErrNoRows
586
291
}
587
-
pull.Created = createdTime
588
292
589
-
// populate source
590
-
if sourceBranch.Valid {
591
-
pull.PullSource = &PullSource{
592
-
Branch: sourceBranch.String,
593
-
}
594
-
if sourceRepoAt.Valid {
595
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
596
-
if err != nil {
597
-
return nil, err
598
-
}
599
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
600
-
}
601
-
}
293
+
return pulls[0], nil
294
+
}
602
295
603
-
if stackId.Valid {
604
-
pull.StackId = stackId.String
605
-
}
606
-
if changeId.Valid {
607
-
pull.ChangeId = changeId.String
296
+
// mapping from pull -> pull submissions
297
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
298
+
var conditions []string
299
+
var args []any
300
+
for _, filter := range filters {
301
+
conditions = append(conditions, filter.Condition())
302
+
args = append(args, filter.Arg()...)
608
303
}
609
-
if parentChangeId.Valid {
610
-
pull.ParentChangeId = parentChangeId.String
304
+
305
+
whereClause := ""
306
+
if conditions != nil {
307
+
whereClause = " where " + strings.Join(conditions, " and ")
611
308
}
612
309
613
-
submissionsQuery := `
310
+
query := fmt.Sprintf(`
614
311
select
615
-
id, pull_id, repo_at, round_number, patch, created, source_rev
312
+
id,
313
+
pull_at,
314
+
round_number,
315
+
patch,
316
+
created,
317
+
source_rev
616
318
from
617
319
pull_submissions
618
-
where
619
-
repo_at = ? and pull_id = ?
620
-
`
621
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
320
+
%s
321
+
order by
322
+
round_number asc
323
+
`, whereClause)
324
+
325
+
rows, err := e.Query(query, args...)
622
326
if err != nil {
623
327
return nil, err
624
328
}
625
-
defer submissionsRows.Close()
329
+
defer rows.Close()
626
330
627
-
submissionsMap := make(map[int]*PullSubmission)
331
+
submissionMap := make(map[int]*models.PullSubmission)
628
332
629
-
for submissionsRows.Next() {
630
-
var submission PullSubmission
631
-
var submissionCreatedStr string
632
-
var submissionSourceRev sql.NullString
633
-
err := submissionsRows.Scan(
333
+
for rows.Next() {
334
+
var submission models.PullSubmission
335
+
var createdAt string
336
+
var sourceRev sql.NullString
337
+
err := rows.Scan(
634
338
&submission.ID,
635
-
&submission.PullId,
636
-
&submission.RepoAt,
339
+
&submission.PullAt,
637
340
&submission.RoundNumber,
638
341
&submission.Patch,
639
-
&submissionCreatedStr,
640
-
&submissionSourceRev,
342
+
&createdAt,
343
+
&sourceRev,
641
344
)
642
345
if err != nil {
643
346
return nil, err
644
347
}
645
348
646
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
349
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
647
350
if err != nil {
648
351
return nil, err
649
352
}
650
-
submission.Created = submissionCreatedTime
353
+
submission.Created = createdTime
651
354
652
-
if submissionSourceRev.Valid {
653
-
submission.SourceRev = submissionSourceRev.String
355
+
if sourceRev.Valid {
356
+
submission.SourceRev = sourceRev.String
654
357
}
655
358
656
-
submissionsMap[submission.ID] = &submission
359
+
submissionMap[submission.ID] = &submission
657
360
}
658
-
if err = submissionsRows.Close(); err != nil {
361
+
362
+
if err := rows.Err(); err != nil {
363
+
return nil, err
364
+
}
365
+
366
+
// Get comments for all submissions using GetPullComments
367
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
368
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
369
+
if err != nil {
659
370
return nil, err
660
371
}
661
-
if len(submissionsMap) == 0 {
662
-
return &pull, nil
372
+
for _, comment := range comments {
373
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
374
+
submission.Comments = append(submission.Comments, comment)
375
+
}
376
+
}
377
+
378
+
// group the submissions by pull_at
379
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
380
+
for _, s := range submissionMap {
381
+
m[s.PullAt] = append(m[s.PullAt], s)
382
+
}
383
+
384
+
// sort each one by round number
385
+
for _, s := range m {
386
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
387
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
388
+
})
663
389
}
664
390
391
+
return m, nil
392
+
}
393
+
394
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
395
+
var conditions []string
665
396
var args []any
666
-
for k := range submissionsMap {
667
-
args = append(args, k)
397
+
for _, filter := range filters {
398
+
conditions = append(conditions, filter.Condition())
399
+
args = append(args, filter.Arg()...)
668
400
}
669
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
670
-
commentsQuery := fmt.Sprintf(`
401
+
402
+
whereClause := ""
403
+
if conditions != nil {
404
+
whereClause = " where " + strings.Join(conditions, " and ")
405
+
}
406
+
407
+
query := fmt.Sprintf(`
671
408
select
672
409
id,
673
410
pull_id,
···
679
416
created
680
417
from
681
418
pull_comments
682
-
where
683
-
submission_id IN (%s)
419
+
%s
684
420
order by
685
421
created asc
686
-
`, inClause)
687
-
commentsRows, err := e.Query(commentsQuery, args...)
422
+
`, whereClause)
423
+
424
+
rows, err := e.Query(query, args...)
688
425
if err != nil {
689
426
return nil, err
690
427
}
691
-
defer commentsRows.Close()
428
+
defer rows.Close()
692
429
693
-
for commentsRows.Next() {
694
-
var comment PullComment
695
-
var commentCreatedStr string
696
-
err := commentsRows.Scan(
430
+
var comments []models.PullComment
431
+
for rows.Next() {
432
+
var comment models.PullComment
433
+
var createdAt string
434
+
err := rows.Scan(
697
435
&comment.ID,
698
436
&comment.PullId,
699
437
&comment.SubmissionId,
···
701
439
&comment.OwnerDid,
702
440
&comment.CommentAt,
703
441
&comment.Body,
704
-
&commentCreatedStr,
442
+
&createdAt,
705
443
)
706
444
if err != nil {
707
445
return nil, err
708
446
}
709
447
710
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
711
-
if err != nil {
712
-
return nil, err
713
-
}
714
-
comment.Created = commentCreatedTime
715
-
716
-
// Add the comment to its submission
717
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
718
-
submission.Comments = append(submission.Comments, comment)
448
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
449
+
comment.Created = t
719
450
}
720
451
721
-
}
722
-
if err = commentsRows.Err(); err != nil {
723
-
return nil, err
724
-
}
725
-
726
-
var pullSourceRepo *Repo
727
-
if pull.PullSource != nil {
728
-
if pull.PullSource.RepoAt != nil {
729
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
730
-
if err != nil {
731
-
log.Printf("failed to get repo by at uri: %v", err)
732
-
} else {
733
-
pull.PullSource.Repo = pullSourceRepo
734
-
}
735
-
}
452
+
comments = append(comments, comment)
736
453
}
737
454
738
-
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
739
-
for _, submission := range submissionsMap {
740
-
pull.Submissions[submission.RoundNumber] = submission
455
+
if err := rows.Err(); err != nil {
456
+
return nil, err
741
457
}
742
458
743
-
return &pull, nil
459
+
return comments, nil
744
460
}
745
461
746
462
// timeframe here is directly passed into the sql query filter, and any
747
463
// timeframe in the past should be negative; e.g.: "-3 months"
748
-
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
749
-
var pulls []Pull
464
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
465
+
var pulls []models.Pull
750
466
751
467
rows, err := e.Query(`
752
468
select
···
775
491
defer rows.Close()
776
492
777
493
for rows.Next() {
778
-
var pull Pull
779
-
var repo Repo
494
+
var pull models.Pull
495
+
var repo models.Repo
780
496
var pullCreatedAt, repoCreatedAt string
781
497
err := rows.Scan(
782
498
&pull.OwnerDid,
···
819
535
return pulls, nil
820
536
}
821
537
822
-
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
538
+
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
823
539
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
824
540
res, err := e.Exec(
825
541
query,
···
842
558
return i, nil
843
559
}
844
560
845
-
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
561
+
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
846
562
_, err := e.Exec(
847
563
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
848
564
pullState,
849
565
repoAt,
850
566
pullId,
851
-
PullDeleted, // only update state of non-deleted pulls
852
-
PullMerged, // only update state of non-merged pulls
567
+
models.PullDeleted, // only update state of non-deleted pulls
568
+
models.PullMerged, // only update state of non-merged pulls
853
569
)
854
570
return err
855
571
}
856
572
857
573
func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
858
-
err := SetPullState(e, repoAt, pullId, PullClosed)
574
+
err := SetPullState(e, repoAt, pullId, models.PullClosed)
859
575
return err
860
576
}
861
577
862
578
func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
863
-
err := SetPullState(e, repoAt, pullId, PullOpen)
579
+
err := SetPullState(e, repoAt, pullId, models.PullOpen)
864
580
return err
865
581
}
866
582
867
583
func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
868
-
err := SetPullState(e, repoAt, pullId, PullMerged)
584
+
err := SetPullState(e, repoAt, pullId, models.PullMerged)
869
585
return err
870
586
}
871
587
872
588
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
873
-
err := SetPullState(e, repoAt, pullId, PullDeleted)
589
+
err := SetPullState(e, repoAt, pullId, models.PullDeleted)
874
590
return err
875
591
}
876
592
877
-
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
593
+
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
878
594
newRoundNumber := len(pull.Submissions)
879
595
_, err := e.Exec(`
880
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
881
-
values (?, ?, ?, ?, ?)
882
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
596
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
+
values (?, ?, ?, ?)
598
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
883
599
884
600
return err
885
601
}
···
931
647
return err
932
648
}
933
649
934
-
type PullCount struct {
935
-
Open int
936
-
Merged int
937
-
Closed int
938
-
Deleted int
939
-
}
940
-
941
-
func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) {
650
+
func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) {
942
651
row := e.QueryRow(`
943
652
select
944
653
count(case when state = ? then 1 end) as open_count,
···
947
656
count(case when state = ? then 1 end) as deleted_count
948
657
from pulls
949
658
where repo_at = ?`,
950
-
PullOpen,
951
-
PullMerged,
952
-
PullClosed,
953
-
PullDeleted,
659
+
models.PullOpen,
660
+
models.PullMerged,
661
+
models.PullClosed,
662
+
models.PullDeleted,
954
663
repoAt,
955
664
)
956
665
957
-
var count PullCount
666
+
var count models.PullCount
958
667
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
959
-
return PullCount{0, 0, 0, 0}, err
668
+
return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err
960
669
}
961
670
962
671
return count, nil
963
672
}
964
-
965
-
type Stack []*Pull
966
673
967
674
// change-id parent-change-id
968
675
//
···
972
679
// 1 x <------' nil (BOT)
973
680
//
974
681
// `w` is parent of none, so it is the top of the stack
975
-
func GetStack(e Execer, stackId string) (Stack, error) {
682
+
func GetStack(e Execer, stackId string) (models.Stack, error) {
976
683
unorderedPulls, err := GetPulls(
977
684
e,
978
685
FilterEq("stack_id", stackId),
979
-
FilterNotEq("state", PullDeleted),
686
+
FilterNotEq("state", models.PullDeleted),
980
687
)
981
688
if err != nil {
982
689
return nil, err
983
690
}
984
691
// map of parent-change-id to pull
985
-
changeIdMap := make(map[string]*Pull, len(unorderedPulls))
986
-
parentMap := make(map[string]*Pull, len(unorderedPulls))
692
+
changeIdMap := make(map[string]*models.Pull, len(unorderedPulls))
693
+
parentMap := make(map[string]*models.Pull, len(unorderedPulls))
987
694
for _, p := range unorderedPulls {
988
695
changeIdMap[p.ChangeId] = p
989
696
if p.ParentChangeId != "" {
···
992
699
}
993
700
994
701
// the top of the stack is the pull that is not a parent of any pull
995
-
var topPull *Pull
702
+
var topPull *models.Pull
996
703
for _, maybeTop := range unorderedPulls {
997
704
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
998
705
topPull = maybeTop
···
1000
707
}
1001
708
}
1002
709
1003
-
pulls := []*Pull{}
710
+
pulls := []*models.Pull{}
1004
711
for {
1005
712
pulls = append(pulls, topPull)
1006
713
if topPull.ParentChangeId != "" {
···
1017
724
return pulls, nil
1018
725
}
1019
726
1020
-
func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) {
727
+
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
1021
728
pulls, err := GetPulls(
1022
729
e,
1023
730
FilterEq("stack_id", stackId),
1024
-
FilterEq("state", PullDeleted),
731
+
FilterEq("state", models.PullDeleted),
1025
732
)
1026
733
if err != nil {
1027
734
return nil, err
···
1029
736
1030
737
return pulls, nil
1031
738
}
1032
-
1033
-
// position of this pull in the stack
1034
-
func (stack Stack) Position(pull *Pull) int {
1035
-
return slices.IndexFunc(stack, func(p *Pull) bool {
1036
-
return p.ChangeId == pull.ChangeId
1037
-
})
1038
-
}
1039
-
1040
-
// all pulls below this pull (including self) in this stack
1041
-
//
1042
-
// nil if this pull does not belong to this stack
1043
-
func (stack Stack) Below(pull *Pull) Stack {
1044
-
position := stack.Position(pull)
1045
-
1046
-
if position < 0 {
1047
-
return nil
1048
-
}
1049
-
1050
-
return stack[position:]
1051
-
}
1052
-
1053
-
// all pulls below this pull (excluding self) in this stack
1054
-
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
1055
-
below := stack.Below(pull)
1056
-
1057
-
if len(below) > 0 {
1058
-
return below[1:]
1059
-
}
1060
-
1061
-
return nil
1062
-
}
1063
-
1064
-
// all pulls above this pull (including self) in this stack
1065
-
func (stack Stack) Above(pull *Pull) Stack {
1066
-
position := stack.Position(pull)
1067
-
1068
-
if position < 0 {
1069
-
return nil
1070
-
}
1071
-
1072
-
return stack[:position+1]
1073
-
}
1074
-
1075
-
// all pulls below this pull (excluding self) in this stack
1076
-
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
1077
-
above := stack.Above(pull)
1078
-
1079
-
if len(above) > 0 {
1080
-
return above[:len(above)-1]
1081
-
}
1082
-
1083
-
return nil
1084
-
}
1085
-
1086
-
// the combined format-patches of all the newest submissions in this stack
1087
-
func (stack Stack) CombinedPatch() string {
1088
-
// go in reverse order because the bottom of the stack is the last element in the slice
1089
-
var combined strings.Builder
1090
-
for idx := range stack {
1091
-
pull := stack[len(stack)-1-idx]
1092
-
combined.WriteString(pull.LatestPatch())
1093
-
combined.WriteString("\n")
1094
-
}
1095
-
return combined.String()
1096
-
}
1097
-
1098
-
// filter out PRs that are "active"
1099
-
//
1100
-
// PRs that are still open are active
1101
-
func (stack Stack) Mergeable() Stack {
1102
-
var mergeable Stack
1103
-
1104
-
for _, p := range stack {
1105
-
// stop at the first merged PR
1106
-
if p.State == PullMerged || p.State == PullClosed {
1107
-
break
1108
-
}
1109
-
1110
-
// skip over deleted PRs
1111
-
if p.State != PullDeleted {
1112
-
mergeable = append(mergeable, p)
1113
-
}
1114
-
}
1115
-
1116
-
return mergeable
1117
-
}
+7
-16
appview/db/punchcard.go
+7
-16
appview/db/punchcard.go
···
5
5
"fmt"
6
6
"strings"
7
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
8
10
)
9
11
10
-
type Punch struct {
11
-
Did string
12
-
Date time.Time
13
-
Count int
14
-
}
15
-
16
12
// this adds to the existing count
17
-
func AddPunch(e Execer, punch Punch) error {
13
+
func AddPunch(e Execer, punch models.Punch) error {
18
14
_, err := e.Exec(`
19
15
insert into punchcard (did, date, count)
20
16
values (?, ?, ?)
···
24
20
return err
25
21
}
26
22
27
-
type Punchcard struct {
28
-
Total int
29
-
Punches []Punch
30
-
}
31
-
32
-
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
-
punchcard := &Punchcard{}
23
+
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
+
punchcard := &models.Punchcard{}
34
25
now := time.Now()
35
26
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
27
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
37
28
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
38
-
punchcard.Punches = append(punchcard.Punches, Punch{
29
+
punchcard.Punches = append(punchcard.Punches, models.Punch{
39
30
Date: d,
40
31
Count: 0,
41
32
})
···
68
59
defer rows.Close()
69
60
70
61
for rows.Next() {
71
-
var punch Punch
62
+
var punch models.Punch
72
63
var date string
73
64
var count sql.NullInt64
74
65
if err := rows.Scan(&date, &count); err != nil {
+14
-63
appview/db/reaction.go
+14
-63
appview/db/reaction.go
···
5
5
"time"
6
6
7
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
)
9
-
10
-
type ReactionKind string
11
-
12
-
const (
13
-
Like ReactionKind = "👍"
14
-
Unlike ReactionKind = "👎"
15
-
Laugh ReactionKind = "😆"
16
-
Celebration ReactionKind = "🎉"
17
-
Confused ReactionKind = "🫤"
18
-
Heart ReactionKind = "❤️"
19
-
Rocket ReactionKind = "🚀"
20
-
Eyes ReactionKind = "👀"
8
+
"tangled.org/core/appview/models"
21
9
)
22
10
23
-
func (rk ReactionKind) String() string {
24
-
return string(rk)
25
-
}
26
-
27
-
var OrderedReactionKinds = []ReactionKind{
28
-
Like,
29
-
Unlike,
30
-
Laugh,
31
-
Celebration,
32
-
Confused,
33
-
Heart,
34
-
Rocket,
35
-
Eyes,
36
-
}
37
-
38
-
func ParseReactionKind(raw string) (ReactionKind, bool) {
39
-
k, ok := (map[string]ReactionKind{
40
-
"👍": Like,
41
-
"👎": Unlike,
42
-
"😆": Laugh,
43
-
"🎉": Celebration,
44
-
"🫤": Confused,
45
-
"❤️": Heart,
46
-
"🚀": Rocket,
47
-
"👀": Eyes,
48
-
})[raw]
49
-
return k, ok
50
-
}
51
-
52
-
type Reaction struct {
53
-
ReactedByDid string
54
-
ThreadAt syntax.ATURI
55
-
Created time.Time
56
-
Rkey string
57
-
Kind ReactionKind
58
-
}
59
-
60
-
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
11
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error {
61
12
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
62
13
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
63
14
return err
64
15
}
65
16
66
17
// Get a reaction record
67
-
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
18
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) {
68
19
query := `
69
20
select reacted_by_did, thread_at, created, rkey
70
21
from reactions
71
22
where reacted_by_did = ? and thread_at = ? and kind = ?`
72
23
row := e.QueryRow(query, reactedByDid, threadAt, kind)
73
24
74
-
var reaction Reaction
25
+
var reaction models.Reaction
75
26
var created string
76
27
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
77
28
if err != nil {
···
90
41
}
91
42
92
43
// Remove a reaction
93
-
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
44
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error {
94
45
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
95
46
return err
96
47
}
···
101
52
return err
102
53
}
103
54
104
-
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
55
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) {
105
56
count := 0
106
57
err := e.QueryRow(
107
58
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
111
62
return count, nil
112
63
}
113
64
114
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
115
-
countMap := map[ReactionKind]int{}
116
-
for _, kind := range OrderedReactionKinds {
65
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
+
countMap := map[models.ReactionKind]int{}
67
+
for _, kind := range models.OrderedReactionKinds {
117
68
count, err := GetReactionCount(e, threadAt, kind)
118
69
if err != nil {
119
-
return map[ReactionKind]int{}, nil
70
+
return map[models.ReactionKind]int{}, nil
120
71
}
121
72
countMap[kind] = count
122
73
}
123
74
return countMap, nil
124
75
}
125
76
126
-
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
77
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
127
78
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
128
79
return false
129
80
} else {
···
131
82
}
132
83
}
133
84
134
-
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
135
-
statusMap := map[ReactionKind]bool{}
136
-
for _, kind := range OrderedReactionKinds {
85
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool {
86
+
statusMap := map[models.ReactionKind]bool{}
87
+
for _, kind := range models.OrderedReactionKinds {
137
88
count := GetReactionStatus(e, userDid, threadAt, kind)
138
89
statusMap[kind] = count
139
90
}
+4
-43
appview/db/registration.go
+4
-43
appview/db/registration.go
···
5
5
"fmt"
6
6
"strings"
7
7
"time"
8
-
)
9
-
10
-
// Registration represents a knot registration. Knot would've been a better
11
-
// name but we're stuck with this for historical reasons.
12
-
type Registration struct {
13
-
Id int64
14
-
Domain string
15
-
ByDid string
16
-
Created *time.Time
17
-
Registered *time.Time
18
-
NeedsUpgrade bool
19
-
}
20
8
21
-
func (r *Registration) Status() Status {
22
-
if r.NeedsUpgrade {
23
-
return NeedsUpgrade
24
-
} else if r.Registered != nil {
25
-
return Registered
26
-
} else {
27
-
return Pending
28
-
}
29
-
}
30
-
31
-
func (r *Registration) IsRegistered() bool {
32
-
return r.Status() == Registered
33
-
}
34
-
35
-
func (r *Registration) IsNeedsUpgrade() bool {
36
-
return r.Status() == NeedsUpgrade
37
-
}
38
-
39
-
func (r *Registration) IsPending() bool {
40
-
return r.Status() == Pending
41
-
}
42
-
43
-
type Status uint32
44
-
45
-
const (
46
-
Registered Status = iota
47
-
Pending
48
-
NeedsUpgrade
9
+
"tangled.org/core/appview/models"
49
10
)
50
11
51
-
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
52
-
var registrations []Registration
12
+
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
+
var registrations []models.Registration
53
14
54
15
var conditions []string
55
16
var args []any
···
81
42
var createdAt string
82
43
var registeredAt sql.Null[string]
83
44
var needsUpgrade int
84
-
var reg Registration
45
+
var reg models.Registration
85
46
86
47
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
87
48
if err != nil {
+63
-87
appview/db/repos.go
+63
-87
appview/db/repos.go
···
11
11
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/models"
15
16
)
16
17
17
18
type Repo struct {
19
+
Id int64
18
20
Did string
19
21
Name string
20
22
Knot string
···
22
24
Created time.Time
23
25
Description string
24
26
Spindle string
25
-
Labels []string
26
27
27
28
// optionally, populate this when querying for reverse mappings
28
-
RepoStats *RepoStats
29
+
RepoStats *models.RepoStats
29
30
30
31
// optional
31
32
Source string
32
33
}
33
34
34
-
func (r *Repo) AsRecord() tangled.Repo {
35
-
var source, spindle, description *string
36
-
37
-
if r.Source != "" {
38
-
source = &r.Source
39
-
}
40
-
41
-
if r.Spindle != "" {
42
-
spindle = &r.Spindle
43
-
}
44
-
45
-
if r.Description != "" {
46
-
description = &r.Description
47
-
}
48
-
49
-
return tangled.Repo{
50
-
Knot: r.Knot,
51
-
Name: r.Name,
52
-
Description: description,
53
-
CreatedAt: r.Created.Format(time.RFC3339),
54
-
Source: source,
55
-
Spindle: spindle,
56
-
Labels: r.Labels,
57
-
}
58
-
}
59
-
60
35
func (r Repo) RepoAt() syntax.ATURI {
61
36
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
62
37
}
···
66
41
return p
67
42
}
68
43
69
-
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
70
-
repoMap := make(map[syntax.ATURI]*Repo)
44
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
+
repoMap := make(map[syntax.ATURI]*models.Repo)
71
46
72
47
var conditions []string
73
48
var args []any
···
88
63
89
64
repoQuery := fmt.Sprintf(
90
65
`select
66
+
id,
91
67
did,
92
68
name,
93
69
knot,
···
111
87
}
112
88
113
89
for rows.Next() {
114
-
var repo Repo
90
+
var repo models.Repo
115
91
var createdAt string
116
92
var description, source, spindle sql.NullString
117
93
118
94
err := rows.Scan(
95
+
&repo.Id,
119
96
&repo.Did,
120
97
&repo.Name,
121
98
&repo.Knot,
···
142
119
repo.Spindle = spindle.String
143
120
}
144
121
145
-
repo.RepoStats = &RepoStats{}
122
+
repo.RepoStats = &models.RepoStats{}
146
123
repoMap[repo.RepoAt()] = &repo
147
124
}
148
125
···
184
161
185
162
languageQuery := fmt.Sprintf(
186
163
`
187
-
select
188
-
repo_at, language
189
-
from
190
-
repo_languages r1
191
-
where
192
-
repo_at IN (%s)
164
+
select repo_at, language
165
+
from (
166
+
select
167
+
repo_at,
168
+
language,
169
+
row_number() over (
170
+
partition by repo_at
171
+
order by bytes desc
172
+
) as rn
173
+
from repo_languages
174
+
where repo_at in (%s)
193
175
and is_default_ref = 1
194
-
and id = (
195
-
select id
196
-
from repo_languages r2
197
-
where r2.repo_at = r1.repo_at
198
-
and r2.is_default_ref = 1
199
-
order by bytes desc
200
-
limit 1
201
-
);
176
+
)
177
+
where rn = 1
202
178
`,
203
179
inClause,
204
180
)
···
290
266
inClause,
291
267
)
292
268
args = append([]any{
293
-
PullOpen,
294
-
PullMerged,
295
-
PullClosed,
296
-
PullDeleted,
269
+
models.PullOpen,
270
+
models.PullMerged,
271
+
models.PullClosed,
272
+
models.PullDeleted,
297
273
}, args...)
298
274
rows, err = e.Query(
299
275
pullCountQuery,
···
320
296
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
321
297
}
322
298
323
-
var repos []Repo
299
+
var repos []models.Repo
324
300
for _, r := range repoMap {
325
301
repos = append(repos, *r)
326
302
}
327
303
328
-
slices.SortFunc(repos, func(a, b Repo) int {
304
+
slices.SortFunc(repos, func(a, b models.Repo) int {
329
305
if a.Created.After(b.Created) {
330
306
return -1
331
307
}
···
336
312
}
337
313
338
314
// helper to get exactly one repo
339
-
func GetRepo(e Execer, filters ...filter) (*Repo, error) {
315
+
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
340
316
repos, err := GetRepos(e, 0, filters...)
341
317
if err != nil {
342
318
return nil, err
···
377
353
return count, nil
378
354
}
379
355
380
-
func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
381
-
var repo Repo
356
+
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
+
var repo models.Repo
382
358
var nullableDescription sql.NullString
383
359
384
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
385
361
386
362
var createdAt string
387
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
388
364
return nil, err
389
365
}
390
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
399
375
return &repo, nil
400
376
}
401
377
402
-
func AddRepo(e Execer, repo *Repo) error {
403
-
_, err := e.Exec(
378
+
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
+
_, err := tx.Exec(
404
380
`insert into repos
405
381
(did, name, knot, rkey, at_uri, description, source)
406
382
values (?, ?, ?, ?, ?, ?, ?)`,
407
383
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
408
384
)
409
-
return err
385
+
if err != nil {
386
+
return fmt.Errorf("failed to insert repo: %w", err)
387
+
}
388
+
389
+
for _, dl := range repo.Labels {
390
+
if err := SubscribeLabel(tx, &models.RepoLabel{
391
+
RepoAt: repo.RepoAt(),
392
+
LabelAt: syntax.ATURI(dl),
393
+
}); err != nil {
394
+
return fmt.Errorf("failed to subscribe to label: %w", err)
395
+
}
396
+
}
397
+
398
+
return nil
410
399
}
411
400
412
401
func RemoveRepo(e Execer, did, name string) error {
···
423
412
return nullableSource.String, nil
424
413
}
425
414
426
-
func GetForksByDid(e Execer, did string) ([]Repo, error) {
427
-
var repos []Repo
415
+
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
416
+
var repos []models.Repo
428
417
429
418
rows, err := e.Query(
430
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
431
420
from repos r
432
421
left join collaborators c on r.at_uri = c.repo_at
433
422
where (r.did = ? or c.subject_did = ?)
···
442
431
defer rows.Close()
443
432
444
433
for rows.Next() {
445
-
var repo Repo
434
+
var repo models.Repo
446
435
var createdAt string
447
436
var nullableDescription sql.NullString
448
437
var nullableSource sql.NullString
449
438
450
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
451
440
if err != nil {
452
441
return nil, err
453
442
}
···
477
466
return repos, nil
478
467
}
479
468
480
-
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
481
-
var repo Repo
469
+
func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) {
470
+
var repo models.Repo
482
471
var createdAt string
483
472
var nullableDescription sql.NullString
484
473
var nullableSource sql.NullString
485
474
486
475
row := e.QueryRow(
487
-
`select did, name, knot, rkey, description, created, source
476
+
`select id, did, name, knot, rkey, description, created, source
488
477
from repos
489
478
where did = ? and name = ? and source is not null and source != ''`,
490
479
did, name,
491
480
)
492
481
493
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
494
483
if err != nil {
495
484
return nil, err
496
485
}
···
525
514
return err
526
515
}
527
516
528
-
type RepoStats struct {
529
-
Language string
530
-
StarCount int
531
-
IssueCount IssueCount
532
-
PullCount PullCount
533
-
}
534
-
535
-
type RepoLabel struct {
536
-
Id int64
537
-
RepoAt syntax.ATURI
538
-
LabelAt syntax.ATURI
539
-
}
540
-
541
-
func SubscribeLabel(e Execer, rl *RepoLabel) error {
517
+
func SubscribeLabel(e Execer, rl *models.RepoLabel) error {
542
518
query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)`
543
519
544
520
_, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String())
···
563
539
return err
564
540
}
565
541
566
-
func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) {
542
+
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
567
543
var conditions []string
568
544
var args []any
569
545
for _, filter := range filters {
···
584
560
}
585
561
defer rows.Close()
586
562
587
-
var labels []RepoLabel
563
+
var labels []models.RepoLabel
588
564
for rows.Next() {
589
-
var label RepoLabel
565
+
var label models.RepoLabel
590
566
591
567
err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt)
592
568
if err != nil {
+4
-9
appview/db/signup.go
+4
-9
appview/db/signup.go
···
1
1
package db
2
2
3
-
import "time"
3
+
import (
4
+
"tangled.org/core/appview/models"
5
+
)
4
6
5
-
type InflightSignup struct {
6
-
Id int64
7
-
Email string
8
-
InviteCode string
9
-
Created time.Time
10
-
}
11
-
12
-
func AddInflightSignup(e Execer, signup InflightSignup) error {
7
+
func AddInflightSignup(e Execer, signup models.InflightSignup) error {
13
8
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
9
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
10
return err
+9
-27
appview/db/spindle.go
+9
-27
appview/db/spindle.go
···
6
6
"strings"
7
7
"time"
8
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/appview/models"
10
10
)
11
11
12
-
type Spindle struct {
13
-
Id int
14
-
Owner syntax.DID
15
-
Instance string
16
-
Verified *time.Time
17
-
Created time.Time
18
-
NeedsUpgrade bool
19
-
}
20
-
21
-
type SpindleMember struct {
22
-
Id int
23
-
Did syntax.DID // owner of the record
24
-
Rkey string // rkey of the record
25
-
Instance string
26
-
Subject syntax.DID // the member being added
27
-
Created time.Time
28
-
}
29
-
30
-
func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
31
-
var spindles []Spindle
12
+
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
+
var spindles []models.Spindle
32
14
33
15
var conditions []string
34
16
var args []any
···
59
41
defer rows.Close()
60
42
61
43
for rows.Next() {
62
-
var spindle Spindle
44
+
var spindle models.Spindle
63
45
var createdAt string
64
46
var verified sql.NullString
65
47
var needsUpgrade int
···
100
82
}
101
83
102
84
// if there is an existing spindle with the same instance, this returns an error
103
-
func AddSpindle(e Execer, spindle Spindle) error {
85
+
func AddSpindle(e Execer, spindle models.Spindle) error {
104
86
_, err := e.Exec(
105
87
`insert into spindles (owner, instance) values (?, ?)`,
106
88
spindle.Owner,
···
151
133
return err
152
134
}
153
135
154
-
func AddSpindleMember(e Execer, member SpindleMember) error {
136
+
func AddSpindleMember(e Execer, member models.SpindleMember) error {
155
137
_, err := e.Exec(
156
138
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
157
139
member.Did,
···
181
163
return err
182
164
}
183
165
184
-
func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
185
-
var members []SpindleMember
166
+
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
+
var members []models.SpindleMember
186
168
187
169
var conditions []string
188
170
var args []any
···
213
195
defer rows.Close()
214
196
215
197
for rows.Next() {
216
-
var member SpindleMember
198
+
var member models.SpindleMember
217
199
var createdAt string
218
200
219
201
if err := rows.Scan(
+27
-39
appview/db/star.go
+27
-39
appview/db/star.go
···
5
5
"errors"
6
6
"fmt"
7
7
"log"
8
+
"slices"
8
9
"strings"
9
10
"time"
10
11
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
12
14
)
13
15
14
-
type Star struct {
15
-
StarredByDid string
16
-
RepoAt syntax.ATURI
17
-
Created time.Time
18
-
Rkey string
19
-
20
-
// optionally, populate this when querying for reverse mappings
21
-
Repo *Repo
22
-
}
23
-
24
-
func (star *Star) ResolveRepo(e Execer) error {
25
-
if star.Repo != nil {
26
-
return nil
27
-
}
28
-
29
-
repo, err := GetRepoByAtUri(e, star.RepoAt.String())
30
-
if err != nil {
31
-
return err
32
-
}
33
-
34
-
star.Repo = repo
35
-
return nil
36
-
}
37
-
38
-
func AddStar(e Execer, star *Star) error {
16
+
func AddStar(e Execer, star *models.Star) error {
39
17
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
40
18
_, err := e.Exec(
41
19
query,
···
47
25
}
48
26
49
27
// Get a star record
50
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
28
+
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
51
29
query := `
52
30
select starred_by_did, repo_at, created, rkey
53
31
from stars
54
32
where starred_by_did = ? and repo_at = ?`
55
33
row := e.QueryRow(query, starredByDid, repoAt)
56
34
57
-
var star Star
35
+
var star models.Star
58
36
var created string
59
37
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
60
38
if err != nil {
···
152
130
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
153
131
return getStarStatuses(e, userDid, repoAts)
154
132
}
155
-
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
133
+
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
156
134
var conditions []string
157
135
var args []any
158
136
for _, filter := range filters {
···
184
162
return nil, err
185
163
}
186
164
187
-
starMap := make(map[string][]Star)
165
+
starMap := make(map[string][]models.Star)
188
166
for rows.Next() {
189
-
var star Star
167
+
var star models.Star
190
168
var created string
191
169
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
192
170
if err != nil {
···
227
205
}
228
206
}
229
207
230
-
var stars []Star
208
+
var stars []models.Star
231
209
for _, s := range starMap {
232
210
stars = append(stars, s...)
233
211
}
212
+
213
+
slices.SortFunc(stars, func(a, b models.Star) int {
214
+
if a.Created.After(b.Created) {
215
+
return -1
216
+
}
217
+
if b.Created.After(a.Created) {
218
+
return 1
219
+
}
220
+
return 0
221
+
})
234
222
235
223
return stars, nil
236
224
}
···
259
247
return count, nil
260
248
}
261
249
262
-
func GetAllStars(e Execer, limit int) ([]Star, error) {
263
-
var stars []Star
250
+
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
251
+
var stars []models.Star
264
252
265
253
rows, err := e.Query(`
266
254
select
···
283
271
defer rows.Close()
284
272
285
273
for rows.Next() {
286
-
var star Star
287
-
var repo Repo
274
+
var star models.Star
275
+
var repo models.Repo
288
276
var starCreatedAt, repoCreatedAt string
289
277
290
278
if err := rows.Scan(
···
322
310
}
323
311
324
312
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
325
-
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
313
+
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
326
314
// first, get the top repo URIs by star count from the last week
327
315
query := `
328
316
with recent_starred_repos as (
···
366
354
}
367
355
368
356
if len(repoUris) == 0 {
369
-
return []Repo{}, nil
357
+
return []models.Repo{}, nil
370
358
}
371
359
372
360
// get full repo data
···
376
364
}
377
365
378
366
// sort repos by the original trending order
379
-
repoMap := make(map[string]Repo)
367
+
repoMap := make(map[string]models.Repo)
380
368
for _, repo := range repos {
381
369
repoMap[repo.RepoAt().String()] = repo
382
370
}
383
371
384
-
orderedRepos := make([]Repo, 0, len(repoUris))
372
+
orderedRepos := make([]models.Repo, 0, len(repoUris))
385
373
for _, uri := range repoUris {
386
374
if repo, exists := repoMap[uri]; exists {
387
375
orderedRepos = append(orderedRepos, repo)
+5
-110
appview/db/strings.go
+5
-110
appview/db/strings.go
···
1
1
package db
2
2
3
3
import (
4
-
"bytes"
5
4
"database/sql"
6
5
"errors"
7
6
"fmt"
8
-
"io"
9
7
"strings"
10
8
"time"
11
-
"unicode/utf8"
12
9
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
10
+
"tangled.org/core/appview/models"
15
11
)
16
12
17
-
type String struct {
18
-
Did syntax.DID
19
-
Rkey string
20
-
21
-
Filename string
22
-
Description string
23
-
Contents string
24
-
Created time.Time
25
-
Edited *time.Time
26
-
}
27
-
28
-
func (s *String) StringAt() syntax.ATURI {
29
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
-
}
31
-
32
-
type StringStats struct {
33
-
LineCount uint64
34
-
ByteCount uint64
35
-
}
36
-
37
-
func (s String) Stats() StringStats {
38
-
lineCount, err := countLines(strings.NewReader(s.Contents))
39
-
if err != nil {
40
-
// non-fatal
41
-
// TODO: log this?
42
-
}
43
-
44
-
return StringStats{
45
-
LineCount: uint64(lineCount),
46
-
ByteCount: uint64(len(s.Contents)),
47
-
}
48
-
}
49
-
50
-
func (s String) Validate() error {
51
-
var err error
52
-
53
-
if utf8.RuneCountInString(s.Filename) > 140 {
54
-
err = errors.Join(err, fmt.Errorf("filename too long"))
55
-
}
56
-
57
-
if utf8.RuneCountInString(s.Description) > 280 {
58
-
err = errors.Join(err, fmt.Errorf("description too long"))
59
-
}
60
-
61
-
if len(s.Contents) == 0 {
62
-
err = errors.Join(err, fmt.Errorf("contents is empty"))
63
-
}
64
-
65
-
return err
66
-
}
67
-
68
-
func (s *String) AsRecord() tangled.String {
69
-
return tangled.String{
70
-
Filename: s.Filename,
71
-
Description: s.Description,
72
-
Contents: s.Contents,
73
-
CreatedAt: s.Created.Format(time.RFC3339),
74
-
}
75
-
}
76
-
77
-
func StringFromRecord(did, rkey string, record tangled.String) String {
78
-
created, err := time.Parse(record.CreatedAt, time.RFC3339)
79
-
if err != nil {
80
-
created = time.Now()
81
-
}
82
-
return String{
83
-
Did: syntax.DID(did),
84
-
Rkey: rkey,
85
-
Filename: record.Filename,
86
-
Description: record.Description,
87
-
Contents: record.Contents,
88
-
Created: created,
89
-
}
90
-
}
91
-
92
-
func AddString(e Execer, s String) error {
13
+
func AddString(e Execer, s models.String) error {
93
14
_, err := e.Exec(
94
15
`insert into strings (
95
16
did,
···
123
44
return err
124
45
}
125
46
126
-
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127
-
var all []String
47
+
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
+
var all []models.String
128
49
129
50
var conditions []string
130
51
var args []any
···
167
88
defer rows.Close()
168
89
169
90
for rows.Next() {
170
-
var s String
91
+
var s models.String
171
92
var createdAt string
172
93
var editedAt sql.NullString
173
94
···
248
169
_, err := e.Exec(query, args...)
249
170
return err
250
171
}
251
-
252
-
func countLines(r io.Reader) (int, error) {
253
-
buf := make([]byte, 32*1024)
254
-
bufLen := 0
255
-
count := 0
256
-
nl := []byte{'\n'}
257
-
258
-
for {
259
-
c, err := r.Read(buf)
260
-
if c > 0 {
261
-
bufLen += c
262
-
}
263
-
count += bytes.Count(buf[:c], nl)
264
-
265
-
switch {
266
-
case err == io.EOF:
267
-
/* handle last line not having a newline at the end */
268
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
269
-
count++
270
-
}
271
-
return count, nil
272
-
case err != nil:
273
-
return 0, err
274
-
}
275
-
}
276
-
}
+20
-40
appview/db/timeline.go
+20
-40
appview/db/timeline.go
···
2
2
3
3
import (
4
4
"sort"
5
-
"time"
6
5
7
6
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
8
8
)
9
9
10
-
type TimelineEvent struct {
11
-
*Repo
12
-
*Follow
13
-
*Star
14
-
15
-
EventAt time.Time
16
-
17
-
// optional: populate only if Repo is a fork
18
-
Source *Repo
19
-
20
-
// optional: populate only if event is Follow
21
-
*Profile
22
-
*FollowStats
23
-
*FollowStatus
24
-
25
-
// optional: populate only if event is Repo
26
-
IsStarred bool
27
-
StarCount int64
28
-
}
29
-
30
10
// TODO: this gathers heterogenous events from different sources and aggregates
31
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
32
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
33
-
var events []TimelineEvent
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13
+
var events []models.TimelineEvent
34
14
35
15
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
36
16
if err != nil {
···
63
43
return events, nil
64
44
}
65
45
66
-
func fetchStarStatuses(e Execer, loggedInUserDid string, repos []Repo) (map[string]bool, error) {
46
+
func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
67
47
if loggedInUserDid == "" {
68
48
return nil, nil
69
49
}
···
76
56
return GetStarStatuses(e, loggedInUserDid, repoAts)
77
57
}
78
58
79
-
func getRepoStarInfo(repo *Repo, starStatuses map[string]bool) (bool, int64) {
59
+
func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) {
80
60
var isStarred bool
81
61
if starStatuses != nil {
82
62
isStarred = starStatuses[repo.RepoAt().String()]
···
90
70
return isStarred, starCount
91
71
}
92
72
93
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
73
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
94
74
repos, err := GetRepos(e, limit)
95
75
if err != nil {
96
76
return nil, err
···
104
84
}
105
85
}
106
86
107
-
var origRepos []Repo
87
+
var origRepos []models.Repo
108
88
if args != nil {
109
89
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
110
90
}
···
112
92
return nil, err
113
93
}
114
94
115
-
uriToRepo := make(map[string]Repo)
95
+
uriToRepo := make(map[string]models.Repo)
116
96
for _, r := range origRepos {
117
97
uriToRepo[r.RepoAt().String()] = r
118
98
}
···
122
102
return nil, err
123
103
}
124
104
125
-
var events []TimelineEvent
105
+
var events []models.TimelineEvent
126
106
for _, r := range repos {
127
-
var source *Repo
107
+
var source *models.Repo
128
108
if r.Source != "" {
129
109
if origRepo, ok := uriToRepo[r.Source]; ok {
130
110
source = &origRepo
···
133
113
134
114
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
135
115
136
-
events = append(events, TimelineEvent{
116
+
events = append(events, models.TimelineEvent{
137
117
Repo: &r,
138
118
EventAt: r.Created,
139
119
Source: source,
···
145
125
return events, nil
146
126
}
147
127
148
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
128
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
149
129
stars, err := GetStars(e, limit)
150
130
if err != nil {
151
131
return nil, err
···
161
141
}
162
142
stars = stars[:n]
163
143
164
-
var repos []Repo
144
+
var repos []models.Repo
165
145
for _, s := range stars {
166
146
repos = append(repos, *s.Repo)
167
147
}
···
171
151
return nil, err
172
152
}
173
153
174
-
var events []TimelineEvent
154
+
var events []models.TimelineEvent
175
155
for _, s := range stars {
176
156
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
177
157
178
-
events = append(events, TimelineEvent{
158
+
events = append(events, models.TimelineEvent{
179
159
Star: &s,
180
160
EventAt: s.Created,
181
161
IsStarred: isStarred,
···
186
166
return events, nil
187
167
}
188
168
189
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
169
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
190
170
follows, err := GetFollows(e, limit)
191
171
if err != nil {
192
172
return nil, err
···
211
191
return nil, err
212
192
}
213
193
214
-
var followStatuses map[string]FollowStatus
194
+
var followStatuses map[string]models.FollowStatus
215
195
if loggedInUserDid != "" {
216
196
followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
217
197
if err != nil {
···
219
199
}
220
200
}
221
201
222
-
var events []TimelineEvent
202
+
var events []models.TimelineEvent
223
203
for _, f := range follows {
224
204
profile, _ := profiles[f.SubjectDid]
225
205
followStatMap, _ := followStatMap[f.SubjectDid]
226
206
227
-
followStatus := IsNotFollowing
207
+
followStatus := models.IsNotFollowing
228
208
if followStatuses != nil {
229
209
followStatus = followStatuses[f.SubjectDid]
230
210
}
231
211
232
-
events = append(events, TimelineEvent{
212
+
events = append(events, models.TimelineEvent{
233
213
Follow: &f,
234
214
Profile: profile,
235
215
FollowStats: &followStatMap,
+1
-1
appview/dns/cloudflare.go
+1
-1
appview/dns/cloudflare.go
+146
-65
appview/ingester.go
+146
-65
appview/ingester.go
···
5
5
"encoding/json"
6
6
"fmt"
7
7
"log/slog"
8
+
"maps"
9
+
"slices"
8
10
9
11
"time"
10
12
11
13
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"github.com/bluesky-social/jetstream/pkg/models"
14
+
jmodels "github.com/bluesky-social/jetstream/pkg/models"
13
15
"github.com/go-git/go-git/v5/plumbing"
14
16
"github.com/ipfs/go-cid"
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/validator"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/config"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/serververify"
22
+
"tangled.org/core/appview/validator"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
22
25
)
23
26
24
27
type Ingester struct {
···
30
33
Validator *validator.Validator
31
34
}
32
35
33
-
type processFunc func(ctx context.Context, e *models.Event) error
36
+
type processFunc func(ctx context.Context, e *jmodels.Event) error
34
37
35
38
func (i *Ingester) Ingest() processFunc {
36
-
return func(ctx context.Context, e *models.Event) error {
39
+
return func(ctx context.Context, e *jmodels.Event) error {
37
40
var err error
38
41
defer func() {
39
42
eventTime := e.TimeUS
···
45
48
46
49
l := i.Logger.With("kind", e.Kind)
47
50
switch e.Kind {
48
-
case models.EventKindAccount:
51
+
case jmodels.EventKindAccount:
49
52
if !e.Account.Active && *e.Account.Status == "deactivated" {
50
53
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
51
54
}
52
-
case models.EventKindIdentity:
55
+
case jmodels.EventKindIdentity:
53
56
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
54
-
case models.EventKindCommit:
57
+
case jmodels.EventKindCommit:
55
58
switch e.Commit.Collection {
56
59
case tangled.GraphFollowNSID:
57
60
err = i.ingestFollow(e)
···
79
82
err = i.ingestIssueComment(e)
80
83
case tangled.LabelDefinitionNSID:
81
84
err = i.ingestLabelDefinition(e)
85
+
case tangled.LabelOpNSID:
86
+
err = i.ingestLabelOp(e)
82
87
}
83
88
l = i.Logger.With("nsid", e.Commit.Collection)
84
89
}
···
91
96
}
92
97
}
93
98
94
-
func (i *Ingester) ingestStar(e *models.Event) error {
99
+
func (i *Ingester) ingestStar(e *jmodels.Event) error {
95
100
var err error
96
101
did := e.Did
97
102
···
99
104
l = l.With("nsid", e.Commit.Collection)
100
105
101
106
switch e.Commit.Operation {
102
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
107
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
103
108
var subjectUri syntax.ATURI
104
109
105
110
raw := json.RawMessage(e.Commit.Record)
···
115
120
l.Error("invalid record", "err", err)
116
121
return err
117
122
}
118
-
err = db.AddStar(i.Db, &db.Star{
123
+
err = db.AddStar(i.Db, &models.Star{
119
124
StarredByDid: did,
120
125
RepoAt: subjectUri,
121
126
Rkey: e.Commit.RKey,
122
127
})
123
-
case models.CommitOperationDelete:
128
+
case jmodels.CommitOperationDelete:
124
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
125
130
}
126
131
···
131
136
return nil
132
137
}
133
138
134
-
func (i *Ingester) ingestFollow(e *models.Event) error {
139
+
func (i *Ingester) ingestFollow(e *jmodels.Event) error {
135
140
var err error
136
141
did := e.Did
137
142
···
139
144
l = l.With("nsid", e.Commit.Collection)
140
145
141
146
switch e.Commit.Operation {
142
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
147
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
143
148
raw := json.RawMessage(e.Commit.Record)
144
149
record := tangled.GraphFollow{}
145
150
err = json.Unmarshal(raw, &record)
···
148
153
return err
149
154
}
150
155
151
-
err = db.AddFollow(i.Db, &db.Follow{
156
+
err = db.AddFollow(i.Db, &models.Follow{
152
157
UserDid: did,
153
158
SubjectDid: record.Subject,
154
159
Rkey: e.Commit.RKey,
155
160
})
156
-
case models.CommitOperationDelete:
161
+
case jmodels.CommitOperationDelete:
157
162
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
158
163
}
159
164
···
164
169
return nil
165
170
}
166
171
167
-
func (i *Ingester) ingestPublicKey(e *models.Event) error {
172
+
func (i *Ingester) ingestPublicKey(e *jmodels.Event) error {
168
173
did := e.Did
169
174
var err error
170
175
···
172
177
l = l.With("nsid", e.Commit.Collection)
173
178
174
179
switch e.Commit.Operation {
175
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
180
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
176
181
l.Debug("processing add of pubkey")
177
182
raw := json.RawMessage(e.Commit.Record)
178
183
record := tangled.PublicKey{}
···
185
190
name := record.Name
186
191
key := record.Key
187
192
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
188
-
case models.CommitOperationDelete:
193
+
case jmodels.CommitOperationDelete:
189
194
l.Debug("processing delete of pubkey")
190
195
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
191
196
}
···
197
202
return nil
198
203
}
199
204
200
-
func (i *Ingester) ingestArtifact(e *models.Event) error {
205
+
func (i *Ingester) ingestArtifact(e *jmodels.Event) error {
201
206
did := e.Did
202
207
var err error
203
208
···
205
210
l = l.With("nsid", e.Commit.Collection)
206
211
207
212
switch e.Commit.Operation {
208
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
213
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
209
214
raw := json.RawMessage(e.Commit.Record)
210
215
record := tangled.RepoArtifact{}
211
216
err = json.Unmarshal(raw, &record)
···
234
239
createdAt = time.Now()
235
240
}
236
241
237
-
artifact := db.Artifact{
242
+
artifact := models.Artifact{
238
243
Did: did,
239
244
Rkey: e.Commit.RKey,
240
245
RepoAt: repoAt,
···
247
252
}
248
253
249
254
err = db.AddArtifact(i.Db, artifact)
250
-
case models.CommitOperationDelete:
255
+
case jmodels.CommitOperationDelete:
251
256
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
252
257
}
253
258
···
258
263
return nil
259
264
}
260
265
261
-
func (i *Ingester) ingestProfile(e *models.Event) error {
266
+
func (i *Ingester) ingestProfile(e *jmodels.Event) error {
262
267
did := e.Did
263
268
var err error
264
269
···
270
275
}
271
276
272
277
switch e.Commit.Operation {
273
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
278
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
274
279
raw := json.RawMessage(e.Commit.Record)
275
280
record := tangled.ActorProfile{}
276
281
err = json.Unmarshal(raw, &record)
···
298
303
}
299
304
}
300
305
301
-
var stats [2]db.VanityStat
306
+
var stats [2]models.VanityStat
302
307
for i, s := range record.Stats {
303
308
if i < 2 {
304
-
stats[i].Kind = db.VanityStatKind(s)
309
+
stats[i].Kind = models.VanityStatKind(s)
305
310
}
306
311
}
307
312
···
312
317
}
313
318
}
314
319
315
-
profile := db.Profile{
320
+
profile := models.Profile{
316
321
Did: did,
317
322
Description: description,
318
323
IncludeBluesky: includeBluesky,
···
338
343
}
339
344
340
345
err = db.UpsertProfile(tx, &profile)
341
-
case models.CommitOperationDelete:
346
+
case jmodels.CommitOperationDelete:
342
347
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
343
348
}
344
349
···
349
354
return nil
350
355
}
351
356
352
-
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
357
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error {
353
358
did := e.Did
354
359
var err error
355
360
···
357
362
l = l.With("nsid", e.Commit.Collection)
358
363
359
364
switch e.Commit.Operation {
360
-
case models.CommitOperationCreate:
365
+
case jmodels.CommitOperationCreate:
361
366
raw := json.RawMessage(e.Commit.Record)
362
367
record := tangled.SpindleMember{}
363
368
err = json.Unmarshal(raw, &record)
···
386
391
return fmt.Errorf("failed to index profile record, invalid db cast")
387
392
}
388
393
389
-
err = db.AddSpindleMember(ddb, db.SpindleMember{
394
+
err = db.AddSpindleMember(ddb, models.SpindleMember{
390
395
Did: syntax.DID(did),
391
396
Rkey: e.Commit.RKey,
392
397
Instance: record.Instance,
···
402
407
}
403
408
404
409
l.Info("added spindle member")
405
-
case models.CommitOperationDelete:
410
+
case jmodels.CommitOperationDelete:
406
411
rkey := e.Commit.RKey
407
412
408
413
ddb, ok := i.Db.Execer.(*db.DB)
···
455
460
return nil
456
461
}
457
462
458
-
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
463
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error {
459
464
did := e.Did
460
465
var err error
461
466
···
463
468
l = l.With("nsid", e.Commit.Collection)
464
469
465
470
switch e.Commit.Operation {
466
-
case models.CommitOperationCreate:
471
+
case jmodels.CommitOperationCreate:
467
472
raw := json.RawMessage(e.Commit.Record)
468
473
record := tangled.Spindle{}
469
474
err = json.Unmarshal(raw, &record)
···
479
484
return fmt.Errorf("failed to index profile record, invalid db cast")
480
485
}
481
486
482
-
err := db.AddSpindle(ddb, db.Spindle{
487
+
err := db.AddSpindle(ddb, models.Spindle{
483
488
Owner: syntax.DID(did),
484
489
Instance: instance,
485
490
})
···
501
506
502
507
return nil
503
508
504
-
case models.CommitOperationDelete:
509
+
case jmodels.CommitOperationDelete:
505
510
instance := e.Commit.RKey
506
511
507
512
ddb, ok := i.Db.Execer.(*db.DB)
···
569
574
return nil
570
575
}
571
576
572
-
func (i *Ingester) ingestString(e *models.Event) error {
577
+
func (i *Ingester) ingestString(e *jmodels.Event) error {
573
578
did := e.Did
574
579
rkey := e.Commit.RKey
575
580
···
584
589
}
585
590
586
591
switch e.Commit.Operation {
587
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
592
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
588
593
raw := json.RawMessage(e.Commit.Record)
589
594
record := tangled.String{}
590
595
err = json.Unmarshal(raw, &record)
···
593
598
return err
594
599
}
595
600
596
-
string := db.StringFromRecord(did, rkey, record)
601
+
string := models.StringFromRecord(did, rkey, record)
597
602
598
-
if err = string.Validate(); err != nil {
603
+
if err = i.Validator.ValidateString(&string); err != nil {
599
604
l.Error("invalid record", "err", err)
600
605
return err
601
606
}
···
607
612
608
613
return nil
609
614
610
-
case models.CommitOperationDelete:
615
+
case jmodels.CommitOperationDelete:
611
616
if err := db.DeleteString(
612
617
ddb,
613
618
db.FilterEq("did", did),
···
623
628
return nil
624
629
}
625
630
626
-
func (i *Ingester) ingestKnotMember(e *models.Event) error {
631
+
func (i *Ingester) ingestKnotMember(e *jmodels.Event) error {
627
632
did := e.Did
628
633
var err error
629
634
···
631
636
l = l.With("nsid", e.Commit.Collection)
632
637
633
638
switch e.Commit.Operation {
634
-
case models.CommitOperationCreate:
639
+
case jmodels.CommitOperationCreate:
635
640
raw := json.RawMessage(e.Commit.Record)
636
641
record := tangled.KnotMember{}
637
642
err = json.Unmarshal(raw, &record)
···
661
666
}
662
667
663
668
l.Info("added knot member")
664
-
case models.CommitOperationDelete:
669
+
case jmodels.CommitOperationDelete:
665
670
// we don't store knot members in a table (like we do for spindle)
666
671
// and we can't remove this just yet. possibly fixed if we switch
667
672
// to either:
···
675
680
return nil
676
681
}
677
682
678
-
func (i *Ingester) ingestKnot(e *models.Event) error {
683
+
func (i *Ingester) ingestKnot(e *jmodels.Event) error {
679
684
did := e.Did
680
685
var err error
681
686
···
683
688
l = l.With("nsid", e.Commit.Collection)
684
689
685
690
switch e.Commit.Operation {
686
-
case models.CommitOperationCreate:
691
+
case jmodels.CommitOperationCreate:
687
692
raw := json.RawMessage(e.Commit.Record)
688
693
record := tangled.Knot{}
689
694
err = json.Unmarshal(raw, &record)
···
718
723
719
724
return nil
720
725
721
-
case models.CommitOperationDelete:
726
+
case jmodels.CommitOperationDelete:
722
727
domain := e.Commit.RKey
723
728
724
729
ddb, ok := i.Db.Execer.(*db.DB)
···
778
783
779
784
return nil
780
785
}
781
-
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
786
+
func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error {
782
787
did := e.Did
783
788
rkey := e.Commit.RKey
784
789
···
793
798
}
794
799
795
800
switch e.Commit.Operation {
796
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
801
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
797
802
raw := json.RawMessage(e.Commit.Record)
798
803
record := tangled.RepoIssue{}
799
804
err = json.Unmarshal(raw, &record)
···
802
807
return err
803
808
}
804
809
805
-
issue := db.IssueFromRecord(did, rkey, record)
810
+
issue := models.IssueFromRecord(did, rkey, record)
806
811
807
812
if err := i.Validator.ValidateIssue(&issue); err != nil {
808
813
return fmt.Errorf("failed to validate issue: %w", err)
···
829
834
830
835
return nil
831
836
832
-
case models.CommitOperationDelete:
837
+
case jmodels.CommitOperationDelete:
833
838
if err := db.DeleteIssues(
834
839
ddb,
835
840
db.FilterEq("did", did),
···
845
850
return nil
846
851
}
847
852
848
-
func (i *Ingester) ingestIssueComment(e *models.Event) error {
853
+
func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
849
854
did := e.Did
850
855
rkey := e.Commit.RKey
851
856
···
860
865
}
861
866
862
867
switch e.Commit.Operation {
863
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
868
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
864
869
raw := json.RawMessage(e.Commit.Record)
865
870
record := tangled.RepoIssueComment{}
866
871
err = json.Unmarshal(raw, &record)
···
868
873
return fmt.Errorf("invalid record: %w", err)
869
874
}
870
875
871
-
comment, err := db.IssueCommentFromRecord(did, rkey, record)
876
+
comment, err := models.IssueCommentFromRecord(did, rkey, record)
872
877
if err != nil {
873
878
return fmt.Errorf("failed to parse comment from record: %w", err)
874
879
}
···
884
889
885
890
return nil
886
891
887
-
case models.CommitOperationDelete:
892
+
case jmodels.CommitOperationDelete:
888
893
if err := db.DeleteIssueComments(
889
894
ddb,
890
895
db.FilterEq("did", did),
···
899
904
return nil
900
905
}
901
906
902
-
func (i *Ingester) ingestLabelDefinition(e *models.Event) error {
907
+
func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error {
903
908
did := e.Did
904
909
rkey := e.Commit.RKey
905
910
···
914
919
}
915
920
916
921
switch e.Commit.Operation {
917
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
922
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
918
923
raw := json.RawMessage(e.Commit.Record)
919
924
record := tangled.LabelDefinition{}
920
925
err = json.Unmarshal(raw, &record)
···
922
927
return fmt.Errorf("invalid record: %w", err)
923
928
}
924
929
925
-
def, err := db.LabelDefinitionFromRecord(did, rkey, record)
930
+
def, err := models.LabelDefinitionFromRecord(did, rkey, record)
926
931
if err != nil {
927
932
return fmt.Errorf("failed to parse labeldef from record: %w", err)
928
933
}
···
938
943
939
944
return nil
940
945
941
-
case models.CommitOperationDelete:
946
+
case jmodels.CommitOperationDelete:
942
947
if err := db.DeleteLabelDefinition(
943
948
ddb,
944
949
db.FilterEq("did", did),
···
952
957
953
958
return nil
954
959
}
960
+
961
+
func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
962
+
did := e.Did
963
+
rkey := e.Commit.RKey
964
+
965
+
var err error
966
+
967
+
l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
968
+
l.Info("ingesting record")
969
+
970
+
ddb, ok := i.Db.Execer.(*db.DB)
971
+
if !ok {
972
+
return fmt.Errorf("failed to index label op, invalid db cast")
973
+
}
974
+
975
+
switch e.Commit.Operation {
976
+
case jmodels.CommitOperationCreate:
977
+
raw := json.RawMessage(e.Commit.Record)
978
+
record := tangled.LabelOp{}
979
+
err = json.Unmarshal(raw, &record)
980
+
if err != nil {
981
+
return fmt.Errorf("invalid record: %w", err)
982
+
}
983
+
984
+
subject := syntax.ATURI(record.Subject)
985
+
collection := subject.Collection()
986
+
987
+
var repo *models.Repo
988
+
switch collection {
989
+
case tangled.RepoIssueNSID:
990
+
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
991
+
if err != nil || len(i) != 1 {
992
+
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993
+
}
994
+
repo = i[0].Repo
995
+
default:
996
+
return fmt.Errorf("unsupport label subject: %s", collection)
997
+
}
998
+
999
+
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1000
+
if err != nil {
1001
+
return fmt.Errorf("failed to build label application ctx: %w", err)
1002
+
}
1003
+
1004
+
ops := models.LabelOpsFromRecord(did, rkey, record)
1005
+
1006
+
for _, o := range ops {
1007
+
def, ok := actx.Defs[o.OperandKey]
1008
+
if !ok {
1009
+
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010
+
}
1011
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012
+
return fmt.Errorf("failed to validate labelop: %w", err)
1013
+
}
1014
+
}
1015
+
1016
+
tx, err := ddb.Begin()
1017
+
if err != nil {
1018
+
return err
1019
+
}
1020
+
defer tx.Rollback()
1021
+
1022
+
for _, o := range ops {
1023
+
_, err = db.AddLabelOp(tx, &o)
1024
+
if err != nil {
1025
+
return fmt.Errorf("failed to add labelop: %w", err)
1026
+
}
1027
+
}
1028
+
1029
+
if err = tx.Commit(); err != nil {
1030
+
return err
1031
+
}
1032
+
}
1033
+
1034
+
return nil
1035
+
}
+44
-31
appview/issues/issues.go
+44
-31
appview/issues/issues.go
···
16
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
17
"github.com/go-chi/chi/v5"
18
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/config"
21
-
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/notify"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages"
25
-
"tangled.sh/tangled.sh/core/appview/pagination"
26
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
-
"tangled.sh/tangled.sh/core/appview/validator"
28
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
29
-
"tangled.sh/tangled.sh/core/idresolver"
30
-
tlog "tangled.sh/tangled.sh/core/log"
31
-
"tangled.sh/tangled.sh/core/tid"
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/config"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/notify"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/pages"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/appview/reporesolver"
28
+
"tangled.org/core/appview/validator"
29
+
"tangled.org/core/appview/xrpcclient"
30
+
"tangled.org/core/idresolver"
31
+
tlog "tangled.org/core/log"
32
+
"tangled.org/core/tid"
32
33
)
33
34
34
35
type Issues struct {
···
75
76
return
76
77
}
77
78
78
-
issue, ok := r.Context().Value("issue").(*db.Issue)
79
+
issue, ok := r.Context().Value("issue").(*models.Issue)
79
80
if !ok {
80
81
l.Error("failed to get issue")
81
82
rp.pages.Error404(w)
···
87
88
l.Error("failed to get issue reactions", "err", err)
88
89
}
89
90
90
-
userReactions := map[db.ReactionKind]bool{}
91
+
userReactions := map[models.ReactionKind]bool{}
91
92
if user != nil {
92
93
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93
94
}
···
103
104
return
104
105
}
105
106
106
-
defs := make(map[string]*db.LabelDefinition)
107
+
defs := make(map[string]*models.LabelDefinition)
107
108
for _, l := range labelDefs {
108
109
defs[l.AtUri().String()] = &l
109
110
}
···
113
114
RepoInfo: f.RepoInfo(user),
114
115
Issue: issue,
115
116
CommentList: issue.CommentList(),
116
-
OrderedReactionKinds: db.OrderedReactionKinds,
117
+
OrderedReactionKinds: models.OrderedReactionKinds,
117
118
Reactions: reactionCountMap,
118
119
UserReacted: userReactions,
119
120
LabelDefs: defs,
···
129
130
return
130
131
}
131
132
132
-
issue, ok := r.Context().Value("issue").(*db.Issue)
133
+
issue, ok := r.Context().Value("issue").(*models.Issue)
133
134
if !ok {
134
135
l.Error("failed to get issue")
135
136
rp.pages.Error404(w)
···
225
226
return
226
227
}
227
228
228
-
issue, ok := r.Context().Value("issue").(*db.Issue)
229
+
issue, ok := r.Context().Value("issue").(*models.Issue)
229
230
if !ok {
230
231
l.Error("failed to get issue")
231
232
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···
272
273
return
273
274
}
274
275
275
-
issue, ok := r.Context().Value("issue").(*db.Issue)
276
+
issue, ok := r.Context().Value("issue").(*models.Issue)
276
277
if !ok {
277
278
l.Error("failed to get issue")
278
279
rp.pages.Error404(w)
···
300
301
return
301
302
}
302
303
304
+
// notify about the issue closure
305
+
rp.notifier.NewIssueClosed(r.Context(), issue)
306
+
303
307
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
304
308
return
305
309
} else {
···
318
322
return
319
323
}
320
324
321
-
issue, ok := r.Context().Value("issue").(*db.Issue)
325
+
issue, ok := r.Context().Value("issue").(*models.Issue)
322
326
if !ok {
323
327
l.Error("failed to get issue")
324
328
rp.pages.Error404(w)
···
362
366
return
363
367
}
364
368
365
-
issue, ok := r.Context().Value("issue").(*db.Issue)
369
+
issue, ok := r.Context().Value("issue").(*models.Issue)
366
370
if !ok {
367
371
l.Error("failed to get issue")
368
372
rp.pages.Error404(w)
···
381
385
replyTo = &replyToUri
382
386
}
383
387
384
-
comment := db.IssueComment{
388
+
comment := models.IssueComment{
385
389
Did: user.Did,
386
390
Rkey: tid.TID(),
387
391
IssueAt: issue.AtUri().String(),
···
433
437
434
438
// reset atUri to make rollback a no-op
435
439
atUri = ""
440
+
441
+
// notify about the new comment
442
+
comment.Id = commentId
443
+
rp.notifier.NewIssueComment(r.Context(), &comment)
444
+
436
445
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
437
446
}
438
447
···
445
454
return
446
455
}
447
456
448
-
issue, ok := r.Context().Value("issue").(*db.Issue)
457
+
issue, ok := r.Context().Value("issue").(*models.Issue)
449
458
if !ok {
450
459
l.Error("failed to get issue")
451
460
rp.pages.Error404(w)
···
486
495
return
487
496
}
488
497
489
-
issue, ok := r.Context().Value("issue").(*db.Issue)
498
+
issue, ok := r.Context().Value("issue").(*models.Issue)
490
499
if !ok {
491
500
l.Error("failed to get issue")
492
501
rp.pages.Error404(w)
···
590
599
return
591
600
}
592
601
593
-
issue, ok := r.Context().Value("issue").(*db.Issue)
602
+
issue, ok := r.Context().Value("issue").(*models.Issue)
594
603
if !ok {
595
604
l.Error("failed to get issue")
596
605
rp.pages.Error404(w)
···
631
640
return
632
641
}
633
642
634
-
issue, ok := r.Context().Value("issue").(*db.Issue)
643
+
issue, ok := r.Context().Value("issue").(*models.Issue)
635
644
if !ok {
636
645
l.Error("failed to get issue")
637
646
rp.pages.Error404(w)
···
672
681
return
673
682
}
674
683
675
-
issue, ok := r.Context().Value("issue").(*db.Issue)
684
+
issue, ok := r.Context().Value("issue").(*models.Issue)
676
685
if !ok {
677
686
l.Error("failed to get issue")
678
687
rp.pages.Error404(w)
···
789
798
return
790
799
}
791
800
792
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
801
+
labelDefs, err := db.GetLabelDefinitions(
802
+
rp.db,
803
+
db.FilterIn("at_uri", f.Repo.Labels),
804
+
db.FilterContains("scope", tangled.RepoIssueNSID),
805
+
)
793
806
if err != nil {
794
807
log.Println("failed to fetch labels", err)
795
808
rp.pages.Error503(w)
796
809
return
797
810
}
798
811
799
-
defs := make(map[string]*db.LabelDefinition)
812
+
defs := make(map[string]*models.LabelDefinition)
800
813
for _, l := range labelDefs {
801
814
defs[l.AtUri().String()] = &l
802
815
}
···
828
841
RepoInfo: f.RepoInfo(user),
829
842
})
830
843
case http.MethodPost:
831
-
issue := &db.Issue{
844
+
issue := &models.Issue{
832
845
RepoAt: f.RepoAt(),
833
846
Rkey: tid.TID(),
834
847
Title: r.FormValue("title"),
+1
-1
appview/issues/router.go
+1
-1
appview/issues/router.go
+14
-13
appview/knots/knots.go
+14
-13
appview/knots/knots.go
···
9
9
"time"
10
10
11
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/eventconsumer"
21
-
"tangled.sh/tangled.sh/core/idresolver"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/tid"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/eventconsumer"
22
+
"tangled.org/core/idresolver"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/tid"
24
25
25
26
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
27
lexutil "github.com/bluesky-social/indigo/lex/util"
···
119
120
}
120
121
121
122
// organize repos by did
122
-
repoMap := make(map[string][]db.Repo)
123
+
repoMap := make(map[string][]models.Repo)
123
124
for _, r := range repos {
124
125
repoMap[r.Did] = append(repoMap[r.Did], r)
125
126
}
+32
-24
appview/labels/labels.go
+32
-24
appview/labels/labels.go
···
14
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
15
"github.com/go-chi/chi/v5"
16
16
17
-
"tangled.sh/tangled.sh/core/api/tangled"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/middleware"
20
-
"tangled.sh/tangled.sh/core/appview/oauth"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
-
"tangled.sh/tangled.sh/core/appview/validator"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/log"
25
-
"tangled.sh/tangled.sh/core/tid"
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/middleware"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/oauth"
22
+
"tangled.org/core/appview/pages"
23
+
"tangled.org/core/appview/validator"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/log"
26
+
"tangled.org/core/rbac"
27
+
"tangled.org/core/tid"
26
28
)
27
29
28
30
type Labels struct {
···
31
33
db *db.DB
32
34
logger *slog.Logger
33
35
validator *validator.Validator
36
+
enforcer *rbac.Enforcer
34
37
}
35
38
36
39
func New(
···
38
41
pages *pages.Pages,
39
42
db *db.DB,
40
43
validator *validator.Validator,
44
+
enforcer *rbac.Enforcer,
41
45
) *Labels {
42
46
logger := log.New("labels")
43
47
···
47
51
db: db,
48
52
logger: logger,
49
53
validator: validator,
54
+
enforcer: enforcer,
50
55
}
51
56
}
52
57
···
85
90
repoAt := r.Form.Get("repo")
86
91
subjectUri := r.Form.Get("subject")
87
92
93
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
94
+
if err != nil {
95
+
fail("Failed to get repository.", err)
96
+
return
97
+
}
98
+
88
99
// find all the labels that this repo subscribes to
89
100
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
90
101
if err != nil {
···
103
114
return
104
115
}
105
116
106
-
l.logger.Info("actx", "labels", labelAts)
107
-
l.logger.Info("actx", "defs", actx.Defs)
108
-
109
117
// calculate the start state by applying already known labels
110
118
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
111
119
if err != nil {
···
113
121
return
114
122
}
115
123
116
-
labelState := db.NewLabelState()
124
+
labelState := models.NewLabelState()
117
125
actx.ApplyLabelOps(labelState, existingOps)
118
126
119
-
var labelOps []db.LabelOp
127
+
var labelOps []models.LabelOp
120
128
121
129
// first delete all existing state
122
130
for key, vals := range labelState.Inner() {
123
131
for val := range vals {
124
-
labelOps = append(labelOps, db.LabelOp{
132
+
labelOps = append(labelOps, models.LabelOp{
125
133
Did: did,
126
134
Rkey: rkey,
127
135
Subject: syntax.ATURI(subjectUri),
128
-
Operation: db.LabelOperationDel,
136
+
Operation: models.LabelOperationDel,
129
137
OperandKey: key,
130
138
OperandValue: val,
131
139
PerformedAt: performedAt,
···
141
149
}
142
150
143
151
for _, val := range vals {
144
-
labelOps = append(labelOps, db.LabelOp{
152
+
labelOps = append(labelOps, models.LabelOp{
145
153
Did: did,
146
154
Rkey: rkey,
147
155
Subject: syntax.ATURI(subjectUri),
148
-
Operation: db.LabelOperationAdd,
156
+
Operation: models.LabelOperationAdd,
149
157
OperandKey: key,
150
158
OperandValue: val,
151
159
PerformedAt: performedAt,
···
154
162
}
155
163
}
156
164
157
-
// reduce the opset
158
-
labelOps = db.ReduceLabelOps(labelOps)
159
-
160
165
for i := range labelOps {
161
166
def := actx.Defs[labelOps[i].OperandKey]
162
-
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
167
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
163
168
fail(fmt.Sprintf("Invalid form data: %s", err), err)
164
169
return
165
170
}
166
171
}
167
172
173
+
// reduce the opset
174
+
labelOps = models.ReduceLabelOps(labelOps)
175
+
168
176
// next, apply all ops introduced in this request and filter out ones that are no-ops
169
177
validLabelOps := labelOps[:0]
170
178
for _, op := range labelOps {
171
-
if err = actx.ApplyLabelOp(labelState, op); err != db.LabelNoOpError {
179
+
if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError {
172
180
validLabelOps = append(validLabelOps, op)
173
181
}
174
182
}
···
180
188
}
181
189
182
190
// create an atproto record of valid ops
183
-
record := db.LabelOpsAsRecord(validLabelOps)
191
+
record := models.LabelOpsAsRecord(validLabelOps)
184
192
185
193
client, err := l.oauth.AuthorizedClient(r)
186
194
if err != nil {
+16
-7
appview/middleware/middleware.go
+16
-7
appview/middleware/middleware.go
···
12
12
13
13
"github.com/bluesky-social/indigo/atproto/identity"
14
14
"github.com/go-chi/chi/v5"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/pagination"
19
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pagination"
19
+
"tangled.org/core/appview/reporesolver"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
22
22
)
23
23
24
24
type Middleware struct {
···
42
42
}
43
43
44
44
type middlewareFunc func(http.Handler) http.Handler
45
+
46
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
+
return func(next http.Handler) http.Handler {
48
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
_, _, _ = mw.oauth.GetSession(r)
50
+
next.ServeHTTP(w, r)
51
+
})
52
+
}
53
+
}
45
54
46
55
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
56
return func(next http.Handler) http.Handler {
+30
appview/models/artifact.go
+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
+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
+16
appview/models/email.go
+38
appview/models/follow.go
+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
+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
+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
+14
appview/models/language.go
+82
appview/models/notifications.go
+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
+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
+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
+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
+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
+14
appview/models/punchcard.go
+57
appview/models/reaction.go
+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
+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
+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
+10
appview/models/signup.go
+25
appview/models/spindle.go
+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
+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
+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
+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
+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
+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
+35
-12
appview/notify/merged_notifier.go
···
3
3
import (
4
4
"context"
5
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
6
+
"tangled.org/core/appview/models"
7
7
)
8
8
9
9
type mergedNotifier struct {
···
16
16
17
17
var _ Notifier = &mergedNotifier{}
18
18
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
20
for _, notifier := range m.notifiers {
21
21
notifier.NewRepo(ctx, repo)
22
22
}
23
23
}
24
24
25
-
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
26
for _, notifier := range m.notifiers {
27
27
notifier.NewStar(ctx, star)
28
28
}
29
29
}
30
-
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
31
for _, notifier := range m.notifiers {
32
32
notifier.DeleteStar(ctx, star)
33
33
}
34
34
}
35
35
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
37
for _, notifier := range m.notifiers {
38
38
notifier.NewIssue(ctx, issue)
39
39
}
40
40
}
41
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
+
for _, notifier := range m.notifiers {
43
+
notifier.NewIssueComment(ctx, comment)
44
+
}
45
+
}
41
46
42
-
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
47
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.NewIssueClosed(ctx, issue)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
43
54
for _, notifier := range m.notifiers {
44
55
notifier.NewFollow(ctx, follow)
45
56
}
46
57
}
47
-
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
58
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
48
59
for _, notifier := range m.notifiers {
49
60
notifier.DeleteFollow(ctx, follow)
50
61
}
51
62
}
52
63
53
-
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
64
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
54
65
for _, notifier := range m.notifiers {
55
66
notifier.NewPull(ctx, pull)
56
67
}
57
68
}
58
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
69
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
59
70
for _, notifier := range m.notifiers {
60
71
notifier.NewPullComment(ctx, comment)
61
72
}
62
73
}
63
74
64
-
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
79
+
}
80
+
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
+
}
85
+
}
86
+
87
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
65
88
for _, notifier := range m.notifiers {
66
89
notifier.UpdateProfile(ctx, profile)
67
90
}
68
91
}
69
92
70
-
func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) {
93
+
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
71
94
for _, notifier := range m.notifiers {
72
95
notifier.NewString(ctx, string)
73
96
}
74
97
}
75
98
76
-
func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) {
99
+
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
77
100
for _, notifier := range m.notifiers {
78
101
notifier.EditString(ctx, string)
79
102
}
+31
-23
appview/notify/notifier.go
+31
-23
appview/notify/notifier.go
···
3
3
import (
4
4
"context"
5
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
6
+
"tangled.org/core/appview/models"
7
7
)
8
8
9
9
type Notifier interface {
10
-
NewRepo(ctx context.Context, repo *db.Repo)
10
+
NewRepo(ctx context.Context, repo *models.Repo)
11
11
12
-
NewStar(ctx context.Context, star *db.Star)
13
-
DeleteStar(ctx context.Context, star *db.Star)
12
+
NewStar(ctx context.Context, star *models.Star)
13
+
DeleteStar(ctx context.Context, star *models.Star)
14
14
15
-
NewIssue(ctx context.Context, issue *db.Issue)
15
+
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
16
18
17
-
NewFollow(ctx context.Context, follow *db.Follow)
18
-
DeleteFollow(ctx context.Context, follow *db.Follow)
19
+
NewFollow(ctx context.Context, follow *models.Follow)
20
+
DeleteFollow(ctx context.Context, follow *models.Follow)
19
21
20
-
NewPull(ctx context.Context, pull *db.Pull)
21
-
NewPullComment(ctx context.Context, comment *db.PullComment)
22
+
NewPull(ctx context.Context, pull *models.Pull)
23
+
NewPullComment(ctx context.Context, comment *models.PullComment)
24
+
NewPullMerged(ctx context.Context, pull *models.Pull)
25
+
NewPullClosed(ctx context.Context, pull *models.Pull)
22
26
23
-
UpdateProfile(ctx context.Context, profile *db.Profile)
27
+
UpdateProfile(ctx context.Context, profile *models.Profile)
24
28
25
-
NewString(ctx context.Context, s *db.String)
26
-
EditString(ctx context.Context, s *db.String)
29
+
NewString(ctx context.Context, s *models.String)
30
+
EditString(ctx context.Context, s *models.String)
27
31
DeleteString(ctx context.Context, did, rkey string)
28
32
}
29
33
···
32
36
33
37
var _ Notifier = &BaseNotifier{}
34
38
35
-
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
39
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {}
36
40
37
-
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
38
-
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
41
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
39
43
40
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
44
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
41
47
42
-
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
43
-
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
48
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
44
50
45
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
46
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
51
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
47
55
48
-
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
56
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
49
57
50
-
func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {}
51
-
func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {}
58
+
func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {}
59
+
func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {}
52
60
func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
+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
+12
-12
appview/oauth/handler/handler.go
···
16
16
"github.com/gorilla/sessions"
17
17
"github.com/lestrrat-go/jwx/v2/jwk"
18
18
"github.com/posthog/posthog-go"
19
+
tangled "tangled.org/core/api/tangled"
20
+
sessioncache "tangled.org/core/appview/cache/session"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/middleware"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/oauth/client"
26
+
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/consts"
28
+
"tangled.org/core/idresolver"
29
+
"tangled.org/core/rbac"
30
+
"tangled.org/core/tid"
19
31
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
20
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
21
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
22
-
"tangled.sh/tangled.sh/core/appview/config"
23
-
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/middleware"
25
-
"tangled.sh/tangled.sh/core/appview/oauth"
26
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
27
-
"tangled.sh/tangled.sh/core/appview/pages"
28
-
"tangled.sh/tangled.sh/core/consts"
29
-
"tangled.sh/tangled.sh/core/idresolver"
30
-
"tangled.sh/tangled.sh/core/rbac"
31
-
"tangled.sh/tangled.sh/core/tid"
32
32
)
33
33
34
34
const (
+4
-4
appview/oauth/oauth.go
+4
-4
appview/oauth/oauth.go
···
9
9
10
10
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
11
"github.com/gorilla/sessions"
12
+
sessioncache "tangled.org/core/appview/cache/session"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/oauth/client"
15
+
xrpc "tangled.org/core/appview/xrpcclient"
12
16
oauth "tangled.sh/icyphox.sh/atproto-oauth"
13
17
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
14
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
17
-
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
18
18
)
19
19
20
20
type OAuth struct {
+18
-18
appview/pages/funcmap.go
+18
-18
appview/pages/funcmap.go
···
19
19
20
20
"github.com/dustin/go-humanize"
21
21
"github.com/go-enry/go-enry/v2"
22
-
"tangled.sh/tangled.sh/core/appview/filetree"
23
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/crypto"
22
+
"tangled.org/core/appview/filetree"
23
+
"tangled.org/core/appview/pages/markup"
24
+
"tangled.org/core/crypto"
25
25
)
26
26
27
27
func (p *Pages) funcMap() template.FuncMap {
···
141
141
"relTimeFmt": humanize.Time,
142
142
"shortRelTimeFmt": func(t time.Time) string {
143
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
144
-
{time.Second, "now", time.Second},
145
-
{2 * time.Second, "1s %s", 1},
146
-
{time.Minute, "%ds %s", time.Second},
147
-
{2 * time.Minute, "1min %s", 1},
148
-
{time.Hour, "%dmin %s", time.Minute},
149
-
{2 * time.Hour, "1hr %s", 1},
150
-
{humanize.Day, "%dhrs %s", time.Hour},
151
-
{2 * humanize.Day, "1d %s", 1},
152
-
{20 * humanize.Day, "%dd %s", humanize.Day},
153
-
{8 * humanize.Week, "%dw %s", humanize.Week},
154
-
{humanize.Year, "%dmo %s", humanize.Month},
155
-
{18 * humanize.Month, "1y %s", 1},
156
-
{2 * humanize.Year, "2y %s", 1},
157
-
{humanize.LongTime, "%dy %s", humanize.Year},
158
-
{math.MaxInt64, "a long while %s", 1},
144
+
{D: time.Second, Format: "now", DivBy: time.Second},
145
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
146
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
147
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
148
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
149
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
150
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
151
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
152
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
153
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
154
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
155
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
156
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
157
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
158
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
159
159
})
160
160
},
161
161
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
+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
+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
+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
+15
-17
appview/pages/markup/format.go
···
1
1
package markup
2
2
3
-
import "strings"
3
+
import (
4
+
"regexp"
5
+
)
4
6
5
7
type Format string
6
8
···
10
12
)
11
13
12
14
var FileTypes map[Format][]string = map[Format][]string{
13
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
15
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
16
}
15
17
16
-
// ReadmeFilenames contains the list of common README filenames to search for,
17
-
// in order of preference. Only includes well-supported formats.
18
-
var ReadmeFilenames = []string{
19
-
"README.md", "readme.md",
20
-
"README",
21
-
"readme",
22
-
"README.markdown",
23
-
"readme.markdown",
24
-
"README.txt",
25
-
"readme.txt",
18
+
var FileTypePatterns = map[Format]*regexp.Regexp{
19
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20
+
}
21
+
22
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23
+
24
+
func IsReadmeFile(filename string) bool {
25
+
return ReadmePattern.MatchString(filename)
26
26
}
27
27
28
28
func GetFormat(filename string) Format {
29
-
for format, extensions := range FileTypes {
30
-
for _, extension := range extensions {
31
-
if strings.HasSuffix(filename, extension) {
32
-
return format
33
-
}
29
+
for format, pattern := range FileTypePatterns {
30
+
if pattern.MatchString(filename) {
31
+
return format
34
32
}
35
33
}
36
34
// default format
+2
-2
appview/pages/markup/markdown.go
+2
-2
appview/pages/markup/markdown.go
···
22
22
"github.com/yuin/goldmark/util"
23
23
htmlparse "golang.org/x/net/html"
24
24
25
-
"tangled.sh/tangled.sh/core/api/tangled"
26
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
25
+
"tangled.org/core/api/tangled"
26
+
"tangled.org/core/appview/pages/repoinfo"
27
27
)
28
28
29
29
// RendererType defines the type of renderer to use based on context
+210
-130
appview/pages/pages.go
+210
-130
appview/pages/pages.go
···
16
16
"strings"
17
17
"sync"
18
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/commitverify"
21
-
"tangled.sh/tangled.sh/core/appview/config"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
-
"tangled.sh/tangled.sh/core/appview/pagination"
27
-
"tangled.sh/tangled.sh/core/idresolver"
28
-
"tangled.sh/tangled.sh/core/patchutil"
29
-
"tangled.sh/tangled.sh/core/types"
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/commitverify"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/oauth"
24
+
"tangled.org/core/appview/pages/markup"
25
+
"tangled.org/core/appview/pages/repoinfo"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/idresolver"
28
+
"tangled.org/core/patchutil"
29
+
"tangled.org/core/types"
30
30
31
31
"github.com/alecthomas/chroma/v2"
32
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
···
38
38
"github.com/go-git/go-git/v5/plumbing/object"
39
39
)
40
40
41
-
//go:embed templates/* static
41
+
//go:embed templates/* static legal
42
42
var Files embed.FS
43
43
44
44
type Pages struct {
···
81
81
}
82
82
83
83
return p
84
-
}
85
-
86
-
func (p *Pages) pathToName(s string) string {
87
-
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
84
}
89
85
90
86
// reverse of pathToName
···
230
226
return p.executePlain("user/login", w, params)
231
227
}
232
228
233
-
func (p *Pages) Signup(w io.Writer) error {
234
-
return p.executePlain("user/signup", w, nil)
229
+
type SignupParams struct {
230
+
CloudflareSiteKey string
231
+
}
232
+
233
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
234
+
return p.executePlain("user/signup", w, params)
235
235
}
236
236
237
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
247
filename := "terms.md"
248
248
filePath := filepath.Join("legal", filename)
249
-
markdownBytes, err := os.ReadFile(filePath)
249
+
250
+
file, err := p.embedFS.Open(filePath)
251
+
if err != nil {
252
+
return fmt.Errorf("failed to read %s: %w", filename, err)
253
+
}
254
+
defer file.Close()
255
+
256
+
markdownBytes, err := io.ReadAll(file)
250
257
if err != nil {
251
258
return fmt.Errorf("failed to read %s: %w", filename, err)
252
259
}
···
267
274
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
275
filename := "privacy.md"
269
276
filePath := filepath.Join("legal", filename)
270
-
markdownBytes, err := os.ReadFile(filePath)
277
+
278
+
file, err := p.embedFS.Open(filePath)
279
+
if err != nil {
280
+
return fmt.Errorf("failed to read %s: %w", filename, err)
281
+
}
282
+
defer file.Close()
283
+
284
+
markdownBytes, err := io.ReadAll(file)
271
285
if err != nil {
272
286
return fmt.Errorf("failed to read %s: %w", filename, err)
273
287
}
···
280
294
return p.execute("legal/privacy", w, params)
281
295
}
282
296
297
+
type BrandParams struct {
298
+
LoggedInUser *oauth.User
299
+
}
300
+
301
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
302
+
return p.execute("brand/brand", w, params)
303
+
}
304
+
283
305
type TimelineParams struct {
284
306
LoggedInUser *oauth.User
285
-
Timeline []db.TimelineEvent
286
-
Repos []db.Repo
307
+
Timeline []models.TimelineEvent
308
+
Repos []models.Repo
309
+
GfiLabel *models.LabelDefinition
287
310
}
288
311
289
312
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
290
313
return p.execute("timeline/timeline", w, params)
291
314
}
292
315
316
+
type GoodFirstIssuesParams struct {
317
+
LoggedInUser *oauth.User
318
+
Issues []models.Issue
319
+
RepoGroups []*models.RepoGroup
320
+
LabelDefs map[string]*models.LabelDefinition
321
+
GfiLabel *models.LabelDefinition
322
+
Page pagination.Page
323
+
}
324
+
325
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
326
+
return p.execute("goodfirstissues/index", w, params)
327
+
}
328
+
293
329
type UserProfileSettingsParams struct {
294
330
LoggedInUser *oauth.User
295
331
Tabs []map[string]any
···
300
336
return p.execute("user/settings/profile", w, params)
301
337
}
302
338
339
+
type NotificationsParams struct {
340
+
LoggedInUser *oauth.User
341
+
Notifications []*models.NotificationWithEntity
342
+
UnreadCount int
343
+
Page pagination.Page
344
+
Total int64
345
+
}
346
+
347
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
348
+
return p.execute("notifications/list", w, params)
349
+
}
350
+
351
+
type NotificationItemParams struct {
352
+
Notification *models.Notification
353
+
}
354
+
355
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
356
+
return p.executePlain("notifications/fragments/item", w, params)
357
+
}
358
+
359
+
type NotificationCountParams struct {
360
+
Count int64
361
+
}
362
+
363
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
364
+
return p.executePlain("notifications/fragments/count", w, params)
365
+
}
366
+
303
367
type UserKeysSettingsParams struct {
304
368
LoggedInUser *oauth.User
305
-
PubKeys []db.PublicKey
369
+
PubKeys []models.PublicKey
306
370
Tabs []map[string]any
307
371
Tab string
308
372
}
···
313
377
314
378
type UserEmailsSettingsParams struct {
315
379
LoggedInUser *oauth.User
316
-
Emails []db.Email
380
+
Emails []models.Email
317
381
Tabs []map[string]any
318
382
Tab string
319
383
}
···
322
386
return p.execute("user/settings/emails", w, params)
323
387
}
324
388
389
+
type UserNotificationSettingsParams struct {
390
+
LoggedInUser *oauth.User
391
+
Preferences *models.NotificationPreferences
392
+
Tabs []map[string]any
393
+
Tab string
394
+
}
395
+
396
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
397
+
return p.execute("user/settings/notifications", w, params)
398
+
}
399
+
325
400
type UpgradeBannerParams struct {
326
-
Registrations []db.Registration
327
-
Spindles []db.Spindle
401
+
Registrations []models.Registration
402
+
Spindles []models.Spindle
328
403
}
329
404
330
405
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
···
333
408
334
409
type KnotsParams struct {
335
410
LoggedInUser *oauth.User
336
-
Registrations []db.Registration
411
+
Registrations []models.Registration
337
412
}
338
413
339
414
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
342
417
343
418
type KnotParams struct {
344
419
LoggedInUser *oauth.User
345
-
Registration *db.Registration
420
+
Registration *models.Registration
346
421
Members []string
347
-
Repos map[string][]db.Repo
422
+
Repos map[string][]models.Repo
348
423
IsOwner bool
349
424
}
350
425
···
353
428
}
354
429
355
430
type KnotListingParams struct {
356
-
*db.Registration
431
+
*models.Registration
357
432
}
358
433
359
434
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
···
362
437
363
438
type SpindlesParams struct {
364
439
LoggedInUser *oauth.User
365
-
Spindles []db.Spindle
440
+
Spindles []models.Spindle
366
441
}
367
442
368
443
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
370
445
}
371
446
372
447
type SpindleListingParams struct {
373
-
db.Spindle
448
+
models.Spindle
374
449
}
375
450
376
451
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
379
454
380
455
type SpindleDashboardParams struct {
381
456
LoggedInUser *oauth.User
382
-
Spindle db.Spindle
457
+
Spindle models.Spindle
383
458
Members []string
384
-
Repos map[string][]db.Repo
459
+
Repos map[string][]models.Repo
385
460
}
386
461
387
462
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
410
485
type ProfileCard struct {
411
486
UserDid string
412
487
UserHandle string
413
-
FollowStatus db.FollowStatus
414
-
Punchcard *db.Punchcard
415
-
Profile *db.Profile
488
+
FollowStatus models.FollowStatus
489
+
Punchcard *models.Punchcard
490
+
Profile *models.Profile
416
491
Stats ProfileStats
417
492
Active string
418
493
}
···
438
513
439
514
type ProfileOverviewParams struct {
440
515
LoggedInUser *oauth.User
441
-
Repos []db.Repo
442
-
CollaboratingRepos []db.Repo
443
-
ProfileTimeline *db.ProfileTimeline
516
+
Repos []models.Repo
517
+
CollaboratingRepos []models.Repo
518
+
ProfileTimeline *models.ProfileTimeline
444
519
Card *ProfileCard
445
520
Active string
446
521
}
···
452
527
453
528
type ProfileReposParams struct {
454
529
LoggedInUser *oauth.User
455
-
Repos []db.Repo
530
+
Repos []models.Repo
456
531
Card *ProfileCard
457
532
Active string
458
533
}
···
464
539
465
540
type ProfileStarredParams struct {
466
541
LoggedInUser *oauth.User
467
-
Repos []db.Repo
542
+
Repos []models.Repo
468
543
Card *ProfileCard
469
544
Active string
470
545
}
···
476
551
477
552
type ProfileStringsParams struct {
478
553
LoggedInUser *oauth.User
479
-
Strings []db.String
554
+
Strings []models.String
480
555
Card *ProfileCard
481
556
Active string
482
557
}
···
488
563
489
564
type FollowCard struct {
490
565
UserDid string
491
-
FollowStatus db.FollowStatus
566
+
LoggedInUser *oauth.User
567
+
FollowStatus models.FollowStatus
492
568
FollowersCount int64
493
569
FollowingCount int64
494
-
Profile *db.Profile
570
+
Profile *models.Profile
495
571
}
496
572
497
573
type ProfileFollowersParams struct {
···
520
596
521
597
type FollowFragmentParams struct {
522
598
UserDid string
523
-
FollowStatus db.FollowStatus
599
+
FollowStatus models.FollowStatus
524
600
}
525
601
526
602
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
···
529
605
530
606
type EditBioParams struct {
531
607
LoggedInUser *oauth.User
532
-
Profile *db.Profile
608
+
Profile *models.Profile
533
609
}
534
610
535
611
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
···
538
614
539
615
type EditPinsParams struct {
540
616
LoggedInUser *oauth.User
541
-
Profile *db.Profile
617
+
Profile *models.Profile
542
618
AllRepos []PinnedRepo
543
619
}
544
620
545
621
type PinnedRepo struct {
546
622
IsPinned bool
547
-
db.Repo
623
+
models.Repo
548
624
}
549
625
550
626
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
···
554
630
type RepoStarFragmentParams struct {
555
631
IsStarred bool
556
632
RepoAt syntax.ATURI
557
-
Stats db.RepoStats
633
+
Stats models.RepoStats
558
634
}
559
635
560
636
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
···
587
663
EmailToDidOrHandle map[string]string
588
664
VerifiedCommits commitverify.VerifiedCommits
589
665
Languages []types.RepoLanguageDetails
590
-
Pipelines map[string]db.Pipeline
666
+
Pipelines map[string]models.Pipeline
591
667
NeedsKnotUpgrade bool
592
668
types.RepoIndexResponse
593
669
}
···
630
706
Active string
631
707
EmailToDidOrHandle map[string]string
632
708
VerifiedCommits commitverify.VerifiedCommits
633
-
Pipelines map[string]db.Pipeline
709
+
Pipelines map[string]models.Pipeline
634
710
}
635
711
636
712
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
643
719
RepoInfo repoinfo.RepoInfo
644
720
Active string
645
721
EmailToDidOrHandle map[string]string
646
-
Pipeline *db.Pipeline
722
+
Pipeline *models.Pipeline
647
723
DiffOpts types.DiffOpts
648
724
649
725
// singular because it's always going to be just one
···
658
734
}
659
735
660
736
type RepoTreeParams struct {
661
-
LoggedInUser *oauth.User
662
-
RepoInfo repoinfo.RepoInfo
663
-
Active string
664
-
BreadCrumbs [][]string
665
-
TreePath string
666
-
Readme string
667
-
ReadmeFileName string
668
-
HTMLReadme template.HTML
669
-
Raw bool
737
+
LoggedInUser *oauth.User
738
+
RepoInfo repoinfo.RepoInfo
739
+
Active string
740
+
BreadCrumbs [][]string
741
+
TreePath string
742
+
Raw bool
743
+
HTMLReadme template.HTML
670
744
types.RepoTreeResponse
671
745
}
672
746
···
694
768
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
695
769
params.Active = "overview"
696
770
697
-
if params.ReadmeFileName != "" {
698
-
params.ReadmeFileName = filepath.Base(params.ReadmeFileName)
771
+
p.rctx.RepoInfo = params.RepoInfo
772
+
p.rctx.RepoInfo.Ref = params.Ref
773
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
699
774
775
+
if params.ReadmeFileName != "" {
700
776
ext := filepath.Ext(params.ReadmeFileName)
701
777
switch ext {
702
778
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
729
805
RepoInfo repoinfo.RepoInfo
730
806
Active string
731
807
types.RepoTagsResponse
732
-
ArtifactMap map[plumbing.Hash][]db.Artifact
733
-
DanglingArtifacts []db.Artifact
808
+
ArtifactMap map[plumbing.Hash][]models.Artifact
809
+
DanglingArtifacts []models.Artifact
734
810
}
735
811
736
812
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
···
741
817
type RepoArtifactParams struct {
742
818
LoggedInUser *oauth.User
743
819
RepoInfo repoinfo.RepoInfo
744
-
Artifact db.Artifact
820
+
Artifact models.Artifact
745
821
}
746
822
747
823
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
···
838
914
}
839
915
840
916
type RepoGeneralSettingsParams struct {
841
-
LoggedInUser *oauth.User
842
-
RepoInfo repoinfo.RepoInfo
843
-
Labels []db.LabelDefinition
844
-
DefaultLabels []db.LabelDefinition
845
-
SubscribedLabels map[string]struct{}
846
-
Active string
847
-
Tabs []map[string]any
848
-
Tab string
849
-
Branches []types.Branch
917
+
LoggedInUser *oauth.User
918
+
RepoInfo repoinfo.RepoInfo
919
+
Labels []models.LabelDefinition
920
+
DefaultLabels []models.LabelDefinition
921
+
SubscribedLabels map[string]struct{}
922
+
ShouldSubscribeAll bool
923
+
Active string
924
+
Tabs []map[string]any
925
+
Tab string
926
+
Branches []types.Branch
850
927
}
851
928
852
929
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
888
965
LoggedInUser *oauth.User
889
966
RepoInfo repoinfo.RepoInfo
890
967
Active string
891
-
Issues []db.Issue
892
-
LabelDefs map[string]*db.LabelDefinition
968
+
Issues []models.Issue
969
+
LabelDefs map[string]*models.LabelDefinition
893
970
Page pagination.Page
894
971
FilteringByOpen bool
895
972
}
···
903
980
LoggedInUser *oauth.User
904
981
RepoInfo repoinfo.RepoInfo
905
982
Active string
906
-
Issue *db.Issue
907
-
CommentList []db.CommentListItem
908
-
LabelDefs map[string]*db.LabelDefinition
983
+
Issue *models.Issue
984
+
CommentList []models.CommentListItem
985
+
LabelDefs map[string]*models.LabelDefinition
909
986
910
-
OrderedReactionKinds []db.ReactionKind
911
-
Reactions map[db.ReactionKind]int
912
-
UserReacted map[db.ReactionKind]bool
987
+
OrderedReactionKinds []models.ReactionKind
988
+
Reactions map[models.ReactionKind]int
989
+
UserReacted map[models.ReactionKind]bool
913
990
}
914
991
915
992
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
920
997
type EditIssueParams struct {
921
998
LoggedInUser *oauth.User
922
999
RepoInfo repoinfo.RepoInfo
923
-
Issue *db.Issue
1000
+
Issue *models.Issue
924
1001
Action string
925
1002
}
926
1003
···
931
1008
932
1009
type ThreadReactionFragmentParams struct {
933
1010
ThreadAt syntax.ATURI
934
-
Kind db.ReactionKind
1011
+
Kind models.ReactionKind
935
1012
Count int
936
1013
IsReacted bool
937
1014
}
···
943
1020
type RepoNewIssueParams struct {
944
1021
LoggedInUser *oauth.User
945
1022
RepoInfo repoinfo.RepoInfo
946
-
Issue *db.Issue // existing issue if any -- passed when editing
1023
+
Issue *models.Issue // existing issue if any -- passed when editing
947
1024
Active string
948
1025
Action string
949
1026
}
···
957
1034
type EditIssueCommentParams struct {
958
1035
LoggedInUser *oauth.User
959
1036
RepoInfo repoinfo.RepoInfo
960
-
Issue *db.Issue
961
-
Comment *db.IssueComment
1037
+
Issue *models.Issue
1038
+
Comment *models.IssueComment
962
1039
}
963
1040
964
1041
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
968
1045
type ReplyIssueCommentPlaceholderParams struct {
969
1046
LoggedInUser *oauth.User
970
1047
RepoInfo repoinfo.RepoInfo
971
-
Issue *db.Issue
972
-
Comment *db.IssueComment
1048
+
Issue *models.Issue
1049
+
Comment *models.IssueComment
973
1050
}
974
1051
975
1052
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
979
1056
type ReplyIssueCommentParams struct {
980
1057
LoggedInUser *oauth.User
981
1058
RepoInfo repoinfo.RepoInfo
982
-
Issue *db.Issue
983
-
Comment *db.IssueComment
1059
+
Issue *models.Issue
1060
+
Comment *models.IssueComment
984
1061
}
985
1062
986
1063
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
990
1067
type IssueCommentBodyParams struct {
991
1068
LoggedInUser *oauth.User
992
1069
RepoInfo repoinfo.RepoInfo
993
-
Issue *db.Issue
994
-
Comment *db.IssueComment
1070
+
Issue *models.Issue
1071
+
Comment *models.IssueComment
995
1072
}
996
1073
997
1074
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1018
1095
type RepoPullsParams struct {
1019
1096
LoggedInUser *oauth.User
1020
1097
RepoInfo repoinfo.RepoInfo
1021
-
Pulls []*db.Pull
1098
+
Pulls []*models.Pull
1022
1099
Active string
1023
-
FilteringBy db.PullState
1024
-
Stacks map[string]db.Stack
1025
-
Pipelines map[string]db.Pipeline
1100
+
FilteringBy models.PullState
1101
+
Stacks map[string]models.Stack
1102
+
Pipelines map[string]models.Pipeline
1103
+
LabelDefs map[string]*models.LabelDefinition
1026
1104
}
1027
1105
1028
1106
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1052
1130
LoggedInUser *oauth.User
1053
1131
RepoInfo repoinfo.RepoInfo
1054
1132
Active string
1055
-
Pull *db.Pull
1056
-
Stack db.Stack
1057
-
AbandonedPulls []*db.Pull
1133
+
Pull *models.Pull
1134
+
Stack models.Stack
1135
+
AbandonedPulls []*models.Pull
1058
1136
MergeCheck types.MergeCheckResponse
1059
1137
ResubmitCheck ResubmitResult
1060
-
Pipelines map[string]db.Pipeline
1138
+
Pipelines map[string]models.Pipeline
1139
+
1140
+
OrderedReactionKinds []models.ReactionKind
1141
+
Reactions map[models.ReactionKind]int
1142
+
UserReacted map[models.ReactionKind]bool
1061
1143
1062
-
OrderedReactionKinds []db.ReactionKind
1063
-
Reactions map[db.ReactionKind]int
1064
-
UserReacted map[db.ReactionKind]bool
1144
+
LabelDefs map[string]*models.LabelDefinition
1065
1145
}
1066
1146
1067
1147
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1072
1152
type RepoPullPatchParams struct {
1073
1153
LoggedInUser *oauth.User
1074
1154
RepoInfo repoinfo.RepoInfo
1075
-
Pull *db.Pull
1076
-
Stack db.Stack
1155
+
Pull *models.Pull
1156
+
Stack models.Stack
1077
1157
Diff *types.NiceDiff
1078
1158
Round int
1079
-
Submission *db.PullSubmission
1080
-
OrderedReactionKinds []db.ReactionKind
1159
+
Submission *models.PullSubmission
1160
+
OrderedReactionKinds []models.ReactionKind
1081
1161
DiffOpts types.DiffOpts
1082
1162
}
1083
1163
···
1089
1169
type RepoPullInterdiffParams struct {
1090
1170
LoggedInUser *oauth.User
1091
1171
RepoInfo repoinfo.RepoInfo
1092
-
Pull *db.Pull
1172
+
Pull *models.Pull
1093
1173
Round int
1094
1174
Interdiff *patchutil.InterdiffResult
1095
-
OrderedReactionKinds []db.ReactionKind
1175
+
OrderedReactionKinds []models.ReactionKind
1096
1176
DiffOpts types.DiffOpts
1097
1177
}
1098
1178
···
1121
1201
1122
1202
type PullCompareForkParams struct {
1123
1203
RepoInfo repoinfo.RepoInfo
1124
-
Forks []db.Repo
1204
+
Forks []models.Repo
1125
1205
Selected string
1126
1206
}
1127
1207
···
1142
1222
type PullResubmitParams struct {
1143
1223
LoggedInUser *oauth.User
1144
1224
RepoInfo repoinfo.RepoInfo
1145
-
Pull *db.Pull
1225
+
Pull *models.Pull
1146
1226
SubmissionId int
1147
1227
}
1148
1228
···
1153
1233
type PullActionsParams struct {
1154
1234
LoggedInUser *oauth.User
1155
1235
RepoInfo repoinfo.RepoInfo
1156
-
Pull *db.Pull
1236
+
Pull *models.Pull
1157
1237
RoundNumber int
1158
1238
MergeCheck types.MergeCheckResponse
1159
1239
ResubmitCheck ResubmitResult
1160
-
Stack db.Stack
1240
+
Stack models.Stack
1161
1241
}
1162
1242
1163
1243
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1167
1247
type PullNewCommentParams struct {
1168
1248
LoggedInUser *oauth.User
1169
1249
RepoInfo repoinfo.RepoInfo
1170
-
Pull *db.Pull
1250
+
Pull *models.Pull
1171
1251
RoundNumber int
1172
1252
}
1173
1253
···
1178
1258
type RepoCompareParams struct {
1179
1259
LoggedInUser *oauth.User
1180
1260
RepoInfo repoinfo.RepoInfo
1181
-
Forks []db.Repo
1261
+
Forks []models.Repo
1182
1262
Branches []types.Branch
1183
1263
Tags []*types.TagReference
1184
1264
Base string
···
1197
1277
type RepoCompareNewParams struct {
1198
1278
LoggedInUser *oauth.User
1199
1279
RepoInfo repoinfo.RepoInfo
1200
-
Forks []db.Repo
1280
+
Forks []models.Repo
1201
1281
Branches []types.Branch
1202
1282
Tags []*types.TagReference
1203
1283
Base string
···
1235
1315
type LabelPanelParams struct {
1236
1316
LoggedInUser *oauth.User
1237
1317
RepoInfo repoinfo.RepoInfo
1238
-
Defs map[string]*db.LabelDefinition
1318
+
Defs map[string]*models.LabelDefinition
1239
1319
Subject string
1240
-
State db.LabelState
1320
+
State models.LabelState
1241
1321
}
1242
1322
1243
1323
func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
···
1247
1327
type EditLabelPanelParams struct {
1248
1328
LoggedInUser *oauth.User
1249
1329
RepoInfo repoinfo.RepoInfo
1250
-
Defs map[string]*db.LabelDefinition
1330
+
Defs map[string]*models.LabelDefinition
1251
1331
Subject string
1252
-
State db.LabelState
1332
+
State models.LabelState
1253
1333
}
1254
1334
1255
1335
func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
···
1259
1339
type PipelinesParams struct {
1260
1340
LoggedInUser *oauth.User
1261
1341
RepoInfo repoinfo.RepoInfo
1262
-
Pipelines []db.Pipeline
1342
+
Pipelines []models.Pipeline
1263
1343
Active string
1264
1344
}
1265
1345
···
1291
1371
type WorkflowParams struct {
1292
1372
LoggedInUser *oauth.User
1293
1373
RepoInfo repoinfo.RepoInfo
1294
-
Pipeline db.Pipeline
1374
+
Pipeline models.Pipeline
1295
1375
Workflow string
1296
1376
LogUrl string
1297
1377
Active string
···
1307
1387
Action string
1308
1388
1309
1389
// this is supplied in the case of editing an existing string
1310
-
String db.String
1390
+
String models.String
1311
1391
}
1312
1392
1313
1393
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
···
1317
1397
type StringsDashboardParams struct {
1318
1398
LoggedInUser *oauth.User
1319
1399
Card ProfileCard
1320
-
Strings []db.String
1400
+
Strings []models.String
1321
1401
}
1322
1402
1323
1403
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
···
1326
1406
1327
1407
type StringTimelineParams struct {
1328
1408
LoggedInUser *oauth.User
1329
-
Strings []db.String
1409
+
Strings []models.String
1330
1410
}
1331
1411
1332
1412
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
···
1338
1418
ShowRendered bool
1339
1419
RenderToggle bool
1340
1420
RenderedContents template.HTML
1341
-
String db.String
1342
-
Stats db.StringStats
1421
+
String models.String
1422
+
Stats models.StringStats
1343
1423
Owner identity.Identity
1344
1424
}
1345
1425
+4
-4
appview/pages/repoinfo/repoinfo.go
+4
-4
appview/pages/repoinfo/repoinfo.go
···
7
7
"strings"
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/state/userutil"
12
12
)
13
13
14
14
func (r RepoInfo) OwnerWithAt() string {
···
60
60
Spindle string
61
61
RepoAt syntax.ATURI
62
62
IsStarred bool
63
-
Stats db.RepoStats
63
+
Stats models.RepoStats
64
64
Roles RolesInRepo
65
-
Source *db.Repo
65
+
Source *models.Repo
66
66
SourceHandle string
67
67
Ref string
68
68
DisableFork bool
+224
appview/pages/templates/brand/brand.html
+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
+4
-11
appview/pages/templates/errors/500.html
···
5
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
6
<div class="mb-6">
7
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
9
</div>
10
10
</div>
11
11
···
14
14
500 — internal server error
15
15
</h1>
16
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
26
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
20
<button onclick="location.reload()" class="btn-create gap-2">
28
21
{{ i "refresh-cw" "w-4 h-4" }}
29
22
try again
30
23
</button>
31
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
25
+
{{ i "arrow-left" "w-4 h-4" }}
33
26
back to home
34
27
</a>
35
28
</div>
+167
appview/pages/templates/goodfirstissues/index.html
+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
+1
-1
appview/pages/templates/labels/fragments/label.html
···
2
2
{{ $d := .def }}
3
3
{{ $v := .val }}
4
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
7
8
8
{{ $lhs := printf "%s" $d.Name }}
+16
-12
appview/pages/templates/layouts/base.html
+16
-12
appview/pages/templates/layouts/base.html
···
14
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
17
20
<!-- preload main font -->
18
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
22
···
21
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
25
{{ block "extrameta" . }}{{ end }}
23
26
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"
25
-
style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);">
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
26
28
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
29
+
<header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
30
29
31
{{ if .LoggedInUser }}
30
32
<div id="upgrade-banner"
···
38
40
{{ end }}
39
41
40
42
{{ block "mainLayout" . }}
41
-
<div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4">
42
-
{{ block "contentLayout" . }}
43
-
<main class="col-span-1 md:col-span-8">
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
44
47
{{ block "content" . }}{{ end }}
45
48
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
50
53
{{ block "contentAfter" . }}{{ end }}
51
54
</main>
52
-
{{ end }}
55
+
{{ end }}
56
+
</div>
53
57
</div>
54
58
{{ end }}
55
59
56
60
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
61
+
<footer class="bg-white dark:bg-gray-800 mt-12">
58
62
{{ template "layouts/fragments/footer" . }}
59
63
</footer>
60
64
{{ end }}
+17
-7
appview/pages/templates/layouts/fragments/topbar.html
+17
-7
appview/pages/templates/layouts/fragments/topbar.html
···
1
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
2
+
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm">
3
3
<div class="flex justify-between p-0 items-center">
4
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
6
-
{{ template "fragments/logotypeSmall" }}
5
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
+
alpha
10
+
</span>
7
11
</a>
8
12
</div>
9
13
10
-
<div id="right-items" class="flex items-center gap-2">
14
+
<div id="right-items" class="flex items-center gap-4">
11
15
{{ with .LoggedInUser }}
12
16
{{ block "newButton" . }} {{ end }}
17
+
{{ template "notifications/fragments/bell" }}
13
18
{{ block "dropDown" . }} {{ end }}
14
19
{{ else }}
15
20
<a href="/login">login</a>
···
26
31
{{ define "newButton" }}
27
32
<details class="relative inline-block text-left nav-dropdown">
28
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
-
{{ i "plus" "w-4 h-4" }} new
34
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
30
35
</summary>
31
36
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
37
<a href="/repo/new" class="flex items-center gap-2">
···
44
49
{{ define "dropDown" }}
45
50
<details class="relative inline-block text-left nav-dropdown">
46
51
<summary
47
-
class="cursor-pointer list-none flex items-center"
52
+
class="cursor-pointer list-none flex items-center gap-1"
48
53
>
49
54
{{ $user := didOrHandle .Did .Handle }}
50
-
{{ template "user/fragments/picHandle" $user }}
55
+
<img
56
+
src="{{ tinyAvatar $user }}"
57
+
alt=""
58
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
59
+
/>
60
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
51
61
</summary>
52
62
<div
53
63
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+13
-6
appview/pages/templates/legal/privacy.html
+13
-6
appview/pages/templates/legal/privacy.html
···
1
1
{{ define "title" }}privacy policy{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Learn how we collect, use, and protect your personal information.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
9
15
</div>
16
+
</main>
10
17
</div>
11
-
{{ end }}
18
+
{{ end }}
+13
-6
appview/pages/templates/legal/terms.html
+13
-6
appview/pages/templates/legal/terms.html
···
1
1
{{ define "title" }}terms of service{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
A few things you should know.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
9
15
</div>
16
+
</main>
10
17
</div>
11
-
{{ end }}
18
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
+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
+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
+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
+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
+7
appview/pages/templates/repo/fork.html
···
6
6
</div>
7
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
+
10
+
<fieldset class="space-y-3">
11
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
+
</fieldset>
15
+
9
16
<fieldset class="space-y-3">
10
17
<legend class="dark:text-white">Select a knot to fork into</legend>
11
18
<div class="space-y-2">
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
1
{{ define "repo/fragments/cloneDropdown" }}
2
2
{{ $knot := .RepoInfo.Knot }}
3
3
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
4
+
{{ $knot = "tangled.org" }}
5
5
{{ end }}
6
6
7
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
29
<code
30
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
32
+
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
34
<button
35
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+26
appview/pages/templates/repo/fragments/participants.html
+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
+2
-2
appview/pages/templates/repo/fragments/readme.html
···
1
1
{{ define "repo/fragments/readme" }}
2
2
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
3
{{- if .ReadmeFileName -}}
4
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
4
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
5
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
6
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
7
</div>
8
8
{{- end -}}
9
9
<section
10
-
class="p-6 overflow-auto {{ if not .Raw }}
10
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
11
11
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
12
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
13
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+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
+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
+1
-27
appview/pages/templates/repo/issues/issue.html
···
22
22
"Defs" $.LabelDefs
23
23
"Subject" $.Issue.AtUri
24
24
"State" $.Issue.Labels) }}
25
-
{{ template "issueParticipants" . }}
25
+
{{ template "repo/fragments/participants" $.Issue.Participants }}
26
26
</div>
27
27
</div>
28
28
{{ end }}
···
122
122
</div>
123
123
{{ end }}
124
124
125
-
{{ define "issueParticipants" }}
126
-
{{ $all := .Issue.Participants }}
127
-
{{ $ps := take $all 5 }}
128
-
<div>
129
-
<div class="py-1 flex items-center text-sm">
130
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
131
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
132
-
</div>
133
-
<div class="flex items-center -space-x-3 mt-2">
134
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
135
-
{{ range $i, $p := $ps }}
136
-
<img
137
-
src="{{ tinyAvatar . }}"
138
-
alt=""
139
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
140
-
/>
141
-
{{ end }}
142
-
143
-
{{ if gt (len $all) 5 }}
144
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
145
-
+{{ sub (len $all) 5 }}
146
-
</span>
147
-
{{ end }}
148
-
</div>
149
-
</div>
150
-
{{ end }}
151
125
152
126
{{ define "repoAfter" }}
153
127
<div class="flex flex-col gap-4 mt-4">
+2
-52
appview/pages/templates/repo/issues/issues.html
+2
-52
appview/pages/templates/repo/issues/issues.html
···
37
37
{{ end }}
38
38
39
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
83
-
{{ $state := .Labels }}
84
-
{{ range $k, $d := $.LabelDefs }}
85
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
86
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
87
-
{{ end }}
88
-
{{ end }}
89
-
</div>
90
-
</div>
91
-
{{ end }}
40
+
<div class="mt-2">
41
+
{{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }}
92
42
</div>
93
43
{{ block "pagination" . }} {{ end }}
94
44
{{ end }}
+163
-61
appview/pages/templates/repo/new.html
+163
-61
appview/pages/templates/repo/new.html
···
1
1
{{ define "title" }}new repo{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
4
+
<div class="grid grid-cols-12">
5
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
6
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Repositories contain a project's files and version history. All
9
+
repositories are publicly accessible.
10
+
</p>
11
+
</div>
12
+
{{ template "newRepoPanel" . }}
6
13
</div>
7
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
<div class="space-y-2">
10
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
-
<input
12
-
type="text"
13
-
id="name"
14
-
name="name"
15
-
required
16
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
-
/>
18
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
14
+
{{ end }}
19
15
20
-
<label for="branch" class="dark:text-white">Default branch</label>
21
-
<input
22
-
type="text"
23
-
id="branch"
24
-
name="branch"
25
-
value="main"
26
-
required
27
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
-
/>
16
+
{{ define "newRepoPanel" }}
17
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
18
+
{{ template "newRepoForm" . }}
19
+
</div>
20
+
{{ end }}
29
21
30
-
<label for="description" class="dark:text-white">Description</label>
31
-
<input
32
-
type="text"
33
-
id="description"
34
-
name="description"
35
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
-
/>
22
+
{{ define "newRepoForm" }}
23
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
24
+
{{ template "step-1" . }}
25
+
{{ template "step-2" . }}
26
+
27
+
<div class="mt-8 flex justify-end">
28
+
<button type="submit" class="btn-create flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
create repo
31
+
<span id="spinner" class="group">
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</span>
34
+
</button>
37
35
</div>
36
+
<div id="repo" class="error mt-2"></div>
38
37
39
-
<fieldset class="space-y-3">
40
-
<legend class="dark:text-white">Select a knot</legend>
38
+
</form>
39
+
{{ end }}
40
+
41
+
{{ define "step-1" }}
42
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
43
+
<div class="absolute -left-3 -top-0">
44
+
{{ template "numberCircle" 1 }}
45
+
</div>
46
+
47
+
<!-- Content column -->
48
+
<div class="flex-1 pb-12">
49
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
50
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
51
+
41
52
<div class="space-y-2">
42
-
<div class="flex flex-col">
43
-
{{ range .Knots }}
44
-
<div class="flex items-center">
45
-
<input
46
-
type="radio"
47
-
name="domain"
48
-
value="{{ . }}"
49
-
class="mr-2"
50
-
id="domain-{{ . }}"
51
-
/>
52
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
53
-
</div>
54
-
{{ else }}
55
-
<p class="dark:text-white">No knots available.</p>
56
-
{{ end }}
57
-
</div>
53
+
{{ template "name" . }}
54
+
{{ template "description" . }}
58
55
</div>
59
-
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
-
</fieldset>
56
+
</div>
57
+
</div>
58
+
{{ end }}
61
59
62
-
<div class="space-y-2">
63
-
<button type="submit" class="btn-create flex items-center gap-2">
64
-
{{ i "book-plus" "w-4 h-4" }}
65
-
create repo
66
-
<span id="spinner" class="group">
67
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
-
</span>
69
-
</button>
70
-
<div id="repo" class="error"></div>
60
+
{{ define "step-2" }}
61
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
62
+
<div class="absolute -left-3 -top-0">
63
+
{{ template "numberCircle" 2 }}
71
64
</div>
72
-
</form>
73
-
</div>
65
+
66
+
<div class="flex-1">
67
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
68
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
69
+
70
+
<div class="space-y-2">
71
+
{{ template "defaultBranch" . }}
72
+
{{ template "knot" . }}
73
+
</div>
74
+
</div>
75
+
</div>
76
+
{{ end }}
77
+
78
+
{{ define "name" }}
79
+
<!-- Repository Name with Owner -->
80
+
<div>
81
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
82
+
Repository name
83
+
</label>
84
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
85
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
86
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
87
+
</div>
88
+
<input
89
+
type="text"
90
+
id="name"
91
+
name="name"
92
+
required
93
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
94
+
placeholder="repository-name"
95
+
/>
96
+
</div>
97
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
98
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
99
+
</p>
100
+
</div>
101
+
{{ end }}
102
+
103
+
{{ define "description" }}
104
+
<!-- Description -->
105
+
<div>
106
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
107
+
Description
108
+
</label>
109
+
<input
110
+
type="text"
111
+
id="description"
112
+
name="description"
113
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
114
+
placeholder="A brief description of your project..."
115
+
/>
116
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
117
+
Optional. A short description to help others understand what your project does.
118
+
</p>
119
+
</div>
120
+
{{ end }}
121
+
122
+
{{ define "defaultBranch" }}
123
+
<!-- Default Branch -->
124
+
<div>
125
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
126
+
Default branch
127
+
</label>
128
+
<input
129
+
type="text"
130
+
id="branch"
131
+
name="branch"
132
+
value="main"
133
+
required
134
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
135
+
/>
136
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
137
+
The primary branch where development happens. Common choices are "main" or "master".
138
+
</p>
139
+
</div>
140
+
{{ end }}
141
+
142
+
{{ define "knot" }}
143
+
<!-- Knot Selection -->
144
+
<div>
145
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
146
+
Select a knot
147
+
</label>
148
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
149
+
{{ range .Knots }}
150
+
<div class="flex items-center">
151
+
<input
152
+
type="radio"
153
+
name="domain"
154
+
value="{{ . }}"
155
+
class="mr-2"
156
+
id="domain-{{ . }}"
157
+
required
158
+
/>
159
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
+
</div>
161
+
{{ else }}
162
+
<p class="dark:text-white">no knots available.</p>
163
+
{{ end }}
164
+
</div>
165
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
+
A knot hosts repository data and handles Git operations.
167
+
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
</p>
169
+
</div>
170
+
{{ end }}
171
+
172
+
{{ define "numberCircle" }}
173
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
174
+
{{.}}
175
+
</div>
74
176
{{ end }}
+30
-12
appview/pages/templates/repo/pulls/pull.html
+30
-12
appview/pages/templates/repo/pulls/pull.html
···
9
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
10
{{ end }}
11
11
12
+
{{ define "repoContentLayout" }}
13
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
14
+
<div class="col-span-1 md:col-span-8">
15
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
16
+
{{ block "repoContent" . }}{{ end }}
17
+
</section>
18
+
{{ block "repoAfter" . }}{{ end }}
19
+
</div>
20
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
21
+
{{ template "repo/fragments/labelPanel"
22
+
(dict "RepoInfo" $.RepoInfo
23
+
"Defs" $.LabelDefs
24
+
"Subject" $.Pull.PullAt
25
+
"State" $.Pull.Labels) }}
26
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
12
30
13
31
{{ define "repoContent" }}
14
32
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
57
{{ with $item }}
40
58
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
59
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
60
+
<div class="flex flex-wrap gap-2 items-stretch">
43
61
<!-- round number -->
44
62
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
63
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
64
</div>
47
65
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
66
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
67
<span class="gap-1 flex items-center">
50
68
{{ $owner := resolve $.Pull.OwnerDid }}
51
69
{{ $re := "re" }}
···
72
90
<span class="hidden md:inline">diff</span>
73
91
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
92
</a>
75
-
{{ if not (eq .RoundNumber 0) }}
76
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
-
hx-boost="true"
78
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
-
<span class="hidden md:inline">interdiff</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</a>
83
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
93
+
{{ if ne $idx 0 }}
94
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
95
+
hx-boost="true"
96
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
97
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
98
+
<span class="hidden md:inline">interdiff</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</a>
84
101
{{ end }}
102
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
85
103
</div>
86
104
</summary>
87
105
···
146
164
147
165
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
166
{{ range $cidx, $c := .Comments }}
149
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
167
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
150
168
{{ if gt $cidx 0 }}
151
169
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
170
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
+7
appview/pages/templates/repo/pulls/pulls.html
···
108
108
<span class="before:content-['·']"></span>
109
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
110
{{ end }}
111
+
112
+
{{ $state := .Labels }}
113
+
{{ range $k, $d := $.LabelDefs }}
114
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
115
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
116
+
{{ end }}
117
+
{{ end }}
111
118
</div>
112
119
</div>
113
120
{{ if .StackId }}
+36
-6
appview/pages/templates/repo/settings/general.html
+36
-6
appview/pages/templates/repo/settings/general.html
···
46
46
47
47
{{ define "defaultLabelSettings" }}
48
48
<div class="flex flex-col gap-2">
49
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
50
-
<p class="text-gray-500 dark:text-gray-400">
51
-
Manage your issues and pulls by creating labels to categorize them. Only
52
-
repository owners may configure labels. You may choose to subscribe to
53
-
default labels, or create entirely custom labels.
54
-
</p>
49
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
50
+
<div class="col-span-1 md:col-span-2">
51
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
52
+
<p class="text-gray-500 dark:text-gray-400">
53
+
Manage your issues and pulls by creating labels to categorize them. Only
54
+
repository owners may configure labels. You may choose to subscribe to
55
+
default labels, or create entirely custom labels.
56
+
<p>
57
+
</div>
58
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
59
+
{{ $title := "Unubscribe from all labels" }}
60
+
{{ $icon := "x" }}
61
+
{{ $text := "unsubscribe all" }}
62
+
{{ $action := "unsubscribe" }}
63
+
{{ if $.ShouldSubscribeAll }}
64
+
{{ $title = "Subscribe to all labels" }}
65
+
{{ $icon = "check-check" }}
66
+
{{ $text = "subscribe all" }}
67
+
{{ $action = "subscribe" }}
68
+
{{ end }}
69
+
{{ range .DefaultLabels }}
70
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
71
+
{{ end }}
72
+
<button
73
+
type="submit"
74
+
title="{{$title}}"
75
+
class="btn flex items-center gap-2 group"
76
+
hx-swap="none"
77
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
78
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
79
+
{{ i $icon "size-4" }}
80
+
{{ $text }}
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</button>
83
+
</form>
84
+
</div>
55
85
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
56
86
{{ range .DefaultLabels }}
57
87
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1
-1
appview/pages/templates/repo/tree.html
+1
-1
appview/pages/templates/repo/tree.html
+2
-2
appview/pages/templates/strings/put.html
+2
-2
appview/pages/templates/strings/put.html
···
3
3
{{ define "content" }}
4
4
<div class="px-6 py-2 mb-4">
5
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
8
{{ else }}
9
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
10
{{ end }}
+5
-7
appview/pages/templates/strings/timeline.html
+5
-7
appview/pages/templates/strings/timeline.html
···
26
26
{{ end }}
27
27
28
28
{{ define "stringCard" }}
29
+
{{ $resolved := resolve .Did.String }}
29
30
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
-
<div class="font-medium dark:text-white flex gap-2 items-center">
31
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
31
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
32
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
33
+
<span class="select-none">/</span>
34
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
32
35
</div>
33
36
{{ with .Description }}
34
37
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
42
45
43
46
{{ define "stringCardInfo" }}
44
47
{{ $stat := .Stats }}
45
-
{{ $resolved := resolve .Did.String }}
46
48
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
-
{{ template "user/fragments/picHandle" $resolved }}
49
-
</a>
50
-
<span class="select-none [&:before]:content-['·']"></span>
51
49
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
50
<span class="select-none [&:before]:content-['·']"></span>
53
51
{{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
+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
+10
-33
appview/pages/templates/timeline/fragments/timeline.html
···
82
82
{{ $event := index . 1 }}
83
83
{{ $follow := $event.Follow }}
84
84
{{ $profile := $event.Profile }}
85
-
{{ $stat := $event.FollowStats }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
86
87
87
88
{{ $userHandle := resolve $follow.UserDid }}
88
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
92
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
93
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
94
95
</div>
95
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
96
-
<div class="flex items-center gap-4 flex-1">
97
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
98
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
99
-
</div>
100
-
101
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
102
-
<a href="/{{ $subjectHandle }}">
103
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
104
-
</a>
105
-
{{ with $profile }}
106
-
{{ with .Description }}
107
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
108
-
{{ end }}
109
-
{{ end }}
110
-
{{ with $stat }}
111
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
112
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
113
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
114
-
<span class="select-none after:content-['·']"></span>
115
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
116
-
</div>
117
-
{{ end }}
118
-
</div>
119
-
</div>
120
-
121
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
122
-
<div class="flex-shrink-0 w-fit ml-auto">
123
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
124
-
</div>
125
-
{{ end }}
126
-
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
127
104
{{ end }}
+1
appview/pages/templates/timeline/home.html
+1
appview/pages/templates/timeline/home.html
···
12
12
<div class="flex flex-col gap-4">
13
13
{{ template "timeline/fragments/hero" . }}
14
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
15
16
{{ template "timeline/fragments/trending" . }}
16
17
{{ template "timeline/fragments/timeline" . }}
17
18
<div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/timeline/timeline.html
+1
appview/pages/templates/user/completeSignup.html
+1
appview/pages/templates/user/completeSignup.html
+8
-1
appview/pages/templates/user/followers.html
+8
-1
appview/pages/templates/user/followers.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
14
21
{{ else }}
15
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
23
{{ end }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
1
{{ define "user/fragments/follow" }}
2
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 flex gap-2 items-center group"
3
+
class="btn w-full flex gap-2 items-center group"
4
4
5
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
6
hx-post="/follow?subject={{.UserDid}}"
···
12
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
13
hx-swap="outerHTML"
14
14
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
16
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
21
</button>
18
22
{{ end }}
+20
-17
appview/pages/templates/user/fragments/followCard.html
+20
-17
appview/pages/templates/user/fragments/followCard.html
···
1
1
{{ define "user/fragments/followCard" }}
2
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
7
</div>
8
8
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['·']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
+
<a href="/{{ $userIdent }}">
12
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
+
</a>
14
+
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
+
{{ end }}
17
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
19
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
20
+
<span class="select-none after:content-['·']"></span>
21
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
22
+
</div>
19
23
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
25
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
24
26
{{ template "user/fragments/follow" . }}
25
27
</div>
26
-
{{ end }}
28
+
{{ end }}
29
+
</div>
27
30
</div>
28
31
</div>
29
-
{{ end }}
32
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
+2
-3
appview/pages/templates/user/fragments/picHandleLink.html
···
1
1
{{ define "user/fragments/picHandleLink" }}
2
-
{{ $resolved := resolve . }}
3
-
<a href="/{{ $resolved }}" class="flex items-center">
4
-
{{ template "user/fragments/picHandle" $resolved }}
2
+
<a href="/{{ resolve . }}" class="flex items-center gap-1">
3
+
{{ template "user/fragments/picHandle" . }}
5
4
</a>
6
5
{{ end }}
+10
-10
appview/pages/templates/user/fragments/repoCard.html
+10
-10
appview/pages/templates/user/fragments/repoCard.html
···
14
14
{{ with $repo }}
15
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
16
<div class="font-medium dark:text-white flex items-center justify-between">
17
-
<div class="flex items-center">
18
-
{{ if .Source }}
19
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
-
{{ else }}
21
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
-
{{ end }}
23
-
17
+
<div class="flex items-center min-w-0 flex-1 mr-2">
18
+
{{ if .Source }}
19
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
+
{{ else }}
21
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
+
{{ end }}
24
23
{{ $repoOwner := resolve .Did }}
25
24
{{- if $fullName -}}
26
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
25
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
27
26
{{- else -}}
28
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
27
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
29
28
{{- end -}}
30
29
</div>
31
-
32
30
{{ if and $starButton $root.LoggedInUser }}
31
+
<div class="shrink-0">
33
32
{{ template "repo/fragments/repoStar" $starData }}
33
+
</div>
34
34
{{ end }}
35
35
</div>
36
36
{{ with .Description }}
+2
-1
appview/pages/templates/user/login.html
+2
-1
appview/pages/templates/user/login.html
···
8
8
<meta property="og:url" content="https://tangled.org/login" />
9
9
<meta property="og:description" content="login to for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>login · tangled</title>
13
14
</head>
···
36
37
placeholder="akshay.tngl.sh"
37
38
/>
38
39
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
40
+
Use your <a href="https://atproto.com">AT Protocol</a>
40
41
handle to log in. If you're unsure, this is likely
41
42
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
43
</span>
+173
appview/pages/templates/user/settings/notifications.html
+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
+7
-1
appview/pages/templates/user/signup.html
···
8
8
<meta property="og:url" content="https://tangled.org/signup" />
9
9
<meta property="og:description" content="sign up for tangled" />
10
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
11
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
13
<title>sign up · tangled</title>
14
+
15
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
13
16
</head>
14
17
<body class="flex items-center justify-center min-h-screen">
15
18
<main class="max-w-md px-6 -mt-4">
···
39
42
invite code, desired username, and password in the next
40
43
page to complete your registration.
41
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
+
</div>
42
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
43
49
<span>join now</span>
44
50
</button>
45
51
</form>
46
52
<p class="text-sm text-gray-500">
47
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
53
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
48
54
</p>
49
55
50
56
<p id="signup-msg" class="error w-full"></p>
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
+10
-10
appview/pipelines/pipelines.go
+10
-10
appview/pipelines/pipelines.go
···
9
9
"strings"
10
10
"time"
11
11
12
-
"tangled.sh/tangled.sh/core/appview/config"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/oauth"
15
-
"tangled.sh/tangled.sh/core/appview/pages"
16
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
17
-
"tangled.sh/tangled.sh/core/eventconsumer"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/log"
20
-
"tangled.sh/tangled.sh/core/rbac"
21
-
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
12
+
"tangled.org/core/appview/config"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/oauth"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/appview/reporesolver"
17
+
"tangled.org/core/eventconsumer"
18
+
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindlemodel "tangled.org/core/spindle/models"
22
22
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/gorilla/websocket"
+1
-1
appview/pipelines/router.go
+1
-1
appview/pipelines/router.go
-164
appview/posthog/notifier.go
-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
+124
-75
appview/pulls/pulls.go
···
12
12
"strings"
13
13
"time"
14
14
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/notify"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/idresolver"
25
-
"tangled.sh/tangled.sh/core/patchutil"
26
-
"tangled.sh/tangled.sh/core/tid"
27
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.org/core/api/tangled"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/models"
19
+
"tangled.org/core/appview/notify"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/markup"
23
+
"tangled.org/core/appview/reporesolver"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/idresolver"
26
+
"tangled.org/core/patchutil"
27
+
"tangled.org/core/tid"
28
+
"tangled.org/core/types"
28
29
29
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
75
76
return
76
77
}
77
78
78
-
pull, ok := r.Context().Value("pull").(*db.Pull)
79
+
pull, ok := r.Context().Value("pull").(*models.Pull)
79
80
if !ok {
80
81
log.Println("failed to get pull")
81
82
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
83
84
}
84
85
85
86
// can be nil if this pull is not stacked
86
-
stack, _ := r.Context().Value("stack").(db.Stack)
87
+
stack, _ := r.Context().Value("stack").(models.Stack)
87
88
88
89
roundNumberStr := chi.URLParam(r, "round")
89
90
roundNumber, err := strconv.Atoi(roundNumberStr)
···
123
124
return
124
125
}
125
126
126
-
pull, ok := r.Context().Value("pull").(*db.Pull)
127
+
pull, ok := r.Context().Value("pull").(*models.Pull)
127
128
if !ok {
128
129
log.Println("failed to get pull")
129
130
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
131
132
}
132
133
133
134
// can be nil if this pull is not stacked
134
-
stack, _ := r.Context().Value("stack").(db.Stack)
135
-
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
135
+
stack, _ := r.Context().Value("stack").(models.Stack)
136
+
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
136
137
137
138
totalIdents := 1
138
139
for _, submission := range pull.Submissions {
···
159
160
160
161
repoInfo := f.RepoInfo(user)
161
162
162
-
m := make(map[string]db.Pipeline)
163
+
m := make(map[string]models.Pipeline)
163
164
164
165
var shas []string
165
166
for _, s := range pull.Submissions {
···
194
195
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
195
196
}
196
197
197
-
userReactions := map[db.ReactionKind]bool{}
198
+
userReactions := map[models.ReactionKind]bool{}
198
199
if user != nil {
199
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
200
201
}
201
202
203
+
labelDefs, err := db.GetLabelDefinitions(
204
+
s.db,
205
+
db.FilterIn("at_uri", f.Repo.Labels),
206
+
db.FilterContains("scope", tangled.RepoPullNSID),
207
+
)
208
+
if err != nil {
209
+
log.Println("failed to fetch labels", err)
210
+
s.pages.Error503(w)
211
+
return
212
+
}
213
+
214
+
defs := make(map[string]*models.LabelDefinition)
215
+
for _, l := range labelDefs {
216
+
defs[l.AtUri().String()] = &l
217
+
}
218
+
202
219
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
203
220
LoggedInUser: user,
204
221
RepoInfo: repoInfo,
···
209
226
ResubmitCheck: resubmitResult,
210
227
Pipelines: m,
211
228
212
-
OrderedReactionKinds: db.OrderedReactionKinds,
229
+
OrderedReactionKinds: models.OrderedReactionKinds,
213
230
Reactions: reactionCountMap,
214
231
UserReacted: userReactions,
232
+
233
+
LabelDefs: defs,
215
234
})
216
235
}
217
236
218
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
219
-
if pull.State == db.PullMerged {
237
+
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
238
+
if pull.State == models.PullMerged {
220
239
return types.MergeCheckResponse{}
221
240
}
222
241
···
282
301
return result
283
302
}
284
303
285
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
286
-
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
304
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
305
+
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
287
306
return pages.Unknown
288
307
}
289
308
···
356
375
diffOpts.Split = true
357
376
}
358
377
359
-
pull, ok := r.Context().Value("pull").(*db.Pull)
378
+
pull, ok := r.Context().Value("pull").(*models.Pull)
360
379
if !ok {
361
380
log.Println("failed to get pull")
362
381
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
363
382
return
364
383
}
365
384
366
-
stack, _ := r.Context().Value("stack").(db.Stack)
385
+
stack, _ := r.Context().Value("stack").(models.Stack)
367
386
368
387
roundId := chi.URLParam(r, "round")
369
388
roundIdInt, err := strconv.Atoi(roundId)
···
403
422
diffOpts.Split = true
404
423
}
405
424
406
-
pull, ok := r.Context().Value("pull").(*db.Pull)
425
+
pull, ok := r.Context().Value("pull").(*models.Pull)
407
426
if !ok {
408
427
log.Println("failed to get pull")
409
428
s.pages.Notice(w, "pull-error", "Failed to get pull.")
···
451
470
}
452
471
453
472
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
454
-
pull, ok := r.Context().Value("pull").(*db.Pull)
473
+
pull, ok := r.Context().Value("pull").(*models.Pull)
455
474
if !ok {
456
475
log.Println("failed to get pull")
457
476
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
474
493
user := s.oauth.GetUser(r)
475
494
params := r.URL.Query()
476
495
477
-
state := db.PullOpen
496
+
state := models.PullOpen
478
497
switch params.Get("state") {
479
498
case "closed":
480
-
state = db.PullClosed
499
+
state = models.PullClosed
481
500
case "merged":
482
-
state = db.PullMerged
501
+
state = models.PullMerged
483
502
}
484
503
485
504
f, err := s.repoResolver.Resolve(r)
···
500
519
}
501
520
502
521
for _, p := range pulls {
503
-
var pullSourceRepo *db.Repo
522
+
var pullSourceRepo *models.Repo
504
523
if p.PullSource != nil {
505
524
if p.PullSource.RepoAt != nil {
506
525
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
···
515
534
}
516
535
517
536
// we want to group all stacked PRs into just one list
518
-
stacks := make(map[string]db.Stack)
537
+
stacks := make(map[string]models.Stack)
519
538
var shas []string
520
539
n := 0
521
540
for _, p := range pulls {
···
551
570
log.Printf("failed to fetch pipeline statuses: %s", err)
552
571
// non-fatal
553
572
}
554
-
m := make(map[string]db.Pipeline)
573
+
m := make(map[string]models.Pipeline)
555
574
for _, p := range ps {
556
575
m[p.Sha] = p
557
576
}
558
577
578
+
labelDefs, err := db.GetLabelDefinitions(
579
+
s.db,
580
+
db.FilterIn("at_uri", f.Repo.Labels),
581
+
db.FilterContains("scope", tangled.RepoPullNSID),
582
+
)
583
+
if err != nil {
584
+
log.Println("failed to fetch labels", err)
585
+
s.pages.Error503(w)
586
+
return
587
+
}
588
+
589
+
defs := make(map[string]*models.LabelDefinition)
590
+
for _, l := range labelDefs {
591
+
defs[l.AtUri().String()] = &l
592
+
}
593
+
559
594
s.pages.RepoPulls(w, pages.RepoPullsParams{
560
595
LoggedInUser: s.oauth.GetUser(r),
561
596
RepoInfo: f.RepoInfo(user),
562
597
Pulls: pulls,
598
+
LabelDefs: defs,
563
599
FilteringBy: state,
564
600
Stacks: stacks,
565
601
Pipelines: m,
···
574
610
return
575
611
}
576
612
577
-
pull, ok := r.Context().Value("pull").(*db.Pull)
613
+
pull, ok := r.Context().Value("pull").(*models.Pull)
578
614
if !ok {
579
615
log.Println("failed to get pull")
580
616
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
647
683
return
648
684
}
649
685
650
-
comment := &db.PullComment{
686
+
comment := &models.PullComment{
651
687
OwnerDid: user.Did,
652
688
RepoAt: f.RepoAt().String(),
653
689
PullId: pull.PullId,
···
890
926
return
891
927
}
892
928
893
-
pullSource := &db.PullSource{
929
+
pullSource := &models.PullSource{
894
930
Branch: sourceBranch,
895
931
}
896
932
recordPullSource := &tangled.RepoPull_Source{
···
1000
1036
forkAtUri := fork.RepoAt()
1001
1037
forkAtUriStr := forkAtUri.String()
1002
1038
1003
-
pullSource := &db.PullSource{
1039
+
pullSource := &models.PullSource{
1004
1040
Branch: sourceBranch,
1005
1041
RepoAt: &forkAtUri,
1006
1042
}
···
1021
1057
title, body, targetBranch string,
1022
1058
patch string,
1023
1059
sourceRev string,
1024
-
pullSource *db.PullSource,
1060
+
pullSource *models.PullSource,
1025
1061
recordPullSource *tangled.RepoPull_Source,
1026
1062
isStacked bool,
1027
1063
) {
···
1057
1093
1058
1094
// We've already checked earlier if it's diff-based and title is empty,
1059
1095
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1060
-
if title == "" {
1096
+
if title == "" || body == "" {
1061
1097
formatPatches, err := patchutil.ExtractPatches(patch)
1062
1098
if err != nil {
1063
1099
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1068
1104
return
1069
1105
}
1070
1106
1071
-
title = formatPatches[0].Title
1072
-
body = formatPatches[0].Body
1107
+
if title == "" {
1108
+
title = formatPatches[0].Title
1109
+
}
1110
+
if body == "" {
1111
+
body = formatPatches[0].Body
1112
+
}
1073
1113
}
1074
1114
1075
1115
rkey := tid.TID()
1076
-
initialSubmission := db.PullSubmission{
1116
+
initialSubmission := models.PullSubmission{
1077
1117
Patch: patch,
1078
1118
SourceRev: sourceRev,
1079
1119
}
1080
-
pull := &db.Pull{
1120
+
pull := &models.Pull{
1081
1121
Title: title,
1082
1122
Body: body,
1083
1123
TargetBranch: targetBranch,
1084
1124
OwnerDid: user.Did,
1085
1125
RepoAt: f.RepoAt(),
1086
1126
Rkey: rkey,
1087
-
Submissions: []*db.PullSubmission{
1127
+
Submissions: []*models.PullSubmission{
1088
1128
&initialSubmission,
1089
1129
},
1090
1130
PullSource: pullSource,
···
1143
1183
targetBranch string,
1144
1184
patch string,
1145
1185
sourceRev string,
1146
-
pullSource *db.PullSource,
1186
+
pullSource *models.PullSource,
1147
1187
) {
1148
1188
// run some necessary checks for stacked-prs first
1149
1189
···
1451
1491
return
1452
1492
}
1453
1493
1454
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1494
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1455
1495
if !ok {
1456
1496
log.Println("failed to get pull")
1457
1497
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1482
1522
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1483
1523
user := s.oauth.GetUser(r)
1484
1524
1485
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1525
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1486
1526
if !ok {
1487
1527
log.Println("failed to get pull")
1488
1528
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1509
1549
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1510
1550
user := s.oauth.GetUser(r)
1511
1551
1512
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1552
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1513
1553
if !ok {
1514
1554
log.Println("failed to get pull")
1515
1555
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1572
1612
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1573
1613
user := s.oauth.GetUser(r)
1574
1614
1575
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1615
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1576
1616
if !ok {
1577
1617
log.Println("failed to get pull")
1578
1618
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1665
1705
}
1666
1706
1667
1707
// validate a resubmission against a pull request
1668
-
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1708
+
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1669
1709
if patch == "" {
1670
1710
return fmt.Errorf("Patch is empty.")
1671
1711
}
···
1686
1726
r *http.Request,
1687
1727
f *reporesolver.ResolvedRepo,
1688
1728
user *oauth.User,
1689
-
pull *db.Pull,
1729
+
pull *models.Pull,
1690
1730
patch string,
1691
1731
sourceRev string,
1692
1732
) {
···
1790
1830
r *http.Request,
1791
1831
f *reporesolver.ResolvedRepo,
1792
1832
user *oauth.User,
1793
-
pull *db.Pull,
1833
+
pull *models.Pull,
1794
1834
patch string,
1795
1835
stackId string,
1796
1836
) {
1797
1837
targetBranch := pull.TargetBranch
1798
1838
1799
-
origStack, _ := r.Context().Value("stack").(db.Stack)
1839
+
origStack, _ := r.Context().Value("stack").(models.Stack)
1800
1840
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1801
1841
if err != nil {
1802
1842
log.Println("failed to create resubmitted stack", err)
···
1805
1845
}
1806
1846
1807
1847
// find the diff between the stacks, first, map them by changeId
1808
-
origById := make(map[string]*db.Pull)
1809
-
newById := make(map[string]*db.Pull)
1848
+
origById := make(map[string]*models.Pull)
1849
+
newById := make(map[string]*models.Pull)
1810
1850
for _, p := range origStack {
1811
1851
origById[p.ChangeId] = p
1812
1852
}
···
1819
1859
// commits that got updated: corresponding pull is resubmitted & new round begins
1820
1860
//
1821
1861
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1822
-
additions := make(map[string]*db.Pull)
1823
-
deletions := make(map[string]*db.Pull)
1862
+
additions := make(map[string]*models.Pull)
1863
+
deletions := make(map[string]*models.Pull)
1824
1864
unchanged := make(map[string]struct{})
1825
1865
updated := make(map[string]struct{})
1826
1866
···
1880
1920
// deleted pulls are marked as deleted in the DB
1881
1921
for _, p := range deletions {
1882
1922
// do not do delete already merged PRs
1883
-
if p.State == db.PullMerged {
1923
+
if p.State == models.PullMerged {
1884
1924
continue
1885
1925
}
1886
1926
···
1925
1965
np, _ := newById[id]
1926
1966
1927
1967
// do not update already merged PRs
1928
-
if op.State == db.PullMerged {
1968
+
if op.State == models.PullMerged {
1929
1969
continue
1930
1970
}
1931
1971
···
2046
2086
return
2047
2087
}
2048
2088
2049
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2089
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2050
2090
if !ok {
2051
2091
log.Println("failed to get pull")
2052
2092
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2053
2093
return
2054
2094
}
2055
2095
2056
-
var pullsToMerge db.Stack
2096
+
var pullsToMerge models.Stack
2057
2097
pullsToMerge = append(pullsToMerge, pull)
2058
2098
if pull.IsStacked() {
2059
-
stack, ok := r.Context().Value("stack").(db.Stack)
2099
+
stack, ok := r.Context().Value("stack").(models.Stack)
2060
2100
if !ok {
2061
2101
log.Println("failed to get stack")
2062
2102
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
···
2146
2186
return
2147
2187
}
2148
2188
2189
+
// notify about the pull merge
2190
+
for _, p := range pullsToMerge {
2191
+
s.notifier.NewPullMerged(r.Context(), p)
2192
+
}
2193
+
2149
2194
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2150
2195
}
2151
2196
···
2158
2203
return
2159
2204
}
2160
2205
2161
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2206
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2162
2207
if !ok {
2163
2208
log.Println("failed to get pull")
2164
2209
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2186
2231
}
2187
2232
defer tx.Rollback()
2188
2233
2189
-
var pullsToClose []*db.Pull
2234
+
var pullsToClose []*models.Pull
2190
2235
pullsToClose = append(pullsToClose, pull)
2191
2236
2192
2237
// if this PR is stacked, then we want to close all PRs below this one on the stack
2193
2238
if pull.IsStacked() {
2194
-
stack := r.Context().Value("stack").(db.Stack)
2239
+
stack := r.Context().Value("stack").(models.Stack)
2195
2240
subStack := stack.StrictlyBelow(pull)
2196
2241
pullsToClose = append(pullsToClose, subStack...)
2197
2242
}
···
2213
2258
return
2214
2259
}
2215
2260
2261
+
for _, p := range pullsToClose {
2262
+
s.notifier.NewPullClosed(r.Context(), p)
2263
+
}
2264
+
2216
2265
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2217
2266
}
2218
2267
···
2226
2275
return
2227
2276
}
2228
2277
2229
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2278
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2230
2279
if !ok {
2231
2280
log.Println("failed to get pull")
2232
2281
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2254
2303
}
2255
2304
defer tx.Rollback()
2256
2305
2257
-
var pullsToReopen []*db.Pull
2306
+
var pullsToReopen []*models.Pull
2258
2307
pullsToReopen = append(pullsToReopen, pull)
2259
2308
2260
2309
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
2261
2310
if pull.IsStacked() {
2262
-
stack := r.Context().Value("stack").(db.Stack)
2311
+
stack := r.Context().Value("stack").(models.Stack)
2263
2312
subStack := stack.StrictlyAbove(pull)
2264
2313
pullsToReopen = append(pullsToReopen, subStack...)
2265
2314
}
···
2284
2333
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2285
2334
}
2286
2335
2287
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2336
+
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2288
2337
formatPatches, err := patchutil.ExtractPatches(patch)
2289
2338
if err != nil {
2290
2339
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2296
2345
}
2297
2346
2298
2347
// the stack is identified by a UUID
2299
-
var stack db.Stack
2348
+
var stack models.Stack
2300
2349
parentChangeId := ""
2301
2350
for _, fp := range formatPatches {
2302
2351
// all patches must have a jj change-id
···
2309
2358
body := fp.Body
2310
2359
rkey := tid.TID()
2311
2360
2312
-
initialSubmission := db.PullSubmission{
2361
+
initialSubmission := models.PullSubmission{
2313
2362
Patch: fp.Raw,
2314
2363
SourceRev: fp.SHA,
2315
2364
}
2316
-
pull := db.Pull{
2365
+
pull := models.Pull{
2317
2366
Title: title,
2318
2367
Body: body,
2319
2368
TargetBranch: targetBranch,
2320
2369
OwnerDid: user.Did,
2321
2370
RepoAt: f.RepoAt(),
2322
2371
Rkey: rkey,
2323
-
Submissions: []*db.PullSubmission{
2372
+
Submissions: []*models.PullSubmission{
2324
2373
&initialSubmission,
2325
2374
},
2326
2375
PullSource: pullSource,
+1
-1
appview/pulls/router.go
+1
-1
appview/pulls/router.go
+49
-22
appview/repo/artifact.go
+49
-22
appview/repo/artifact.go
···
4
4
"context"
5
5
"encoding/json"
6
6
"fmt"
7
+
"io"
7
8
"log"
8
9
"net/http"
9
10
"net/url"
···
16
17
"github.com/go-chi/chi/v5"
17
18
"github.com/go-git/go-git/v5/plumbing"
18
19
"github.com/ipfs/go-cid"
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/db"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/tid"
25
-
"tangled.sh/tangled.sh/core/types"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/pages"
24
+
"tangled.org/core/appview/reporesolver"
25
+
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/tid"
27
+
"tangled.org/core/types"
26
28
)
27
29
28
30
// TODO: proper statuses here on early exit
···
100
102
}
101
103
defer tx.Rollback()
102
104
103
-
artifact := db.Artifact{
105
+
artifact := models.Artifact{
104
106
Did: user.Did,
105
107
Rkey: rkey,
106
108
RepoAt: f.RepoAt(),
···
133
135
})
134
136
}
135
137
136
-
// TODO: proper statuses here on early exit
137
138
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
138
-
tagParam := chi.URLParam(r, "tag")
139
-
filename := chi.URLParam(r, "file")
140
139
f, err := rp.repoResolver.Resolve(r)
141
140
if err != nil {
142
141
log.Println("failed to get repo and knot", err)
142
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
143
143
return
144
144
}
145
145
146
+
tagParam := chi.URLParam(r, "tag")
147
+
filename := chi.URLParam(r, "file")
148
+
146
149
tag, err := rp.resolveTag(r.Context(), f, tagParam)
147
150
if err != nil {
148
151
log.Println("failed to resolve tag", err)
···
150
153
return
151
154
}
152
155
153
-
client, err := rp.oauth.AuthorizedClient(r)
154
-
if err != nil {
155
-
log.Println("failed to get authorized client", err)
156
-
return
157
-
}
158
-
159
156
artifacts, err := db.GetArtifact(
160
157
rp.db,
161
158
db.FilterEq("repo_at", f.RepoAt()),
···
164
161
)
165
162
if err != nil {
166
163
log.Println("failed to get artifacts", err)
164
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
167
165
return
168
166
}
167
+
169
168
if len(artifacts) != 1 {
170
-
log.Printf("too many or too little artifacts found")
169
+
log.Printf("too many or too few artifacts found")
170
+
http.Error(w, "artifact not found", http.StatusNotFound)
171
171
return
172
172
}
173
173
174
174
artifact := artifacts[0]
175
175
176
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
176
+
ownerPds := f.OwnerId.PDSEndpoint()
177
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
178
+
q := url.Query()
179
+
q.Set("cid", artifact.BlobCid.String())
180
+
q.Set("did", artifact.Did)
181
+
url.RawQuery = q.Encode()
182
+
183
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
177
184
if err != nil {
178
-
log.Println("failed to get blob from pds", err)
185
+
log.Println("failed to create request", err)
186
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
187
+
return
188
+
}
189
+
req.Header.Set("Content-Type", "application/json")
190
+
191
+
resp, err := http.DefaultClient.Do(req)
192
+
if err != nil {
193
+
log.Println("failed to make request", err)
194
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
179
195
return
180
196
}
197
+
defer resp.Body.Close()
181
198
182
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
183
-
w.Write(getBlobResp)
199
+
// copy status code and relevant headers from upstream response
200
+
w.WriteHeader(resp.StatusCode)
201
+
for key, values := range resp.Header {
202
+
for _, v := range values {
203
+
w.Header().Add(key, v)
204
+
}
205
+
}
206
+
207
+
// stream the body directly to the client
208
+
if _, err := io.Copy(w, resp.Body); err != nil {
209
+
log.Println("error streaming response to client:", err)
210
+
}
184
211
}
185
212
186
213
// TODO: proper statuses here on early exit
+10
-9
appview/repo/feed.go
+10
-9
appview/repo/feed.go
···
8
8
"slices"
9
9
"time"
10
10
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pagination"
13
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/appview/reporesolver"
14
15
15
16
"github.com/bluesky-social/indigo/atproto/syntax"
16
17
"github.com/gorilla/feeds"
···
70
71
return feed, nil
71
72
}
72
73
73
-
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
75
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
75
76
if err != nil {
76
77
return nil, err
···
108
109
return items, nil
109
110
}
110
111
111
-
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
113
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
113
114
if err != nil {
114
115
return nil, err
···
128
129
}, nil
129
130
}
130
131
131
-
func (rp *Repo) getPullState(pull *db.Pull) string {
132
-
if pull.State == db.PullOpen {
132
+
func (rp *Repo) getPullState(pull *models.Pull) string {
133
+
if pull.State == models.PullOpen {
133
134
return "opened"
134
135
}
135
136
return pull.State.String()
136
137
}
137
138
138
-
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
139
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string {
139
140
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
140
141
141
-
if pull.State == db.PullMerged {
142
+
if pull.State == models.PullMerged {
142
143
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
143
144
}
144
145
+26
-30
appview/repo/index.go
+26
-30
appview/repo/index.go
···
17
17
18
18
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
19
"github.com/go-git/go-git/v5/plumbing"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview/commitverify"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/pages"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
27
-
"tangled.sh/tangled.sh/core/types"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/commitverify"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/models"
24
+
"tangled.org/core/appview/pages"
25
+
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/xrpcclient"
27
+
"tangled.org/core/types"
28
28
29
29
"github.com/go-chi/chi/v5"
30
30
"github.com/go-enry/go-enry/v2"
···
191
191
}
192
192
193
193
for _, lang := range ls.Languages {
194
-
langs = append(langs, db.RepoLanguage{
194
+
langs = append(langs, models.RepoLanguage{
195
195
RepoAt: f.RepoAt(),
196
196
Ref: currentRef,
197
197
IsDefaultRef: isDefaultRef,
···
200
200
})
201
201
}
202
202
203
+
tx, err := rp.db.Begin()
204
+
if err != nil {
205
+
return nil, err
206
+
}
207
+
defer tx.Rollback()
208
+
203
209
// update appview's cache
204
-
err = db.InsertRepoLanguages(rp.db, langs)
210
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
205
211
if err != nil {
206
212
// non-fatal
207
213
log.Println("failed to cache lang results", err)
208
214
}
215
+
216
+
err = tx.Commit()
217
+
if err != nil {
218
+
return nil, err
219
+
}
209
220
}
210
221
211
222
var total int64
···
327
338
}
328
339
}()
329
340
330
-
// readme content
331
-
wg.Add(1)
332
-
go func() {
333
-
defer wg.Done()
334
-
for _, filename := range markup.ReadmeFilenames {
335
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
336
-
if err != nil {
337
-
continue
338
-
}
339
-
340
-
if blobResp == nil {
341
-
continue
342
-
}
343
-
344
-
readmeContent = blobResp.Content
345
-
readmeFileName = filename
346
-
break
347
-
}
348
-
}()
349
-
350
341
wg.Wait()
351
342
352
343
if errs != nil {
···
373
364
}
374
365
files = append(files, niceFile)
375
366
}
367
+
}
368
+
369
+
if treeResp != nil && treeResp.Readme != nil {
370
+
readmeFileName = treeResp.Readme.Filename
371
+
readmeContent = treeResp.Readme.Contents
376
372
}
377
373
378
374
result := &types.RepoIndexResponse{
+110
-83
appview/repo/repo.go
+110
-83
appview/repo/repo.go
···
20
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
-
"tangled.sh/tangled.sh/core/api/tangled"
24
-
"tangled.sh/tangled.sh/core/appview/commitverify"
25
-
"tangled.sh/tangled.sh/core/appview/config"
26
-
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/notify"
28
-
"tangled.sh/tangled.sh/core/appview/oauth"
29
-
"tangled.sh/tangled.sh/core/appview/pages"
30
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
31
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
32
-
"tangled.sh/tangled.sh/core/appview/validator"
33
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
34
-
"tangled.sh/tangled.sh/core/eventconsumer"
35
-
"tangled.sh/tangled.sh/core/idresolver"
36
-
"tangled.sh/tangled.sh/core/patchutil"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
"tangled.sh/tangled.sh/core/types"
40
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
23
+
"tangled.org/core/api/tangled"
24
+
"tangled.org/core/appview/commitverify"
25
+
"tangled.org/core/appview/config"
26
+
"tangled.org/core/appview/db"
27
+
"tangled.org/core/appview/models"
28
+
"tangled.org/core/appview/notify"
29
+
"tangled.org/core/appview/oauth"
30
+
"tangled.org/core/appview/pages"
31
+
"tangled.org/core/appview/pages/markup"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/patchutil"
38
+
"tangled.org/core/rbac"
39
+
"tangled.org/core/tid"
40
+
"tangled.org/core/types"
41
+
"tangled.org/core/xrpc/serviceauth"
41
42
42
43
securejoin "github.com/cyphar/filepath-securejoin"
43
44
"github.com/go-chi/chi/v5"
···
399
400
log.Println(err)
400
401
// non-fatal
401
402
}
402
-
var pipeline *db.Pipeline
403
+
var pipeline *models.Pipeline
403
404
if p, ok := pipelines[result.Diff.Commit.This]; ok {
404
405
pipeline = &p
405
406
}
···
448
449
return
449
450
}
450
451
451
-
// readme content
452
-
var (
453
-
readmeContent string
454
-
readmeFileName string
455
-
)
456
-
457
-
for _, filename := range markup.ReadmeFilenames {
458
-
path := fmt.Sprintf("%s/%s", treePath, filename)
459
-
blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo)
460
-
if err != nil {
461
-
continue
462
-
}
463
-
464
-
if blobResp == nil {
465
-
continue
466
-
}
467
-
468
-
readmeContent = blobResp.Content
469
-
readmeFileName = path
470
-
break
471
-
}
472
-
473
452
// Convert XRPC response to internal types.RepoTreeResponse
474
453
files := make([]types.NiceTree, len(xrpcResp.Files))
475
454
for i, xrpcFile := range xrpcResp.Files {
···
505
484
if xrpcResp.Dotdot != nil {
506
485
result.DotDot = *xrpcResp.Dotdot
507
486
}
487
+
if xrpcResp.Readme != nil {
488
+
result.ReadmeFileName = xrpcResp.Readme.Filename
489
+
result.Readme = xrpcResp.Readme.Contents
490
+
}
508
491
509
492
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
510
493
// so we can safely redirect to the "parent" (which is the same file).
···
531
514
BreadCrumbs: breadcrumbs,
532
515
TreePath: treePath,
533
516
RepoInfo: f.RepoInfo(user),
534
-
Readme: readmeContent,
535
-
ReadmeFileName: readmeFileName,
536
517
RepoTreeResponse: result,
537
518
})
538
519
}
···
575
556
}
576
557
577
558
// convert artifacts to map for easy UI building
578
-
artifactMap := make(map[plumbing.Hash][]db.Artifact)
559
+
artifactMap := make(map[plumbing.Hash][]models.Artifact)
579
560
for _, a := range artifacts {
580
561
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
581
562
}
582
563
583
-
var danglingArtifacts []db.Artifact
564
+
var danglingArtifacts []models.Artifact
584
565
for _, a := range artifacts {
585
566
found := false
586
567
for _, t := range result.Tags {
···
1004
985
concreteType = "null"
1005
986
}
1006
987
1007
-
format := db.ValueTypeFormatAny
988
+
format := models.ValueTypeFormatAny
1008
989
if valueFormat == "did" {
1009
-
format = db.ValueTypeFormatDid
990
+
format = models.ValueTypeFormatDid
1010
991
}
1011
992
1012
-
valueType := db.ValueType{
1013
-
Type: db.ConcreteType(concreteType),
993
+
valueType := models.ValueType{
994
+
Type: models.ConcreteType(concreteType),
1014
995
Format: format,
1015
996
Enum: variants,
1016
997
}
1017
998
1018
-
label := db.LabelDefinition{
999
+
label := models.LabelDefinition{
1019
1000
Did: user.Did,
1020
1001
Rkey: tid.TID(),
1021
1002
Name: name,
···
1109
1090
return
1110
1091
}
1111
1092
1112
-
err = db.SubscribeLabel(tx, &db.RepoLabel{
1093
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1113
1094
RepoAt: f.RepoAt(),
1114
1095
LabelAt: label.AtUri(),
1115
1096
})
···
1247
1228
return
1248
1229
}
1249
1230
1231
+
if err := r.ParseForm(); err != nil {
1232
+
l.Error("invalid form", "err", err)
1233
+
return
1234
+
}
1235
+
1250
1236
errorId := "default-label-operation"
1251
1237
fail := func(msg string, err error) {
1252
1238
l.Error(msg, "err", err)
1253
1239
rp.pages.Notice(w, errorId, msg)
1254
1240
}
1255
1241
1256
-
labelAt := r.FormValue("label")
1257
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
1242
+
labelAts := r.Form["label"]
1243
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1258
1244
if err != nil {
1259
1245
fail("Failed to subscribe to label.", err)
1260
1246
return
1261
1247
}
1262
1248
1263
1249
newRepo := f.Repo
1264
-
newRepo.Labels = append(newRepo.Labels, labelAt)
1250
+
newRepo.Labels = append(newRepo.Labels, labelAts...)
1251
+
1252
+
// dedup
1253
+
slices.Sort(newRepo.Labels)
1254
+
newRepo.Labels = slices.Compact(newRepo.Labels)
1255
+
1265
1256
repoRecord := newRepo.AsRecord()
1266
1257
1267
1258
client, err := rp.oauth.AuthorizedClient(r)
···
1285
1276
},
1286
1277
})
1287
1278
1288
-
err = db.SubscribeLabel(rp.db, &db.RepoLabel{
1289
-
RepoAt: f.RepoAt(),
1290
-
LabelAt: syntax.ATURI(labelAt),
1291
-
})
1279
+
tx, err := rp.db.Begin()
1292
1280
if err != nil {
1281
+
fail("Failed to subscribe to label.", err)
1282
+
return
1283
+
}
1284
+
defer tx.Rollback()
1285
+
1286
+
for _, l := range labelAts {
1287
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1288
+
RepoAt: f.RepoAt(),
1289
+
LabelAt: syntax.ATURI(l),
1290
+
})
1291
+
if err != nil {
1292
+
fail("Failed to subscribe to label.", err)
1293
+
return
1294
+
}
1295
+
}
1296
+
1297
+
if err := tx.Commit(); err != nil {
1293
1298
fail("Failed to subscribe to label.", err)
1294
1299
return
1295
1300
}
···
1310
1315
return
1311
1316
}
1312
1317
1318
+
if err := r.ParseForm(); err != nil {
1319
+
l.Error("invalid form", "err", err)
1320
+
return
1321
+
}
1322
+
1313
1323
errorId := "default-label-operation"
1314
1324
fail := func(msg string, err error) {
1315
1325
l.Error(msg, "err", err)
1316
1326
rp.pages.Notice(w, errorId, msg)
1317
1327
}
1318
1328
1319
-
labelAt := r.FormValue("label")
1320
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
1329
+
labelAts := r.Form["label"]
1330
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1321
1331
if err != nil {
1322
1332
fail("Failed to unsubscribe to label.", err)
1323
1333
return
···
1327
1337
newRepo := f.Repo
1328
1338
var updated []string
1329
1339
for _, l := range newRepo.Labels {
1330
-
if l != labelAt {
1340
+
if !slices.Contains(labelAts, l) {
1331
1341
updated = append(updated, l)
1332
1342
}
1333
1343
}
···
1358
1368
err = db.UnsubscribeLabel(
1359
1369
rp.db,
1360
1370
db.FilterEq("repo_at", f.RepoAt()),
1361
-
db.FilterEq("label_at", labelAt),
1371
+
db.FilterIn("label_at", labelAts),
1362
1372
)
1363
1373
if err != nil {
1364
1374
fail("Failed to unsubscribe label.", err)
···
1395
1405
return
1396
1406
}
1397
1407
1398
-
defs := make(map[string]*db.LabelDefinition)
1408
+
defs := make(map[string]*models.LabelDefinition)
1399
1409
for _, l := range labelDefs {
1400
1410
defs[l.AtUri().String()] = &l
1401
1411
}
···
1443
1453
return
1444
1454
}
1445
1455
1446
-
defs := make(map[string]*db.LabelDefinition)
1456
+
defs := make(map[string]*models.LabelDefinition)
1447
1457
for _, l := range labelDefs {
1448
1458
defs[l.AtUri().String()] = &l
1449
1459
}
···
1566
1576
return
1567
1577
}
1568
1578
1569
-
err = db.AddCollaborator(tx, db.Collaborator{
1579
+
err = db.AddCollaborator(tx, models.Collaborator{
1570
1580
Did: syntax.DID(currentUser.Did),
1571
1581
Rkey: rkey,
1572
1582
SubjectDid: collaboratorIdent.DID,
···
1894
1904
return
1895
1905
}
1896
1906
1897
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs()))
1907
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1898
1908
if err != nil {
1899
1909
log.Println("failed to fetch labels", err)
1900
1910
rp.pages.Error503(w)
···
1926
1936
subscribedLabels[l] = struct{}{}
1927
1937
}
1928
1938
1939
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1940
+
// if all default labels are subbed, show the "unsubscribe all" button
1941
+
shouldSubscribeAll := false
1942
+
for _, dl := range defaultLabels {
1943
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1944
+
// one of the default labels is not subscribed to
1945
+
shouldSubscribeAll = true
1946
+
break
1947
+
}
1948
+
}
1949
+
1929
1950
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1930
-
LoggedInUser: user,
1931
-
RepoInfo: f.RepoInfo(user),
1932
-
Branches: result.Branches,
1933
-
Labels: labels,
1934
-
DefaultLabels: defaultLabels,
1935
-
SubscribedLabels: subscribedLabels,
1936
-
Tabs: settingsTabs,
1937
-
Tab: "general",
1951
+
LoggedInUser: user,
1952
+
RepoInfo: f.RepoInfo(user),
1953
+
Branches: result.Branches,
1954
+
Labels: labels,
1955
+
DefaultLabels: defaultLabels,
1956
+
SubscribedLabels: subscribedLabels,
1957
+
ShouldSubscribeAll: shouldSubscribeAll,
1958
+
Tabs: settingsTabs,
1959
+
Tab: "general",
1938
1960
})
1939
1961
}
1940
1962
···
2107
2129
}
2108
2130
2109
2131
// choose a name for a fork
2110
-
forkName := f.Name
2132
+
forkName := r.FormValue("repo_name")
2133
+
if forkName == "" {
2134
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2135
+
return
2136
+
}
2137
+
2111
2138
// this check is *only* to see if the forked repo name already exists
2112
2139
// in the user's account.
2113
2140
existingRepo, err := db.GetRepo(
2114
2141
rp.db,
2115
2142
db.FilterEq("did", user.Did),
2116
-
db.FilterEq("name", f.Name),
2143
+
db.FilterEq("name", forkName),
2117
2144
)
2118
2145
if err != nil {
2119
-
if errors.Is(err, sql.ErrNoRows) {
2120
-
// no existing repo with this name found, we can use the name as is
2121
-
} else {
2146
+
if !errors.Is(err, sql.ErrNoRows) {
2122
2147
log.Println("error fetching existing repo from db", "err", err)
2123
2148
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2124
2149
return
2125
2150
}
2126
2151
} else if existingRepo != nil {
2127
-
// repo with this name already exists, append random string
2128
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
2152
+
// repo with this name already exists
2153
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2154
+
return
2129
2155
}
2130
2156
l = l.With("forkName", forkName)
2131
2157
···
2141
2167
2142
2168
// create an atproto record for this fork
2143
2169
rkey := tid.TID()
2144
-
repo := &db.Repo{
2170
+
repo := &models.Repo{
2145
2171
Did: user.Did,
2146
2172
Name: forkName,
2147
2173
Knot: targetKnot,
2148
2174
Rkey: rkey,
2149
2175
Source: sourceAt,
2150
-
Description: existingRepo.Description,
2176
+
Description: f.Repo.Description,
2151
2177
Created: time.Now(),
2178
+
Labels: models.DefaultLabelDefs(),
2152
2179
}
2153
2180
record := repo.AsRecord()
2154
2181
+6
-5
appview/repo/repo_util.go
+6
-5
appview/repo/repo_util.go
···
9
9
"sort"
10
10
"strings"
11
11
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
-
"tangled.sh/tangled.sh/core/types"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages/repoinfo"
15
+
"tangled.org/core/types"
15
16
16
17
"github.com/go-git/go-git/v5/plumbing/object"
17
18
)
···
143
144
d *db.DB,
144
145
repoInfo repoinfo.RepoInfo,
145
146
shas []string,
146
-
) (map[string]db.Pipeline, error) {
147
-
m := make(map[string]db.Pipeline)
147
+
) (map[string]models.Pipeline, error) {
148
+
m := make(map[string]models.Pipeline)
148
149
149
150
if len(shas) == 0 {
150
151
return m, nil
+3
-4
appview/repo/router.go
+3
-4
appview/repo/router.go
···
4
4
"net/http"
5
5
6
6
"github.com/go-chi/chi/v5"
7
-
"tangled.sh/tangled.sh/core/appview/middleware"
7
+
"tangled.org/core/appview/middleware"
8
8
)
9
9
10
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
···
21
21
r.Route("/tags", func(r chi.Router) {
22
22
r.Get("/", rp.RepoTags)
23
23
r.Route("/{tag}", func(r chi.Router) {
24
-
r.Use(middleware.AuthMiddleware(rp.oauth))
25
-
// require auth to download for now
26
24
r.Get("/download/{file}", rp.DownloadArtifact)
27
25
28
26
// require repo:push to upload or delete artifacts
···
30
28
// additionally: only the uploader can truly delete an artifact
31
29
// (record+blob will live on their pds)
32
30
r.Group(func(r chi.Router) {
33
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
31
+
r.Use(middleware.AuthMiddleware(rp.oauth))
32
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
34
33
r.Post("/upload", rp.AttachArtifact)
35
34
r.Delete("/{file}", rp.DeleteArtifact)
36
35
})
+13
-12
appview/reporesolver/resolver.go
+13
-12
appview/reporesolver/resolver.go
···
14
14
"github.com/bluesky-social/indigo/atproto/identity"
15
15
securejoin "github.com/cyphar/filepath-securejoin"
16
16
"github.com/go-chi/chi/v5"
17
-
"tangled.sh/tangled.sh/core/appview/config"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
22
-
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/rbac"
17
+
"tangled.org/core/appview/config"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/repoinfo"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
24
25
)
25
26
26
27
type ResolvedRepo struct {
27
-
db.Repo
28
+
models.Repo
28
29
OwnerId identity.Identity
29
30
CurrentDir string
30
31
Ref string
···
44
45
}
45
46
46
47
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
47
-
repo, ok := r.Context().Value("repo").(*db.Repo)
48
+
repo, ok := r.Context().Value("repo").(*models.Repo)
48
49
if !ok {
49
50
log.Println("malformed middleware: `repo` not exist in context")
50
51
return nil, fmt.Errorf("malformed middleware")
···
162
163
log.Println("failed to get repo source for ", repoAt, err)
163
164
}
164
165
165
-
var sourceRepo *db.Repo
166
+
var sourceRepo *models.Repo
166
167
if source != "" {
167
168
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
168
169
if err != nil {
···
191
192
Knot: knot,
192
193
Spindle: f.Spindle,
193
194
Roles: f.RolesInRepo(user),
194
-
Stats: db.RepoStats{
195
+
Stats: models.RepoStats{
195
196
StarCount: starCount,
196
197
IssueCount: issueCount,
197
198
PullCount: pullCount,
···
211
212
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
212
213
if u != nil {
213
214
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
214
-
return repoinfo.RolesInRepo{r}
215
+
return repoinfo.RolesInRepo{Roles: r}
215
216
} else {
216
217
return repoinfo.RolesInRepo{}
217
218
}
+4
-4
appview/serververify/verify.go
+4
-4
appview/serververify/verify.go
···
6
6
"fmt"
7
7
8
8
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
12
-
"tangled.sh/tangled.sh/core/rbac"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/db"
11
+
"tangled.org/core/appview/xrpcclient"
12
+
"tangled.org/core/rbac"
13
13
)
14
14
15
15
var (
+62
-10
appview/settings/settings.go
+62
-10
appview/settings/settings.go
···
11
11
"time"
12
12
13
13
"github.com/go-chi/chi/v5"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/email"
18
-
"tangled.sh/tangled.sh/core/appview/middleware"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/tid"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/config"
16
+
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/email"
18
+
"tangled.org/core/appview/middleware"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/tid"
22
23
23
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
25
lexutil "github.com/bluesky-social/indigo/lex/util"
···
40
41
{"Name": "profile", "Icon": "user"},
41
42
{"Name": "keys", "Icon": "key"},
42
43
{"Name": "emails", "Icon": "mail"},
44
+
{"Name": "notifications", "Icon": "bell"},
43
45
}
44
46
)
45
47
···
67
69
r.Post("/primary", s.emailsPrimary)
68
70
})
69
71
72
+
r.Route("/notifications", func(r chi.Router) {
73
+
r.Get("/", s.notificationsSettings)
74
+
r.Put("/", s.updateNotificationPreferences)
75
+
})
76
+
70
77
return r
71
78
}
72
79
···
80
87
})
81
88
}
82
89
90
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
91
+
user := s.OAuth.GetUser(r)
92
+
did := s.OAuth.GetDid(r)
93
+
94
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
if err != nil {
96
+
log.Printf("failed to get notification preferences: %s", err)
97
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
98
+
return
99
+
}
100
+
101
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
102
+
LoggedInUser: user,
103
+
Preferences: prefs,
104
+
Tabs: settingsTabs,
105
+
Tab: "notifications",
106
+
})
107
+
}
108
+
109
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
110
+
did := s.OAuth.GetDid(r)
111
+
112
+
prefs := &models.NotificationPreferences{
113
+
UserDid: did,
114
+
RepoStarred: r.FormValue("repo_starred") == "on",
115
+
IssueCreated: r.FormValue("issue_created") == "on",
116
+
IssueCommented: r.FormValue("issue_commented") == "on",
117
+
IssueClosed: r.FormValue("issue_closed") == "on",
118
+
PullCreated: r.FormValue("pull_created") == "on",
119
+
PullCommented: r.FormValue("pull_commented") == "on",
120
+
PullMerged: r.FormValue("pull_merged") == "on",
121
+
Followed: r.FormValue("followed") == "on",
122
+
EmailNotifications: r.FormValue("email_notifications") == "on",
123
+
}
124
+
125
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
126
+
if err != nil {
127
+
log.Printf("failed to update notification preferences: %s", err)
128
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
129
+
return
130
+
}
131
+
132
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
133
+
}
134
+
83
135
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
84
136
user := s.OAuth.GetUser(r)
85
137
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
···
185
237
}
186
238
defer tx.Rollback()
187
239
188
-
if err := db.AddEmail(tx, db.Email{
240
+
if err := db.AddEmail(tx, models.Email{
189
241
Did: did,
190
242
Address: emAddr,
191
243
Verified: false,
···
246
298
if s.Config.Core.Dev {
247
299
appUrl = "http://" + s.Config.Core.ListenAddr
248
300
} else {
249
-
appUrl = "https://tangled.sh"
301
+
appUrl = s.Config.Core.AppviewHost
250
302
}
251
303
252
304
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+76
-11
appview/signup/signup.go
+76
-11
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"encoding/json"
6
+
"errors"
5
7
"fmt"
6
8
"log/slog"
7
9
"net/http"
10
+
"net/url"
8
11
"os"
9
12
"strings"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/posthog/posthog-go"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/dns"
16
-
"tangled.sh/tangled.sh/core/appview/email"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/dns"
19
+
"tangled.org/core/appview/email"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/state/userutil"
23
+
"tangled.org/core/appview/xrpcclient"
24
+
"tangled.org/core/idresolver"
21
25
)
22
26
23
27
type Signup struct {
···
115
119
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
120
switch r.Method {
117
121
case http.MethodGet:
118
-
s.pages.Signup(w)
122
+
s.pages.Signup(w, pages.SignupParams{
123
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
124
+
})
119
125
case http.MethodPost:
120
126
if s.cf == nil {
121
127
http.Error(w, "signup is disabled", http.StatusFailedDependency)
128
+
return
122
129
}
123
130
emailId := r.FormValue("email")
131
+
cfToken := r.FormValue("cf-turnstile-response")
124
132
125
133
noticeId := "signup-msg"
134
+
135
+
if err := s.validateCaptcha(cfToken, r); err != nil {
136
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
+
return
139
+
}
140
+
126
141
if !email.IsValidEmail(emailId) {
127
142
s.pages.Notice(w, noticeId, "Invalid email address.")
128
143
return
···
163
178
s.pages.Notice(w, noticeId, "Failed to send email.")
164
179
return
165
180
}
166
-
err = db.AddInflightSignup(s.db, db.InflightSignup{
181
+
err = db.AddInflightSignup(s.db, models.InflightSignup{
167
182
Email: emailId,
168
183
InviteCode: code,
169
184
})
···
229
244
return
230
245
}
231
246
232
-
err = db.AddEmail(s.db, db.Email{
247
+
err = db.AddEmail(s.db, models.Email{
233
248
Did: did,
234
249
Address: email,
235
250
Verified: true,
···
254
269
return
255
270
}
256
271
}
272
+
273
+
type turnstileResponse struct {
274
+
Success bool `json:"success"`
275
+
ErrorCodes []string `json:"error-codes,omitempty"`
276
+
ChallengeTs string `json:"challenge_ts,omitempty"`
277
+
Hostname string `json:"hostname,omitempty"`
278
+
}
279
+
280
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
281
+
if cfToken == "" {
282
+
return errors.New("captcha token is empty")
283
+
}
284
+
285
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
286
+
return errors.New("turnstile secret key not configured")
287
+
}
288
+
289
+
data := url.Values{}
290
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
291
+
data.Set("response", cfToken)
292
+
293
+
// include the client IP if we have it
294
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
295
+
data.Set("remoteip", remoteIP)
296
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
297
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
298
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
299
+
}
300
+
} else {
301
+
data.Set("remoteip", r.RemoteAddr)
302
+
}
303
+
304
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
305
+
if err != nil {
306
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
307
+
}
308
+
defer resp.Body.Close()
309
+
310
+
var turnstileResp turnstileResponse
311
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
312
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
313
+
}
314
+
315
+
if !turnstileResp.Success {
316
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
317
+
return errors.New("turnstile validation failed")
318
+
}
319
+
320
+
return nil
321
+
}
+15
-14
appview/spindles/spindles.go
+15
-14
appview/spindles/spindles.go
···
9
9
"time"
10
10
11
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
-
"tangled.sh/tangled.sh/core/tid"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/idresolver"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/tid"
23
24
24
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
26
"github.com/bluesky-social/indigo/atproto/syntax"
···
115
116
}
116
117
117
118
// organize repos by did
118
-
repoMap := make(map[string][]db.Repo)
119
+
repoMap := make(map[string][]models.Repo)
119
120
for _, r := range repos {
120
121
repoMap[r.Did] = append(repoMap[r.Did], r)
121
122
}
···
163
164
s.Enforcer.E.LoadPolicy()
164
165
}()
165
166
166
-
err = db.AddSpindle(tx, db.Spindle{
167
+
err = db.AddSpindle(tx, models.Spindle{
167
168
Owner: syntax.DID(user.Did),
168
169
Instance: instance,
169
170
})
···
524
525
rkey := tid.TID()
525
526
526
527
// add member to db
527
-
if err = db.AddSpindleMember(tx, db.SpindleMember{
528
+
if err = db.AddSpindleMember(tx, models.SpindleMember{
528
529
Did: syntax.DID(user.Did),
529
530
Rkey: rkey,
530
531
Instance: instance,
+8
-7
appview/state/follow.go
+8
-7
appview/state/follow.go
···
7
7
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pages"
13
-
"tangled.sh/tangled.sh/core/tid"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/tid"
14
15
)
15
16
16
17
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
59
60
60
61
log.Println("created atproto record: ", resp.Uri)
61
62
62
-
follow := &db.Follow{
63
+
follow := &models.Follow{
63
64
UserDid: currentUser.Did,
64
65
SubjectDid: subjectIdent.DID.String(),
65
66
Rkey: rkey,
···
75
76
76
77
s.pages.FollowFragment(w, pages.FollowFragmentParams{
77
78
UserDid: subjectIdent.DID.String(),
78
-
FollowStatus: db.IsFollowing,
79
+
FollowStatus: models.IsFollowing,
79
80
})
80
81
81
82
return
···
106
107
107
108
s.pages.FollowFragment(w, pages.FollowFragmentParams{
108
109
UserDid: subjectIdent.DID.String(),
109
-
FollowStatus: db.IsNotFollowing,
110
+
FollowStatus: models.IsNotFollowing,
110
111
})
111
112
112
113
s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
+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
+4
-4
appview/state/git_http.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/identity"
10
10
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/appview/db"
11
+
"tangled.org/core/appview/models"
12
12
)
13
13
14
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
-
repo := r.Context().Value("repo").(*db.Repo)
16
+
repo := r.Context().Value("repo").(*models.Repo)
17
17
18
18
scheme := "https"
19
19
if s.config.Core.Dev {
···
31
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
32
return
33
33
}
34
-
repo := r.Context().Value("repo").(*db.Repo)
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
35
36
36
scheme := "https"
37
37
if s.config.Core.Dev {
···
48
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
49
return
50
50
}
51
-
repo := r.Context().Value("repo").(*db.Repo)
51
+
repo := r.Context().Value("repo").(*models.Repo)
52
52
53
53
scheme := "https"
54
54
if s.config.Core.Dev {
+29
-15
appview/state/knotstream.go
+29
-15
appview/state/knotstream.go
···
8
8
"slices"
9
9
"time"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/cache"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
16
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/workflow"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/cache"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/models"
16
+
ec "tangled.org/core/eventconsumer"
17
+
"tangled.org/core/eventconsumer/cursor"
18
+
"tangled.org/core/log"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/workflow"
20
21
21
22
"github.com/bluesky-social/indigo/atproto/syntax"
22
23
"github.com/go-git/go-git/v5/plumbing"
···
124
125
}
125
126
}
126
127
127
-
punch := db.Punch{
128
+
punch := models.Punch{
128
129
Did: record.CommitterDid,
129
130
Date: time.Now(),
130
131
Count: count,
···
156
157
return fmt.Errorf("%s is not a valid reference name", ref)
157
158
}
158
159
159
-
var langs []db.RepoLanguage
160
+
var langs []models.RepoLanguage
160
161
for _, l := range record.Meta.LangBreakdown.Inputs {
161
162
if l == nil {
162
163
continue
163
164
}
164
165
165
-
langs = append(langs, db.RepoLanguage{
166
+
langs = append(langs, models.RepoLanguage{
166
167
RepoAt: repo.RepoAt(),
167
168
Ref: ref.Short(),
168
169
IsDefaultRef: record.Meta.IsDefaultRef,
···
171
172
})
172
173
}
173
174
174
-
return db.InsertRepoLanguages(d, langs)
175
+
tx, err := d.Begin()
176
+
if err != nil {
177
+
return err
178
+
}
179
+
defer tx.Rollback()
180
+
181
+
// update appview's cache
182
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
183
+
if err != nil {
184
+
fmt.Printf("failed; %s\n", err)
185
+
// non-fatal
186
+
}
187
+
188
+
return tx.Commit()
175
189
}
176
190
177
191
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
207
221
}
208
222
209
223
// trigger info
210
-
var trigger db.Trigger
224
+
var trigger models.Trigger
211
225
var sha string
212
226
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
213
227
switch trigger.Kind {
···
234
248
return fmt.Errorf("failed to add trigger entry: %w", err)
235
249
}
236
250
237
-
pipeline := db.Pipeline{
251
+
pipeline := models.Pipeline{
238
252
Rkey: msg.Rkey,
239
253
Knot: source.Key(),
240
254
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+30
-37
appview/state/profile.go
+30
-37
appview/state/profile.go
···
15
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
16
"github.com/go-chi/chi/v5"
17
17
"github.com/gorilla/feeds"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
21
22
)
22
23
23
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
76
77
}
77
78
78
79
loggedInUser := s.oauth.GetUser(r)
79
-
followStatus := db.IsNotFollowing
80
+
followStatus := models.IsNotFollowing
80
81
if loggedInUser != nil {
81
82
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
82
83
}
···
130
131
}
131
132
132
133
// filter out ones that are pinned
133
-
pinnedRepos := []db.Repo{}
134
+
pinnedRepos := []models.Repo{}
134
135
for i, r := range repos {
135
136
// if this is a pinned repo, add it
136
137
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
148
149
l.Error("failed to fetch collaborating repos", "err", err)
149
150
}
150
151
151
-
pinnedCollaboratingRepos := []db.Repo{}
152
+
pinnedCollaboratingRepos := []models.Repo{}
152
153
for _, r := range collaboratingRepos {
153
154
// if this is a pinned repo, add it
154
155
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
216
217
s.pages.Error500(w)
217
218
return
218
219
}
219
-
var repoAts []string
220
+
var repos []models.Repo
220
221
for _, s := range stars {
221
-
repoAts = append(repoAts, string(s.RepoAt))
222
-
}
223
-
224
-
repos, err := db.GetRepos(
225
-
s.db,
226
-
0,
227
-
db.FilterIn("at_uri", repoAts),
228
-
)
229
-
if err != nil {
230
-
l.Error("failed to get repos", "err", err)
231
-
s.pages.Error500(w)
232
-
return
222
+
if s.Repo != nil {
223
+
repos = append(repos, *s.Repo)
224
+
}
233
225
}
234
226
235
227
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
271
263
272
264
func (s *State) followPage(
273
265
r *http.Request,
274
-
fetchFollows func(db.Execer, string) ([]db.Follow, error),
275
-
extractDid func(db.Follow) string,
266
+
fetchFollows func(db.Execer, string) ([]models.Follow, error),
267
+
extractDid func(models.Follow) string,
276
268
) (*FollowsPageParams, error) {
277
269
l := s.logger.With("handler", "reposPage")
278
270
···
329
321
followCards := make([]pages.FollowCard, len(follows))
330
322
for i, did := range followDids {
331
323
followStats := followStatsMap[did]
332
-
followStatus := db.IsNotFollowing
324
+
followStatus := models.IsNotFollowing
333
325
if _, exists := loggedInUserFollowing[did]; exists {
334
-
followStatus = db.IsFollowing
326
+
followStatus = models.IsFollowing
335
327
} else if loggedInUser != nil && loggedInUser.Did == did {
336
-
followStatus = db.IsSelf
328
+
followStatus = models.IsSelf
337
329
}
338
330
339
-
var profile *db.Profile
331
+
var profile *models.Profile
340
332
if p, exists := profiles[did]; exists {
341
333
profile = p
342
334
} else {
343
-
profile = &db.Profile{}
335
+
profile = &models.Profile{}
344
336
profile.Did = did
345
337
}
346
338
followCards[i] = pages.FollowCard{
339
+
LoggedInUser: loggedInUser,
347
340
UserDid: did,
348
341
FollowStatus: followStatus,
349
342
FollowersCount: followStats.Followers,
···
358
351
}
359
352
360
353
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
361
-
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
354
+
followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid })
362
355
if err != nil {
363
356
s.pages.Notice(w, "all-followers", "Failed to load followers")
364
357
return
···
372
365
}
373
366
374
367
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
375
-
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
368
+
followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid })
376
369
if err != nil {
377
370
s.pages.Notice(w, "all-following", "Failed to load following")
378
371
return
···
453
446
return &feed, nil
454
447
}
455
448
456
-
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
449
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error {
457
450
for _, pull := range pulls {
458
451
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
459
452
if err != nil {
···
466
459
return nil
467
460
}
468
461
469
-
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
462
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error {
470
463
for _, issue := range issues {
471
464
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
472
465
if err != nil {
···
478
471
return nil
479
472
}
480
473
481
-
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
474
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error {
482
475
for _, repo := range repos {
483
476
item, err := s.createRepoItem(ctx, repo, author)
484
477
if err != nil {
···
489
482
return nil
490
483
}
491
484
492
-
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
485
+
func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
493
486
return &feeds.Item{
494
487
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
495
488
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
···
498
491
}
499
492
}
500
493
501
-
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
494
+
func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
502
495
return &feeds.Item{
503
496
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
504
497
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
···
507
500
}
508
501
}
509
502
510
-
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
503
+
func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
511
504
var title string
512
505
if repo.Source != nil {
513
506
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
···
558
551
stat1 := r.FormValue("stat1")
559
552
560
553
if stat0 != "" {
561
-
profile.Stats[0].Kind = db.VanityStatKind(stat0)
554
+
profile.Stats[0].Kind = models.VanityStatKind(stat0)
562
555
}
563
556
564
557
if stat1 != "" {
565
-
profile.Stats[1].Kind = db.VanityStatKind(stat1)
558
+
profile.Stats[1].Kind = models.VanityStatKind(stat1)
566
559
}
567
560
568
561
if err := db.ValidateProfile(s.db, profile); err != nil {
···
613
606
s.updateProfile(profile, w, r)
614
607
}
615
608
616
-
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
609
+
func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
617
610
user := s.oauth.GetUser(r)
618
611
tx, err := s.db.BeginTx(r.Context(), nil)
619
612
if err != nil {
+6
-5
appview/state/reaction.go
+6
-5
appview/state/reaction.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
11
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/pages"
15
-
"tangled.sh/tangled.sh/core/tid"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/tid"
16
17
)
17
18
18
19
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
30
31
return
31
32
}
32
33
33
-
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
34
+
reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind"))
34
35
if !ok {
35
36
log.Println("invalid reaction kind")
36
37
return
+46
-16
appview/state/router.go
+46
-16
appview/state/router.go
···
6
6
7
7
"github.com/go-chi/chi/v5"
8
8
"github.com/gorilla/sessions"
9
-
"tangled.sh/tangled.sh/core/appview/issues"
10
-
"tangled.sh/tangled.sh/core/appview/knots"
11
-
"tangled.sh/tangled.sh/core/appview/labels"
12
-
"tangled.sh/tangled.sh/core/appview/middleware"
13
-
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
14
-
"tangled.sh/tangled.sh/core/appview/pipelines"
15
-
"tangled.sh/tangled.sh/core/appview/pulls"
16
-
"tangled.sh/tangled.sh/core/appview/repo"
17
-
"tangled.sh/tangled.sh/core/appview/settings"
18
-
"tangled.sh/tangled.sh/core/appview/signup"
19
-
"tangled.sh/tangled.sh/core/appview/spindles"
20
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
21
-
avstrings "tangled.sh/tangled.sh/core/appview/strings"
22
-
"tangled.sh/tangled.sh/core/log"
9
+
"tangled.org/core/appview/issues"
10
+
"tangled.org/core/appview/knots"
11
+
"tangled.org/core/appview/labels"
12
+
"tangled.org/core/appview/middleware"
13
+
"tangled.org/core/appview/notifications"
14
+
oauthhandler "tangled.org/core/appview/oauth/handler"
15
+
"tangled.org/core/appview/pipelines"
16
+
"tangled.org/core/appview/pulls"
17
+
"tangled.org/core/appview/repo"
18
+
"tangled.org/core/appview/settings"
19
+
"tangled.org/core/appview/signup"
20
+
"tangled.org/core/appview/spindles"
21
+
"tangled.org/core/appview/state/userutil"
22
+
avstrings "tangled.org/core/appview/strings"
23
+
"tangled.org/core/log"
23
24
)
24
25
25
26
func (s *State) Router() http.Handler {
···
33
34
s.pages,
34
35
)
35
36
37
+
router.Use(middleware.TryRefreshSession())
36
38
router.Get("/favicon.svg", s.Favicon)
37
39
router.Get("/favicon.ico", s.Favicon)
40
+
router.Get("/pwa-manifest.json", s.PWAManifest)
38
41
39
42
userRouter := s.UserRouter(&middleware)
40
43
standardRouter := s.StandardRouter(&middleware)
···
115
118
116
119
r.Get("/", s.HomeOrTimeline)
117
120
r.Get("/timeline", s.Timeline)
118
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
121
+
r.Get("/upgradeBanner", s.UpgradeBanner)
122
+
123
+
// special-case handler for serving tangled.org/core
124
+
r.Get("/core", s.Core())
119
125
120
126
r.Route("/repo", func(r chi.Router) {
121
127
r.Route("/new", func(r chi.Router) {
···
126
132
// r.Post("/import", s.ImportRepo)
127
133
})
128
134
135
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
136
+
129
137
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
130
138
r.Post("/", s.Follow)
131
139
r.Delete("/", s.Follow)
···
153
161
r.Mount("/strings", s.StringsRouter(mw))
154
162
r.Mount("/knots", s.KnotsRouter())
155
163
r.Mount("/spindles", s.SpindlesRouter())
164
+
r.Mount("/notifications", s.NotificationsRouter(mw))
165
+
156
166
r.Mount("/signup", s.SignupRouter())
157
167
r.Mount("/", s.OAuthRouter())
158
168
159
169
r.Get("/keys/{user}", s.Keys)
160
170
r.Get("/terms", s.TermsOfService)
161
171
r.Get("/privacy", s.PrivacyPolicy)
172
+
r.Get("/brand", s.Brand)
162
173
163
174
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
164
175
s.pages.Error404(w)
165
176
})
166
177
return r
178
+
}
179
+
180
+
// Core serves tangled.org/core go-import meta tags, and redirects
181
+
// to the core repository if accessed normally.
182
+
func (s *State) Core() http.HandlerFunc {
183
+
return func(w http.ResponseWriter, r *http.Request) {
184
+
if r.URL.Query().Get("go-get") == "1" {
185
+
w.Header().Set("Content-Type", "text/html")
186
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
187
+
return
188
+
}
189
+
190
+
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
191
+
}
167
192
}
168
193
169
194
func (s *State) OAuthRouter() http.Handler {
···
253
278
}
254
279
255
280
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
256
-
ls := labels.New(s.oauth, s.pages, s.db, s.validator)
281
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
257
282
return ls.Router(mw)
283
+
}
284
+
285
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
286
+
notifs := notifications.New(s.db, s.oauth, s.pages)
287
+
return notifs.Router(mw)
258
288
}
259
289
260
290
func (s *State) SignupRouter() http.Handler {
+11
-10
appview/state/spindlestream.go
+11
-10
appview/state/spindlestream.go
···
9
9
"time"
10
10
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/cache"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
17
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
18
-
"tangled.sh/tangled.sh/core/log"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/cache"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/models"
17
+
ec "tangled.org/core/eventconsumer"
18
+
"tangled.org/core/eventconsumer/cursor"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindle "tangled.org/core/spindle/models"
21
22
)
22
23
23
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
89
90
created = t
90
91
}
91
92
92
-
status := db.PipelineStatus{
93
+
status := models.PipelineStatus{
93
94
Spindle: source.Key(),
94
95
Rkey: msg.Rkey,
95
96
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+8
-7
appview/state/star.go
+8
-7
appview/state/star.go
···
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages"
14
-
"tangled.sh/tangled.sh/core/tid"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
"tangled.org/core/tid"
15
16
)
16
17
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
55
56
}
56
57
log.Println("created atproto record: ", resp.Uri)
57
58
58
-
star := &db.Star{
59
+
star := &models.Star{
59
60
StarredByDid: currentUser.Did,
60
61
RepoAt: subjectUri,
61
62
Rkey: rkey,
···
77
78
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
79
IsStarred: true,
79
80
RepoAt: subjectUri,
80
-
Stats: db.RepoStats{
81
+
Stats: models.RepoStats{
81
82
StarCount: starCount,
82
83
},
83
84
})
···
119
120
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
120
121
IsStarred: false,
121
122
RepoAt: subjectUri,
122
-
Stats: db.RepoStats{
123
+
Stats: models.RepoStats{
123
124
StarCount: starCount,
124
125
},
125
126
})
+105
-26
appview/state/state.go
+105
-26
appview/state/state.go
···
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
18
"github.com/go-chi/chi/v5"
19
19
"github.com/posthog/posthog-go"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
-
"tangled.sh/tangled.sh/core/appview/cache"
23
-
"tangled.sh/tangled.sh/core/appview/cache/session"
24
-
"tangled.sh/tangled.sh/core/appview/config"
25
-
"tangled.sh/tangled.sh/core/appview/db"
26
-
"tangled.sh/tangled.sh/core/appview/notify"
27
-
"tangled.sh/tangled.sh/core/appview/oauth"
28
-
"tangled.sh/tangled.sh/core/appview/pages"
29
-
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
-
"tangled.sh/tangled.sh/core/appview/validator"
32
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
33
-
"tangled.sh/tangled.sh/core/eventconsumer"
34
-
"tangled.sh/tangled.sh/core/idresolver"
35
-
"tangled.sh/tangled.sh/core/jetstream"
36
-
tlog "tangled.sh/tangled.sh/core/log"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview"
22
+
"tangled.org/core/appview/cache"
23
+
"tangled.org/core/appview/cache/session"
24
+
"tangled.org/core/appview/config"
25
+
"tangled.org/core/appview/db"
26
+
"tangled.org/core/appview/models"
27
+
"tangled.org/core/appview/notify"
28
+
dbnotify "tangled.org/core/appview/notify/db"
29
+
phnotify "tangled.org/core/appview/notify/posthog"
30
+
"tangled.org/core/appview/oauth"
31
+
"tangled.org/core/appview/pages"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/jetstream"
38
+
tlog "tangled.org/core/log"
39
+
"tangled.org/core/rbac"
40
+
"tangled.org/core/tid"
40
41
)
41
42
42
43
type State struct {
···
78
79
cache := cache.New(config.Redis.Addr)
79
80
sess := session.New(cache)
80
81
oauth := oauth.NewOAuth(config, sess)
81
-
validator := validator.New(d, res)
82
+
validator := validator.New(d, res, enforcer)
82
83
83
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
84
85
if err != nil {
···
87
88
88
89
repoResolver := reporesolver.New(config, enforcer, res, d)
89
90
90
-
wrapper := db.DbWrapper{d}
91
+
wrapper := db.DbWrapper{Execer: d}
91
92
jc, err := jetstream.NewJetstreamClient(
92
93
config.Jetstream.Endpoint,
93
94
"appview",
···
103
104
tangled.RepoIssueNSID,
104
105
tangled.RepoIssueCommentNSID,
105
106
tangled.LabelDefinitionNSID,
107
+
tangled.LabelOpNSID,
106
108
},
107
109
nil,
108
110
slog.Default(),
···
115
117
)
116
118
if err != nil {
117
119
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
120
+
}
121
+
122
+
if err := BackfillDefaultDefs(d, res); err != nil {
123
+
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
118
124
}
119
125
120
126
ingester := appview.Ingester{
···
143
149
spindlestream.Start(ctx)
144
150
145
151
var notifiers []notify.Notifier
152
+
153
+
// Always add the database notifier
154
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
155
+
156
+
// Add other notifiers in production only
146
157
if !config.Core.Dev {
147
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
158
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
148
159
}
149
160
notifier := notify.NewMergedNotifier(notifiers...)
150
161
···
187
198
s.pages.Favicon(w)
188
199
}
189
200
201
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
202
+
const manifestJson = `{
203
+
"name": "tangled",
204
+
"description": "tightly-knit social coding.",
205
+
"icons": [
206
+
{
207
+
"src": "/favicon.svg",
208
+
"sizes": "144x144"
209
+
}
210
+
],
211
+
"start_url": "/",
212
+
"id": "org.tangled",
213
+
214
+
"display": "standalone",
215
+
"background_color": "#111827",
216
+
"theme_color": "#111827"
217
+
}`
218
+
219
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
220
+
w.Header().Set("Content-Type", "application/json")
221
+
w.Write([]byte(manifestJson))
222
+
}
223
+
190
224
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
191
225
user := s.oauth.GetUser(r)
192
226
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
201
235
})
202
236
}
203
237
238
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
239
+
user := s.oauth.GetUser(r)
240
+
s.pages.Brand(w, pages.BrandParams{
241
+
LoggedInUser: user,
242
+
})
243
+
}
244
+
204
245
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
205
246
if s.oauth.GetUser(r) != nil {
206
247
s.Timeline(w, r)
···
229
270
return
230
271
}
231
272
232
-
s.pages.Timeline(w, pages.TimelineParams{
273
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
274
+
if err != nil {
275
+
// non-fatal
276
+
}
277
+
278
+
fmt.Println(s.pages.Timeline(w, pages.TimelineParams{
233
279
LoggedInUser: user,
234
280
Timeline: timeline,
235
281
Repos: repos,
236
-
})
282
+
GfiLabel: gfiLabel,
283
+
}))
237
284
}
238
285
239
286
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
240
287
user := s.oauth.GetUser(r)
288
+
if user == nil {
289
+
return
290
+
}
291
+
241
292
l := s.logger.With("handler", "UpgradeBanner")
242
293
l = l.With("did", user.Did)
243
294
l = l.With("handle", user.Handle)
···
433
484
434
485
// create atproto record for this repo
435
486
rkey := tid.TID()
436
-
repo := &db.Repo{
487
+
repo := &models.Repo{
437
488
Did: user.Did,
438
489
Name: repoName,
439
490
Knot: domain,
440
491
Rkey: rkey,
441
492
Description: description,
442
493
Created: time.Now(),
494
+
Labels: models.DefaultLabelDefs(),
443
495
}
444
496
record := repo.AsRecord()
445
497
···
580
632
})
581
633
return err
582
634
}
635
+
636
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
637
+
defaults := models.DefaultLabelDefs()
638
+
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
639
+
if err != nil {
640
+
return err
641
+
}
642
+
// already present
643
+
if len(defaultLabels) == len(defaults) {
644
+
return nil
645
+
}
646
+
647
+
labelDefs, err := models.FetchDefaultDefs(r)
648
+
if err != nil {
649
+
return err
650
+
}
651
+
652
+
// Insert each label definition to the database
653
+
for _, labelDef := range labelDefs {
654
+
_, err = db.AddLabelDefinition(e, &labelDef)
655
+
if err != nil {
656
+
return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
657
+
}
658
+
}
659
+
660
+
return nil
661
+
}
+12
-11
appview/strings/strings.go
+12
-11
appview/strings/strings.go
···
8
8
"strconv"
9
9
"time"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/middleware"
14
-
"tangled.sh/tangled.sh/core/appview/notify"
15
-
"tangled.sh/tangled.sh/core/appview/oauth"
16
-
"tangled.sh/tangled.sh/core/appview/pages"
17
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/tid"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/middleware"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/notify"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pages/markup"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/tid"
20
21
21
22
"github.com/bluesky-social/indigo/api/atproto"
22
23
"github.com/bluesky-social/indigo/atproto/identity"
···
235
236
description := r.FormValue("description")
236
237
237
238
// construct new string from form values
238
-
entry := db.String{
239
+
entry := models.String{
239
240
Did: first.Did,
240
241
Rkey: first.Rkey,
241
242
Filename: filename,
···
318
319
319
320
description := r.FormValue("description")
320
321
321
-
string := db.String{
322
+
string := models.String{
322
323
Did: syntax.DID(user.Did),
323
324
Rkey: tid.TID(),
324
325
Filename: filename,
+4
-3
appview/validator/issue.go
+4
-3
appview/validator/issue.go
···
4
4
"fmt"
5
5
"strings"
6
6
7
-
"tangled.sh/tangled.sh/core/appview/db"
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
8
9
)
9
10
10
-
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
11
+
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
11
12
// if comments have parents, only ingest ones that are 1 level deep
12
13
if comment.ReplyTo != nil {
13
14
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
32
33
return nil
33
34
}
34
35
35
-
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
+
func (v *Validator) ValidateIssue(issue *models.Issue) error {
36
37
if issue.Title == "" {
37
38
return fmt.Errorf("issue title is empty")
38
39
}
+27
-13
appview/validator/label.go
+27
-13
appview/validator/label.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
"golang.org/x/exp/slices"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/models"
13
13
)
14
14
15
15
var (
···
21
21
validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22
22
)
23
23
24
-
func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error {
24
+
func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error {
25
25
if label.Name == "" {
26
26
return fmt.Errorf("label name is empty")
27
27
}
···
95
95
return nil
96
96
}
97
97
98
-
func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
98
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99
99
if labelDef == nil {
100
100
return fmt.Errorf("label definition is required")
101
101
}
102
+
if repo == nil {
103
+
return fmt.Errorf("repo is required")
104
+
}
102
105
if labelOp == nil {
103
106
return fmt.Errorf("label operation is required")
104
107
}
105
108
109
+
// validate permissions: only collaborators can apply labels currently
110
+
//
111
+
// TODO: introduce a repo:triage permission
112
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113
+
if err != nil {
114
+
return fmt.Errorf("failed to enforce permissions: %w", err)
115
+
}
116
+
if !ok {
117
+
return fmt.Errorf("unauhtorized label operation")
118
+
}
119
+
106
120
expectedKey := labelDef.AtUri().String()
107
121
if labelOp.OperandKey != expectedKey {
108
122
return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
109
123
}
110
124
111
-
if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel {
125
+
if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel {
112
126
return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
113
127
}
114
128
···
131
145
return nil
132
146
}
133
147
134
-
func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
148
+
func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
135
149
valueType := labelDef.ValueType
136
150
137
151
// this is permitted, it "unsets" a label
138
152
if labelOp.OperandValue == "" {
139
-
labelOp.Operation = db.LabelOperationDel
153
+
labelOp.Operation = models.LabelOperationDel
140
154
return nil
141
155
}
142
156
143
157
switch valueType.Type {
144
-
case db.ConcreteTypeNull:
158
+
case models.ConcreteTypeNull:
145
159
// For null type, value should be empty
146
160
if labelOp.OperandValue != "null" {
147
161
return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
148
162
}
149
163
150
-
case db.ConcreteTypeString:
164
+
case models.ConcreteTypeString:
151
165
// For string type, validate enum constraints if present
152
166
if valueType.IsEnum() {
153
167
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
···
156
170
}
157
171
158
172
switch valueType.Format {
159
-
case db.ValueTypeFormatDid:
173
+
case models.ValueTypeFormatDid:
160
174
id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
161
175
if err != nil {
162
176
return fmt.Errorf("failed to resolve did/handle: %w", err)
···
164
178
165
179
labelOp.OperandValue = id.DID.String()
166
180
167
-
case db.ValueTypeFormatAny, "":
181
+
case models.ValueTypeFormatAny, "":
168
182
default:
169
183
return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
170
184
}
171
185
172
-
case db.ConcreteTypeInt:
186
+
case models.ConcreteTypeInt:
173
187
if labelOp.OperandValue == "" {
174
188
return fmt.Errorf("integer type requires non-empty value")
175
189
}
···
183
197
}
184
198
}
185
199
186
-
case db.ConcreteTypeBool:
200
+
case models.ConcreteTypeBool:
187
201
if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
188
202
return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
189
203
}
+27
appview/validator/string.go
+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
+7
-4
appview/validator/validator.go
···
1
1
package validator
2
2
3
3
import (
4
-
"tangled.sh/tangled.sh/core/appview/db"
5
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
-
"tangled.sh/tangled.sh/core/idresolver"
4
+
"tangled.org/core/appview/db"
5
+
"tangled.org/core/appview/pages/markup"
6
+
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
7
8
)
8
9
9
10
type Validator struct {
10
11
db *db.DB
11
12
sanitizer markup.Sanitizer
12
13
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
13
15
}
14
16
15
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
16
18
return &Validator{
17
19
db: db,
18
20
sanitizer: markup.NewSanitizer(),
19
21
resolver: res,
22
+
enforcer: enforcer,
20
23
}
21
24
}
+2
-2
cmd/appview/main.go
+2
-2
cmd/appview/main.go
+1
-1
cmd/combinediff/main.go
+1
-1
cmd/combinediff/main.go
+1
-1
cmd/gen.go
+1
-1
cmd/gen.go
+1
-1
cmd/interdiff/main.go
+1
-1
cmd/interdiff/main.go
+5
-5
cmd/knot/main.go
+5
-5
cmd/knot/main.go
···
5
5
"os"
6
6
7
7
"github.com/urfave/cli/v3"
8
-
"tangled.sh/tangled.sh/core/guard"
9
-
"tangled.sh/tangled.sh/core/hook"
10
-
"tangled.sh/tangled.sh/core/keyfetch"
11
-
"tangled.sh/tangled.sh/core/knotserver"
12
-
"tangled.sh/tangled.sh/core/log"
8
+
"tangled.org/core/guard"
9
+
"tangled.org/core/hook"
10
+
"tangled.org/core/keyfetch"
11
+
"tangled.org/core/knotserver"
12
+
"tangled.org/core/log"
13
13
)
14
14
15
15
func main() {
+3
-3
cmd/spindle/main.go
+3
-3
cmd/spindle/main.go
+1
-1
cmd/verifysig/main.go
+1
-1
cmd/verifysig/main.go
+1
-1
crypto/verify.go
+1
-1
crypto/verify.go
+2
-2
docs/knot-hosting.md
+2
-2
docs/knot-hosting.md
···
19
19
First, clone this repository:
20
20
21
21
```
22
-
git clone https://tangled.sh/@tangled.sh/core
22
+
git clone https://tangled.org/@tangled.org/core
23
23
```
24
24
25
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
130
131
131
You should now have a running knot server! You can finalize
132
132
your registration by hitting the `verify` button on the
133
-
[/knots](https://tangled.sh/knots) page. This simply creates
133
+
[/knots](https://tangled.org/knots) page. This simply creates
134
134
a record on your PDS to announce the existence of the knot.
135
135
136
136
### custom paths
+4
-5
docs/migrations.md
+4
-5
docs/migrations.md
···
14
14
For knots:
15
15
16
16
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
17
+
- Head to the [knot dashboard](https://tangled.org/knots) and
18
18
hit the "retry" button to verify your knot
19
19
20
20
For spindles:
21
21
22
22
- Upgrade to latest tag (v1.9.0 or above)
23
23
- Head to the [spindle
24
-
dashboard](https://tangled.sh/spindles) and hit the
24
+
dashboard](https://tangled.org/spindles) and hit the
25
25
"retry" button to verify your spindle
26
26
27
27
## Upgrading from v1.7.x
···
38
38
environment variable entirely
39
39
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
40
your DID. You can find your DID in the
41
-
[settings](https://tangled.sh/settings) page.
41
+
[settings](https://tangled.org/settings) page.
42
42
- Restart your knot once you have replaced the environment
43
43
variable
44
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
44
+
- Head to the [knot dashboard](https://tangled.org/knots) and
45
45
hit the "retry" button to verify your knot. This simply
46
46
writes a `sh.tangled.knot` record to your PDS.
47
47
···
57
57
};
58
58
};
59
59
```
60
-
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
···
44
44
### production
45
45
46
46
You would typically use a systemd service with a configuration file. Refer to
47
-
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
47
+
[@tangled.org/infra](https://tangled.org/@tangled.org/infra) for how this can be
48
48
achieved using Nix.
49
49
50
50
Then, initialize the bao server:
+3
-3
docs/spindle/pipeline.md
+3
-3
docs/spindle/pipeline.md
···
21
21
- `manual`: The workflow can be triggered manually.
22
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
25
26
26
```yaml
27
27
when:
···
73
73
- nodejs
74
74
- go
75
75
# custom registry
76
-
git+https://tangled.sh/@example.com/my_pkg:
76
+
git+https://tangled.org/@example.com/my_pkg:
77
77
- my_pkg
78
78
```
79
79
···
141
141
- nodejs
142
142
- go
143
143
# custom registry
144
-
git+https://tangled.sh/@example.com/my_pkg:
144
+
git+https://tangled.org/@example.com/my_pkg:
145
145
- my_pkg
146
146
147
147
environment:
+2
-2
eventconsumer/consumer.go
+2
-2
eventconsumer/consumer.go
+1
-1
eventconsumer/cursor/redis.go
+1
-1
eventconsumer/cursor/redis.go
+2
-2
go.mod
+2
-2
go.mod
···
1
-
module tangled.sh/tangled.sh/core
1
+
module tangled.org/core
2
2
3
3
go 1.24.4
4
4
···
43
43
github.com/yuin/goldmark v1.7.12
44
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
45
golang.org/x/crypto v0.40.0
46
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
46
47
golang.org/x/net v0.42.0
47
48
golang.org/x/sync v0.16.0
48
49
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
168
169
go.uber.org/atomic v1.11.0 // indirect
169
170
go.uber.org/multierr v1.11.0 // indirect
170
171
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
172
golang.org/x/sys v0.34.0 // indirect
173
173
golang.org/x/text v0.27.0 // indirect
174
174
golang.org/x/time v0.12.0 // indirect
+2
-2
guard/guard.go
+2
-2
guard/guard.go
···
15
15
"github.com/bluesky-social/indigo/atproto/identity"
16
16
securejoin "github.com/cyphar/filepath-securejoin"
17
17
"github.com/urfave/cli/v3"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/log"
18
+
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
20
20
)
21
21
22
22
func Command() *cli.Command {
+1
-1
jetstream/jetstream.go
+1
-1
jetstream/jetstream.go
+1
-1
keyfetch/keyfetch.go
+1
-1
keyfetch/keyfetch.go
+1
-1
knotserver/config/config.go
+1
-1
knotserver/config/config.go
···
41
41
Repo Repo `env:",prefix=KNOT_REPO_"`
42
42
Server Server `env:",prefix=KNOT_SERVER_"`
43
43
Git Git `env:",prefix=KNOT_GIT_"`
44
-
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"`
44
+
AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"`
45
45
}
46
46
47
47
func Load(ctx context.Context) (*Config, error) {
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/events.go
+1
-1
knotserver/db/pubkeys.go
+1
-1
knotserver/db/pubkeys.go
+1
-1
knotserver/git/branch.go
+1
-1
knotserver/git/branch.go
+2
-2
knotserver/git/diff.go
+2
-2
knotserver/git/diff.go
···
12
12
"github.com/bluekeyes/go-gitdiff/gitdiff"
13
13
"github.com/go-git/go-git/v5/plumbing"
14
14
"github.com/go-git/go-git/v5/plumbing/object"
15
-
"tangled.sh/tangled.sh/core/patchutil"
16
-
"tangled.sh/tangled.sh/core/types"
15
+
"tangled.org/core/patchutil"
16
+
"tangled.org/core/types"
17
17
)
18
18
19
19
func (g *GitRepo) Diff() (*types.NiceDiff, error) {
-103
knotserver/git/git.go
-103
knotserver/git/git.go
···
27
27
h plumbing.Hash
28
28
}
29
29
30
-
type TagList struct {
31
-
refs []*TagReference
32
-
r *git.Repository
33
-
}
34
-
35
-
// TagReference is used to list both tag and non-annotated tags.
36
-
// Non-annotated tags should only contains a reference.
37
-
// Annotated tags should contain its reference and its tag information.
38
-
type TagReference struct {
39
-
ref *plumbing.Reference
40
-
tag *object.Tag
41
-
}
42
-
43
30
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
44
31
// to tar WriteHeader
45
32
type infoWrapper struct {
···
48
35
mode fs.FileMode
49
36
modTime time.Time
50
37
isDir bool
51
-
}
52
-
53
-
func (self *TagList) Len() int {
54
-
return len(self.refs)
55
-
}
56
-
57
-
func (self *TagList) Swap(i, j int) {
58
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
59
-
}
60
-
61
-
// sorting tags in reverse chronological order
62
-
func (self *TagList) Less(i, j int) bool {
63
-
var dateI time.Time
64
-
var dateJ time.Time
65
-
66
-
if self.refs[i].tag != nil {
67
-
dateI = self.refs[i].tag.Tagger.When
68
-
} else {
69
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
70
-
if err != nil {
71
-
dateI = time.Now()
72
-
} else {
73
-
dateI = c.Committer.When
74
-
}
75
-
}
76
-
77
-
if self.refs[j].tag != nil {
78
-
dateJ = self.refs[j].tag.Tagger.When
79
-
} else {
80
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
81
-
if err != nil {
82
-
dateJ = time.Now()
83
-
} else {
84
-
dateJ = c.Committer.When
85
-
}
86
-
}
87
-
88
-
return dateI.After(dateJ)
89
38
}
90
39
91
40
func Open(path string, ref string) (*GitRepo, error) {
···
171
120
return g.r.CommitObject(h)
172
121
}
173
122
174
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
175
-
c, err := g.r.CommitObject(g.h)
176
-
if err != nil {
177
-
return nil, fmt.Errorf("last commit: %w", err)
178
-
}
179
-
return c, nil
180
-
}
181
-
182
123
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
183
124
c, err := g.r.CommitObject(g.h)
184
125
if err != nil {
···
211
152
}
212
153
213
154
return buf.Bytes(), nil
214
-
}
215
-
216
-
func (g *GitRepo) FileContent(path string) (string, error) {
217
-
c, err := g.r.CommitObject(g.h)
218
-
if err != nil {
219
-
return "", fmt.Errorf("commit object: %w", err)
220
-
}
221
-
222
-
tree, err := c.Tree()
223
-
if err != nil {
224
-
return "", fmt.Errorf("file tree: %w", err)
225
-
}
226
-
227
-
file, err := tree.File(path)
228
-
if err != nil {
229
-
return "", err
230
-
}
231
-
232
-
isbin, _ := file.IsBinary()
233
-
234
-
if !isbin {
235
-
return file.Contents()
236
-
} else {
237
-
return "", ErrBinaryFile
238
-
}
239
155
}
240
156
241
157
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
410
326
func (i *infoWrapper) Sys() any {
411
327
return nil
412
328
}
413
-
414
-
func (t *TagReference) Name() string {
415
-
return t.ref.Name().Short()
416
-
}
417
-
418
-
func (t *TagReference) Message() string {
419
-
if t.tag != nil {
420
-
return t.tag.Message
421
-
}
422
-
return ""
423
-
}
424
-
425
-
func (t *TagReference) TagObject() *object.Tag {
426
-
return t.tag
427
-
}
428
-
429
-
func (t *TagReference) Hash() plumbing.Hash {
430
-
return t.ref.Hash()
431
-
}
+1
-1
knotserver/git/post_receive.go
+1
-1
knotserver/git/post_receive.go
+1
-3
knotserver/git/tag.go
+1
-3
knotserver/git/tag.go
···
2
2
3
3
import (
4
4
"fmt"
5
-
"slices"
6
5
"strconv"
7
6
"strings"
8
7
"time"
···
35
34
outFormat.WriteString("")
36
35
outFormat.WriteString(recordSeparator)
37
36
38
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
37
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
39
38
if err != nil {
40
39
return nil, fmt.Errorf("failed to get tags: %w", err)
41
40
}
···
94
93
tags = append(tags, tag)
95
94
}
96
95
97
-
slices.Reverse(tags)
98
96
return tags, nil
99
97
}
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git/tree.go
+1
-1
knotserver/git.go
+1
-1
knotserver/git.go
-4
knotserver/http_util.go
-4
knotserver/http_util.go
+8
-8
knotserver/ingester.go
+8
-8
knotserver/ingester.go
···
15
15
"github.com/bluesky-social/indigo/xrpc"
16
16
"github.com/bluesky-social/jetstream/pkg/models"
17
17
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/idresolver"
20
-
"tangled.sh/tangled.sh/core/knotserver/db"
21
-
"tangled.sh/tangled.sh/core/knotserver/git"
22
-
"tangled.sh/tangled.sh/core/log"
23
-
"tangled.sh/tangled.sh/core/rbac"
24
-
"tangled.sh/tangled.sh/core/workflow"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/knotserver/db"
21
+
"tangled.org/core/knotserver/git"
22
+
"tangled.org/core/log"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/workflow"
25
25
)
26
26
27
27
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
151
151
return fmt.Errorf("failed to construct absolute repo path: %w", err)
152
152
}
153
153
154
-
gr, err := git.Open(repoPath, record.Source.Branch)
154
+
gr, err := git.Open(repoPath, record.Source.Sha)
155
155
if err != nil {
156
156
return fmt.Errorf("failed to open git repository: %w", err)
157
157
}
+8
-8
knotserver/internal.go
+8
-8
knotserver/internal.go
···
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
14
"github.com/go-chi/chi/v5"
15
15
"github.com/go-chi/chi/v5/middleware"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/config"
19
-
"tangled.sh/tangled.sh/core/knotserver/db"
20
-
"tangled.sh/tangled.sh/core/knotserver/git"
21
-
"tangled.sh/tangled.sh/core/notifier"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/workflow"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/config"
19
+
"tangled.org/core/knotserver/db"
20
+
"tangled.org/core/knotserver/git"
21
+
"tangled.org/core/notifier"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/workflow"
24
24
)
25
25
26
26
type InternalHandle struct {
+9
-9
knotserver/router.go
+9
-9
knotserver/router.go
···
7
7
"net/http"
8
8
9
9
"github.com/go-chi/chi/v5"
10
-
"tangled.sh/tangled.sh/core/idresolver"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
15
-
tlog "tangled.sh/tangled.sh/core/log"
16
-
"tangled.sh/tangled.sh/core/notifier"
17
-
"tangled.sh/tangled.sh/core/rbac"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
10
+
"tangled.org/core/idresolver"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/knotserver/xrpc"
15
+
tlog "tangled.org/core/log"
16
+
"tangled.org/core/notifier"
17
+
"tangled.org/core/rbac"
18
+
"tangled.org/core/xrpc/serviceauth"
19
19
)
20
20
21
21
type Knot struct {
+8
-8
knotserver/server.go
+8
-8
knotserver/server.go
···
6
6
"net/http"
7
7
8
8
"github.com/urfave/cli/v3"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/hook"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/log"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/hook"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/log"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
17
)
18
18
19
19
func Command() *cli.Command {
+5
-5
knotserver/xrpc/create_repo.go
+5
-5
knotserver/xrpc/create_repo.go
···
13
13
"github.com/bluesky-social/indigo/xrpc"
14
14
securejoin "github.com/cyphar/filepath-securejoin"
15
15
gogit "github.com/go-git/go-git/v5"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/git"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/git"
19
+
"tangled.org/core/rbac"
20
+
xrpcerr "tangled.org/core/xrpc/errors"
21
21
)
22
22
23
23
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/delete_repo.go
+3
-3
knotserver/xrpc/delete_repo.go
···
11
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
12
"github.com/bluesky-social/indigo/xrpc"
13
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/rbac"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/rbac"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5
-5
knotserver/xrpc/fork_status.go
+5
-5
knotserver/xrpc/fork_status.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/types"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/types"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
16
)
17
17
18
18
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4
-4
knotserver/xrpc/fork_sync.go
+4
-4
knotserver/xrpc/fork_sync.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
15
)
16
16
17
17
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+2
-2
knotserver/xrpc/list_keys.go
+2
-2
knotserver/xrpc/list_keys.go
+6
-6
knotserver/xrpc/merge.go
+6
-6
knotserver/xrpc/merge.go
···
8
8
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/patchutil"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/types"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/merge_check.go
+3
-3
knotserver/xrpc/merge_check.go
···
7
7
"net/http"
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/knotserver/git"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
13
)
14
14
15
15
func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) {
+2
-2
knotserver/xrpc/owner.go
+2
-2
knotserver/xrpc/owner.go
+2
-2
knotserver/xrpc/repo_archive.go
+2
-2
knotserver/xrpc/repo_archive.go
···
8
8
9
9
"github.com/go-git/go-git/v5/plumbing"
10
10
11
-
"tangled.sh/tangled.sh/core/knotserver/git"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
13
)
14
14
15
15
func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) {
+4
-4
knotserver/xrpc/repo_blob.go
+4
-4
knotserver/xrpc/repo_blob.go
···
9
9
"slices"
10
10
"strings"
11
11
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
15
)
16
16
17
17
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
44
44
45
45
contents, err := gr.RawContent(treePath)
46
46
if err != nil {
47
-
x.Logger.Error("file content", "error", err.Error())
47
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
48
48
writeError(w, xrpcerr.NewXrpcError(
49
49
xrpcerr.WithTag("FileNotFound"),
50
50
xrpcerr.WithMessage("file not found at the specified path"),
+3
-3
knotserver/xrpc/repo_branch.go
+3
-3
knotserver/xrpc/repo_branch.go
···
5
5
"net/url"
6
6
"time"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/knotserver/git"
10
+
xrpcerr "tangled.org/core/xrpc/errors"
11
11
)
12
12
13
13
func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_branches.go
+3
-3
knotserver/xrpc/repo_branches.go
···
4
4
"net/http"
5
5
"strconv"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_compare.go
+3
-3
knotserver/xrpc/repo_compare.go
···
4
4
"fmt"
5
5
"net/http"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_diff.go
+3
-3
knotserver/xrpc/repo_diff.go
···
3
3
import (
4
4
"net/http"
5
5
6
-
"tangled.sh/tangled.sh/core/knotserver/git"
7
-
"tangled.sh/tangled.sh/core/types"
8
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
6
+
"tangled.org/core/knotserver/git"
7
+
"tangled.org/core/types"
8
+
xrpcerr "tangled.org/core/xrpc/errors"
9
9
)
10
10
11
11
func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_get_default_branch.go
+3
-3
knotserver/xrpc/repo_get_default_branch.go
···
4
4
"net/http"
5
5
"time"
6
6
7
-
"tangled.sh/tangled.sh/core/api/tangled"
8
-
"tangled.sh/tangled.sh/core/knotserver/git"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/api/tangled"
8
+
"tangled.org/core/knotserver/git"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_languages.go
+3
-3
knotserver/xrpc/repo_languages.go
···
6
6
"net/http"
7
7
"time"
8
8
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/knotserver/git"
11
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/knotserver/git"
11
+
xrpcerr "tangled.org/core/xrpc/errors"
12
12
)
13
13
14
14
func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/repo_log.go
+3
-3
knotserver/xrpc/repo_log.go
···
4
4
"net/http"
5
5
"strconv"
6
6
7
-
"tangled.sh/tangled.sh/core/knotserver/git"
8
-
"tangled.sh/tangled.sh/core/types"
9
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
7
+
"tangled.org/core/knotserver/git"
8
+
"tangled.org/core/types"
9
+
xrpcerr "tangled.org/core/xrpc/errors"
10
10
)
11
11
12
12
func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) {
+27
-3
knotserver/xrpc/repo_tree.go
+27
-3
knotserver/xrpc/repo_tree.go
···
4
4
"net/http"
5
5
"path/filepath"
6
6
"time"
7
+
"unicode/utf8"
7
8
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/pages/markup"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
11
13
)
12
14
13
15
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
···
43
45
return
44
46
}
45
47
48
+
// if any of these files are a readme candidate, pass along its blob contents too
49
+
var readmeFileName string
50
+
var readmeContents string
51
+
for _, file := range files {
52
+
if markup.IsReadmeFile(file.Name) {
53
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
54
+
if err != nil {
55
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56
+
}
57
+
58
+
if utf8.Valid(contents) {
59
+
readmeFileName = file.Name
60
+
readmeContents = string(contents)
61
+
break
62
+
}
63
+
}
64
+
}
65
+
46
66
// convert NiceTree -> tangled.RepoTree_TreeEntry
47
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
48
68
for i, file := range files {
···
83
103
Parent: parentPtr,
84
104
Dotdot: dotdotPtr,
85
105
Files: treeEntries,
106
+
Readme: &tangled.RepoTree_Readme{
107
+
Filename: readmeFileName,
108
+
Contents: readmeContents,
109
+
},
86
110
}
87
111
88
112
writeJson(w, response)
+4
-4
knotserver/xrpc/set_default_branch.go
+4
-4
knotserver/xrpc/set_default_branch.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
"github.com/bluesky-social/indigo/xrpc"
11
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
"tangled.sh/tangled.sh/core/rbac"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
"tangled.org/core/rbac"
15
15
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
const ActorDid string = "ActorDid"
+2
-2
knotserver/xrpc/version.go
+2
-2
knotserver/xrpc/version.go
···
5
5
"net/http"
6
6
"runtime/debug"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
8
+
"tangled.org/core/api/tangled"
9
9
)
10
10
11
11
// version is set during build time.
···
24
24
var modified bool
25
25
26
26
for _, mod := range info.Deps {
27
-
if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" {
27
+
if mod.Path == "tangled.org/tangled.org/knotserver/xrpc" {
28
28
modVer = mod.Version
29
29
break
30
30
}
+9
-9
knotserver/xrpc/xrpc.go
+9
-9
knotserver/xrpc/xrpc.go
···
7
7
"strings"
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
"tangled.sh/tangled.sh/core/jetstream"
13
-
"tangled.sh/tangled.sh/core/knotserver/config"
14
-
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
17
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/idresolver"
12
+
"tangled.org/core/jetstream"
13
+
"tangled.org/core/knotserver/config"
14
+
"tangled.org/core/knotserver/db"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
+
xrpcerr "tangled.org/core/xrpc/errors"
18
+
"tangled.org/core/xrpc/serviceauth"
19
19
20
20
"github.com/go-chi/chi/v5"
21
21
)
-158
legal/privacy.md
-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
-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
+1
-1
lexicon-build-config.json
+19
lexicons/repo/tree.json
+19
lexicons/repo/tree.json
···
41
41
"type": "string",
42
42
"description": "Parent directory path"
43
43
},
44
+
"readme": {
45
+
"type": "ref",
46
+
"ref": "#readme",
47
+
"description": "Readme for this file tree"
48
+
},
44
49
"files": {
45
50
"type": "array",
46
51
"items": {
···
69
74
"description": "Invalid request parameters"
70
75
}
71
76
]
77
+
},
78
+
"readme": {
79
+
"type": "object",
80
+
"required": ["filename", "contents"],
81
+
"properties": {
82
+
"filename": {
83
+
"type": "string",
84
+
"description": "Name of the readme file"
85
+
},
86
+
"contents": {
87
+
"type": "string",
88
+
"description": "Contents of the readme file"
89
+
}
90
+
}
72
91
},
73
92
"treeEntry": {
74
93
"type": "object",
+2
-2
nix/pkgs/knot-unwrapped.nix
+2
-2
nix/pkgs/knot-unwrapped.nix
···
4
4
sqlite-lib,
5
5
src,
6
6
}: let
7
-
version = "1.9.0-alpha";
7
+
version = "1.9.1-alpha";
8
8
in
9
9
buildGoApplication {
10
10
pname = "knot";
···
16
16
tags = ["libsqlite3"];
17
17
18
18
ldflags = [
19
-
"-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}"
19
+
"-X tangled.org/core/knotserver/xrpc.version=${version}"
20
20
];
21
21
22
22
env.CGO_CFLAGS = "-I ${sqlite-lib}/include ";
+1
-1
patchutil/interdiff.go
+1
-1
patchutil/interdiff.go
+1
-1
patchutil/patchutil.go
+1
-1
patchutil/patchutil.go
+1
-1
rbac/rbac_test.go
+1
-1
rbac/rbac_test.go
+4
-4
spindle/db/events.go
+4
-4
spindle/db/events.go
···
5
5
"fmt"
6
6
"time"
7
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/notifier"
10
-
"tangled.sh/tangled.sh/core/spindle/models"
11
-
"tangled.sh/tangled.sh/core/tid"
8
+
"tangled.org/core/api/tangled"
9
+
"tangled.org/core/notifier"
10
+
"tangled.org/core/spindle/models"
11
+
"tangled.org/core/tid"
12
12
)
13
13
14
14
type Event struct {
+5
-5
spindle/engine/engine.go
+5
-5
spindle/engine/engine.go
···
8
8
9
9
securejoin "github.com/cyphar/filepath-securejoin"
10
10
"golang.org/x/sync/errgroup"
11
-
"tangled.sh/tangled.sh/core/notifier"
12
-
"tangled.sh/tangled.sh/core/spindle/config"
13
-
"tangled.sh/tangled.sh/core/spindle/db"
14
-
"tangled.sh/tangled.sh/core/spindle/models"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
11
+
"tangled.org/core/notifier"
12
+
"tangled.org/core/spindle/config"
13
+
"tangled.org/core/spindle/db"
14
+
"tangled.org/core/spindle/models"
15
+
"tangled.org/core/spindle/secrets"
16
16
)
17
17
18
18
var (
+6
-6
spindle/engines/nixery/engine.go
+6
-6
spindle/engines/nixery/engine.go
···
19
19
"github.com/docker/docker/client"
20
20
"github.com/docker/docker/pkg/stdcopy"
21
21
"gopkg.in/yaml.v3"
22
-
"tangled.sh/tangled.sh/core/api/tangled"
23
-
"tangled.sh/tangled.sh/core/log"
24
-
"tangled.sh/tangled.sh/core/spindle/config"
25
-
"tangled.sh/tangled.sh/core/spindle/engine"
26
-
"tangled.sh/tangled.sh/core/spindle/models"
27
-
"tangled.sh/tangled.sh/core/spindle/secrets"
22
+
"tangled.org/core/api/tangled"
23
+
"tangled.org/core/log"
24
+
"tangled.org/core/spindle/config"
25
+
"tangled.org/core/spindle/engine"
26
+
"tangled.org/core/spindle/models"
27
+
"tangled.org/core/spindle/secrets"
28
28
)
29
29
30
30
const (
+2
-2
spindle/engines/nixery/setup_steps.go
+2
-2
spindle/engines/nixery/setup_steps.go
+5
-5
spindle/ingester.go
+5
-5
spindle/ingester.go
···
7
7
"fmt"
8
8
"time"
9
9
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/eventconsumer"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/db"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/eventconsumer"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/db"
15
15
16
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
17
"github.com/bluesky-social/indigo/atproto/identity"
+2
-2
spindle/models/engine.go
+2
-2
spindle/models/engine.go
+1
-1
spindle/models/models.go
+1
-1
spindle/models/models.go
+17
-17
spindle/server.go
+17
-17
spindle/server.go
···
9
9
"net/http"
10
10
11
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/eventconsumer"
14
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
-
"tangled.sh/tangled.sh/core/idresolver"
16
-
"tangled.sh/tangled.sh/core/jetstream"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/notifier"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
"tangled.sh/tangled.sh/core/spindle/config"
21
-
"tangled.sh/tangled.sh/core/spindle/db"
22
-
"tangled.sh/tangled.sh/core/spindle/engine"
23
-
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
24
-
"tangled.sh/tangled.sh/core/spindle/models"
25
-
"tangled.sh/tangled.sh/core/spindle/queue"
26
-
"tangled.sh/tangled.sh/core/spindle/secrets"
27
-
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/eventconsumer"
14
+
"tangled.org/core/eventconsumer/cursor"
15
+
"tangled.org/core/idresolver"
16
+
"tangled.org/core/jetstream"
17
+
"tangled.org/core/log"
18
+
"tangled.org/core/notifier"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/spindle/config"
21
+
"tangled.org/core/spindle/db"
22
+
"tangled.org/core/spindle/engine"
23
+
"tangled.org/core/spindle/engines/nixery"
24
+
"tangled.org/core/spindle/models"
25
+
"tangled.org/core/spindle/queue"
26
+
"tangled.org/core/spindle/secrets"
27
+
"tangled.org/core/spindle/xrpc"
28
+
"tangled.org/core/xrpc/serviceauth"
29
29
)
30
30
31
31
//go:embed motd
+1
-1
spindle/stream.go
+1
-1
spindle/stream.go
+4
-4
spindle/xrpc/add_secret.go
+4
-4
spindle/xrpc/add_secret.go
···
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
"github.com/bluesky-social/indigo/xrpc"
12
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
+4
-4
spindle/xrpc/list_secrets.go
+4
-4
spindle/xrpc/list_secrets.go
···
10
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
11
"github.com/bluesky-social/indigo/xrpc"
12
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
17
)
18
18
19
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
+2
-2
spindle/xrpc/owner.go
+2
-2
spindle/xrpc/owner.go
+4
-4
spindle/xrpc/remove_secret.go
+4
-4
spindle/xrpc/remove_secret.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
"github.com/bluesky-social/indigo/xrpc"
11
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/secrets"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/secrets"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
16
)
17
17
18
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
+9
-9
spindle/xrpc/xrpc.go
+9
-9
spindle/xrpc/xrpc.go
···
8
8
9
9
"github.com/go-chi/chi/v5"
10
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/config"
15
-
"tangled.sh/tangled.sh/core/spindle/db"
16
-
"tangled.sh/tangled.sh/core/spindle/models"
17
-
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/config"
15
+
"tangled.org/core/spindle/db"
16
+
"tangled.org/core/spindle/models"
17
+
"tangled.org/core/spindle/secrets"
18
+
xrpcerr "tangled.org/core/xrpc/errors"
19
+
"tangled.org/core/xrpc/serviceauth"
20
20
)
21
21
22
22
const ActorDid string = "ActorDid"
+7
-5
types/repo.go
+7
-5
types/repo.go
···
41
41
}
42
42
43
43
type RepoTreeResponse struct {
44
-
Ref string `json:"ref,omitempty"`
45
-
Parent string `json:"parent,omitempty"`
46
-
Description string `json:"description,omitempty"`
47
-
DotDot string `json:"dotdot,omitempty"`
48
-
Files []NiceTree `json:"files,omitempty"`
44
+
Ref string `json:"ref,omitempty"`
45
+
Parent string `json:"parent,omitempty"`
46
+
Description string `json:"description,omitempty"`
47
+
DotDot string `json:"dotdot,omitempty"`
48
+
Files []NiceTree `json:"files,omitempty"`
49
+
ReadmeFileName string `json:"readme_filename,omitempty"`
50
+
Readme string `json:"readme_contents,omitempty"`
49
51
}
50
52
51
53
type TagReference struct {
+1
-1
workflow/compile.go
+1
-1
workflow/compile.go
+1
-1
workflow/compile_test.go
+1
-1
workflow/compile_test.go
+1
-1
workflow/def.go
+1
-1
workflow/def.go
+2
-2
xrpc/serviceauth/service_auth.go
+2
-2
xrpc/serviceauth/service_auth.go
···
8
8
"strings"
9
9
10
10
"github.com/bluesky-social/indigo/atproto/auth"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
+
"tangled.org/core/idresolver"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
13
)
14
14
15
15
const ActorDid string = "ActorDid"