+1
-1
.air/knotserver.toml
+1
-1
.air/knotserver.toml
+6
.tangled/workflows/test.yml
+6
.tangled/workflows/test.yml
+10
api/tangled/repotree.go
+10
api/tangled/repotree.go
···
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
// parent: The parent path in the tree
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
// ref: The git reference used
35
Ref string `json:"ref" cborgen:"ref"`
36
}
37
38
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
···
31
Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
32
// parent: The parent path in the tree
33
Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34
+
// readme: Readme for this file tree
35
+
Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"`
36
// ref: The git reference used
37
Ref string `json:"ref" cborgen:"ref"`
38
+
}
39
+
40
+
// RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema.
41
+
type RepoTree_Readme struct {
42
+
// contents: Contents of the readme file
43
+
Contents string `json:"contents" cborgen:"contents"`
44
+
// filename: Name of the readme file
45
+
Filename string `json:"filename" cborgen:"filename"`
46
}
47
48
// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+1
-1
appview/cache/session/store.go
+1
-1
appview/cache/session/store.go
+5
-4
appview/commitverify/verify.go
+5
-4
appview/commitverify/verify.go
···
4
"log"
5
6
"github.com/go-git/go-git/v5/plumbing/object"
7
-
"tangled.sh/tangled.sh/core/appview/db"
8
-
"tangled.sh/tangled.sh/core/crypto"
9
-
"tangled.sh/tangled.sh/core/types"
10
)
11
12
type verifiedCommit struct {
···
45
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
46
vcs := VerifiedCommits{}
47
48
-
didPubkeyCache := make(map[string][]db.PublicKey)
49
50
for _, commit := range ndCommits {
51
c := commit.Commit
···
4
"log"
5
6
"github.com/go-git/go-git/v5/plumbing/object"
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
+
"tangled.org/core/crypto"
10
+
"tangled.org/core/types"
11
)
12
13
type verifiedCommit struct {
···
46
func GetVerifiedCommits(e db.Execer, emailToDid map[string]string, ndCommits []types.NiceDiff) (VerifiedCommits, error) {
47
vcs := VerifiedCommits{}
48
49
+
didPubkeyCache := make(map[string][]models.PublicKey)
50
51
for _, commit := range ndCommits {
52
c := commit.Commit
+4
-2
appview/config/config.go
+4
-2
appview/config/config.go
+5
-25
appview/db/artifact.go
+5
-25
appview/db/artifact.go
···
5
"strings"
6
"time"
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
"github.com/go-git/go-git/v5/plumbing"
10
"github.com/ipfs/go-cid"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
)
13
14
-
type Artifact struct {
15
-
Id uint64
16
-
Did string
17
-
Rkey string
18
-
19
-
RepoAt syntax.ATURI
20
-
Tag plumbing.Hash
21
-
CreatedAt time.Time
22
-
23
-
BlobCid cid.Cid
24
-
Name string
25
-
Size uint64
26
-
MimeType string
27
-
}
28
-
29
-
func (a *Artifact) ArtifactAt() syntax.ATURI {
30
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey))
31
-
}
32
-
33
-
func AddArtifact(e Execer, artifact Artifact) error {
34
_, err := e.Exec(
35
`insert or ignore into artifacts (
36
did,
···
57
return err
58
}
59
60
-
func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) {
61
-
var artifacts []Artifact
62
63
var conditions []string
64
var args []any
···
94
defer rows.Close()
95
96
for rows.Next() {
97
-
var artifact Artifact
98
var createdAt string
99
var tag []byte
100
var blobCid string
···
5
"strings"
6
"time"
7
8
"github.com/go-git/go-git/v5/plumbing"
9
"github.com/ipfs/go-cid"
10
+
"tangled.org/core/appview/models"
11
)
12
13
+
func AddArtifact(e Execer, artifact models.Artifact) error {
14
_, err := e.Exec(
15
`insert or ignore into artifacts (
16
did,
···
37
return err
38
}
39
40
+
func GetArtifact(e Execer, filters ...filter) ([]models.Artifact, error) {
41
+
var artifacts []models.Artifact
42
43
var conditions []string
44
var args []any
···
74
defer rows.Close()
75
76
for rows.Next() {
77
+
var artifact models.Artifact
78
var createdAt string
79
var tag []byte
80
var blobCid string
+3
-18
appview/db/collaborators.go
+3
-18
appview/db/collaborators.go
···
3
import (
4
"fmt"
5
"strings"
6
-
"time"
7
8
-
"github.com/bluesky-social/indigo/atproto/syntax"
9
)
10
11
-
type Collaborator struct {
12
-
// identifiers for the record
13
-
Id int64
14
-
Did syntax.DID
15
-
Rkey string
16
-
17
-
// content
18
-
SubjectDid syntax.DID
19
-
RepoAt syntax.ATURI
20
-
21
-
// meta
22
-
Created time.Time
23
-
}
24
-
25
-
func AddCollaborator(e Execer, c Collaborator) error {
26
_, err := e.Exec(
27
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
···
49
return err
50
}
51
52
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
if err != nil {
55
return nil, err
···
3
import (
4
"fmt"
5
"strings"
6
7
+
"tangled.org/core/appview/models"
8
)
9
10
+
func AddCollaborator(e Execer, c models.Collaborator) error {
11
_, err := e.Exec(
12
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
13
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
···
34
return err
35
}
36
37
+
func CollaboratingIn(e Execer, collaborator string) ([]models.Repo, error) {
38
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
39
if err != nil {
40
return nil, err
+172
-10
appview/db/db.go
+172
-10
appview/db/db.go
···
527
-- label to subscribe to
528
label_at text not null,
529
530
-
unique (repo_at, label_at),
531
-
foreign key (label_at) references label_definitions (at_uri)
532
);
533
534
create table if not exists migrations (
···
536
name text unique
537
);
538
539
-
-- indexes for better star query performance
540
create index if not exists idx_stars_created on stars(created);
541
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
542
`)
···
788
_, err := tx.Exec(`
789
alter table spindles add column needs_upgrade integer not null default 0;
790
`)
791
-
if err != nil {
792
-
return err
793
-
}
794
-
795
-
_, err = tx.Exec(`
796
-
update spindles set needs_upgrade = 1;
797
-
`)
798
return err
799
})
800
···
931
_, err = tx.Exec(`drop table comments`)
932
return err
933
})
934
935
return &DB{db}, nil
936
}
···
527
-- label to subscribe to
528
label_at text not null,
529
530
+
unique (repo_at, label_at)
531
+
);
532
+
533
+
create table if not exists notifications (
534
+
id integer primary key autoincrement,
535
+
recipient_did text not null,
536
+
actor_did text not null,
537
+
type text not null,
538
+
entity_type text not null,
539
+
entity_id text not null,
540
+
read integer not null default 0,
541
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
542
+
repo_id integer references repos(id),
543
+
issue_id integer references issues(id),
544
+
pull_id integer references pulls(id)
545
+
);
546
+
547
+
create table if not exists notification_preferences (
548
+
id integer primary key autoincrement,
549
+
user_did text not null unique,
550
+
repo_starred integer not null default 1,
551
+
issue_created integer not null default 1,
552
+
issue_commented integer not null default 1,
553
+
pull_created integer not null default 1,
554
+
pull_commented integer not null default 1,
555
+
followed integer not null default 1,
556
+
pull_merged integer not null default 1,
557
+
issue_closed integer not null default 1,
558
+
email_notifications integer not null default 0
559
);
560
561
create table if not exists migrations (
···
563
name text unique
564
);
565
566
+
-- indexes for better performance
567
+
create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc);
568
+
create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read);
569
create index if not exists idx_stars_created on stars(created);
570
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
571
`)
···
817
_, err := tx.Exec(`
818
alter table spindles add column needs_upgrade integer not null default 0;
819
`)
820
return err
821
})
822
···
953
_, err = tx.Exec(`drop table comments`)
954
return err
955
})
956
+
957
+
// add generated at_uri column to pulls table
958
+
//
959
+
// this requires a full table recreation because stored columns
960
+
// cannot be added via alter
961
+
//
962
+
// disable foreign-keys for the next migration
963
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
964
+
runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error {
965
+
_, err := tx.Exec(`
966
+
create table if not exists pulls_new (
967
+
-- identifiers
968
+
id integer primary key autoincrement,
969
+
pull_id integer not null,
970
+
at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored,
971
+
972
+
-- at identifiers
973
+
repo_at text not null,
974
+
owner_did text not null,
975
+
rkey text not null,
976
+
977
+
-- content
978
+
title text not null,
979
+
body text not null,
980
+
target_branch text not null,
981
+
state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted
982
+
983
+
-- source info
984
+
source_branch text,
985
+
source_repo_at text,
986
+
987
+
-- stacking
988
+
stack_id text,
989
+
change_id text,
990
+
parent_change_id text,
991
+
992
+
-- meta
993
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
994
+
995
+
-- constraints
996
+
unique(repo_at, pull_id),
997
+
unique(at_uri),
998
+
foreign key (repo_at) references repos(at_uri) on delete cascade
999
+
);
1000
+
`)
1001
+
if err != nil {
1002
+
return err
1003
+
}
1004
+
1005
+
// transfer data
1006
+
_, err = tx.Exec(`
1007
+
insert into pulls_new (
1008
+
id, pull_id, repo_at, owner_did, rkey,
1009
+
title, body, target_branch, state,
1010
+
source_branch, source_repo_at,
1011
+
stack_id, change_id, parent_change_id,
1012
+
created
1013
+
)
1014
+
select
1015
+
id, pull_id, repo_at, owner_did, rkey,
1016
+
title, body, target_branch, state,
1017
+
source_branch, source_repo_at,
1018
+
stack_id, change_id, parent_change_id,
1019
+
created
1020
+
from pulls;
1021
+
`)
1022
+
if err != nil {
1023
+
return err
1024
+
}
1025
+
1026
+
// drop old table
1027
+
_, err = tx.Exec(`drop table pulls`)
1028
+
if err != nil {
1029
+
return err
1030
+
}
1031
+
1032
+
// rename new table
1033
+
_, err = tx.Exec(`alter table pulls_new rename to pulls`)
1034
+
return err
1035
+
})
1036
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1037
+
1038
+
// remove repo_at and pull_id from pull_submissions and replace with pull_at
1039
+
//
1040
+
// this requires a full table recreation because stored columns
1041
+
// cannot be added via alter
1042
+
//
1043
+
// disable foreign-keys for the next migration
1044
+
conn.ExecContext(ctx, "pragma foreign_keys = off;")
1045
+
runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error {
1046
+
_, err := tx.Exec(`
1047
+
create table if not exists pull_submissions_new (
1048
+
-- identifiers
1049
+
id integer primary key autoincrement,
1050
+
pull_at text not null,
1051
+
1052
+
-- content, these are immutable, and require a resubmission to update
1053
+
round_number integer not null default 0,
1054
+
patch text,
1055
+
source_rev text,
1056
+
1057
+
-- meta
1058
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
1059
+
1060
+
-- constraints
1061
+
unique(pull_at, round_number),
1062
+
foreign key (pull_at) references pulls(at_uri) on delete cascade
1063
+
);
1064
+
`)
1065
+
if err != nil {
1066
+
return err
1067
+
}
1068
+
1069
+
// transfer data, constructing pull_at from pulls table
1070
+
_, err = tx.Exec(`
1071
+
insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1072
+
select
1073
+
ps.id,
1074
+
'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1075
+
ps.round_number,
1076
+
ps.patch,
1077
+
ps.created
1078
+
from pull_submissions ps
1079
+
join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id;
1080
+
`)
1081
+
if err != nil {
1082
+
return err
1083
+
}
1084
+
1085
+
// drop old table
1086
+
_, err = tx.Exec(`drop table pull_submissions`)
1087
+
if err != nil {
1088
+
return err
1089
+
}
1090
+
1091
+
// rename new table
1092
+
_, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`)
1093
+
return err
1094
+
})
1095
+
conn.ExecContext(ctx, "pragma foreign_keys = on;")
1096
1097
return &DB{db}, nil
1098
}
+29
-34
appview/db/email.go
+29
-34
appview/db/email.go
···
3
import (
4
"strings"
5
"time"
6
-
)
7
8
-
type Email struct {
9
-
ID int64
10
-
Did string
11
-
Address string
12
-
Verified bool
13
-
Primary bool
14
-
VerificationCode string
15
-
LastSent *time.Time
16
-
CreatedAt time.Time
17
-
}
18
19
-
func GetPrimaryEmail(e Execer, did string) (Email, error) {
20
query := `
21
select id, did, email, verified, is_primary, verification_code, last_sent, created
22
from emails
23
where did = ? and is_primary = true
24
`
25
-
var email Email
26
var createdStr string
27
var lastSent string
28
err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
29
if err != nil {
30
-
return Email{}, err
31
}
32
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
33
if err != nil {
34
-
return Email{}, err
35
}
36
parsedTime, err := time.Parse(time.RFC3339, lastSent)
37
if err != nil {
38
-
return Email{}, err
39
}
40
email.LastSent = &parsedTime
41
return email, nil
42
}
43
44
-
func GetEmail(e Execer, did string, em string) (Email, error) {
45
query := `
46
select id, did, email, verified, is_primary, verification_code, last_sent, created
47
from emails
48
where did = ? and email = ?
49
`
50
-
var email Email
51
var createdStr string
52
var lastSent string
53
err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
54
if err != nil {
55
-
return Email{}, err
56
}
57
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
58
if err != nil {
59
-
return Email{}, err
60
}
61
parsedTime, err := time.Parse(time.RFC3339, lastSent)
62
if err != nil {
63
-
return Email{}, err
64
}
65
email.LastSent = &parsedTime
66
return email, nil
···
80
return did, nil
81
}
82
83
-
func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) {
84
-
if len(ems) == 0 {
85
return make(map[string]string), nil
86
}
87
···
90
verifiedFilter = 1
91
}
92
93
// Create placeholders for the IN clause
94
-
placeholders := make([]string, len(ems))
95
-
args := make([]any, len(ems)+1)
96
97
args[0] = verifiedFilter
98
-
for i, em := range ems {
99
-
placeholders[i] = "?"
100
-
args[i+1] = em
101
}
102
103
query := `
···
113
return nil, err
114
}
115
defer rows.Close()
116
-
117
-
assoc := make(map[string]string)
118
119
for rows.Next() {
120
var email, did string
···
187
return count > 0, nil
188
}
189
190
-
func AddEmail(e Execer, email Email) error {
191
// Check if this is the first email for this DID
192
countQuery := `
193
select count(*)
···
254
return err
255
}
256
257
-
func GetAllEmails(e Execer, did string) ([]Email, error) {
258
query := `
259
select did, email, verified, is_primary, verification_code, last_sent, created
260
from emails
···
266
}
267
defer rows.Close()
268
269
-
var emails []Email
270
for rows.Next() {
271
-
var email Email
272
var createdStr string
273
var lastSent string
274
err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
···
3
import (
4
"strings"
5
"time"
6
7
+
"tangled.org/core/appview/models"
8
+
)
9
10
+
func GetPrimaryEmail(e Execer, did string) (models.Email, error) {
11
query := `
12
select id, did, email, verified, is_primary, verification_code, last_sent, created
13
from emails
14
where did = ? and is_primary = true
15
`
16
+
var email models.Email
17
var createdStr string
18
var lastSent string
19
err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
20
if err != nil {
21
+
return models.Email{}, err
22
}
23
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
24
if err != nil {
25
+
return models.Email{}, err
26
}
27
parsedTime, err := time.Parse(time.RFC3339, lastSent)
28
if err != nil {
29
+
return models.Email{}, err
30
}
31
email.LastSent = &parsedTime
32
return email, nil
33
}
34
35
+
func GetEmail(e Execer, did string, em string) (models.Email, error) {
36
query := `
37
select id, did, email, verified, is_primary, verification_code, last_sent, created
38
from emails
39
where did = ? and email = ?
40
`
41
+
var email models.Email
42
var createdStr string
43
var lastSent string
44
err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
45
if err != nil {
46
+
return models.Email{}, err
47
}
48
email.CreatedAt, err = time.Parse(time.RFC3339, createdStr)
49
if err != nil {
50
+
return models.Email{}, err
51
}
52
parsedTime, err := time.Parse(time.RFC3339, lastSent)
53
if err != nil {
54
+
return models.Email{}, err
55
}
56
email.LastSent = &parsedTime
57
return email, nil
···
71
return did, nil
72
}
73
74
+
func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) {
75
+
if len(emails) == 0 {
76
return make(map[string]string), nil
77
}
78
···
81
verifiedFilter = 1
82
}
83
84
+
assoc := make(map[string]string)
85
+
86
// Create placeholders for the IN clause
87
+
placeholders := make([]string, 0, len(emails))
88
+
args := make([]any, 1, len(emails)+1)
89
90
args[0] = verifiedFilter
91
+
for _, email := range emails {
92
+
if strings.HasPrefix(email, "did:") {
93
+
assoc[email] = email
94
+
continue
95
+
}
96
+
placeholders = append(placeholders, "?")
97
+
args = append(args, email)
98
}
99
100
query := `
···
110
return nil, err
111
}
112
defer rows.Close()
113
114
for rows.Next() {
115
var email, did string
···
182
return count > 0, nil
183
}
184
185
+
func AddEmail(e Execer, email models.Email) error {
186
// Check if this is the first email for this DID
187
countQuery := `
188
select count(*)
···
249
return err
250
}
251
252
+
func GetAllEmails(e Execer, did string) ([]models.Email, error) {
253
query := `
254
select did, email, verified, is_primary, verification_code, last_sent, created
255
from emails
···
261
}
262
defer rows.Close()
263
264
+
var emails []models.Email
265
for rows.Next() {
266
+
var email models.Email
267
var createdStr string
268
var lastSent string
269
err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &lastSent, &createdStr)
+26
-57
appview/db/follow.go
+26
-57
appview/db/follow.go
···
5
"log"
6
"strings"
7
"time"
8
)
9
10
-
type Follow struct {
11
-
UserDid string
12
-
SubjectDid string
13
-
FollowedAt time.Time
14
-
Rkey string
15
-
}
16
-
17
-
func AddFollow(e Execer, follow *Follow) error {
18
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
19
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
20
return err
21
}
22
23
// Get a follow record
24
-
func GetFollow(e Execer, userDid, subjectDid string) (*Follow, error) {
25
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
26
row := e.QueryRow(query, userDid, subjectDid)
27
28
-
var follow Follow
29
var followedAt string
30
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
31
if err != nil {
···
55
return err
56
}
57
58
-
type FollowStats struct {
59
-
Followers int64
60
-
Following int64
61
-
}
62
-
63
-
func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) {
64
var followers, following int64
65
err := e.QueryRow(
66
`SELECT
···
68
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
69
FROM follows;`, did, did).Scan(&followers, &following)
70
if err != nil {
71
-
return FollowStats{}, err
72
}
73
-
return FollowStats{
74
Followers: followers,
75
Following: following,
76
}, nil
77
}
78
79
-
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) {
80
if len(dids) == 0 {
81
return nil, nil
82
}
···
112
) g on f.did = g.did`,
113
placeholderStr, placeholderStr)
114
115
-
result := make(map[string]FollowStats)
116
117
rows, err := e.Query(query, args...)
118
if err != nil {
···
126
if err := rows.Scan(&did, &followers, &following); err != nil {
127
return nil, err
128
}
129
-
result[did] = FollowStats{
130
Followers: followers,
131
Following: following,
132
}
···
134
135
for _, did := range dids {
136
if _, exists := result[did]; !exists {
137
-
result[did] = FollowStats{
138
Followers: 0,
139
Following: 0,
140
}
···
144
return result, nil
145
}
146
147
-
func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) {
148
-
var follows []Follow
149
150
var conditions []string
151
var args []any
···
177
return nil, err
178
}
179
for rows.Next() {
180
-
var follow Follow
181
var followedAt string
182
err := rows.Scan(
183
&follow.UserDid,
···
200
return follows, nil
201
}
202
203
-
func GetFollowers(e Execer, did string) ([]Follow, error) {
204
return GetFollows(e, 0, FilterEq("subject_did", did))
205
}
206
207
-
func GetFollowing(e Execer, did string) ([]Follow, error) {
208
return GetFollows(e, 0, FilterEq("user_did", did))
209
}
210
211
-
type FollowStatus int
212
-
213
-
const (
214
-
IsNotFollowing FollowStatus = iota
215
-
IsFollowing
216
-
IsSelf
217
-
)
218
-
219
-
func (s FollowStatus) String() string {
220
-
switch s {
221
-
case IsNotFollowing:
222
-
return "IsNotFollowing"
223
-
case IsFollowing:
224
-
return "IsFollowing"
225
-
case IsSelf:
226
-
return "IsSelf"
227
-
default:
228
-
return "IsNotFollowing"
229
-
}
230
-
}
231
-
232
-
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
233
if len(subjectDids) == 0 || userDid == "" {
234
-
return make(map[string]FollowStatus), nil
235
}
236
237
-
result := make(map[string]FollowStatus)
238
239
for _, subjectDid := range subjectDids {
240
if userDid == subjectDid {
241
-
result[subjectDid] = IsSelf
242
} else {
243
-
result[subjectDid] = IsNotFollowing
244
}
245
}
246
···
281
if err := rows.Scan(&subjectDid); err != nil {
282
return nil, err
283
}
284
-
result[subjectDid] = IsFollowing
285
}
286
287
return result, nil
288
}
289
290
-
func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus {
291
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
292
if err != nil {
293
-
return IsNotFollowing
294
}
295
return statuses[subjectDid]
296
}
297
298
-
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]FollowStatus, error) {
299
return getFollowStatuses(e, userDid, subjectDids)
300
}
···
5
"log"
6
"strings"
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func AddFollow(e Execer, follow *models.Follow) error {
13
query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)`
14
_, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey)
15
return err
16
}
17
18
// Get a follow record
19
+
func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) {
20
query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?`
21
row := e.QueryRow(query, userDid, subjectDid)
22
23
+
var follow models.Follow
24
var followedAt string
25
err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey)
26
if err != nil {
···
50
return err
51
}
52
53
+
func GetFollowerFollowingCount(e Execer, did string) (models.FollowStats, error) {
54
var followers, following int64
55
err := e.QueryRow(
56
`SELECT
···
58
COUNT(CASE WHEN user_did = ? THEN 1 END) AS following
59
FROM follows;`, did, did).Scan(&followers, &following)
60
if err != nil {
61
+
return models.FollowStats{}, err
62
}
63
+
return models.FollowStats{
64
Followers: followers,
65
Following: following,
66
}, nil
67
}
68
69
+
func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]models.FollowStats, error) {
70
if len(dids) == 0 {
71
return nil, nil
72
}
···
102
) g on f.did = g.did`,
103
placeholderStr, placeholderStr)
104
105
+
result := make(map[string]models.FollowStats)
106
107
rows, err := e.Query(query, args...)
108
if err != nil {
···
116
if err := rows.Scan(&did, &followers, &following); err != nil {
117
return nil, err
118
}
119
+
result[did] = models.FollowStats{
120
Followers: followers,
121
Following: following,
122
}
···
124
125
for _, did := range dids {
126
if _, exists := result[did]; !exists {
127
+
result[did] = models.FollowStats{
128
Followers: 0,
129
Following: 0,
130
}
···
134
return result, nil
135
}
136
137
+
func GetFollows(e Execer, limit int, filters ...filter) ([]models.Follow, error) {
138
+
var follows []models.Follow
139
140
var conditions []string
141
var args []any
···
167
return nil, err
168
}
169
for rows.Next() {
170
+
var follow models.Follow
171
var followedAt string
172
err := rows.Scan(
173
&follow.UserDid,
···
190
return follows, nil
191
}
192
193
+
func GetFollowers(e Execer, did string) ([]models.Follow, error) {
194
return GetFollows(e, 0, FilterEq("subject_did", did))
195
}
196
197
+
func GetFollowing(e Execer, did string) ([]models.Follow, error) {
198
return GetFollows(e, 0, FilterEq("user_did", did))
199
}
200
201
+
func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
202
if len(subjectDids) == 0 || userDid == "" {
203
+
return make(map[string]models.FollowStatus), nil
204
}
205
206
+
result := make(map[string]models.FollowStatus)
207
208
for _, subjectDid := range subjectDids {
209
if userDid == subjectDid {
210
+
result[subjectDid] = models.IsSelf
211
} else {
212
+
result[subjectDid] = models.IsNotFollowing
213
}
214
}
215
···
250
if err := rows.Scan(&subjectDid); err != nil {
251
return nil, err
252
}
253
+
result[subjectDid] = models.IsFollowing
254
}
255
256
return result, nil
257
}
258
259
+
func GetFollowStatus(e Execer, userDid, subjectDid string) models.FollowStatus {
260
statuses, err := getFollowStatuses(e, userDid, []string{subjectDid})
261
if err != nil {
262
+
return models.IsNotFollowing
263
}
264
return statuses[subjectDid]
265
}
266
267
+
func GetFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) {
268
return getFollowStatuses(e, userDid, subjectDids)
269
}
+23
-212
appview/db/issues.go
+23
-212
appview/db/issues.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/appview/pagination"
15
)
16
17
-
type Issue struct {
18
-
Id int64
19
-
Did string
20
-
Rkey string
21
-
RepoAt syntax.ATURI
22
-
IssueId int
23
-
Created time.Time
24
-
Edited *time.Time
25
-
Deleted *time.Time
26
-
Title string
27
-
Body string
28
-
Open bool
29
-
30
-
// optionally, populate this when querying for reverse mappings
31
-
// like comment counts, parent repo etc.
32
-
Comments []IssueComment
33
-
Labels LabelState
34
-
Repo *Repo
35
-
}
36
-
37
-
func (i *Issue) AtUri() syntax.ATURI {
38
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey))
39
-
}
40
-
41
-
func (i *Issue) AsRecord() tangled.RepoIssue {
42
-
return tangled.RepoIssue{
43
-
Repo: i.RepoAt.String(),
44
-
Title: i.Title,
45
-
Body: &i.Body,
46
-
CreatedAt: i.Created.Format(time.RFC3339),
47
-
}
48
-
}
49
-
50
-
func (i *Issue) State() string {
51
-
if i.Open {
52
-
return "open"
53
-
}
54
-
return "closed"
55
-
}
56
-
57
-
type CommentListItem struct {
58
-
Self *IssueComment
59
-
Replies []*IssueComment
60
-
}
61
-
62
-
func (i *Issue) CommentList() []CommentListItem {
63
-
// Create a map to quickly find comments by their aturi
64
-
toplevel := make(map[string]*CommentListItem)
65
-
var replies []*IssueComment
66
-
67
-
// collect top level comments into the map
68
-
for _, comment := range i.Comments {
69
-
if comment.IsTopLevel() {
70
-
toplevel[comment.AtUri().String()] = &CommentListItem{
71
-
Self: &comment,
72
-
}
73
-
} else {
74
-
replies = append(replies, &comment)
75
-
}
76
-
}
77
-
78
-
for _, r := range replies {
79
-
parentAt := *r.ReplyTo
80
-
if parent, exists := toplevel[parentAt]; exists {
81
-
parent.Replies = append(parent.Replies, r)
82
-
}
83
-
}
84
-
85
-
var listing []CommentListItem
86
-
for _, v := range toplevel {
87
-
listing = append(listing, *v)
88
-
}
89
-
90
-
// sort everything
91
-
sortFunc := func(a, b *IssueComment) bool {
92
-
return a.Created.Before(b.Created)
93
-
}
94
-
sort.Slice(listing, func(i, j int) bool {
95
-
return sortFunc(listing[i].Self, listing[j].Self)
96
-
})
97
-
for _, r := range listing {
98
-
sort.Slice(r.Replies, func(i, j int) bool {
99
-
return sortFunc(r.Replies[i], r.Replies[j])
100
-
})
101
-
}
102
-
103
-
return listing
104
-
}
105
-
106
-
func (i *Issue) Participants() []string {
107
-
participantSet := make(map[string]struct{})
108
-
participants := []string{}
109
-
110
-
addParticipant := func(did string) {
111
-
if _, exists := participantSet[did]; !exists {
112
-
participantSet[did] = struct{}{}
113
-
participants = append(participants, did)
114
-
}
115
-
}
116
-
117
-
addParticipant(i.Did)
118
-
119
-
for _, c := range i.Comments {
120
-
addParticipant(c.Did)
121
-
}
122
-
123
-
return participants
124
-
}
125
-
126
-
func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue {
127
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
128
-
if err != nil {
129
-
created = time.Now()
130
-
}
131
-
132
-
body := ""
133
-
if record.Body != nil {
134
-
body = *record.Body
135
-
}
136
-
137
-
return Issue{
138
-
RepoAt: syntax.ATURI(record.Repo),
139
-
Did: did,
140
-
Rkey: rkey,
141
-
Created: created,
142
-
Title: record.Title,
143
-
Body: body,
144
-
Open: true, // new issues are open by default
145
-
}
146
-
}
147
-
148
-
type IssueComment struct {
149
-
Id int64
150
-
Did string
151
-
Rkey string
152
-
IssueAt string
153
-
ReplyTo *string
154
-
Body string
155
-
Created time.Time
156
-
Edited *time.Time
157
-
Deleted *time.Time
158
-
}
159
-
160
-
func (i *IssueComment) AtUri() syntax.ATURI {
161
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey))
162
-
}
163
-
164
-
func (i *IssueComment) AsRecord() tangled.RepoIssueComment {
165
-
return tangled.RepoIssueComment{
166
-
Body: i.Body,
167
-
Issue: i.IssueAt,
168
-
CreatedAt: i.Created.Format(time.RFC3339),
169
-
ReplyTo: i.ReplyTo,
170
-
}
171
-
}
172
-
173
-
func (i *IssueComment) IsTopLevel() bool {
174
-
return i.ReplyTo == nil
175
-
}
176
-
177
-
func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
178
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
179
-
if err != nil {
180
-
created = time.Now()
181
-
}
182
-
183
-
ownerDid := did
184
-
185
-
if _, err = syntax.ParseATURI(record.Issue); err != nil {
186
-
return nil, err
187
-
}
188
-
189
-
comment := IssueComment{
190
-
Did: ownerDid,
191
-
Rkey: rkey,
192
-
Body: record.Body,
193
-
IssueAt: record.Issue,
194
-
ReplyTo: record.ReplyTo,
195
-
Created: created,
196
-
}
197
-
198
-
return &comment, nil
199
-
}
200
-
201
-
func PutIssue(tx *sql.Tx, issue *Issue) error {
202
// ensure sequence exists
203
_, err := tx.Exec(`
204
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
233
}
234
}
235
236
-
func createNewIssue(tx *sql.Tx, issue *Issue) error {
237
// get next issue_id
238
var newIssueId int
239
err := tx.QueryRow(`
240
-
update repo_issue_seqs
241
-
set next_issue_id = next_issue_id + 1
242
-
where repo_at = ?
243
returning next_issue_id - 1
244
`, issue.RepoAt).Scan(&newIssueId)
245
if err != nil {
···
256
return row.Scan(&issue.Id, &issue.IssueId)
257
}
258
259
-
func updateIssue(tx *sql.Tx, issue *Issue) error {
260
// update existing issue
261
_, err := tx.Exec(`
262
update issues
···
266
return err
267
}
268
269
-
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) {
270
-
issueMap := make(map[string]*Issue) // at-uri -> issue
271
272
var conditions []string
273
var args []any
···
322
defer rows.Close()
323
324
for rows.Next() {
325
-
var issue Issue
326
var createdAt string
327
var editedAt, deletedAt sql.Null[string]
328
var rowNum int64
···
375
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
376
}
377
378
-
repoMap := make(map[string]*Repo)
379
for i := range repos {
380
repoMap[string(repos[i].RepoAt())] = &repos[i]
381
}
···
415
}
416
}
417
418
-
var issues []Issue
419
for _, i := range issueMap {
420
issues = append(issues, *i)
421
}
···
427
return issues, nil
428
}
429
430
-
func GetIssues(e Execer, filters ...filter) ([]Issue, error) {
431
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
432
}
433
434
-
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
435
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
436
row := e.QueryRow(query, repoAt, issueId)
437
438
-
var issue Issue
439
var createdAt string
440
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
441
if err != nil {
···
451
return &issue, nil
452
}
453
454
-
func AddIssueComment(e Execer, c IssueComment) (int64, error) {
455
result, err := e.Exec(
456
`insert into issue_comments (
457
did,
···
513
return err
514
}
515
516
-
func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) {
517
-
var comments []IssueComment
518
519
var conditions []string
520
var args []any
···
550
}
551
552
for rows.Next() {
553
-
var comment IssueComment
554
var created string
555
var rkey, edited, deleted, replyTo sql.Null[string]
556
err := rows.Scan(
···
657
return err
658
}
659
660
-
type IssueCount struct {
661
-
Open int
662
-
Closed int
663
-
}
664
-
665
-
func GetIssueCount(e Execer, repoAt syntax.ATURI) (IssueCount, error) {
666
row := e.QueryRow(`
667
select
668
count(case when open = 1 then 1 end) as open_count,
···
672
repoAt,
673
)
674
675
-
var count IssueCount
676
if err := row.Scan(&count.Open, &count.Closed); err != nil {
677
-
return IssueCount{0, 0}, err
678
}
679
680
return count, nil
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pagination"
15
)
16
17
+
func PutIssue(tx *sql.Tx, issue *models.Issue) error {
18
// ensure sequence exists
19
_, err := tx.Exec(`
20
insert or ignore into repo_issue_seqs (repo_at, next_issue_id)
···
49
}
50
}
51
52
+
func createNewIssue(tx *sql.Tx, issue *models.Issue) error {
53
// get next issue_id
54
var newIssueId int
55
err := tx.QueryRow(`
56
+
update repo_issue_seqs
57
+
set next_issue_id = next_issue_id + 1
58
+
where repo_at = ?
59
returning next_issue_id - 1
60
`, issue.RepoAt).Scan(&newIssueId)
61
if err != nil {
···
72
return row.Scan(&issue.Id, &issue.IssueId)
73
}
74
75
+
func updateIssue(tx *sql.Tx, issue *models.Issue) error {
76
// update existing issue
77
_, err := tx.Exec(`
78
update issues
···
82
return err
83
}
84
85
+
func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) {
86
+
issueMap := make(map[string]*models.Issue) // at-uri -> issue
87
88
var conditions []string
89
var args []any
···
138
defer rows.Close()
139
140
for rows.Next() {
141
+
var issue models.Issue
142
var createdAt string
143
var editedAt, deletedAt sql.Null[string]
144
var rowNum int64
···
191
return nil, fmt.Errorf("failed to build repo mappings: %w", err)
192
}
193
194
+
repoMap := make(map[string]*models.Repo)
195
for i := range repos {
196
repoMap[string(repos[i].RepoAt())] = &repos[i]
197
}
···
231
}
232
}
233
234
+
var issues []models.Issue
235
for _, i := range issueMap {
236
issues = append(issues, *i)
237
}
···
243
return issues, nil
244
}
245
246
+
func GetIssues(e Execer, filters ...filter) ([]models.Issue, error) {
247
return GetIssuesPaginated(e, pagination.FirstPage(), filters...)
248
}
249
250
+
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*models.Issue, error) {
251
query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?`
252
row := e.QueryRow(query, repoAt, issueId)
253
254
+
var issue models.Issue
255
var createdAt string
256
err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open)
257
if err != nil {
···
267
return &issue, nil
268
}
269
270
+
func AddIssueComment(e Execer, c models.IssueComment) (int64, error) {
271
result, err := e.Exec(
272
`insert into issue_comments (
273
did,
···
329
return err
330
}
331
332
+
func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) {
333
+
var comments []models.IssueComment
334
335
var conditions []string
336
var args []any
···
366
}
367
368
for rows.Next() {
369
+
var comment models.IssueComment
370
var created string
371
var rkey, edited, deleted, replyTo sql.Null[string]
372
err := rows.Scan(
···
473
return err
474
}
475
476
+
func GetIssueCount(e Execer, repoAt syntax.ATURI) (models.IssueCount, error) {
477
row := e.QueryRow(`
478
select
479
count(case when open = 1 then 1 end) as open_count,
···
483
repoAt,
484
)
485
486
+
var count models.IssueCount
487
if err := row.Scan(&count.Open, &count.Closed); err != nil {
488
+
return models.IssueCount{}, err
489
}
490
491
return count, nil
+33
-496
appview/db/label.go
+33
-496
appview/db/label.go
···
1
package db
2
3
import (
4
-
"crypto/sha1"
5
"database/sql"
6
-
"encoding/hex"
7
-
"errors"
8
"fmt"
9
"maps"
10
"slices"
···
12
"time"
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/consts"
17
-
)
18
-
19
-
type ConcreteType string
20
-
21
-
const (
22
-
ConcreteTypeNull ConcreteType = "null"
23
-
ConcreteTypeString ConcreteType = "string"
24
-
ConcreteTypeInt ConcreteType = "integer"
25
-
ConcreteTypeBool ConcreteType = "boolean"
26
-
)
27
-
28
-
type ValueTypeFormat string
29
-
30
-
const (
31
-
ValueTypeFormatAny ValueTypeFormat = "any"
32
-
ValueTypeFormatDid ValueTypeFormat = "did"
33
)
34
35
-
// ValueType represents an atproto lexicon type definition with constraints
36
-
type ValueType struct {
37
-
Type ConcreteType `json:"type"`
38
-
Format ValueTypeFormat `json:"format,omitempty"`
39
-
Enum []string `json:"enum,omitempty"`
40
-
}
41
-
42
-
func (vt *ValueType) AsRecord() tangled.LabelDefinition_ValueType {
43
-
return tangled.LabelDefinition_ValueType{
44
-
Type: string(vt.Type),
45
-
Format: string(vt.Format),
46
-
Enum: vt.Enum,
47
-
}
48
-
}
49
-
50
-
func ValueTypeFromRecord(record tangled.LabelDefinition_ValueType) ValueType {
51
-
return ValueType{
52
-
Type: ConcreteType(record.Type),
53
-
Format: ValueTypeFormat(record.Format),
54
-
Enum: record.Enum,
55
-
}
56
-
}
57
-
58
-
func (vt ValueType) IsConcreteType() bool {
59
-
return vt.Type == ConcreteTypeNull ||
60
-
vt.Type == ConcreteTypeString ||
61
-
vt.Type == ConcreteTypeInt ||
62
-
vt.Type == ConcreteTypeBool
63
-
}
64
-
65
-
func (vt ValueType) IsNull() bool {
66
-
return vt.Type == ConcreteTypeNull
67
-
}
68
-
69
-
func (vt ValueType) IsString() bool {
70
-
return vt.Type == ConcreteTypeString
71
-
}
72
-
73
-
func (vt ValueType) IsInt() bool {
74
-
return vt.Type == ConcreteTypeInt
75
-
}
76
-
77
-
func (vt ValueType) IsBool() bool {
78
-
return vt.Type == ConcreteTypeBool
79
-
}
80
-
81
-
func (vt ValueType) IsEnum() bool {
82
-
return len(vt.Enum) > 0
83
-
}
84
-
85
-
func (vt ValueType) IsDidFormat() bool {
86
-
return vt.Format == ValueTypeFormatDid
87
-
}
88
-
89
-
func (vt ValueType) IsAnyFormat() bool {
90
-
return vt.Format == ValueTypeFormatAny
91
-
}
92
-
93
-
type LabelDefinition struct {
94
-
Id int64
95
-
Did string
96
-
Rkey string
97
-
98
-
Name string
99
-
ValueType ValueType
100
-
Scope []string
101
-
Color *string
102
-
Multiple bool
103
-
Created time.Time
104
-
}
105
-
106
-
func (l *LabelDefinition) AtUri() syntax.ATURI {
107
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", l.Did, tangled.LabelDefinitionNSID, l.Rkey))
108
-
}
109
-
110
-
func (l *LabelDefinition) AsRecord() tangled.LabelDefinition {
111
-
vt := l.ValueType.AsRecord()
112
-
return tangled.LabelDefinition{
113
-
Name: l.Name,
114
-
Color: l.Color,
115
-
CreatedAt: l.Created.Format(time.RFC3339),
116
-
Multiple: &l.Multiple,
117
-
Scope: l.Scope,
118
-
ValueType: &vt,
119
-
}
120
-
}
121
-
122
-
// random color for a given seed
123
-
func randomColor(seed string) string {
124
-
hash := sha1.Sum([]byte(seed))
125
-
hexStr := hex.EncodeToString(hash[:])
126
-
r := hexStr[0:2]
127
-
g := hexStr[2:4]
128
-
b := hexStr[4:6]
129
-
130
-
return fmt.Sprintf("#%s%s%s", r, g, b)
131
-
}
132
-
133
-
func (ld LabelDefinition) GetColor() string {
134
-
if ld.Color == nil {
135
-
seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey)
136
-
color := randomColor(seed)
137
-
return color
138
-
}
139
-
140
-
return *ld.Color
141
-
}
142
-
143
-
func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) {
144
-
created, err := time.Parse(time.RFC3339, record.CreatedAt)
145
-
if err != nil {
146
-
created = time.Now()
147
-
}
148
-
149
-
multiple := false
150
-
if record.Multiple != nil {
151
-
multiple = *record.Multiple
152
-
}
153
-
154
-
var vt ValueType
155
-
if record.ValueType != nil {
156
-
vt = ValueTypeFromRecord(*record.ValueType)
157
-
}
158
-
159
-
return &LabelDefinition{
160
-
Did: did,
161
-
Rkey: rkey,
162
-
163
-
Name: record.Name,
164
-
ValueType: vt,
165
-
Scope: record.Scope,
166
-
Color: record.Color,
167
-
Multiple: multiple,
168
-
Created: created,
169
-
}, nil
170
-
}
171
-
172
-
func DeleteLabelDefinition(e Execer, filters ...filter) error {
173
-
var conditions []string
174
-
var args []any
175
-
for _, filter := range filters {
176
-
conditions = append(conditions, filter.Condition())
177
-
args = append(args, filter.Arg()...)
178
-
}
179
-
whereClause := ""
180
-
if conditions != nil {
181
-
whereClause = " where " + strings.Join(conditions, " and ")
182
-
}
183
-
query := fmt.Sprintf(`delete from label_definitions %s`, whereClause)
184
-
_, err := e.Exec(query, args...)
185
-
return err
186
-
}
187
-
188
// no updating type for now
189
-
func AddLabelDefinition(e Execer, l *LabelDefinition) (int64, error) {
190
result, err := e.Exec(
191
`insert into label_definitions (
192
did,
···
232
return id, nil
233
}
234
235
-
func GetLabelDefinitions(e Execer, filters ...filter) ([]LabelDefinition, error) {
236
-
var labelDefinitions []LabelDefinition
237
var conditions []string
238
var args []any
239
···
275
defer rows.Close()
276
277
for rows.Next() {
278
-
var labelDefinition LabelDefinition
279
var createdAt, enumVariants, scopes string
280
var color sql.Null[string]
281
var multiple int
···
324
}
325
326
// helper to get exactly one label def
327
-
func GetLabelDefinition(e Execer, filters ...filter) (*LabelDefinition, error) {
328
labels, err := GetLabelDefinitions(e, filters...)
329
if err != nil {
330
return nil, err
···
341
return &labels[0], nil
342
}
343
344
-
type LabelOp struct {
345
-
Id int64
346
-
Did string
347
-
Rkey string
348
-
Subject syntax.ATURI
349
-
Operation LabelOperation
350
-
OperandKey string
351
-
OperandValue string
352
-
PerformedAt time.Time
353
-
IndexedAt time.Time
354
-
}
355
-
356
-
func (l LabelOp) SortAt() time.Time {
357
-
createdAt := l.PerformedAt
358
-
indexedAt := l.IndexedAt
359
-
360
-
// if we don't have an indexedat, fall back to now
361
-
if indexedAt.IsZero() {
362
-
indexedAt = time.Now()
363
-
}
364
-
365
-
// if createdat is invalid (before epoch), treat as null -> return zero time
366
-
if createdAt.Before(time.UnixMicro(0)) {
367
-
return time.Time{}
368
-
}
369
-
370
-
// if createdat is <= indexedat, use createdat
371
-
if createdAt.Before(indexedAt) || createdAt.Equal(indexedAt) {
372
-
return createdAt
373
-
}
374
-
375
-
// otherwise, createdat is in the future relative to indexedat -> use indexedat
376
-
return indexedAt
377
-
}
378
-
379
-
type LabelOperation string
380
-
381
-
const (
382
-
LabelOperationAdd LabelOperation = "add"
383
-
LabelOperationDel LabelOperation = "del"
384
-
)
385
-
386
-
// a record can create multiple label ops
387
-
func LabelOpsFromRecord(did, rkey string, record tangled.LabelOp) []LabelOp {
388
-
performed, err := time.Parse(time.RFC3339, record.PerformedAt)
389
-
if err != nil {
390
-
performed = time.Now()
391
-
}
392
-
393
-
mkOp := func(operand *tangled.LabelOp_Operand) LabelOp {
394
-
return LabelOp{
395
-
Did: did,
396
-
Rkey: rkey,
397
-
Subject: syntax.ATURI(record.Subject),
398
-
OperandKey: operand.Key,
399
-
OperandValue: operand.Value,
400
-
PerformedAt: performed,
401
-
}
402
-
}
403
-
404
-
var ops []LabelOp
405
-
for _, o := range record.Add {
406
-
if o != nil {
407
-
op := mkOp(o)
408
-
op.Operation = LabelOperationAdd
409
-
ops = append(ops, op)
410
-
}
411
-
}
412
-
for _, o := range record.Delete {
413
-
if o != nil {
414
-
op := mkOp(o)
415
-
op.Operation = LabelOperationDel
416
-
ops = append(ops, op)
417
-
}
418
-
}
419
-
420
-
return ops
421
-
}
422
-
423
-
func LabelOpsAsRecord(ops []LabelOp) tangled.LabelOp {
424
-
if len(ops) == 0 {
425
-
return tangled.LabelOp{}
426
-
}
427
-
428
-
// use the first operation to establish common fields
429
-
first := ops[0]
430
-
record := tangled.LabelOp{
431
-
Subject: string(first.Subject),
432
-
PerformedAt: first.PerformedAt.Format(time.RFC3339),
433
-
}
434
-
435
-
var addOperands []*tangled.LabelOp_Operand
436
-
var deleteOperands []*tangled.LabelOp_Operand
437
-
438
-
for _, op := range ops {
439
-
operand := &tangled.LabelOp_Operand{
440
-
Key: op.OperandKey,
441
-
Value: op.OperandValue,
442
-
}
443
-
444
-
switch op.Operation {
445
-
case LabelOperationAdd:
446
-
addOperands = append(addOperands, operand)
447
-
case LabelOperationDel:
448
-
deleteOperands = append(deleteOperands, operand)
449
-
default:
450
-
return tangled.LabelOp{}
451
-
}
452
-
}
453
-
454
-
record.Add = addOperands
455
-
record.Delete = deleteOperands
456
-
457
-
return record
458
-
}
459
-
460
-
func AddLabelOp(e Execer, l *LabelOp) (int64, error) {
461
now := time.Now()
462
result, err := e.Exec(
463
`insert into label_ops (
···
500
return id, nil
501
}
502
503
-
func GetLabelOps(e Execer, filters ...filter) ([]LabelOp, error) {
504
-
var labelOps []LabelOp
505
var conditions []string
506
var args []any
507
···
541
defer rows.Close()
542
543
for rows.Next() {
544
-
var labelOp LabelOp
545
var performedAt, indexedAt string
546
547
if err := rows.Scan(
···
575
}
576
577
// get labels for a given list of subject URIs
578
-
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]LabelState, error) {
579
ops, err := GetLabelOps(e, filters...)
580
if err != nil {
581
return nil, err
582
}
583
584
// group ops by subject
585
-
opsBySubject := make(map[syntax.ATURI][]LabelOp)
586
for _, op := range ops {
587
subject := syntax.ATURI(op.Subject)
588
opsBySubject[subject] = append(opsBySubject[subject], op)
···
601
}
602
603
// apply label ops for each subject and collect results
604
-
results := make(map[syntax.ATURI]LabelState)
605
for subject, subjectOps := range opsBySubject {
606
-
state := NewLabelState()
607
actx.ApplyLabelOps(state, subjectOps)
608
results[subject] = state
609
}
···
611
return results, nil
612
}
613
614
-
type set = map[string]struct{}
615
-
616
-
type LabelState struct {
617
-
inner map[string]set
618
-
}
619
-
620
-
func NewLabelState() LabelState {
621
-
return LabelState{
622
-
inner: make(map[string]set),
623
-
}
624
-
}
625
-
626
-
func (s LabelState) Inner() map[string]set {
627
-
return s.inner
628
-
}
629
-
630
-
func (s LabelState) ContainsLabel(l string) bool {
631
-
if valset, exists := s.inner[l]; exists {
632
-
if valset != nil {
633
-
return true
634
-
}
635
-
}
636
-
637
-
return false
638
-
}
639
-
640
-
// go maps behavior in templates make this necessary,
641
-
// indexing a map and getting `set` in return is apparently truthy
642
-
func (s LabelState) ContainsLabelAndVal(l, v string) bool {
643
-
if valset, exists := s.inner[l]; exists {
644
-
if _, exists := valset[v]; exists {
645
-
return true
646
-
}
647
-
}
648
-
649
-
return false
650
-
}
651
-
652
-
func (s LabelState) GetValSet(l string) set {
653
-
if valset, exists := s.inner[l]; exists {
654
-
return valset
655
-
} else {
656
-
return make(set)
657
-
}
658
-
}
659
-
660
-
type LabelApplicationCtx struct {
661
-
Defs map[string]*LabelDefinition // labelAt -> labelDef
662
-
}
663
-
664
-
var (
665
-
LabelNoOpError = errors.New("no-op")
666
-
)
667
-
668
-
func NewLabelApplicationCtx(e Execer, filters ...filter) (*LabelApplicationCtx, error) {
669
labels, err := GetLabelDefinitions(e, filters...)
670
if err != nil {
671
return nil, err
672
}
673
674
-
defs := make(map[string]*LabelDefinition)
675
for _, l := range labels {
676
defs[l.AtUri().String()] = &l
677
}
678
679
-
return &LabelApplicationCtx{defs}, nil
680
-
}
681
-
682
-
func (c *LabelApplicationCtx) ApplyLabelOp(state LabelState, op LabelOp) error {
683
-
def, ok := c.Defs[op.OperandKey]
684
-
if !ok {
685
-
// this def was deleted, but an op exists, so we just skip over the op
686
-
return nil
687
-
}
688
-
689
-
switch op.Operation {
690
-
case LabelOperationAdd:
691
-
// if valueset is empty, init it
692
-
if state.inner[op.OperandKey] == nil {
693
-
state.inner[op.OperandKey] = make(set)
694
-
}
695
-
696
-
// if valueset is populated & this val alr exists, this labelop is a noop
697
-
if valueSet, exists := state.inner[op.OperandKey]; exists {
698
-
if _, exists = valueSet[op.OperandValue]; exists {
699
-
return LabelNoOpError
700
-
}
701
-
}
702
-
703
-
if def.Multiple {
704
-
// append to set
705
-
state.inner[op.OperandKey][op.OperandValue] = struct{}{}
706
-
} else {
707
-
// reset to just this value
708
-
state.inner[op.OperandKey] = set{op.OperandValue: struct{}{}}
709
-
}
710
-
711
-
case LabelOperationDel:
712
-
// if label DNE, then deletion is a no-op
713
-
if valueSet, exists := state.inner[op.OperandKey]; !exists {
714
-
return LabelNoOpError
715
-
} else if _, exists = valueSet[op.OperandValue]; !exists { // if value DNE, then deletion is no-op
716
-
return LabelNoOpError
717
-
}
718
-
719
-
if def.Multiple {
720
-
// remove from set
721
-
delete(state.inner[op.OperandKey], op.OperandValue)
722
-
} else {
723
-
// reset the entire label
724
-
delete(state.inner, op.OperandKey)
725
-
}
726
-
727
-
// if the map becomes empty, then set it to nil, this is just the inverse of add
728
-
if len(state.inner[op.OperandKey]) == 0 {
729
-
state.inner[op.OperandKey] = nil
730
-
}
731
-
732
-
}
733
-
734
-
return nil
735
-
}
736
-
737
-
func (c *LabelApplicationCtx) ApplyLabelOps(state LabelState, ops []LabelOp) {
738
-
// sort label ops in sort order first
739
-
slices.SortFunc(ops, func(a, b LabelOp) int {
740
-
return a.SortAt().Compare(b.SortAt())
741
-
})
742
-
743
-
// apply ops in sequence
744
-
for _, o := range ops {
745
-
_ = c.ApplyLabelOp(state, o)
746
-
}
747
-
}
748
-
749
-
// IsInverse checks if one label operation is the inverse of another
750
-
// returns true if one is an add and the other is a delete with the same key and value
751
-
func (op1 LabelOp) IsInverse(op2 LabelOp) bool {
752
-
if op1.OperandKey != op2.OperandKey || op1.OperandValue != op2.OperandValue {
753
-
return false
754
-
}
755
-
756
-
return (op1.Operation == LabelOperationAdd && op2.Operation == LabelOperationDel) ||
757
-
(op1.Operation == LabelOperationDel && op2.Operation == LabelOperationAdd)
758
-
}
759
-
760
-
// removes pairs of label operations that are inverses of each other
761
-
// from the given slice. the function preserves the order of remaining operations.
762
-
func ReduceLabelOps(ops []LabelOp) []LabelOp {
763
-
if len(ops) <= 1 {
764
-
return ops
765
-
}
766
-
767
-
keep := make([]bool, len(ops))
768
-
for i := range keep {
769
-
keep[i] = true
770
-
}
771
-
772
-
for i := range ops {
773
-
if !keep[i] {
774
-
continue
775
-
}
776
-
777
-
for j := i + 1; j < len(ops); j++ {
778
-
if !keep[j] {
779
-
continue
780
-
}
781
-
782
-
if ops[i].IsInverse(ops[j]) {
783
-
keep[i] = false
784
-
keep[j] = false
785
-
break // move to next i since this one is now eliminated
786
-
}
787
-
}
788
-
}
789
-
790
-
// build result slice with only kept operations
791
-
var result []LabelOp
792
-
for i, op := range ops {
793
-
if keep[i] {
794
-
result = append(result, op)
795
-
}
796
-
}
797
-
798
-
return result
799
-
}
800
-
801
-
func DefaultLabelDefs() []string {
802
-
rkeys := []string{
803
-
"wontfix",
804
-
"duplicate",
805
-
"assignee",
806
-
"good-first-issue",
807
-
"documentation",
808
-
}
809
-
810
-
defs := make([]string, len(rkeys))
811
-
for i, r := range rkeys {
812
-
defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r)
813
-
}
814
-
815
-
return defs
816
}
···
1
package db
2
3
import (
4
"database/sql"
5
"fmt"
6
"maps"
7
"slices"
···
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/core/appview/models"
13
)
14
15
// no updating type for now
16
+
func AddLabelDefinition(e Execer, l *models.LabelDefinition) (int64, error) {
17
result, err := e.Exec(
18
`insert into label_definitions (
19
did,
···
59
return id, nil
60
}
61
62
+
func DeleteLabelDefinition(e Execer, filters ...filter) error {
63
+
var conditions []string
64
+
var args []any
65
+
for _, filter := range filters {
66
+
conditions = append(conditions, filter.Condition())
67
+
args = append(args, filter.Arg()...)
68
+
}
69
+
whereClause := ""
70
+
if conditions != nil {
71
+
whereClause = " where " + strings.Join(conditions, " and ")
72
+
}
73
+
query := fmt.Sprintf(`delete from label_definitions %s`, whereClause)
74
+
_, err := e.Exec(query, args...)
75
+
return err
76
+
}
77
+
78
+
func GetLabelDefinitions(e Execer, filters ...filter) ([]models.LabelDefinition, error) {
79
+
var labelDefinitions []models.LabelDefinition
80
var conditions []string
81
var args []any
82
···
118
defer rows.Close()
119
120
for rows.Next() {
121
+
var labelDefinition models.LabelDefinition
122
var createdAt, enumVariants, scopes string
123
var color sql.Null[string]
124
var multiple int
···
167
}
168
169
// helper to get exactly one label def
170
+
func GetLabelDefinition(e Execer, filters ...filter) (*models.LabelDefinition, error) {
171
labels, err := GetLabelDefinitions(e, filters...)
172
if err != nil {
173
return nil, err
···
184
return &labels[0], nil
185
}
186
187
+
func AddLabelOp(e Execer, l *models.LabelOp) (int64, error) {
188
now := time.Now()
189
result, err := e.Exec(
190
`insert into label_ops (
···
227
return id, nil
228
}
229
230
+
func GetLabelOps(e Execer, filters ...filter) ([]models.LabelOp, error) {
231
+
var labelOps []models.LabelOp
232
var conditions []string
233
var args []any
234
···
268
defer rows.Close()
269
270
for rows.Next() {
271
+
var labelOp models.LabelOp
272
var performedAt, indexedAt string
273
274
if err := rows.Scan(
···
302
}
303
304
// get labels for a given list of subject URIs
305
+
func GetLabels(e Execer, filters ...filter) (map[syntax.ATURI]models.LabelState, error) {
306
ops, err := GetLabelOps(e, filters...)
307
if err != nil {
308
return nil, err
309
}
310
311
// group ops by subject
312
+
opsBySubject := make(map[syntax.ATURI][]models.LabelOp)
313
for _, op := range ops {
314
subject := syntax.ATURI(op.Subject)
315
opsBySubject[subject] = append(opsBySubject[subject], op)
···
328
}
329
330
// apply label ops for each subject and collect results
331
+
results := make(map[syntax.ATURI]models.LabelState)
332
for subject, subjectOps := range opsBySubject {
333
+
state := models.NewLabelState()
334
actx.ApplyLabelOps(state, subjectOps)
335
results[subject] = state
336
}
···
338
return results, nil
339
}
340
341
+
func NewLabelApplicationCtx(e Execer, filters ...filter) (*models.LabelApplicationCtx, error) {
342
labels, err := GetLabelDefinitions(e, filters...)
343
if err != nil {
344
return nil, err
345
}
346
347
+
defs := make(map[string]*models.LabelDefinition)
348
for _, l := range labels {
349
defs[l.AtUri().String()] = &l
350
}
351
352
+
return &models.LabelApplicationCtx{Defs: defs}, nil
353
}
+38
-13
appview/db/language.go
+38
-13
appview/db/language.go
···
1
package db
2
3
import (
4
"fmt"
5
"strings"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
)
9
10
-
type RepoLanguage struct {
11
-
Id int64
12
-
RepoAt syntax.ATURI
13
-
Ref string
14
-
IsDefaultRef bool
15
-
Language string
16
-
Bytes int64
17
-
}
18
-
19
-
func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) {
20
var conditions []string
21
var args []any
22
for _, filter := range filters {
···
39
return nil, fmt.Errorf("failed to execute query: %w ", err)
40
}
41
42
-
var langs []RepoLanguage
43
for rows.Next() {
44
-
var rl RepoLanguage
45
var isDefaultRef int
46
47
err := rows.Scan(
···
69
return langs, nil
70
}
71
72
-
func InsertRepoLanguages(e Execer, langs []RepoLanguage) error {
73
stmt, err := e.Prepare(
74
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
75
)
···
91
92
return nil
93
}
···
1
package db
2
3
import (
4
+
"database/sql"
5
"fmt"
6
"strings"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetRepoLanguages(e Execer, filters ...filter) ([]models.RepoLanguage, error) {
13
var conditions []string
14
var args []any
15
for _, filter := range filters {
···
32
return nil, fmt.Errorf("failed to execute query: %w ", err)
33
}
34
35
+
var langs []models.RepoLanguage
36
for rows.Next() {
37
+
var rl models.RepoLanguage
38
var isDefaultRef int
39
40
err := rows.Scan(
···
62
return langs, nil
63
}
64
65
+
func InsertRepoLanguages(e Execer, langs []models.RepoLanguage) error {
66
stmt, err := e.Prepare(
67
"insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)",
68
)
···
84
85
return nil
86
}
87
+
88
+
func DeleteRepoLanguages(e Execer, filters ...filter) error {
89
+
var conditions []string
90
+
var args []any
91
+
for _, filter := range filters {
92
+
conditions = append(conditions, filter.Condition())
93
+
args = append(args, filter.Arg()...)
94
+
}
95
+
96
+
whereClause := ""
97
+
if conditions != nil {
98
+
whereClause = " where " + strings.Join(conditions, " and ")
99
+
}
100
+
101
+
query := fmt.Sprintf(`delete from repo_languages %s`, whereClause)
102
+
103
+
_, err := e.Exec(query, args...)
104
+
return err
105
+
}
106
+
107
+
func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error {
108
+
err := DeleteRepoLanguages(
109
+
tx,
110
+
FilterEq("repo_at", repoAt),
111
+
FilterEq("ref", ref),
112
+
)
113
+
if err != nil {
114
+
return fmt.Errorf("failed to delete existing languages: %w", err)
115
+
}
116
+
117
+
return InsertRepoLanguages(tx, langs)
118
+
}
+450
appview/db/notifications.go
+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
"strings"
7
"time"
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"github.com/go-git/go-git/v5/plumbing"
11
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
12
-
"tangled.sh/tangled.sh/core/workflow"
13
)
14
15
-
type Pipeline struct {
16
-
Id int
17
-
Rkey string
18
-
Knot string
19
-
RepoOwner syntax.DID
20
-
RepoName string
21
-
TriggerId int
22
-
Sha string
23
-
Created time.Time
24
-
25
-
// populate when querying for reverse mappings
26
-
Trigger *Trigger
27
-
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
type WorkflowStatus struct {
31
-
Data []PipelineStatus
32
-
}
33
-
34
-
func (w WorkflowStatus) Latest() PipelineStatus {
35
-
return w.Data[len(w.Data)-1]
36
-
}
37
-
38
-
// time taken by this workflow to reach an "end state"
39
-
func (w WorkflowStatus) TimeTaken() time.Duration {
40
-
var start, end *time.Time
41
-
for _, s := range w.Data {
42
-
if s.Status.IsStart() {
43
-
start = &s.Created
44
-
}
45
-
if s.Status.IsFinish() {
46
-
end = &s.Created
47
-
}
48
-
}
49
-
50
-
if start != nil && end != nil && end.After(*start) {
51
-
return end.Sub(*start)
52
-
}
53
-
54
-
return 0
55
-
}
56
-
57
-
func (p Pipeline) Counts() map[string]int {
58
-
m := make(map[string]int)
59
-
for _, w := range p.Statuses {
60
-
m[w.Latest().Status.String()] += 1
61
-
}
62
-
return m
63
-
}
64
-
65
-
func (p Pipeline) TimeTaken() time.Duration {
66
-
var s time.Duration
67
-
for _, w := range p.Statuses {
68
-
s += w.TimeTaken()
69
-
}
70
-
return s
71
-
}
72
-
73
-
func (p Pipeline) Workflows() []string {
74
-
var ws []string
75
-
for v := range p.Statuses {
76
-
ws = append(ws, v)
77
-
}
78
-
slices.Sort(ws)
79
-
return ws
80
-
}
81
-
82
-
// if we know that a spindle has picked up this pipeline, then it is Responding
83
-
func (p Pipeline) IsResponding() bool {
84
-
return len(p.Statuses) != 0
85
-
}
86
-
87
-
type Trigger struct {
88
-
Id int
89
-
Kind workflow.TriggerKind
90
-
91
-
// push trigger fields
92
-
PushRef *string
93
-
PushNewSha *string
94
-
PushOldSha *string
95
-
96
-
// pull request trigger fields
97
-
PRSourceBranch *string
98
-
PRTargetBranch *string
99
-
PRSourceSha *string
100
-
PRAction *string
101
-
}
102
-
103
-
func (t *Trigger) IsPush() bool {
104
-
return t != nil && t.Kind == workflow.TriggerKindPush
105
-
}
106
-
107
-
func (t *Trigger) IsPullRequest() bool {
108
-
return t != nil && t.Kind == workflow.TriggerKindPullRequest
109
-
}
110
-
111
-
func (t *Trigger) TargetRef() string {
112
-
if t.IsPush() {
113
-
return plumbing.ReferenceName(*t.PushRef).Short()
114
-
} else if t.IsPullRequest() {
115
-
return *t.PRTargetBranch
116
-
}
117
-
118
-
return ""
119
-
}
120
-
121
-
type PipelineStatus struct {
122
-
ID int
123
-
Spindle string
124
-
Rkey string
125
-
PipelineKnot string
126
-
PipelineRkey string
127
-
Created time.Time
128
-
Workflow string
129
-
Status spindle.StatusKind
130
-
Error *string
131
-
ExitCode int
132
-
}
133
-
134
-
func GetPipelines(e Execer, filters ...filter) ([]Pipeline, error) {
135
-
var pipelines []Pipeline
136
137
var conditions []string
138
var args []any
···
156
defer rows.Close()
157
158
for rows.Next() {
159
-
var pipeline Pipeline
160
var createdAt string
161
err = rows.Scan(
162
&pipeline.Id,
···
185
return pipelines, nil
186
}
187
188
-
func AddPipeline(e Execer, pipeline Pipeline) error {
189
args := []any{
190
pipeline.Rkey,
191
pipeline.Knot,
···
216
return err
217
}
218
219
-
func AddTrigger(e Execer, trigger Trigger) (int64, error) {
220
args := []any{
221
trigger.Kind,
222
trigger.PushRef,
···
252
return res.LastInsertId()
253
}
254
255
-
func AddPipelineStatus(e Execer, status PipelineStatus) error {
256
args := []any{
257
status.Spindle,
258
status.Rkey,
···
290
291
// this is a mega query, but the most useful one:
292
// get N pipelines, for each one get the latest status of its N workflows
293
-
func GetPipelineStatuses(e Execer, filters ...filter) ([]Pipeline, error) {
294
var conditions []string
295
var args []any
296
for _, filter := range filters {
···
335
}
336
defer rows.Close()
337
338
-
pipelines := make(map[string]Pipeline)
339
for rows.Next() {
340
-
var p Pipeline
341
-
var t Trigger
342
var created string
343
344
err := rows.Scan(
···
370
371
t.Id = p.TriggerId
372
p.Trigger = &t
373
-
p.Statuses = make(map[string]WorkflowStatus)
374
375
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
376
pipelines[k] = p
···
409
defer rows.Close()
410
411
for rows.Next() {
412
-
var ps PipelineStatus
413
var created string
414
415
err := rows.Scan(
···
442
}
443
statuses, _ := pipeline.Statuses[ps.Workflow]
444
if !ok {
445
-
pipeline.Statuses[ps.Workflow] = WorkflowStatus{}
446
}
447
448
// append
···
453
pipelines[key] = pipeline
454
}
455
456
-
var all []Pipeline
457
for _, p := range pipelines {
458
for _, s := range p.Statuses {
459
-
slices.SortFunc(s.Data, func(a, b PipelineStatus) int {
460
if a.Created.After(b.Created) {
461
return 1
462
}
···
476
}
477
478
// sort pipelines by date
479
-
slices.SortFunc(all, func(a, b Pipeline) int {
480
if a.Created.After(b.Created) {
481
return -1
482
}
···
6
"strings"
7
"time"
8
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetPipelines(e Execer, filters ...filter) ([]models.Pipeline, error) {
13
+
var pipelines []models.Pipeline
14
15
var conditions []string
16
var args []any
···
34
defer rows.Close()
35
36
for rows.Next() {
37
+
var pipeline models.Pipeline
38
var createdAt string
39
err = rows.Scan(
40
&pipeline.Id,
···
63
return pipelines, nil
64
}
65
66
+
func AddPipeline(e Execer, pipeline models.Pipeline) error {
67
args := []any{
68
pipeline.Rkey,
69
pipeline.Knot,
···
94
return err
95
}
96
97
+
func AddTrigger(e Execer, trigger models.Trigger) (int64, error) {
98
args := []any{
99
trigger.Kind,
100
trigger.PushRef,
···
130
return res.LastInsertId()
131
}
132
133
+
func AddPipelineStatus(e Execer, status models.PipelineStatus) error {
134
args := []any{
135
status.Spindle,
136
status.Rkey,
···
168
169
// this is a mega query, but the most useful one:
170
// get N pipelines, for each one get the latest status of its N workflows
171
+
func GetPipelineStatuses(e Execer, filters ...filter) ([]models.Pipeline, error) {
172
var conditions []string
173
var args []any
174
for _, filter := range filters {
···
213
}
214
defer rows.Close()
215
216
+
pipelines := make(map[string]models.Pipeline)
217
for rows.Next() {
218
+
var p models.Pipeline
219
+
var t models.Trigger
220
var created string
221
222
err := rows.Scan(
···
248
249
t.Id = p.TriggerId
250
p.Trigger = &t
251
+
p.Statuses = make(map[string]models.WorkflowStatus)
252
253
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
254
pipelines[k] = p
···
287
defer rows.Close()
288
289
for rows.Next() {
290
+
var ps models.PipelineStatus
291
var created string
292
293
err := rows.Scan(
···
320
}
321
statuses, _ := pipeline.Statuses[ps.Workflow]
322
if !ok {
323
+
pipeline.Statuses[ps.Workflow] = models.WorkflowStatus{}
324
}
325
326
// append
···
331
pipelines[key] = pipeline
332
}
333
334
+
var all []models.Pipeline
335
for _, p := range pipelines {
336
for _, s := range p.Statuses {
337
+
slices.SortFunc(s.Data, func(a, b models.PipelineStatus) int {
338
if a.Created.After(b.Created) {
339
return 1
340
}
···
354
}
355
356
// sort pipelines by date
357
+
slices.SortFunc(all, func(a, b models.Pipeline) int {
358
if a.Created.After(b.Created) {
359
return -1
360
}
+25
-194
appview/db/profile.go
+25
-194
appview/db/profile.go
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
)
15
16
-
type RepoEvent struct {
17
-
Repo *Repo
18
-
Source *Repo
19
-
}
20
-
21
-
type ProfileTimeline struct {
22
-
ByMonth []ByMonth
23
-
}
24
-
25
-
func (p *ProfileTimeline) IsEmpty() bool {
26
-
if p == nil {
27
-
return true
28
-
}
29
-
30
-
for _, m := range p.ByMonth {
31
-
if !m.IsEmpty() {
32
-
return false
33
-
}
34
-
}
35
-
36
-
return true
37
-
}
38
-
39
-
type ByMonth struct {
40
-
RepoEvents []RepoEvent
41
-
IssueEvents IssueEvents
42
-
PullEvents PullEvents
43
-
}
44
-
45
-
func (b ByMonth) IsEmpty() bool {
46
-
return len(b.RepoEvents) == 0 &&
47
-
len(b.IssueEvents.Items) == 0 &&
48
-
len(b.PullEvents.Items) == 0
49
-
}
50
-
51
-
type IssueEvents struct {
52
-
Items []*Issue
53
-
}
54
-
55
-
type IssueEventStats struct {
56
-
Open int
57
-
Closed int
58
-
}
59
-
60
-
func (i IssueEvents) Stats() IssueEventStats {
61
-
var open, closed int
62
-
for _, issue := range i.Items {
63
-
if issue.Open {
64
-
open += 1
65
-
} else {
66
-
closed += 1
67
-
}
68
-
}
69
-
70
-
return IssueEventStats{
71
-
Open: open,
72
-
Closed: closed,
73
-
}
74
-
}
75
-
76
-
type PullEvents struct {
77
-
Items []*Pull
78
-
}
79
-
80
-
func (p PullEvents) Stats() PullEventStats {
81
-
var open, merged, closed int
82
-
for _, pull := range p.Items {
83
-
switch pull.State {
84
-
case PullOpen:
85
-
open += 1
86
-
case PullMerged:
87
-
merged += 1
88
-
case PullClosed:
89
-
closed += 1
90
-
}
91
-
}
92
-
93
-
return PullEventStats{
94
-
Open: open,
95
-
Merged: merged,
96
-
Closed: closed,
97
-
}
98
-
}
99
-
100
-
type PullEventStats struct {
101
-
Closed int
102
-
Open int
103
-
Merged int
104
-
}
105
-
106
const TimeframeMonths = 7
107
108
-
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
109
-
timeline := ProfileTimeline{
110
-
ByMonth: make([]ByMonth, TimeframeMonths),
111
}
112
currentMonth := time.Now().Month()
113
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
···
162
163
for _, repo := range repos {
164
// TODO: get this in the original query; requires COALESCE because nullable
165
-
var sourceRepo *Repo
166
if repo.Source != "" {
167
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
168
if err != nil {
···
180
idx := currentMonth - repoMonth
181
182
items := &timeline.ByMonth[idx].RepoEvents
183
-
*items = append(*items, RepoEvent{
184
Repo: &repo,
185
Source: sourceRepo,
186
})
···
189
return &timeline, nil
190
}
191
192
-
type Profile struct {
193
-
// ids
194
-
ID int
195
-
Did string
196
-
197
-
// data
198
-
Description string
199
-
IncludeBluesky bool
200
-
Location string
201
-
Links [5]string
202
-
Stats [2]VanityStat
203
-
PinnedRepos [6]syntax.ATURI
204
-
}
205
-
206
-
func (p Profile) IsLinksEmpty() bool {
207
-
for _, l := range p.Links {
208
-
if l != "" {
209
-
return false
210
-
}
211
-
}
212
-
return true
213
-
}
214
-
215
-
func (p Profile) IsStatsEmpty() bool {
216
-
for _, s := range p.Stats {
217
-
if s.Kind != "" {
218
-
return false
219
-
}
220
-
}
221
-
return true
222
-
}
223
-
224
-
func (p Profile) IsPinnedReposEmpty() bool {
225
-
for _, r := range p.PinnedRepos {
226
-
if r != "" {
227
-
return false
228
-
}
229
-
}
230
-
return true
231
-
}
232
-
233
-
type VanityStatKind string
234
-
235
-
const (
236
-
VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count"
237
-
VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count"
238
-
VanityStatOpenPRCount VanityStatKind = "open-pull-request-count"
239
-
VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
240
-
VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
241
-
VanityStatRepositoryCount VanityStatKind = "repository-count"
242
-
)
243
-
244
-
func (v VanityStatKind) String() string {
245
-
switch v {
246
-
case VanityStatMergedPRCount:
247
-
return "Merged PRs"
248
-
case VanityStatClosedPRCount:
249
-
return "Closed PRs"
250
-
case VanityStatOpenPRCount:
251
-
return "Open PRs"
252
-
case VanityStatOpenIssueCount:
253
-
return "Open Issues"
254
-
case VanityStatClosedIssueCount:
255
-
return "Closed Issues"
256
-
case VanityStatRepositoryCount:
257
-
return "Repositories"
258
-
}
259
-
return ""
260
-
}
261
-
262
-
type VanityStat struct {
263
-
Kind VanityStatKind
264
-
Value uint64
265
-
}
266
-
267
-
func (p *Profile) ProfileAt() syntax.ATURI {
268
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self"))
269
-
}
270
-
271
-
func UpsertProfile(tx *sql.Tx, profile *Profile) error {
272
defer tx.Rollback()
273
274
// update links
···
366
return tx.Commit()
367
}
368
369
-
func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) {
370
var conditions []string
371
var args []any
372
for _, filter := range filters {
···
396
return nil, err
397
}
398
399
-
profileMap := make(map[string]*Profile)
400
for rows.Next() {
401
-
var profile Profile
402
var includeBluesky int
403
404
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
···
469
return profileMap, nil
470
}
471
472
-
func GetProfile(e Execer, did string) (*Profile, error) {
473
-
var profile Profile
474
profile.Did = did
475
476
includeBluesky := 0
···
479
did,
480
).Scan(&profile.Description, &includeBluesky, &profile.Location)
481
if err == sql.ErrNoRows {
482
-
profile := Profile{}
483
profile.Did = did
484
return &profile, nil
485
}
···
539
return &profile, nil
540
}
541
542
-
func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) {
543
query := ""
544
var args []any
545
switch stat {
546
-
case VanityStatMergedPRCount:
547
query = `select count(id) from pulls where owner_did = ? and state = ?`
548
-
args = append(args, did, PullMerged)
549
-
case VanityStatClosedPRCount:
550
query = `select count(id) from pulls where owner_did = ? and state = ?`
551
-
args = append(args, did, PullClosed)
552
-
case VanityStatOpenPRCount:
553
query = `select count(id) from pulls where owner_did = ? and state = ?`
554
-
args = append(args, did, PullOpen)
555
-
case VanityStatOpenIssueCount:
556
query = `select count(id) from issues where did = ? and open = 1`
557
args = append(args, did)
558
-
case VanityStatClosedIssueCount:
559
query = `select count(id) from issues where did = ? and open = 0`
560
args = append(args, did)
561
-
case VanityStatRepositoryCount:
562
query = `select count(id) from repos where did = ?`
563
args = append(args, did)
564
}
···
572
return result, nil
573
}
574
575
-
func ValidateProfile(e Execer, profile *Profile) error {
576
// ensure description is not too long
577
if len(profile.Description) > 256 {
578
return fmt.Errorf("Entered bio is too long.")
···
620
return nil
621
}
622
623
-
func validateLinks(profile *Profile) error {
624
for i, link := range profile.Links {
625
if link == "" {
626
continue
···
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
14
)
15
16
const TimeframeMonths = 7
17
18
+
func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) {
19
+
timeline := models.ProfileTimeline{
20
+
ByMonth: make([]models.ByMonth, TimeframeMonths),
21
}
22
currentMonth := time.Now().Month()
23
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
···
72
73
for _, repo := range repos {
74
// TODO: get this in the original query; requires COALESCE because nullable
75
+
var sourceRepo *models.Repo
76
if repo.Source != "" {
77
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
78
if err != nil {
···
90
idx := currentMonth - repoMonth
91
92
items := &timeline.ByMonth[idx].RepoEvents
93
+
*items = append(*items, models.RepoEvent{
94
Repo: &repo,
95
Source: sourceRepo,
96
})
···
99
return &timeline, nil
100
}
101
102
+
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
103
defer tx.Rollback()
104
105
// update links
···
197
return tx.Commit()
198
}
199
200
+
func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) {
201
var conditions []string
202
var args []any
203
for _, filter := range filters {
···
227
return nil, err
228
}
229
230
+
profileMap := make(map[string]*models.Profile)
231
for rows.Next() {
232
+
var profile models.Profile
233
var includeBluesky int
234
235
err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location)
···
300
return profileMap, nil
301
}
302
303
+
func GetProfile(e Execer, did string) (*models.Profile, error) {
304
+
var profile models.Profile
305
profile.Did = did
306
307
includeBluesky := 0
···
310
did,
311
).Scan(&profile.Description, &includeBluesky, &profile.Location)
312
if err == sql.ErrNoRows {
313
+
profile := models.Profile{}
314
profile.Did = did
315
return &profile, nil
316
}
···
370
return &profile, nil
371
}
372
373
+
func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) {
374
query := ""
375
var args []any
376
switch stat {
377
+
case models.VanityStatMergedPRCount:
378
query = `select count(id) from pulls where owner_did = ? and state = ?`
379
+
args = append(args, did, models.PullMerged)
380
+
case models.VanityStatClosedPRCount:
381
query = `select count(id) from pulls where owner_did = ? and state = ?`
382
+
args = append(args, did, models.PullClosed)
383
+
case models.VanityStatOpenPRCount:
384
query = `select count(id) from pulls where owner_did = ? and state = ?`
385
+
args = append(args, did, models.PullOpen)
386
+
case models.VanityStatOpenIssueCount:
387
query = `select count(id) from issues where did = ? and open = 1`
388
args = append(args, did)
389
+
case models.VanityStatClosedIssueCount:
390
query = `select count(id) from issues where did = ? and open = 0`
391
args = append(args, did)
392
+
case models.VanityStatRepositoryCount:
393
query = `select count(id) from repos where did = ?`
394
args = append(args, did)
395
}
···
403
return result, nil
404
}
405
406
+
func ValidateProfile(e Execer, profile *models.Profile) error {
407
// ensure description is not too long
408
if len(profile.Description) > 256 {
409
return fmt.Errorf("Entered bio is too long.")
···
451
return nil
452
}
453
454
+
func validateLinks(profile *models.Profile) error {
455
for i, link := range profile.Links {
456
if link == "" {
457
continue
+7
-26
appview/db/pubkeys.go
+7
-26
appview/db/pubkeys.go
···
1
package db
2
3
import (
4
-
"encoding/json"
5
"time"
6
)
7
···
29
return err
30
}
31
32
-
type PublicKey struct {
33
-
Did string `json:"did"`
34
-
Key string `json:"key"`
35
-
Name string `json:"name"`
36
-
Rkey string `json:"rkey"`
37
-
Created *time.Time
38
-
}
39
-
40
-
func (p PublicKey) MarshalJSON() ([]byte, error) {
41
-
type Alias PublicKey
42
-
return json.Marshal(&struct {
43
-
Created string `json:"created"`
44
-
*Alias
45
-
}{
46
-
Created: p.Created.Format(time.RFC3339),
47
-
Alias: (*Alias)(&p),
48
-
})
49
-
}
50
-
51
-
func GetAllPublicKeys(e Execer) ([]PublicKey, error) {
52
-
var keys []PublicKey
53
54
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
55
if err != nil {
···
58
defer rows.Close()
59
60
for rows.Next() {
61
-
var publicKey PublicKey
62
var createdAt string
63
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
64
return nil, err
···
75
return keys, nil
76
}
77
78
-
func GetPublicKeysForDid(e Execer, did string) ([]PublicKey, error) {
79
-
var keys []PublicKey
80
81
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
82
if err != nil {
···
85
defer rows.Close()
86
87
for rows.Next() {
88
-
var publicKey PublicKey
89
var createdAt string
90
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
91
return nil, err
···
1
package db
2
3
import (
4
+
"tangled.org/core/appview/models"
5
"time"
6
)
7
···
29
return err
30
}
31
32
+
func GetAllPublicKeys(e Execer) ([]models.PublicKey, error) {
33
+
var keys []models.PublicKey
34
35
rows, err := e.Query(`select key, name, did, rkey, created from public_keys`)
36
if err != nil {
···
39
defer rows.Close()
40
41
for rows.Next() {
42
+
var publicKey models.PublicKey
43
var createdAt string
44
if err := rows.Scan(&publicKey.Key, &publicKey.Name, &publicKey.Did, &publicKey.Rkey, &createdAt); err != nil {
45
return nil, err
···
56
return keys, nil
57
}
58
59
+
func GetPublicKeysForDid(e Execer, did string) ([]models.PublicKey, error) {
60
+
var keys []models.PublicKey
61
62
rows, err := e.Query(`select did, key, name, rkey, created from public_keys where did = ?`, did)
63
if err != nil {
···
66
defer rows.Close()
67
68
for rows.Next() {
69
+
var publicKey models.PublicKey
70
var createdAt string
71
if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Name, &publicKey.Rkey, &createdAt); err != nil {
72
return nil, err
+193
-572
appview/db/pulls.go
+193
-572
appview/db/pulls.go
···
1
package db
2
3
import (
4
"database/sql"
5
"fmt"
6
-
"log"
7
"slices"
8
"sort"
9
"strings"
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/patchutil"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
)
17
-
18
-
type PullState int
19
-
20
-
const (
21
-
PullClosed PullState = iota
22
-
PullOpen
23
-
PullMerged
24
-
PullDeleted
25
)
26
27
-
func (p PullState) String() string {
28
-
switch p {
29
-
case PullOpen:
30
-
return "open"
31
-
case PullMerged:
32
-
return "merged"
33
-
case PullClosed:
34
-
return "closed"
35
-
case PullDeleted:
36
-
return "deleted"
37
-
default:
38
-
return "closed"
39
-
}
40
-
}
41
-
42
-
func (p PullState) IsOpen() bool {
43
-
return p == PullOpen
44
-
}
45
-
func (p PullState) IsMerged() bool {
46
-
return p == PullMerged
47
-
}
48
-
func (p PullState) IsClosed() bool {
49
-
return p == PullClosed
50
-
}
51
-
func (p PullState) IsDeleted() bool {
52
-
return p == PullDeleted
53
-
}
54
-
55
-
type Pull struct {
56
-
// ids
57
-
ID int
58
-
PullId int
59
-
60
-
// at ids
61
-
RepoAt syntax.ATURI
62
-
OwnerDid string
63
-
Rkey string
64
-
65
-
// content
66
-
Title string
67
-
Body string
68
-
TargetBranch string
69
-
State PullState
70
-
Submissions []*PullSubmission
71
-
72
-
// stacking
73
-
StackId string // nullable string
74
-
ChangeId string // nullable string
75
-
ParentChangeId string // nullable string
76
-
77
-
// meta
78
-
Created time.Time
79
-
PullSource *PullSource
80
-
81
-
// optionally, populate this when querying for reverse mappings
82
-
Repo *Repo
83
-
}
84
-
85
-
func (p Pull) AsRecord() tangled.RepoPull {
86
-
var source *tangled.RepoPull_Source
87
-
if p.PullSource != nil {
88
-
s := p.PullSource.AsRecord()
89
-
source = &s
90
-
source.Sha = p.LatestSha()
91
-
}
92
-
93
-
record := tangled.RepoPull{
94
-
Title: p.Title,
95
-
Body: &p.Body,
96
-
CreatedAt: p.Created.Format(time.RFC3339),
97
-
Target: &tangled.RepoPull_Target{
98
-
Repo: p.RepoAt.String(),
99
-
Branch: p.TargetBranch,
100
-
},
101
-
Patch: p.LatestPatch(),
102
-
Source: source,
103
-
}
104
-
return record
105
-
}
106
-
107
-
type PullSource struct {
108
-
Branch string
109
-
RepoAt *syntax.ATURI
110
-
111
-
// optionally populate this for reverse mappings
112
-
Repo *Repo
113
-
}
114
-
115
-
func (p PullSource) AsRecord() tangled.RepoPull_Source {
116
-
var repoAt *string
117
-
if p.RepoAt != nil {
118
-
s := p.RepoAt.String()
119
-
repoAt = &s
120
-
}
121
-
record := tangled.RepoPull_Source{
122
-
Branch: p.Branch,
123
-
Repo: repoAt,
124
-
}
125
-
return record
126
-
}
127
-
128
-
type PullSubmission struct {
129
-
// ids
130
-
ID int
131
-
PullId int
132
-
133
-
// at ids
134
-
RepoAt syntax.ATURI
135
-
136
-
// content
137
-
RoundNumber int
138
-
Patch string
139
-
Comments []PullComment
140
-
SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
141
-
142
-
// meta
143
-
Created time.Time
144
-
}
145
-
146
-
type PullComment struct {
147
-
// ids
148
-
ID int
149
-
PullId int
150
-
SubmissionId int
151
-
152
-
// at ids
153
-
RepoAt string
154
-
OwnerDid string
155
-
CommentAt string
156
-
157
-
// content
158
-
Body string
159
-
160
-
// meta
161
-
Created time.Time
162
-
}
163
-
164
-
func (p *Pull) LatestPatch() string {
165
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
166
-
return latestSubmission.Patch
167
-
}
168
-
169
-
func (p *Pull) LatestSha() string {
170
-
latestSubmission := p.Submissions[p.LastRoundNumber()]
171
-
return latestSubmission.SourceRev
172
-
}
173
-
174
-
func (p *Pull) PullAt() syntax.ATURI {
175
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
176
-
}
177
-
178
-
func (p *Pull) LastRoundNumber() int {
179
-
return len(p.Submissions) - 1
180
-
}
181
-
182
-
func (p *Pull) IsPatchBased() bool {
183
-
return p.PullSource == nil
184
-
}
185
-
186
-
func (p *Pull) IsBranchBased() bool {
187
-
if p.PullSource != nil {
188
-
if p.PullSource.RepoAt != nil {
189
-
return p.PullSource.RepoAt == &p.RepoAt
190
-
} else {
191
-
// no repo specified
192
-
return true
193
-
}
194
-
}
195
-
return false
196
-
}
197
-
198
-
func (p *Pull) IsForkBased() bool {
199
-
if p.PullSource != nil {
200
-
if p.PullSource.RepoAt != nil {
201
-
// make sure repos are different
202
-
return p.PullSource.RepoAt != &p.RepoAt
203
-
}
204
-
}
205
-
return false
206
-
}
207
-
208
-
func (p *Pull) IsStacked() bool {
209
-
return p.StackId != ""
210
-
}
211
-
212
-
func (s PullSubmission) IsFormatPatch() bool {
213
-
return patchutil.IsFormatPatch(s.Patch)
214
-
}
215
-
216
-
func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
217
-
patches, err := patchutil.ExtractPatches(s.Patch)
218
-
if err != nil {
219
-
log.Println("error extracting patches from submission:", err)
220
-
return []types.FormatPatch{}
221
-
}
222
-
223
-
return patches
224
-
}
225
-
226
-
func NewPull(tx *sql.Tx, pull *Pull) error {
227
_, err := tx.Exec(`
228
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
229
values (?, 1)
···
244
}
245
246
pull.PullId = nextId
247
-
pull.State = PullOpen
248
249
var sourceBranch, sourceRepoAt *string
250
if pull.PullSource != nil {
···
266
parentChangeId = &pull.ParentChangeId
267
}
268
269
-
_, err = tx.Exec(
270
`
271
insert into pulls (
272
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
290
return err
291
}
292
293
_, err = tx.Exec(`
294
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
295
-
values (?, ?, ?, ?, ?)
296
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
297
return err
298
}
299
···
311
return pullId - 1, err
312
}
313
314
-
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) {
315
-
pulls := make(map[int]*Pull)
316
317
var conditions []string
318
var args []any
···
332
333
query := fmt.Sprintf(`
334
select
335
owner_did,
336
repo_at,
337
pull_id,
···
361
defer rows.Close()
362
363
for rows.Next() {
364
-
var pull Pull
365
var createdAt string
366
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
367
err := rows.Scan(
368
&pull.OwnerDid,
369
&pull.RepoAt,
370
&pull.PullId,
···
391
pull.Created = createdTime
392
393
if sourceBranch.Valid {
394
-
pull.PullSource = &PullSource{
395
Branch: sourceBranch.String,
396
}
397
if sourceRepoAt.Valid {
···
413
pull.ParentChangeId = parentChangeId.String
414
}
415
416
-
pulls[pull.PullId] = &pull
417
}
418
419
-
// get latest round no. for each pull
420
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
421
-
submissionsQuery := fmt.Sprintf(`
422
-
select
423
-
id, pull_id, round_number, patch, created, source_rev
424
-
from
425
-
pull_submissions
426
-
where
427
-
repo_at in (%s) and pull_id in (%s)
428
-
`, inClause, inClause)
429
-
430
-
args = make([]any, len(pulls)*2)
431
-
idx := 0
432
-
for _, p := range pulls {
433
-
args[idx] = p.RepoAt
434
-
idx += 1
435
-
}
436
for _, p := range pulls {
437
-
args[idx] = p.PullId
438
-
idx += 1
439
}
440
-
submissionsRows, err := e.Query(submissionsQuery, args...)
441
if err != nil {
442
-
return nil, err
443
}
444
-
defer submissionsRows.Close()
445
446
-
for submissionsRows.Next() {
447
-
var s PullSubmission
448
-
var sourceRev sql.NullString
449
-
var createdAt string
450
-
err := submissionsRows.Scan(
451
-
&s.ID,
452
-
&s.PullId,
453
-
&s.RoundNumber,
454
-
&s.Patch,
455
-
&createdAt,
456
-
&sourceRev,
457
-
)
458
-
if err != nil {
459
-
return nil, err
460
}
461
-
462
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
463
-
if err != nil {
464
-
return nil, err
465
-
}
466
-
s.Created = createdTime
467
468
-
if sourceRev.Valid {
469
-
s.SourceRev = sourceRev.String
470
}
471
472
-
if p, ok := pulls[s.PullId]; ok {
473
-
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
474
-
p.Submissions[s.RoundNumber] = &s
475
}
476
}
477
-
if err := rows.Err(); err != nil {
478
-
return nil, err
479
}
480
-
481
-
// get comment count on latest submission on each pull
482
-
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
483
-
commentsQuery := fmt.Sprintf(`
484
-
select
485
-
count(id), pull_id
486
-
from
487
-
pull_comments
488
-
where
489
-
submission_id in (%s)
490
-
group by
491
-
submission_id
492
-
`, inClause)
493
-
494
-
args = []any{}
495
for _, p := range pulls {
496
-
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
497
-
}
498
-
commentsRows, err := e.Query(commentsQuery, args...)
499
-
if err != nil {
500
-
return nil, err
501
-
}
502
-
defer commentsRows.Close()
503
-
504
-
for commentsRows.Next() {
505
-
var commentCount, pullId int
506
-
err := commentsRows.Scan(
507
-
&commentCount,
508
-
&pullId,
509
-
)
510
-
if err != nil {
511
-
return nil, err
512
}
513
-
if p, ok := pulls[pullId]; ok {
514
-
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
515
-
}
516
-
}
517
-
if err := rows.Err(); err != nil {
518
-
return nil, err
519
}
520
521
-
orderedByPullId := []*Pull{}
522
for _, p := range pulls {
523
orderedByPullId = append(orderedByPullId, p)
524
}
···
529
return orderedByPullId, nil
530
}
531
532
-
func GetPulls(e Execer, filters ...filter) ([]*Pull, error) {
533
return GetPullsWithLimit(e, 0, filters...)
534
}
535
536
-
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
537
-
query := `
538
-
select
539
-
owner_did,
540
-
pull_id,
541
-
created,
542
-
title,
543
-
state,
544
-
target_branch,
545
-
repo_at,
546
-
body,
547
-
rkey,
548
-
source_branch,
549
-
source_repo_at,
550
-
stack_id,
551
-
change_id,
552
-
parent_change_id
553
-
from
554
-
pulls
555
-
where
556
-
repo_at = ? and pull_id = ?
557
-
`
558
-
row := e.QueryRow(query, repoAt, pullId)
559
-
560
-
var pull Pull
561
-
var createdAt string
562
-
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
563
-
err := row.Scan(
564
-
&pull.OwnerDid,
565
-
&pull.PullId,
566
-
&createdAt,
567
-
&pull.Title,
568
-
&pull.State,
569
-
&pull.TargetBranch,
570
-
&pull.RepoAt,
571
-
&pull.Body,
572
-
&pull.Rkey,
573
-
&sourceBranch,
574
-
&sourceRepoAt,
575
-
&stackId,
576
-
&changeId,
577
-
&parentChangeId,
578
-
)
579
if err != nil {
580
return nil, err
581
}
582
-
583
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
584
-
if err != nil {
585
-
return nil, err
586
}
587
-
pull.Created = createdTime
588
589
-
// populate source
590
-
if sourceBranch.Valid {
591
-
pull.PullSource = &PullSource{
592
-
Branch: sourceBranch.String,
593
-
}
594
-
if sourceRepoAt.Valid {
595
-
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
596
-
if err != nil {
597
-
return nil, err
598
-
}
599
-
pull.PullSource.RepoAt = &sourceRepoAtParsed
600
-
}
601
-
}
602
603
-
if stackId.Valid {
604
-
pull.StackId = stackId.String
605
-
}
606
-
if changeId.Valid {
607
-
pull.ChangeId = changeId.String
608
}
609
-
if parentChangeId.Valid {
610
-
pull.ParentChangeId = parentChangeId.String
611
}
612
613
-
submissionsQuery := `
614
select
615
-
id, pull_id, repo_at, round_number, patch, created, source_rev
616
from
617
pull_submissions
618
-
where
619
-
repo_at = ? and pull_id = ?
620
-
`
621
-
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
622
if err != nil {
623
return nil, err
624
}
625
-
defer submissionsRows.Close()
626
627
-
submissionsMap := make(map[int]*PullSubmission)
628
629
-
for submissionsRows.Next() {
630
-
var submission PullSubmission
631
-
var submissionCreatedStr string
632
-
var submissionSourceRev sql.NullString
633
-
err := submissionsRows.Scan(
634
&submission.ID,
635
-
&submission.PullId,
636
-
&submission.RepoAt,
637
&submission.RoundNumber,
638
&submission.Patch,
639
-
&submissionCreatedStr,
640
-
&submissionSourceRev,
641
)
642
if err != nil {
643
return nil, err
644
}
645
646
-
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
647
if err != nil {
648
return nil, err
649
}
650
-
submission.Created = submissionCreatedTime
651
652
-
if submissionSourceRev.Valid {
653
-
submission.SourceRev = submissionSourceRev.String
654
}
655
656
-
submissionsMap[submission.ID] = &submission
657
}
658
-
if err = submissionsRows.Close(); err != nil {
659
return nil, err
660
}
661
-
if len(submissionsMap) == 0 {
662
-
return &pull, nil
663
}
664
665
var args []any
666
-
for k := range submissionsMap {
667
-
args = append(args, k)
668
}
669
-
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
670
-
commentsQuery := fmt.Sprintf(`
671
select
672
id,
673
pull_id,
···
679
created
680
from
681
pull_comments
682
-
where
683
-
submission_id IN (%s)
684
order by
685
created asc
686
-
`, inClause)
687
-
commentsRows, err := e.Query(commentsQuery, args...)
688
if err != nil {
689
return nil, err
690
}
691
-
defer commentsRows.Close()
692
693
-
for commentsRows.Next() {
694
-
var comment PullComment
695
-
var commentCreatedStr string
696
-
err := commentsRows.Scan(
697
&comment.ID,
698
&comment.PullId,
699
&comment.SubmissionId,
···
701
&comment.OwnerDid,
702
&comment.CommentAt,
703
&comment.Body,
704
-
&commentCreatedStr,
705
)
706
if err != nil {
707
return nil, err
708
}
709
710
-
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
711
-
if err != nil {
712
-
return nil, err
713
-
}
714
-
comment.Created = commentCreatedTime
715
-
716
-
// Add the comment to its submission
717
-
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
718
-
submission.Comments = append(submission.Comments, comment)
719
}
720
721
-
}
722
-
if err = commentsRows.Err(); err != nil {
723
-
return nil, err
724
-
}
725
-
726
-
var pullSourceRepo *Repo
727
-
if pull.PullSource != nil {
728
-
if pull.PullSource.RepoAt != nil {
729
-
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
730
-
if err != nil {
731
-
log.Printf("failed to get repo by at uri: %v", err)
732
-
} else {
733
-
pull.PullSource.Repo = pullSourceRepo
734
-
}
735
-
}
736
}
737
738
-
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
739
-
for _, submission := range submissionsMap {
740
-
pull.Submissions[submission.RoundNumber] = submission
741
}
742
743
-
return &pull, nil
744
}
745
746
// timeframe here is directly passed into the sql query filter, and any
747
// timeframe in the past should be negative; e.g.: "-3 months"
748
-
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
749
-
var pulls []Pull
750
751
rows, err := e.Query(`
752
select
···
775
defer rows.Close()
776
777
for rows.Next() {
778
-
var pull Pull
779
-
var repo Repo
780
var pullCreatedAt, repoCreatedAt string
781
err := rows.Scan(
782
&pull.OwnerDid,
···
819
return pulls, nil
820
}
821
822
-
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
823
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
824
res, err := e.Exec(
825
query,
···
842
return i, nil
843
}
844
845
-
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
846
_, err := e.Exec(
847
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
848
pullState,
849
repoAt,
850
pullId,
851
-
PullDeleted, // only update state of non-deleted pulls
852
-
PullMerged, // only update state of non-merged pulls
853
)
854
return err
855
}
856
857
func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
858
-
err := SetPullState(e, repoAt, pullId, PullClosed)
859
return err
860
}
861
862
func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
863
-
err := SetPullState(e, repoAt, pullId, PullOpen)
864
return err
865
}
866
867
func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
868
-
err := SetPullState(e, repoAt, pullId, PullMerged)
869
return err
870
}
871
872
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
873
-
err := SetPullState(e, repoAt, pullId, PullDeleted)
874
return err
875
}
876
877
-
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
878
newRoundNumber := len(pull.Submissions)
879
_, err := e.Exec(`
880
-
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
881
-
values (?, ?, ?, ?, ?)
882
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
883
884
return err
885
}
···
931
return err
932
}
933
934
-
type PullCount struct {
935
-
Open int
936
-
Merged int
937
-
Closed int
938
-
Deleted int
939
-
}
940
-
941
-
func GetPullCount(e Execer, repoAt syntax.ATURI) (PullCount, error) {
942
row := e.QueryRow(`
943
select
944
count(case when state = ? then 1 end) as open_count,
···
947
count(case when state = ? then 1 end) as deleted_count
948
from pulls
949
where repo_at = ?`,
950
-
PullOpen,
951
-
PullMerged,
952
-
PullClosed,
953
-
PullDeleted,
954
repoAt,
955
)
956
957
-
var count PullCount
958
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
959
-
return PullCount{0, 0, 0, 0}, err
960
}
961
962
return count, nil
963
}
964
-
965
-
type Stack []*Pull
966
967
// change-id parent-change-id
968
//
···
972
// 1 x <------' nil (BOT)
973
//
974
// `w` is parent of none, so it is the top of the stack
975
-
func GetStack(e Execer, stackId string) (Stack, error) {
976
unorderedPulls, err := GetPulls(
977
e,
978
FilterEq("stack_id", stackId),
979
-
FilterNotEq("state", PullDeleted),
980
)
981
if err != nil {
982
return nil, err
983
}
984
// map of parent-change-id to pull
985
-
changeIdMap := make(map[string]*Pull, len(unorderedPulls))
986
-
parentMap := make(map[string]*Pull, len(unorderedPulls))
987
for _, p := range unorderedPulls {
988
changeIdMap[p.ChangeId] = p
989
if p.ParentChangeId != "" {
···
992
}
993
994
// the top of the stack is the pull that is not a parent of any pull
995
-
var topPull *Pull
996
for _, maybeTop := range unorderedPulls {
997
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
998
topPull = maybeTop
···
1000
}
1001
}
1002
1003
-
pulls := []*Pull{}
1004
for {
1005
pulls = append(pulls, topPull)
1006
if topPull.ParentChangeId != "" {
···
1017
return pulls, nil
1018
}
1019
1020
-
func GetAbandonedPulls(e Execer, stackId string) ([]*Pull, error) {
1021
pulls, err := GetPulls(
1022
e,
1023
FilterEq("stack_id", stackId),
1024
-
FilterEq("state", PullDeleted),
1025
)
1026
if err != nil {
1027
return nil, err
···
1029
1030
return pulls, nil
1031
}
1032
-
1033
-
// position of this pull in the stack
1034
-
func (stack Stack) Position(pull *Pull) int {
1035
-
return slices.IndexFunc(stack, func(p *Pull) bool {
1036
-
return p.ChangeId == pull.ChangeId
1037
-
})
1038
-
}
1039
-
1040
-
// all pulls below this pull (including self) in this stack
1041
-
//
1042
-
// nil if this pull does not belong to this stack
1043
-
func (stack Stack) Below(pull *Pull) Stack {
1044
-
position := stack.Position(pull)
1045
-
1046
-
if position < 0 {
1047
-
return nil
1048
-
}
1049
-
1050
-
return stack[position:]
1051
-
}
1052
-
1053
-
// all pulls below this pull (excluding self) in this stack
1054
-
func (stack Stack) StrictlyBelow(pull *Pull) Stack {
1055
-
below := stack.Below(pull)
1056
-
1057
-
if len(below) > 0 {
1058
-
return below[1:]
1059
-
}
1060
-
1061
-
return nil
1062
-
}
1063
-
1064
-
// all pulls above this pull (including self) in this stack
1065
-
func (stack Stack) Above(pull *Pull) Stack {
1066
-
position := stack.Position(pull)
1067
-
1068
-
if position < 0 {
1069
-
return nil
1070
-
}
1071
-
1072
-
return stack[:position+1]
1073
-
}
1074
-
1075
-
// all pulls below this pull (excluding self) in this stack
1076
-
func (stack Stack) StrictlyAbove(pull *Pull) Stack {
1077
-
above := stack.Above(pull)
1078
-
1079
-
if len(above) > 0 {
1080
-
return above[:len(above)-1]
1081
-
}
1082
-
1083
-
return nil
1084
-
}
1085
-
1086
-
// the combined format-patches of all the newest submissions in this stack
1087
-
func (stack Stack) CombinedPatch() string {
1088
-
// go in reverse order because the bottom of the stack is the last element in the slice
1089
-
var combined strings.Builder
1090
-
for idx := range stack {
1091
-
pull := stack[len(stack)-1-idx]
1092
-
combined.WriteString(pull.LatestPatch())
1093
-
combined.WriteString("\n")
1094
-
}
1095
-
return combined.String()
1096
-
}
1097
-
1098
-
// filter out PRs that are "active"
1099
-
//
1100
-
// PRs that are still open are active
1101
-
func (stack Stack) Mergeable() Stack {
1102
-
var mergeable Stack
1103
-
1104
-
for _, p := range stack {
1105
-
// stop at the first merged PR
1106
-
if p.State == PullMerged || p.State == PullClosed {
1107
-
break
1108
-
}
1109
-
1110
-
// skip over deleted PRs
1111
-
if p.State != PullDeleted {
1112
-
mergeable = append(mergeable, p)
1113
-
}
1114
-
}
1115
-
1116
-
return mergeable
1117
-
}
···
1
package db
2
3
import (
4
+
"cmp"
5
"database/sql"
6
+
"errors"
7
"fmt"
8
+
"maps"
9
"slices"
10
"sort"
11
"strings"
12
"time"
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
15
+
"tangled.org/core/appview/models"
16
)
17
18
+
func NewPull(tx *sql.Tx, pull *models.Pull) error {
19
_, err := tx.Exec(`
20
insert or ignore into repo_pull_seqs (repo_at, next_pull_id)
21
values (?, 1)
···
36
}
37
38
pull.PullId = nextId
39
+
pull.State = models.PullOpen
40
41
var sourceBranch, sourceRepoAt *string
42
if pull.PullSource != nil {
···
58
parentChangeId = &pull.ParentChangeId
59
}
60
61
+
result, err := tx.Exec(
62
`
63
insert into pulls (
64
repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id
···
82
return err
83
}
84
85
+
// Set the database primary key ID
86
+
id, err := result.LastInsertId()
87
+
if err != nil {
88
+
return err
89
+
}
90
+
pull.ID = int(id)
91
+
92
_, err = tx.Exec(`
93
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
94
+
values (?, ?, ?, ?)
95
+
`, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
96
return err
97
}
98
···
110
return pullId - 1, err
111
}
112
113
+
func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) {
114
+
pulls := make(map[syntax.ATURI]*models.Pull)
115
116
var conditions []string
117
var args []any
···
131
132
query := fmt.Sprintf(`
133
select
134
+
id,
135
owner_did,
136
repo_at,
137
pull_id,
···
161
defer rows.Close()
162
163
for rows.Next() {
164
+
var pull models.Pull
165
var createdAt string
166
var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString
167
err := rows.Scan(
168
+
&pull.ID,
169
&pull.OwnerDid,
170
&pull.RepoAt,
171
&pull.PullId,
···
192
pull.Created = createdTime
193
194
if sourceBranch.Valid {
195
+
pull.PullSource = &models.PullSource{
196
Branch: sourceBranch.String,
197
}
198
if sourceRepoAt.Valid {
···
214
pull.ParentChangeId = parentChangeId.String
215
}
216
217
+
pulls[pull.PullAt()] = &pull
218
}
219
220
+
var pullAts []syntax.ATURI
221
for _, p := range pulls {
222
+
pullAts = append(pullAts, p.PullAt())
223
}
224
+
submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts))
225
if err != nil {
226
+
return nil, fmt.Errorf("failed to get submissions: %w", err)
227
}
228
229
+
for pullAt, submissions := range submissionsMap {
230
+
if p, ok := pulls[pullAt]; ok {
231
+
p.Submissions = submissions
232
}
233
+
}
234
235
+
// collect allLabels for each issue
236
+
allLabels, err := GetLabels(e, FilterIn("subject", pullAts))
237
+
if err != nil {
238
+
return nil, fmt.Errorf("failed to query labels: %w", err)
239
+
}
240
+
for pullAt, labels := range allLabels {
241
+
if p, ok := pulls[pullAt]; ok {
242
+
p.Labels = labels
243
}
244
+
}
245
246
+
// collect pull source for all pulls that need it
247
+
var sourceAts []syntax.ATURI
248
+
for _, p := range pulls {
249
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
250
+
sourceAts = append(sourceAts, *p.PullSource.RepoAt)
251
}
252
}
253
+
sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts))
254
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
255
+
return nil, fmt.Errorf("failed to get source repos: %w", err)
256
}
257
+
sourceRepoMap := make(map[syntax.ATURI]*models.Repo)
258
+
for _, r := range sourceRepos {
259
+
sourceRepoMap[r.RepoAt()] = &r
260
+
}
261
for _, p := range pulls {
262
+
if p.PullSource != nil && p.PullSource.RepoAt != nil {
263
+
if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok {
264
+
p.PullSource.Repo = sourceRepo
265
+
}
266
}
267
}
268
269
+
orderedByPullId := []*models.Pull{}
270
for _, p := range pulls {
271
orderedByPullId = append(orderedByPullId, p)
272
}
···
277
return orderedByPullId, nil
278
}
279
280
+
func GetPulls(e Execer, filters ...filter) ([]*models.Pull, error) {
281
return GetPullsWithLimit(e, 0, filters...)
282
}
283
284
+
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) {
285
+
pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId))
286
if err != nil {
287
return nil, err
288
}
289
+
if pulls == nil {
290
+
return nil, sql.ErrNoRows
291
}
292
293
+
return pulls[0], nil
294
+
}
295
296
+
// mapping from pull -> pull submissions
297
+
func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) {
298
+
var conditions []string
299
+
var args []any
300
+
for _, filter := range filters {
301
+
conditions = append(conditions, filter.Condition())
302
+
args = append(args, filter.Arg()...)
303
}
304
+
305
+
whereClause := ""
306
+
if conditions != nil {
307
+
whereClause = " where " + strings.Join(conditions, " and ")
308
}
309
310
+
query := fmt.Sprintf(`
311
select
312
+
id,
313
+
pull_at,
314
+
round_number,
315
+
patch,
316
+
created,
317
+
source_rev
318
from
319
pull_submissions
320
+
%s
321
+
order by
322
+
round_number asc
323
+
`, whereClause)
324
+
325
+
rows, err := e.Query(query, args...)
326
if err != nil {
327
return nil, err
328
}
329
+
defer rows.Close()
330
331
+
submissionMap := make(map[int]*models.PullSubmission)
332
333
+
for rows.Next() {
334
+
var submission models.PullSubmission
335
+
var createdAt string
336
+
var sourceRev sql.NullString
337
+
err := rows.Scan(
338
&submission.ID,
339
+
&submission.PullAt,
340
&submission.RoundNumber,
341
&submission.Patch,
342
+
&createdAt,
343
+
&sourceRev,
344
)
345
if err != nil {
346
return nil, err
347
}
348
349
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
350
if err != nil {
351
return nil, err
352
}
353
+
submission.Created = createdTime
354
355
+
if sourceRev.Valid {
356
+
submission.SourceRev = sourceRev.String
357
}
358
359
+
submissionMap[submission.ID] = &submission
360
}
361
+
362
+
if err := rows.Err(); err != nil {
363
+
return nil, err
364
+
}
365
+
366
+
// Get comments for all submissions using GetPullComments
367
+
submissionIds := slices.Collect(maps.Keys(submissionMap))
368
+
comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds))
369
+
if err != nil {
370
return nil, err
371
}
372
+
for _, comment := range comments {
373
+
if submission, ok := submissionMap[comment.SubmissionId]; ok {
374
+
submission.Comments = append(submission.Comments, comment)
375
+
}
376
+
}
377
+
378
+
// group the submissions by pull_at
379
+
m := make(map[syntax.ATURI][]*models.PullSubmission)
380
+
for _, s := range submissionMap {
381
+
m[s.PullAt] = append(m[s.PullAt], s)
382
+
}
383
+
384
+
// sort each one by round number
385
+
for _, s := range m {
386
+
slices.SortFunc(s, func(a, b *models.PullSubmission) int {
387
+
return cmp.Compare(a.RoundNumber, b.RoundNumber)
388
+
})
389
}
390
391
+
return m, nil
392
+
}
393
+
394
+
func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) {
395
+
var conditions []string
396
var args []any
397
+
for _, filter := range filters {
398
+
conditions = append(conditions, filter.Condition())
399
+
args = append(args, filter.Arg()...)
400
}
401
+
402
+
whereClause := ""
403
+
if conditions != nil {
404
+
whereClause = " where " + strings.Join(conditions, " and ")
405
+
}
406
+
407
+
query := fmt.Sprintf(`
408
select
409
id,
410
pull_id,
···
416
created
417
from
418
pull_comments
419
+
%s
420
order by
421
created asc
422
+
`, whereClause)
423
+
424
+
rows, err := e.Query(query, args...)
425
if err != nil {
426
return nil, err
427
}
428
+
defer rows.Close()
429
430
+
var comments []models.PullComment
431
+
for rows.Next() {
432
+
var comment models.PullComment
433
+
var createdAt string
434
+
err := rows.Scan(
435
&comment.ID,
436
&comment.PullId,
437
&comment.SubmissionId,
···
439
&comment.OwnerDid,
440
&comment.CommentAt,
441
&comment.Body,
442
+
&createdAt,
443
)
444
if err != nil {
445
return nil, err
446
}
447
448
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
449
+
comment.Created = t
450
}
451
452
+
comments = append(comments, comment)
453
}
454
455
+
if err := rows.Err(); err != nil {
456
+
return nil, err
457
}
458
459
+
return comments, nil
460
}
461
462
// timeframe here is directly passed into the sql query filter, and any
463
// timeframe in the past should be negative; e.g.: "-3 months"
464
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]models.Pull, error) {
465
+
var pulls []models.Pull
466
467
rows, err := e.Query(`
468
select
···
491
defer rows.Close()
492
493
for rows.Next() {
494
+
var pull models.Pull
495
+
var repo models.Repo
496
var pullCreatedAt, repoCreatedAt string
497
err := rows.Scan(
498
&pull.OwnerDid,
···
535
return pulls, nil
536
}
537
538
+
func NewPullComment(e Execer, comment *models.PullComment) (int64, error) {
539
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
540
res, err := e.Exec(
541
query,
···
558
return i, nil
559
}
560
561
+
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error {
562
_, err := e.Exec(
563
`update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? or state <> ?)`,
564
pullState,
565
repoAt,
566
pullId,
567
+
models.PullDeleted, // only update state of non-deleted pulls
568
+
models.PullMerged, // only update state of non-merged pulls
569
)
570
return err
571
}
572
573
func ClosePull(e Execer, repoAt syntax.ATURI, pullId int) error {
574
+
err := SetPullState(e, repoAt, pullId, models.PullClosed)
575
return err
576
}
577
578
func ReopenPull(e Execer, repoAt syntax.ATURI, pullId int) error {
579
+
err := SetPullState(e, repoAt, pullId, models.PullOpen)
580
return err
581
}
582
583
func MergePull(e Execer, repoAt syntax.ATURI, pullId int) error {
584
+
err := SetPullState(e, repoAt, pullId, models.PullMerged)
585
return err
586
}
587
588
func DeletePull(e Execer, repoAt syntax.ATURI, pullId int) error {
589
+
err := SetPullState(e, repoAt, pullId, models.PullDeleted)
590
return err
591
}
592
593
+
func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error {
594
newRoundNumber := len(pull.Submissions)
595
_, err := e.Exec(`
596
+
insert into pull_submissions (pull_at, round_number, patch, source_rev)
597
+
values (?, ?, ?, ?)
598
+
`, pull.PullAt(), newRoundNumber, newPatch, sourceRev)
599
600
return err
601
}
···
647
return err
648
}
649
650
+
func GetPullCount(e Execer, repoAt syntax.ATURI) (models.PullCount, error) {
651
row := e.QueryRow(`
652
select
653
count(case when state = ? then 1 end) as open_count,
···
656
count(case when state = ? then 1 end) as deleted_count
657
from pulls
658
where repo_at = ?`,
659
+
models.PullOpen,
660
+
models.PullMerged,
661
+
models.PullClosed,
662
+
models.PullDeleted,
663
repoAt,
664
)
665
666
+
var count models.PullCount
667
if err := row.Scan(&count.Open, &count.Merged, &count.Closed, &count.Deleted); err != nil {
668
+
return models.PullCount{Open: 0, Merged: 0, Closed: 0, Deleted: 0}, err
669
}
670
671
return count, nil
672
}
673
674
// change-id parent-change-id
675
//
···
679
// 1 x <------' nil (BOT)
680
//
681
// `w` is parent of none, so it is the top of the stack
682
+
func GetStack(e Execer, stackId string) (models.Stack, error) {
683
unorderedPulls, err := GetPulls(
684
e,
685
FilterEq("stack_id", stackId),
686
+
FilterNotEq("state", models.PullDeleted),
687
)
688
if err != nil {
689
return nil, err
690
}
691
// map of parent-change-id to pull
692
+
changeIdMap := make(map[string]*models.Pull, len(unorderedPulls))
693
+
parentMap := make(map[string]*models.Pull, len(unorderedPulls))
694
for _, p := range unorderedPulls {
695
changeIdMap[p.ChangeId] = p
696
if p.ParentChangeId != "" {
···
699
}
700
701
// the top of the stack is the pull that is not a parent of any pull
702
+
var topPull *models.Pull
703
for _, maybeTop := range unorderedPulls {
704
if _, ok := parentMap[maybeTop.ChangeId]; !ok {
705
topPull = maybeTop
···
707
}
708
}
709
710
+
pulls := []*models.Pull{}
711
for {
712
pulls = append(pulls, topPull)
713
if topPull.ParentChangeId != "" {
···
724
return pulls, nil
725
}
726
727
+
func GetAbandonedPulls(e Execer, stackId string) ([]*models.Pull, error) {
728
pulls, err := GetPulls(
729
e,
730
FilterEq("stack_id", stackId),
731
+
FilterEq("state", models.PullDeleted),
732
)
733
if err != nil {
734
return nil, err
···
736
737
return pulls, nil
738
}
+7
-16
appview/db/punchcard.go
+7
-16
appview/db/punchcard.go
···
5
"fmt"
6
"strings"
7
"time"
8
)
9
10
-
type Punch struct {
11
-
Did string
12
-
Date time.Time
13
-
Count int
14
-
}
15
-
16
// this adds to the existing count
17
-
func AddPunch(e Execer, punch Punch) error {
18
_, err := e.Exec(`
19
insert into punchcard (did, date, count)
20
values (?, ?, ?)
···
24
return err
25
}
26
27
-
type Punchcard struct {
28
-
Total int
29
-
Punches []Punch
30
-
}
31
-
32
-
func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) {
33
-
punchcard := &Punchcard{}
34
now := time.Now()
35
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
36
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
37
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
38
-
punchcard.Punches = append(punchcard.Punches, Punch{
39
Date: d,
40
Count: 0,
41
})
···
68
defer rows.Close()
69
70
for rows.Next() {
71
-
var punch Punch
72
var date string
73
var count sql.NullInt64
74
if err := rows.Scan(&date, &count); err != nil {
···
5
"fmt"
6
"strings"
7
"time"
8
+
9
+
"tangled.org/core/appview/models"
10
)
11
12
// this adds to the existing count
13
+
func AddPunch(e Execer, punch models.Punch) error {
14
_, err := e.Exec(`
15
insert into punchcard (did, date, count)
16
values (?, ?, ?)
···
20
return err
21
}
22
23
+
func MakePunchcard(e Execer, filters ...filter) (*models.Punchcard, error) {
24
+
punchcard := &models.Punchcard{}
25
now := time.Now()
26
startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
27
endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC)
28
for d := startOfYear; d.Before(endOfYear) || d.Equal(endOfYear); d = d.AddDate(0, 0, 1) {
29
+
punchcard.Punches = append(punchcard.Punches, models.Punch{
30
Date: d,
31
Count: 0,
32
})
···
59
defer rows.Close()
60
61
for rows.Next() {
62
+
var punch models.Punch
63
var date string
64
var count sql.NullInt64
65
if err := rows.Scan(&date, &count); err != nil {
+14
-63
appview/db/reaction.go
+14
-63
appview/db/reaction.go
···
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
-
)
9
-
10
-
type ReactionKind string
11
-
12
-
const (
13
-
Like ReactionKind = "👍"
14
-
Unlike ReactionKind = "👎"
15
-
Laugh ReactionKind = "😆"
16
-
Celebration ReactionKind = "🎉"
17
-
Confused ReactionKind = "🫤"
18
-
Heart ReactionKind = "❤️"
19
-
Rocket ReactionKind = "🚀"
20
-
Eyes ReactionKind = "👀"
21
)
22
23
-
func (rk ReactionKind) String() string {
24
-
return string(rk)
25
-
}
26
-
27
-
var OrderedReactionKinds = []ReactionKind{
28
-
Like,
29
-
Unlike,
30
-
Laugh,
31
-
Celebration,
32
-
Confused,
33
-
Heart,
34
-
Rocket,
35
-
Eyes,
36
-
}
37
-
38
-
func ParseReactionKind(raw string) (ReactionKind, bool) {
39
-
k, ok := (map[string]ReactionKind{
40
-
"👍": Like,
41
-
"👎": Unlike,
42
-
"😆": Laugh,
43
-
"🎉": Celebration,
44
-
"🫤": Confused,
45
-
"❤️": Heart,
46
-
"🚀": Rocket,
47
-
"👀": Eyes,
48
-
})[raw]
49
-
return k, ok
50
-
}
51
-
52
-
type Reaction struct {
53
-
ReactedByDid string
54
-
ThreadAt syntax.ATURI
55
-
Created time.Time
56
-
Rkey string
57
-
Kind ReactionKind
58
-
}
59
-
60
-
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error {
61
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
62
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
63
return err
64
}
65
66
// Get a reaction record
67
-
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) {
68
query := `
69
select reacted_by_did, thread_at, created, rkey
70
from reactions
71
where reacted_by_did = ? and thread_at = ? and kind = ?`
72
row := e.QueryRow(query, reactedByDid, threadAt, kind)
73
74
-
var reaction Reaction
75
var created string
76
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
77
if err != nil {
···
90
}
91
92
// Remove a reaction
93
-
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error {
94
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
95
return err
96
}
···
101
return err
102
}
103
104
-
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) {
105
count := 0
106
err := e.QueryRow(
107
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
111
return count, nil
112
}
113
114
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) {
115
-
countMap := map[ReactionKind]int{}
116
-
for _, kind := range OrderedReactionKinds {
117
count, err := GetReactionCount(e, threadAt, kind)
118
if err != nil {
119
-
return map[ReactionKind]int{}, nil
120
}
121
countMap[kind] = count
122
}
123
return countMap, nil
124
}
125
126
-
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool {
127
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
128
return false
129
} else {
···
131
}
132
}
133
134
-
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool {
135
-
statusMap := map[ReactionKind]bool{}
136
-
for _, kind := range OrderedReactionKinds {
137
count := GetReactionStatus(e, userDid, threadAt, kind)
138
statusMap[kind] = count
139
}
···
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.org/core/appview/models"
9
)
10
11
+
func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error {
12
query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)`
13
_, err := e.Exec(query, reactedByDid, threadAt, kind, rkey)
14
return err
15
}
16
17
// Get a reaction record
18
+
func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) {
19
query := `
20
select reacted_by_did, thread_at, created, rkey
21
from reactions
22
where reacted_by_did = ? and thread_at = ? and kind = ?`
23
row := e.QueryRow(query, reactedByDid, threadAt, kind)
24
25
+
var reaction models.Reaction
26
var created string
27
err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey)
28
if err != nil {
···
41
}
42
43
// Remove a reaction
44
+
func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error {
45
_, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind)
46
return err
47
}
···
52
return err
53
}
54
55
+
func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) {
56
count := 0
57
err := e.QueryRow(
58
`select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count)
···
62
return count, nil
63
}
64
65
+
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
66
+
countMap := map[models.ReactionKind]int{}
67
+
for _, kind := range models.OrderedReactionKinds {
68
count, err := GetReactionCount(e, threadAt, kind)
69
if err != nil {
70
+
return map[models.ReactionKind]int{}, nil
71
}
72
countMap[kind] = count
73
}
74
return countMap, nil
75
}
76
77
+
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
78
if _, err := GetReaction(e, userDid, threadAt, kind); err != nil {
79
return false
80
} else {
···
82
}
83
}
84
85
+
func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool {
86
+
statusMap := map[models.ReactionKind]bool{}
87
+
for _, kind := range models.OrderedReactionKinds {
88
count := GetReactionStatus(e, userDid, threadAt, kind)
89
statusMap[kind] = count
90
}
+4
-43
appview/db/registration.go
+4
-43
appview/db/registration.go
···
5
"fmt"
6
"strings"
7
"time"
8
-
)
9
-
10
-
// Registration represents a knot registration. Knot would've been a better
11
-
// name but we're stuck with this for historical reasons.
12
-
type Registration struct {
13
-
Id int64
14
-
Domain string
15
-
ByDid string
16
-
Created *time.Time
17
-
Registered *time.Time
18
-
NeedsUpgrade bool
19
-
}
20
21
-
func (r *Registration) Status() Status {
22
-
if r.NeedsUpgrade {
23
-
return NeedsUpgrade
24
-
} else if r.Registered != nil {
25
-
return Registered
26
-
} else {
27
-
return Pending
28
-
}
29
-
}
30
-
31
-
func (r *Registration) IsRegistered() bool {
32
-
return r.Status() == Registered
33
-
}
34
-
35
-
func (r *Registration) IsNeedsUpgrade() bool {
36
-
return r.Status() == NeedsUpgrade
37
-
}
38
-
39
-
func (r *Registration) IsPending() bool {
40
-
return r.Status() == Pending
41
-
}
42
-
43
-
type Status uint32
44
-
45
-
const (
46
-
Registered Status = iota
47
-
Pending
48
-
NeedsUpgrade
49
)
50
51
-
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
52
-
var registrations []Registration
53
54
var conditions []string
55
var args []any
···
81
var createdAt string
82
var registeredAt sql.Null[string]
83
var needsUpgrade int
84
-
var reg Registration
85
86
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
87
if err != nil {
···
5
"fmt"
6
"strings"
7
"time"
8
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetRegistrations(e Execer, filters ...filter) ([]models.Registration, error) {
13
+
var registrations []models.Registration
14
15
var conditions []string
16
var args []any
···
42
var createdAt string
43
var registeredAt sql.Null[string]
44
var needsUpgrade int
45
+
var reg models.Registration
46
47
err = rows.Scan(®.Id, ®.Domain, ®.ByDid, &createdAt, ®isteredAt, &needsUpgrade)
48
if err != nil {
+63
-87
appview/db/repos.go
+63
-87
appview/db/repos.go
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
)
16
17
type Repo struct {
18
Did string
19
Name string
20
Knot string
···
22
Created time.Time
23
Description string
24
Spindle string
25
-
Labels []string
26
27
// optionally, populate this when querying for reverse mappings
28
-
RepoStats *RepoStats
29
30
// optional
31
Source string
32
}
33
34
-
func (r *Repo) AsRecord() tangled.Repo {
35
-
var source, spindle, description *string
36
-
37
-
if r.Source != "" {
38
-
source = &r.Source
39
-
}
40
-
41
-
if r.Spindle != "" {
42
-
spindle = &r.Spindle
43
-
}
44
-
45
-
if r.Description != "" {
46
-
description = &r.Description
47
-
}
48
-
49
-
return tangled.Repo{
50
-
Knot: r.Knot,
51
-
Name: r.Name,
52
-
Description: description,
53
-
CreatedAt: r.Created.Format(time.RFC3339),
54
-
Source: source,
55
-
Spindle: spindle,
56
-
Labels: r.Labels,
57
-
}
58
-
}
59
-
60
func (r Repo) RepoAt() syntax.ATURI {
61
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
62
}
···
66
return p
67
}
68
69
-
func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) {
70
-
repoMap := make(map[syntax.ATURI]*Repo)
71
72
var conditions []string
73
var args []any
···
88
89
repoQuery := fmt.Sprintf(
90
`select
91
did,
92
name,
93
knot,
···
111
}
112
113
for rows.Next() {
114
-
var repo Repo
115
var createdAt string
116
var description, source, spindle sql.NullString
117
118
err := rows.Scan(
119
&repo.Did,
120
&repo.Name,
121
&repo.Knot,
···
142
repo.Spindle = spindle.String
143
}
144
145
-
repo.RepoStats = &RepoStats{}
146
repoMap[repo.RepoAt()] = &repo
147
}
148
···
184
185
languageQuery := fmt.Sprintf(
186
`
187
-
select
188
-
repo_at, language
189
-
from
190
-
repo_languages r1
191
-
where
192
-
repo_at IN (%s)
193
and is_default_ref = 1
194
-
and id = (
195
-
select id
196
-
from repo_languages r2
197
-
where r2.repo_at = r1.repo_at
198
-
and r2.is_default_ref = 1
199
-
order by bytes desc
200
-
limit 1
201
-
);
202
`,
203
inClause,
204
)
···
290
inClause,
291
)
292
args = append([]any{
293
-
PullOpen,
294
-
PullMerged,
295
-
PullClosed,
296
-
PullDeleted,
297
}, args...)
298
rows, err = e.Query(
299
pullCountQuery,
···
320
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
321
}
322
323
-
var repos []Repo
324
for _, r := range repoMap {
325
repos = append(repos, *r)
326
}
327
328
-
slices.SortFunc(repos, func(a, b Repo) int {
329
if a.Created.After(b.Created) {
330
return -1
331
}
···
336
}
337
338
// helper to get exactly one repo
339
-
func GetRepo(e Execer, filters ...filter) (*Repo, error) {
340
repos, err := GetRepos(e, 0, filters...)
341
if err != nil {
342
return nil, err
···
377
return count, nil
378
}
379
380
-
func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
381
-
var repo Repo
382
var nullableDescription sql.NullString
383
384
-
row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
385
386
var createdAt string
387
-
if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
388
return nil, err
389
}
390
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
399
return &repo, nil
400
}
401
402
-
func AddRepo(e Execer, repo *Repo) error {
403
-
_, err := e.Exec(
404
`insert into repos
405
(did, name, knot, rkey, at_uri, description, source)
406
values (?, ?, ?, ?, ?, ?, ?)`,
407
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
408
)
409
-
return err
410
}
411
412
func RemoveRepo(e Execer, did, name string) error {
···
423
return nullableSource.String, nil
424
}
425
426
-
func GetForksByDid(e Execer, did string) ([]Repo, error) {
427
-
var repos []Repo
428
429
rows, err := e.Query(
430
-
`select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
431
from repos r
432
left join collaborators c on r.at_uri = c.repo_at
433
where (r.did = ? or c.subject_did = ?)
···
442
defer rows.Close()
443
444
for rows.Next() {
445
-
var repo Repo
446
var createdAt string
447
var nullableDescription sql.NullString
448
var nullableSource sql.NullString
449
450
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
451
if err != nil {
452
return nil, err
453
}
···
477
return repos, nil
478
}
479
480
-
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
481
-
var repo Repo
482
var createdAt string
483
var nullableDescription sql.NullString
484
var nullableSource sql.NullString
485
486
row := e.QueryRow(
487
-
`select did, name, knot, rkey, description, created, source
488
from repos
489
where did = ? and name = ? and source is not null and source != ''`,
490
did, name,
491
)
492
493
-
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
494
if err != nil {
495
return nil, err
496
}
···
525
return err
526
}
527
528
-
type RepoStats struct {
529
-
Language string
530
-
StarCount int
531
-
IssueCount IssueCount
532
-
PullCount PullCount
533
-
}
534
-
535
-
type RepoLabel struct {
536
-
Id int64
537
-
RepoAt syntax.ATURI
538
-
LabelAt syntax.ATURI
539
-
}
540
-
541
-
func SubscribeLabel(e Execer, rl *RepoLabel) error {
542
query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)`
543
544
_, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String())
···
563
return err
564
}
565
566
-
func GetRepoLabels(e Execer, filters ...filter) ([]RepoLabel, error) {
567
var conditions []string
568
var args []any
569
for _, filter := range filters {
···
584
}
585
defer rows.Close()
586
587
-
var labels []RepoLabel
588
for rows.Next() {
589
-
var label RepoLabel
590
591
err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt)
592
if err != nil {
···
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/models"
16
)
17
18
type Repo struct {
19
+
Id int64
20
Did string
21
Name string
22
Knot string
···
24
Created time.Time
25
Description string
26
Spindle string
27
28
// optionally, populate this when querying for reverse mappings
29
+
RepoStats *models.RepoStats
30
31
// optional
32
Source string
33
}
34
35
func (r Repo) RepoAt() syntax.ATURI {
36
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey))
37
}
···
41
return p
42
}
43
44
+
func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) {
45
+
repoMap := make(map[syntax.ATURI]*models.Repo)
46
47
var conditions []string
48
var args []any
···
63
64
repoQuery := fmt.Sprintf(
65
`select
66
+
id,
67
did,
68
name,
69
knot,
···
87
}
88
89
for rows.Next() {
90
+
var repo models.Repo
91
var createdAt string
92
var description, source, spindle sql.NullString
93
94
err := rows.Scan(
95
+
&repo.Id,
96
&repo.Did,
97
&repo.Name,
98
&repo.Knot,
···
119
repo.Spindle = spindle.String
120
}
121
122
+
repo.RepoStats = &models.RepoStats{}
123
repoMap[repo.RepoAt()] = &repo
124
}
125
···
161
162
languageQuery := fmt.Sprintf(
163
`
164
+
select repo_at, language
165
+
from (
166
+
select
167
+
repo_at,
168
+
language,
169
+
row_number() over (
170
+
partition by repo_at
171
+
order by bytes desc
172
+
) as rn
173
+
from repo_languages
174
+
where repo_at in (%s)
175
and is_default_ref = 1
176
+
)
177
+
where rn = 1
178
`,
179
inClause,
180
)
···
266
inClause,
267
)
268
args = append([]any{
269
+
models.PullOpen,
270
+
models.PullMerged,
271
+
models.PullClosed,
272
+
models.PullDeleted,
273
}, args...)
274
rows, err = e.Query(
275
pullCountQuery,
···
296
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
297
}
298
299
+
var repos []models.Repo
300
for _, r := range repoMap {
301
repos = append(repos, *r)
302
}
303
304
+
slices.SortFunc(repos, func(a, b models.Repo) int {
305
if a.Created.After(b.Created) {
306
return -1
307
}
···
312
}
313
314
// helper to get exactly one repo
315
+
func GetRepo(e Execer, filters ...filter) (*models.Repo, error) {
316
repos, err := GetRepos(e, 0, filters...)
317
if err != nil {
318
return nil, err
···
353
return count, nil
354
}
355
356
+
func GetRepoByAtUri(e Execer, atUri string) (*models.Repo, error) {
357
+
var repo models.Repo
358
var nullableDescription sql.NullString
359
360
+
row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri)
361
362
var createdAt string
363
+
if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil {
364
return nil, err
365
}
366
createdAtTime, _ := time.Parse(time.RFC3339, createdAt)
···
375
return &repo, nil
376
}
377
378
+
func AddRepo(tx *sql.Tx, repo *models.Repo) error {
379
+
_, err := tx.Exec(
380
`insert into repos
381
(did, name, knot, rkey, at_uri, description, source)
382
values (?, ?, ?, ?, ?, ?, ?)`,
383
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source,
384
)
385
+
if err != nil {
386
+
return fmt.Errorf("failed to insert repo: %w", err)
387
+
}
388
+
389
+
for _, dl := range repo.Labels {
390
+
if err := SubscribeLabel(tx, &models.RepoLabel{
391
+
RepoAt: repo.RepoAt(),
392
+
LabelAt: syntax.ATURI(dl),
393
+
}); err != nil {
394
+
return fmt.Errorf("failed to subscribe to label: %w", err)
395
+
}
396
+
}
397
+
398
+
return nil
399
}
400
401
func RemoveRepo(e Execer, did, name string) error {
···
412
return nullableSource.String, nil
413
}
414
415
+
func GetForksByDid(e Execer, did string) ([]models.Repo, error) {
416
+
var repos []models.Repo
417
418
rows, err := e.Query(
419
+
`select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source
420
from repos r
421
left join collaborators c on r.at_uri = c.repo_at
422
where (r.did = ? or c.subject_did = ?)
···
431
defer rows.Close()
432
433
for rows.Next() {
434
+
var repo models.Repo
435
var createdAt string
436
var nullableDescription sql.NullString
437
var nullableSource sql.NullString
438
439
+
err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
440
if err != nil {
441
return nil, err
442
}
···
466
return repos, nil
467
}
468
469
+
func GetForkByDid(e Execer, did string, name string) (*models.Repo, error) {
470
+
var repo models.Repo
471
var createdAt string
472
var nullableDescription sql.NullString
473
var nullableSource sql.NullString
474
475
row := e.QueryRow(
476
+
`select id, did, name, knot, rkey, description, created, source
477
from repos
478
where did = ? and name = ? and source is not null and source != ''`,
479
did, name,
480
)
481
482
+
err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource)
483
if err != nil {
484
return nil, err
485
}
···
514
return err
515
}
516
517
+
func SubscribeLabel(e Execer, rl *models.RepoLabel) error {
518
query := `insert or ignore into repo_labels (repo_at, label_at) values (?, ?)`
519
520
_, err := e.Exec(query, rl.RepoAt.String(), rl.LabelAt.String())
···
539
return err
540
}
541
542
+
func GetRepoLabels(e Execer, filters ...filter) ([]models.RepoLabel, error) {
543
var conditions []string
544
var args []any
545
for _, filter := range filters {
···
560
}
561
defer rows.Close()
562
563
+
var labels []models.RepoLabel
564
for rows.Next() {
565
+
var label models.RepoLabel
566
567
err := rows.Scan(&label.Id, &label.RepoAt, &label.LabelAt)
568
if err != nil {
+4
-9
appview/db/signup.go
+4
-9
appview/db/signup.go
···
1
package db
2
3
-
import "time"
4
5
-
type InflightSignup struct {
6
-
Id int64
7
-
Email string
8
-
InviteCode string
9
-
Created time.Time
10
-
}
11
-
12
-
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
return err
···
1
package db
2
3
+
import (
4
+
"tangled.org/core/appview/models"
5
+
)
6
7
+
func AddInflightSignup(e Execer, signup models.InflightSignup) error {
8
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
9
_, err := e.Exec(query, signup.Email, signup.InviteCode)
10
return err
+9
-27
appview/db/spindle.go
+9
-27
appview/db/spindle.go
···
6
"strings"
7
"time"
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
)
11
12
-
type Spindle struct {
13
-
Id int
14
-
Owner syntax.DID
15
-
Instance string
16
-
Verified *time.Time
17
-
Created time.Time
18
-
NeedsUpgrade bool
19
-
}
20
-
21
-
type SpindleMember struct {
22
-
Id int
23
-
Did syntax.DID // owner of the record
24
-
Rkey string // rkey of the record
25
-
Instance string
26
-
Subject syntax.DID // the member being added
27
-
Created time.Time
28
-
}
29
-
30
-
func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) {
31
-
var spindles []Spindle
32
33
var conditions []string
34
var args []any
···
59
defer rows.Close()
60
61
for rows.Next() {
62
-
var spindle Spindle
63
var createdAt string
64
var verified sql.NullString
65
var needsUpgrade int
···
100
}
101
102
// if there is an existing spindle with the same instance, this returns an error
103
-
func AddSpindle(e Execer, spindle Spindle) error {
104
_, err := e.Exec(
105
`insert into spindles (owner, instance) values (?, ?)`,
106
spindle.Owner,
···
151
return err
152
}
153
154
-
func AddSpindleMember(e Execer, member SpindleMember) error {
155
_, err := e.Exec(
156
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
157
member.Did,
···
181
return err
182
}
183
184
-
func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) {
185
-
var members []SpindleMember
186
187
var conditions []string
188
var args []any
···
213
defer rows.Close()
214
215
for rows.Next() {
216
-
var member SpindleMember
217
var createdAt string
218
219
if err := rows.Scan(
···
6
"strings"
7
"time"
8
9
+
"tangled.org/core/appview/models"
10
)
11
12
+
func GetSpindles(e Execer, filters ...filter) ([]models.Spindle, error) {
13
+
var spindles []models.Spindle
14
15
var conditions []string
16
var args []any
···
41
defer rows.Close()
42
43
for rows.Next() {
44
+
var spindle models.Spindle
45
var createdAt string
46
var verified sql.NullString
47
var needsUpgrade int
···
82
}
83
84
// if there is an existing spindle with the same instance, this returns an error
85
+
func AddSpindle(e Execer, spindle models.Spindle) error {
86
_, err := e.Exec(
87
`insert into spindles (owner, instance) values (?, ?)`,
88
spindle.Owner,
···
133
return err
134
}
135
136
+
func AddSpindleMember(e Execer, member models.SpindleMember) error {
137
_, err := e.Exec(
138
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
139
member.Did,
···
163
return err
164
}
165
166
+
func GetSpindleMembers(e Execer, filters ...filter) ([]models.SpindleMember, error) {
167
+
var members []models.SpindleMember
168
169
var conditions []string
170
var args []any
···
195
defer rows.Close()
196
197
for rows.Next() {
198
+
var member models.SpindleMember
199
var createdAt string
200
201
if err := rows.Scan(
+27
-39
appview/db/star.go
+27
-39
appview/db/star.go
···
5
"errors"
6
"fmt"
7
"log"
8
"strings"
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
)
13
14
-
type Star struct {
15
-
StarredByDid string
16
-
RepoAt syntax.ATURI
17
-
Created time.Time
18
-
Rkey string
19
-
20
-
// optionally, populate this when querying for reverse mappings
21
-
Repo *Repo
22
-
}
23
-
24
-
func (star *Star) ResolveRepo(e Execer) error {
25
-
if star.Repo != nil {
26
-
return nil
27
-
}
28
-
29
-
repo, err := GetRepoByAtUri(e, star.RepoAt.String())
30
-
if err != nil {
31
-
return err
32
-
}
33
-
34
-
star.Repo = repo
35
-
return nil
36
-
}
37
-
38
-
func AddStar(e Execer, star *Star) error {
39
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
40
_, err := e.Exec(
41
query,
···
47
}
48
49
// Get a star record
50
-
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
51
query := `
52
select starred_by_did, repo_at, created, rkey
53
from stars
54
where starred_by_did = ? and repo_at = ?`
55
row := e.QueryRow(query, starredByDid, repoAt)
56
57
-
var star Star
58
var created string
59
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
60
if err != nil {
···
152
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
153
return getStarStatuses(e, userDid, repoAts)
154
}
155
-
func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) {
156
var conditions []string
157
var args []any
158
for _, filter := range filters {
···
184
return nil, err
185
}
186
187
-
starMap := make(map[string][]Star)
188
for rows.Next() {
189
-
var star Star
190
var created string
191
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
192
if err != nil {
···
227
}
228
}
229
230
-
var stars []Star
231
for _, s := range starMap {
232
stars = append(stars, s...)
233
}
234
235
return stars, nil
236
}
···
259
return count, nil
260
}
261
262
-
func GetAllStars(e Execer, limit int) ([]Star, error) {
263
-
var stars []Star
264
265
rows, err := e.Query(`
266
select
···
283
defer rows.Close()
284
285
for rows.Next() {
286
-
var star Star
287
-
var repo Repo
288
var starCreatedAt, repoCreatedAt string
289
290
if err := rows.Scan(
···
322
}
323
324
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
325
-
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
326
// first, get the top repo URIs by star count from the last week
327
query := `
328
with recent_starred_repos as (
···
366
}
367
368
if len(repoUris) == 0 {
369
-
return []Repo{}, nil
370
}
371
372
// get full repo data
···
376
}
377
378
// sort repos by the original trending order
379
-
repoMap := make(map[string]Repo)
380
for _, repo := range repos {
381
repoMap[repo.RepoAt().String()] = repo
382
}
383
384
-
orderedRepos := make([]Repo, 0, len(repoUris))
385
for _, uri := range repoUris {
386
if repo, exists := repoMap[uri]; exists {
387
orderedRepos = append(orderedRepos, repo)
···
5
"errors"
6
"fmt"
7
"log"
8
+
"slices"
9
"strings"
10
"time"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.org/core/appview/models"
14
)
15
16
+
func AddStar(e Execer, star *models.Star) error {
17
query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)`
18
_, err := e.Exec(
19
query,
···
25
}
26
27
// Get a star record
28
+
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*models.Star, error) {
29
query := `
30
select starred_by_did, repo_at, created, rkey
31
from stars
32
where starred_by_did = ? and repo_at = ?`
33
row := e.QueryRow(query, starredByDid, repoAt)
34
35
+
var star models.Star
36
var created string
37
err := row.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
38
if err != nil {
···
130
func GetStarStatuses(e Execer, userDid string, repoAts []syntax.ATURI) (map[string]bool, error) {
131
return getStarStatuses(e, userDid, repoAts)
132
}
133
+
func GetStars(e Execer, limit int, filters ...filter) ([]models.Star, error) {
134
var conditions []string
135
var args []any
136
for _, filter := range filters {
···
162
return nil, err
163
}
164
165
+
starMap := make(map[string][]models.Star)
166
for rows.Next() {
167
+
var star models.Star
168
var created string
169
err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey)
170
if err != nil {
···
205
}
206
}
207
208
+
var stars []models.Star
209
for _, s := range starMap {
210
stars = append(stars, s...)
211
}
212
+
213
+
slices.SortFunc(stars, func(a, b models.Star) int {
214
+
if a.Created.After(b.Created) {
215
+
return -1
216
+
}
217
+
if b.Created.After(a.Created) {
218
+
return 1
219
+
}
220
+
return 0
221
+
})
222
223
return stars, nil
224
}
···
247
return count, nil
248
}
249
250
+
func GetAllStars(e Execer, limit int) ([]models.Star, error) {
251
+
var stars []models.Star
252
253
rows, err := e.Query(`
254
select
···
271
defer rows.Close()
272
273
for rows.Next() {
274
+
var star models.Star
275
+
var repo models.Repo
276
var starCreatedAt, repoCreatedAt string
277
278
if err := rows.Scan(
···
310
}
311
312
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
313
+
func GetTopStarredReposLastWeek(e Execer) ([]models.Repo, error) {
314
// first, get the top repo URIs by star count from the last week
315
query := `
316
with recent_starred_repos as (
···
354
}
355
356
if len(repoUris) == 0 {
357
+
return []models.Repo{}, nil
358
}
359
360
// get full repo data
···
364
}
365
366
// sort repos by the original trending order
367
+
repoMap := make(map[string]models.Repo)
368
for _, repo := range repos {
369
repoMap[repo.RepoAt().String()] = repo
370
}
371
372
+
orderedRepos := make([]models.Repo, 0, len(repoUris))
373
for _, uri := range repoUris {
374
if repo, exists := repoMap[uri]; exists {
375
orderedRepos = append(orderedRepos, repo)
+5
-110
appview/db/strings.go
+5
-110
appview/db/strings.go
···
1
package db
2
3
import (
4
-
"bytes"
5
"database/sql"
6
"errors"
7
"fmt"
8
-
"io"
9
"strings"
10
"time"
11
-
"unicode/utf8"
12
13
-
"github.com/bluesky-social/indigo/atproto/syntax"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
)
16
17
-
type String struct {
18
-
Did syntax.DID
19
-
Rkey string
20
-
21
-
Filename string
22
-
Description string
23
-
Contents string
24
-
Created time.Time
25
-
Edited *time.Time
26
-
}
27
-
28
-
func (s *String) StringAt() syntax.ATURI {
29
-
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey))
30
-
}
31
-
32
-
type StringStats struct {
33
-
LineCount uint64
34
-
ByteCount uint64
35
-
}
36
-
37
-
func (s String) Stats() StringStats {
38
-
lineCount, err := countLines(strings.NewReader(s.Contents))
39
-
if err != nil {
40
-
// non-fatal
41
-
// TODO: log this?
42
-
}
43
-
44
-
return StringStats{
45
-
LineCount: uint64(lineCount),
46
-
ByteCount: uint64(len(s.Contents)),
47
-
}
48
-
}
49
-
50
-
func (s String) Validate() error {
51
-
var err error
52
-
53
-
if utf8.RuneCountInString(s.Filename) > 140 {
54
-
err = errors.Join(err, fmt.Errorf("filename too long"))
55
-
}
56
-
57
-
if utf8.RuneCountInString(s.Description) > 280 {
58
-
err = errors.Join(err, fmt.Errorf("description too long"))
59
-
}
60
-
61
-
if len(s.Contents) == 0 {
62
-
err = errors.Join(err, fmt.Errorf("contents is empty"))
63
-
}
64
-
65
-
return err
66
-
}
67
-
68
-
func (s *String) AsRecord() tangled.String {
69
-
return tangled.String{
70
-
Filename: s.Filename,
71
-
Description: s.Description,
72
-
Contents: s.Contents,
73
-
CreatedAt: s.Created.Format(time.RFC3339),
74
-
}
75
-
}
76
-
77
-
func StringFromRecord(did, rkey string, record tangled.String) String {
78
-
created, err := time.Parse(record.CreatedAt, time.RFC3339)
79
-
if err != nil {
80
-
created = time.Now()
81
-
}
82
-
return String{
83
-
Did: syntax.DID(did),
84
-
Rkey: rkey,
85
-
Filename: record.Filename,
86
-
Description: record.Description,
87
-
Contents: record.Contents,
88
-
Created: created,
89
-
}
90
-
}
91
-
92
-
func AddString(e Execer, s String) error {
93
_, err := e.Exec(
94
`insert into strings (
95
did,
···
123
return err
124
}
125
126
-
func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) {
127
-
var all []String
128
129
var conditions []string
130
var args []any
···
167
defer rows.Close()
168
169
for rows.Next() {
170
-
var s String
171
var createdAt string
172
var editedAt sql.NullString
173
···
248
_, err := e.Exec(query, args...)
249
return err
250
}
251
-
252
-
func countLines(r io.Reader) (int, error) {
253
-
buf := make([]byte, 32*1024)
254
-
bufLen := 0
255
-
count := 0
256
-
nl := []byte{'\n'}
257
-
258
-
for {
259
-
c, err := r.Read(buf)
260
-
if c > 0 {
261
-
bufLen += c
262
-
}
263
-
count += bytes.Count(buf[:c], nl)
264
-
265
-
switch {
266
-
case err == io.EOF:
267
-
/* handle last line not having a newline at the end */
268
-
if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
269
-
count++
270
-
}
271
-
return count, nil
272
-
case err != nil:
273
-
return 0, err
274
-
}
275
-
}
276
-
}
···
1
package db
2
3
import (
4
"database/sql"
5
"errors"
6
"fmt"
7
"strings"
8
"time"
9
10
+
"tangled.org/core/appview/models"
11
)
12
13
+
func AddString(e Execer, s models.String) error {
14
_, err := e.Exec(
15
`insert into strings (
16
did,
···
44
return err
45
}
46
47
+
func GetStrings(e Execer, limit int, filters ...filter) ([]models.String, error) {
48
+
var all []models.String
49
50
var conditions []string
51
var args []any
···
88
defer rows.Close()
89
90
for rows.Next() {
91
+
var s models.String
92
var createdAt string
93
var editedAt sql.NullString
94
···
169
_, err := e.Exec(query, args...)
170
return err
171
}
+20
-40
appview/db/timeline.go
+20
-40
appview/db/timeline.go
···
2
3
import (
4
"sort"
5
-
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
)
9
10
-
type TimelineEvent struct {
11
-
*Repo
12
-
*Follow
13
-
*Star
14
-
15
-
EventAt time.Time
16
-
17
-
// optional: populate only if Repo is a fork
18
-
Source *Repo
19
-
20
-
// optional: populate only if event is Follow
21
-
*Profile
22
-
*FollowStats
23
-
*FollowStatus
24
-
25
-
// optional: populate only if event is Repo
26
-
IsStarred bool
27
-
StarCount int64
28
-
}
29
-
30
// TODO: this gathers heterogenous events from different sources and aggregates
31
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
32
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
33
-
var events []TimelineEvent
34
35
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
36
if err != nil {
···
63
return events, nil
64
}
65
66
-
func fetchStarStatuses(e Execer, loggedInUserDid string, repos []Repo) (map[string]bool, error) {
67
if loggedInUserDid == "" {
68
return nil, nil
69
}
···
76
return GetStarStatuses(e, loggedInUserDid, repoAts)
77
}
78
79
-
func getRepoStarInfo(repo *Repo, starStatuses map[string]bool) (bool, int64) {
80
var isStarred bool
81
if starStatuses != nil {
82
isStarred = starStatuses[repo.RepoAt().String()]
···
90
return isStarred, starCount
91
}
92
93
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
94
repos, err := GetRepos(e, limit)
95
if err != nil {
96
return nil, err
···
104
}
105
}
106
107
-
var origRepos []Repo
108
if args != nil {
109
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
110
}
···
112
return nil, err
113
}
114
115
-
uriToRepo := make(map[string]Repo)
116
for _, r := range origRepos {
117
uriToRepo[r.RepoAt().String()] = r
118
}
···
122
return nil, err
123
}
124
125
-
var events []TimelineEvent
126
for _, r := range repos {
127
-
var source *Repo
128
if r.Source != "" {
129
if origRepo, ok := uriToRepo[r.Source]; ok {
130
source = &origRepo
···
133
134
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
135
136
-
events = append(events, TimelineEvent{
137
Repo: &r,
138
EventAt: r.Created,
139
Source: source,
···
145
return events, nil
146
}
147
148
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
149
stars, err := GetStars(e, limit)
150
if err != nil {
151
return nil, err
···
161
}
162
stars = stars[:n]
163
164
-
var repos []Repo
165
for _, s := range stars {
166
repos = append(repos, *s.Repo)
167
}
···
171
return nil, err
172
}
173
174
-
var events []TimelineEvent
175
for _, s := range stars {
176
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
177
178
-
events = append(events, TimelineEvent{
179
Star: &s,
180
EventAt: s.Created,
181
IsStarred: isStarred,
···
186
return events, nil
187
}
188
189
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]TimelineEvent, error) {
190
follows, err := GetFollows(e, limit)
191
if err != nil {
192
return nil, err
···
211
return nil, err
212
}
213
214
-
var followStatuses map[string]FollowStatus
215
if loggedInUserDid != "" {
216
followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
217
if err != nil {
···
219
}
220
}
221
222
-
var events []TimelineEvent
223
for _, f := range follows {
224
profile, _ := profiles[f.SubjectDid]
225
followStatMap, _ := followStatMap[f.SubjectDid]
226
227
-
followStatus := IsNotFollowing
228
if followStatuses != nil {
229
followStatus = followStatuses[f.SubjectDid]
230
}
231
232
-
events = append(events, TimelineEvent{
233
Follow: &f,
234
Profile: profile,
235
FollowStats: &followStatMap,
···
2
3
import (
4
"sort"
5
6
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
"tangled.org/core/appview/models"
8
)
9
10
// TODO: this gathers heterogenous events from different sources and aggregates
11
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
12
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
13
+
var events []models.TimelineEvent
14
15
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
16
if err != nil {
···
43
return events, nil
44
}
45
46
+
func fetchStarStatuses(e Execer, loggedInUserDid string, repos []models.Repo) (map[string]bool, error) {
47
if loggedInUserDid == "" {
48
return nil, nil
49
}
···
56
return GetStarStatuses(e, loggedInUserDid, repoAts)
57
}
58
59
+
func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int64) {
60
var isStarred bool
61
if starStatuses != nil {
62
isStarred = starStatuses[repo.RepoAt().String()]
···
70
return isStarred, starCount
71
}
72
73
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
74
repos, err := GetRepos(e, limit)
75
if err != nil {
76
return nil, err
···
84
}
85
}
86
87
+
var origRepos []models.Repo
88
if args != nil {
89
origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args))
90
}
···
92
return nil, err
93
}
94
95
+
uriToRepo := make(map[string]models.Repo)
96
for _, r := range origRepos {
97
uriToRepo[r.RepoAt().String()] = r
98
}
···
102
return nil, err
103
}
104
105
+
var events []models.TimelineEvent
106
for _, r := range repos {
107
+
var source *models.Repo
108
if r.Source != "" {
109
if origRepo, ok := uriToRepo[r.Source]; ok {
110
source = &origRepo
···
113
114
isStarred, starCount := getRepoStarInfo(&r, starStatuses)
115
116
+
events = append(events, models.TimelineEvent{
117
Repo: &r,
118
EventAt: r.Created,
119
Source: source,
···
125
return events, nil
126
}
127
128
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
129
stars, err := GetStars(e, limit)
130
if err != nil {
131
return nil, err
···
141
}
142
stars = stars[:n]
143
144
+
var repos []models.Repo
145
for _, s := range stars {
146
repos = append(repos, *s.Repo)
147
}
···
151
return nil, err
152
}
153
154
+
var events []models.TimelineEvent
155
for _, s := range stars {
156
isStarred, starCount := getRepoStarInfo(s.Repo, starStatuses)
157
158
+
events = append(events, models.TimelineEvent{
159
Star: &s,
160
EventAt: s.Created,
161
IsStarred: isStarred,
···
166
return events, nil
167
}
168
169
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
170
follows, err := GetFollows(e, limit)
171
if err != nil {
172
return nil, err
···
191
return nil, err
192
}
193
194
+
var followStatuses map[string]models.FollowStatus
195
if loggedInUserDid != "" {
196
followStatuses, err = GetFollowStatuses(e, loggedInUserDid, subjects)
197
if err != nil {
···
199
}
200
}
201
202
+
var events []models.TimelineEvent
203
for _, f := range follows {
204
profile, _ := profiles[f.SubjectDid]
205
followStatMap, _ := followStatMap[f.SubjectDid]
206
207
+
followStatus := models.IsNotFollowing
208
if followStatuses != nil {
209
followStatus = followStatuses[f.SubjectDid]
210
}
211
212
+
events = append(events, models.TimelineEvent{
213
Follow: &f,
214
Profile: profile,
215
FollowStats: &followStatMap,
+1
-1
appview/dns/cloudflare.go
+1
-1
appview/dns/cloudflare.go
+146
-65
appview/ingester.go
+146
-65
appview/ingester.go
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"github.com/bluesky-social/jetstream/pkg/models"
13
"github.com/go-git/go-git/v5/plumbing"
14
"github.com/ipfs/go-cid"
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/validator"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
)
23
24
type Ingester struct {
···
30
Validator *validator.Validator
31
}
32
33
-
type processFunc func(ctx context.Context, e *models.Event) error
34
35
func (i *Ingester) Ingest() processFunc {
36
-
return func(ctx context.Context, e *models.Event) error {
37
var err error
38
defer func() {
39
eventTime := e.TimeUS
···
45
46
l := i.Logger.With("kind", e.Kind)
47
switch e.Kind {
48
-
case models.EventKindAccount:
49
if !e.Account.Active && *e.Account.Status == "deactivated" {
50
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
51
}
52
-
case models.EventKindIdentity:
53
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
54
-
case models.EventKindCommit:
55
switch e.Commit.Collection {
56
case tangled.GraphFollowNSID:
57
err = i.ingestFollow(e)
···
79
err = i.ingestIssueComment(e)
80
case tangled.LabelDefinitionNSID:
81
err = i.ingestLabelDefinition(e)
82
}
83
l = i.Logger.With("nsid", e.Commit.Collection)
84
}
···
91
}
92
}
93
94
-
func (i *Ingester) ingestStar(e *models.Event) error {
95
var err error
96
did := e.Did
97
···
99
l = l.With("nsid", e.Commit.Collection)
100
101
switch e.Commit.Operation {
102
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
103
var subjectUri syntax.ATURI
104
105
raw := json.RawMessage(e.Commit.Record)
···
115
l.Error("invalid record", "err", err)
116
return err
117
}
118
-
err = db.AddStar(i.Db, &db.Star{
119
StarredByDid: did,
120
RepoAt: subjectUri,
121
Rkey: e.Commit.RKey,
122
})
123
-
case models.CommitOperationDelete:
124
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
125
}
126
···
131
return nil
132
}
133
134
-
func (i *Ingester) ingestFollow(e *models.Event) error {
135
var err error
136
did := e.Did
137
···
139
l = l.With("nsid", e.Commit.Collection)
140
141
switch e.Commit.Operation {
142
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
143
raw := json.RawMessage(e.Commit.Record)
144
record := tangled.GraphFollow{}
145
err = json.Unmarshal(raw, &record)
···
148
return err
149
}
150
151
-
err = db.AddFollow(i.Db, &db.Follow{
152
UserDid: did,
153
SubjectDid: record.Subject,
154
Rkey: e.Commit.RKey,
155
})
156
-
case models.CommitOperationDelete:
157
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
158
}
159
···
164
return nil
165
}
166
167
-
func (i *Ingester) ingestPublicKey(e *models.Event) error {
168
did := e.Did
169
var err error
170
···
172
l = l.With("nsid", e.Commit.Collection)
173
174
switch e.Commit.Operation {
175
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
176
l.Debug("processing add of pubkey")
177
raw := json.RawMessage(e.Commit.Record)
178
record := tangled.PublicKey{}
···
185
name := record.Name
186
key := record.Key
187
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
188
-
case models.CommitOperationDelete:
189
l.Debug("processing delete of pubkey")
190
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
191
}
···
197
return nil
198
}
199
200
-
func (i *Ingester) ingestArtifact(e *models.Event) error {
201
did := e.Did
202
var err error
203
···
205
l = l.With("nsid", e.Commit.Collection)
206
207
switch e.Commit.Operation {
208
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
209
raw := json.RawMessage(e.Commit.Record)
210
record := tangled.RepoArtifact{}
211
err = json.Unmarshal(raw, &record)
···
234
createdAt = time.Now()
235
}
236
237
-
artifact := db.Artifact{
238
Did: did,
239
Rkey: e.Commit.RKey,
240
RepoAt: repoAt,
···
247
}
248
249
err = db.AddArtifact(i.Db, artifact)
250
-
case models.CommitOperationDelete:
251
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
252
}
253
···
258
return nil
259
}
260
261
-
func (i *Ingester) ingestProfile(e *models.Event) error {
262
did := e.Did
263
var err error
264
···
270
}
271
272
switch e.Commit.Operation {
273
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
274
raw := json.RawMessage(e.Commit.Record)
275
record := tangled.ActorProfile{}
276
err = json.Unmarshal(raw, &record)
···
298
}
299
}
300
301
-
var stats [2]db.VanityStat
302
for i, s := range record.Stats {
303
if i < 2 {
304
-
stats[i].Kind = db.VanityStatKind(s)
305
}
306
}
307
···
312
}
313
}
314
315
-
profile := db.Profile{
316
Did: did,
317
Description: description,
318
IncludeBluesky: includeBluesky,
···
338
}
339
340
err = db.UpsertProfile(tx, &profile)
341
-
case models.CommitOperationDelete:
342
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
343
}
344
···
349
return nil
350
}
351
352
-
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error {
353
did := e.Did
354
var err error
355
···
357
l = l.With("nsid", e.Commit.Collection)
358
359
switch e.Commit.Operation {
360
-
case models.CommitOperationCreate:
361
raw := json.RawMessage(e.Commit.Record)
362
record := tangled.SpindleMember{}
363
err = json.Unmarshal(raw, &record)
···
386
return fmt.Errorf("failed to index profile record, invalid db cast")
387
}
388
389
-
err = db.AddSpindleMember(ddb, db.SpindleMember{
390
Did: syntax.DID(did),
391
Rkey: e.Commit.RKey,
392
Instance: record.Instance,
···
402
}
403
404
l.Info("added spindle member")
405
-
case models.CommitOperationDelete:
406
rkey := e.Commit.RKey
407
408
ddb, ok := i.Db.Execer.(*db.DB)
···
455
return nil
456
}
457
458
-
func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error {
459
did := e.Did
460
var err error
461
···
463
l = l.With("nsid", e.Commit.Collection)
464
465
switch e.Commit.Operation {
466
-
case models.CommitOperationCreate:
467
raw := json.RawMessage(e.Commit.Record)
468
record := tangled.Spindle{}
469
err = json.Unmarshal(raw, &record)
···
479
return fmt.Errorf("failed to index profile record, invalid db cast")
480
}
481
482
-
err := db.AddSpindle(ddb, db.Spindle{
483
Owner: syntax.DID(did),
484
Instance: instance,
485
})
···
501
502
return nil
503
504
-
case models.CommitOperationDelete:
505
instance := e.Commit.RKey
506
507
ddb, ok := i.Db.Execer.(*db.DB)
···
569
return nil
570
}
571
572
-
func (i *Ingester) ingestString(e *models.Event) error {
573
did := e.Did
574
rkey := e.Commit.RKey
575
···
584
}
585
586
switch e.Commit.Operation {
587
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
588
raw := json.RawMessage(e.Commit.Record)
589
record := tangled.String{}
590
err = json.Unmarshal(raw, &record)
···
593
return err
594
}
595
596
-
string := db.StringFromRecord(did, rkey, record)
597
598
-
if err = string.Validate(); err != nil {
599
l.Error("invalid record", "err", err)
600
return err
601
}
···
607
608
return nil
609
610
-
case models.CommitOperationDelete:
611
if err := db.DeleteString(
612
ddb,
613
db.FilterEq("did", did),
···
623
return nil
624
}
625
626
-
func (i *Ingester) ingestKnotMember(e *models.Event) error {
627
did := e.Did
628
var err error
629
···
631
l = l.With("nsid", e.Commit.Collection)
632
633
switch e.Commit.Operation {
634
-
case models.CommitOperationCreate:
635
raw := json.RawMessage(e.Commit.Record)
636
record := tangled.KnotMember{}
637
err = json.Unmarshal(raw, &record)
···
661
}
662
663
l.Info("added knot member")
664
-
case models.CommitOperationDelete:
665
// we don't store knot members in a table (like we do for spindle)
666
// and we can't remove this just yet. possibly fixed if we switch
667
// to either:
···
675
return nil
676
}
677
678
-
func (i *Ingester) ingestKnot(e *models.Event) error {
679
did := e.Did
680
var err error
681
···
683
l = l.With("nsid", e.Commit.Collection)
684
685
switch e.Commit.Operation {
686
-
case models.CommitOperationCreate:
687
raw := json.RawMessage(e.Commit.Record)
688
record := tangled.Knot{}
689
err = json.Unmarshal(raw, &record)
···
718
719
return nil
720
721
-
case models.CommitOperationDelete:
722
domain := e.Commit.RKey
723
724
ddb, ok := i.Db.Execer.(*db.DB)
···
778
779
return nil
780
}
781
-
func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error {
782
did := e.Did
783
rkey := e.Commit.RKey
784
···
793
}
794
795
switch e.Commit.Operation {
796
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
797
raw := json.RawMessage(e.Commit.Record)
798
record := tangled.RepoIssue{}
799
err = json.Unmarshal(raw, &record)
···
802
return err
803
}
804
805
-
issue := db.IssueFromRecord(did, rkey, record)
806
807
if err := i.Validator.ValidateIssue(&issue); err != nil {
808
return fmt.Errorf("failed to validate issue: %w", err)
···
829
830
return nil
831
832
-
case models.CommitOperationDelete:
833
if err := db.DeleteIssues(
834
ddb,
835
db.FilterEq("did", did),
···
845
return nil
846
}
847
848
-
func (i *Ingester) ingestIssueComment(e *models.Event) error {
849
did := e.Did
850
rkey := e.Commit.RKey
851
···
860
}
861
862
switch e.Commit.Operation {
863
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
864
raw := json.RawMessage(e.Commit.Record)
865
record := tangled.RepoIssueComment{}
866
err = json.Unmarshal(raw, &record)
···
868
return fmt.Errorf("invalid record: %w", err)
869
}
870
871
-
comment, err := db.IssueCommentFromRecord(did, rkey, record)
872
if err != nil {
873
return fmt.Errorf("failed to parse comment from record: %w", err)
874
}
···
884
885
return nil
886
887
-
case models.CommitOperationDelete:
888
if err := db.DeleteIssueComments(
889
ddb,
890
db.FilterEq("did", did),
···
899
return nil
900
}
901
902
-
func (i *Ingester) ingestLabelDefinition(e *models.Event) error {
903
did := e.Did
904
rkey := e.Commit.RKey
905
···
914
}
915
916
switch e.Commit.Operation {
917
-
case models.CommitOperationCreate, models.CommitOperationUpdate:
918
raw := json.RawMessage(e.Commit.Record)
919
record := tangled.LabelDefinition{}
920
err = json.Unmarshal(raw, &record)
···
922
return fmt.Errorf("invalid record: %w", err)
923
}
924
925
-
def, err := db.LabelDefinitionFromRecord(did, rkey, record)
926
if err != nil {
927
return fmt.Errorf("failed to parse labeldef from record: %w", err)
928
}
···
938
939
return nil
940
941
-
case models.CommitOperationDelete:
942
if err := db.DeleteLabelDefinition(
943
ddb,
944
db.FilterEq("did", did),
···
952
953
return nil
954
}
···
5
"encoding/json"
6
"fmt"
7
"log/slog"
8
+
"maps"
9
+
"slices"
10
11
"time"
12
13
"github.com/bluesky-social/indigo/atproto/syntax"
14
+
jmodels "github.com/bluesky-social/jetstream/pkg/models"
15
"github.com/go-git/go-git/v5/plumbing"
16
"github.com/ipfs/go-cid"
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/config"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/serververify"
22
+
"tangled.org/core/appview/validator"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
25
)
26
27
type Ingester struct {
···
33
Validator *validator.Validator
34
}
35
36
+
type processFunc func(ctx context.Context, e *jmodels.Event) error
37
38
func (i *Ingester) Ingest() processFunc {
39
+
return func(ctx context.Context, e *jmodels.Event) error {
40
var err error
41
defer func() {
42
eventTime := e.TimeUS
···
48
49
l := i.Logger.With("kind", e.Kind)
50
switch e.Kind {
51
+
case jmodels.EventKindAccount:
52
if !e.Account.Active && *e.Account.Status == "deactivated" {
53
err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did)
54
}
55
+
case jmodels.EventKindIdentity:
56
err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did)
57
+
case jmodels.EventKindCommit:
58
switch e.Commit.Collection {
59
case tangled.GraphFollowNSID:
60
err = i.ingestFollow(e)
···
82
err = i.ingestIssueComment(e)
83
case tangled.LabelDefinitionNSID:
84
err = i.ingestLabelDefinition(e)
85
+
case tangled.LabelOpNSID:
86
+
err = i.ingestLabelOp(e)
87
}
88
l = i.Logger.With("nsid", e.Commit.Collection)
89
}
···
96
}
97
}
98
99
+
func (i *Ingester) ingestStar(e *jmodels.Event) error {
100
var err error
101
did := e.Did
102
···
104
l = l.With("nsid", e.Commit.Collection)
105
106
switch e.Commit.Operation {
107
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
108
var subjectUri syntax.ATURI
109
110
raw := json.RawMessage(e.Commit.Record)
···
120
l.Error("invalid record", "err", err)
121
return err
122
}
123
+
err = db.AddStar(i.Db, &models.Star{
124
StarredByDid: did,
125
RepoAt: subjectUri,
126
Rkey: e.Commit.RKey,
127
})
128
+
case jmodels.CommitOperationDelete:
129
err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey)
130
}
131
···
136
return nil
137
}
138
139
+
func (i *Ingester) ingestFollow(e *jmodels.Event) error {
140
var err error
141
did := e.Did
142
···
144
l = l.With("nsid", e.Commit.Collection)
145
146
switch e.Commit.Operation {
147
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
148
raw := json.RawMessage(e.Commit.Record)
149
record := tangled.GraphFollow{}
150
err = json.Unmarshal(raw, &record)
···
153
return err
154
}
155
156
+
err = db.AddFollow(i.Db, &models.Follow{
157
UserDid: did,
158
SubjectDid: record.Subject,
159
Rkey: e.Commit.RKey,
160
})
161
+
case jmodels.CommitOperationDelete:
162
err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey)
163
}
164
···
169
return nil
170
}
171
172
+
func (i *Ingester) ingestPublicKey(e *jmodels.Event) error {
173
did := e.Did
174
var err error
175
···
177
l = l.With("nsid", e.Commit.Collection)
178
179
switch e.Commit.Operation {
180
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
181
l.Debug("processing add of pubkey")
182
raw := json.RawMessage(e.Commit.Record)
183
record := tangled.PublicKey{}
···
190
name := record.Name
191
key := record.Key
192
err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey)
193
+
case jmodels.CommitOperationDelete:
194
l.Debug("processing delete of pubkey")
195
err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey)
196
}
···
202
return nil
203
}
204
205
+
func (i *Ingester) ingestArtifact(e *jmodels.Event) error {
206
did := e.Did
207
var err error
208
···
210
l = l.With("nsid", e.Commit.Collection)
211
212
switch e.Commit.Operation {
213
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
214
raw := json.RawMessage(e.Commit.Record)
215
record := tangled.RepoArtifact{}
216
err = json.Unmarshal(raw, &record)
···
239
createdAt = time.Now()
240
}
241
242
+
artifact := models.Artifact{
243
Did: did,
244
Rkey: e.Commit.RKey,
245
RepoAt: repoAt,
···
252
}
253
254
err = db.AddArtifact(i.Db, artifact)
255
+
case jmodels.CommitOperationDelete:
256
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
257
}
258
···
263
return nil
264
}
265
266
+
func (i *Ingester) ingestProfile(e *jmodels.Event) error {
267
did := e.Did
268
var err error
269
···
275
}
276
277
switch e.Commit.Operation {
278
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
279
raw := json.RawMessage(e.Commit.Record)
280
record := tangled.ActorProfile{}
281
err = json.Unmarshal(raw, &record)
···
303
}
304
}
305
306
+
var stats [2]models.VanityStat
307
for i, s := range record.Stats {
308
if i < 2 {
309
+
stats[i].Kind = models.VanityStatKind(s)
310
}
311
}
312
···
317
}
318
}
319
320
+
profile := models.Profile{
321
Did: did,
322
Description: description,
323
IncludeBluesky: includeBluesky,
···
343
}
344
345
err = db.UpsertProfile(tx, &profile)
346
+
case jmodels.CommitOperationDelete:
347
err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey))
348
}
349
···
354
return nil
355
}
356
357
+
func (i *Ingester) ingestSpindleMember(ctx context.Context, e *jmodels.Event) error {
358
did := e.Did
359
var err error
360
···
362
l = l.With("nsid", e.Commit.Collection)
363
364
switch e.Commit.Operation {
365
+
case jmodels.CommitOperationCreate:
366
raw := json.RawMessage(e.Commit.Record)
367
record := tangled.SpindleMember{}
368
err = json.Unmarshal(raw, &record)
···
391
return fmt.Errorf("failed to index profile record, invalid db cast")
392
}
393
394
+
err = db.AddSpindleMember(ddb, models.SpindleMember{
395
Did: syntax.DID(did),
396
Rkey: e.Commit.RKey,
397
Instance: record.Instance,
···
407
}
408
409
l.Info("added spindle member")
410
+
case jmodels.CommitOperationDelete:
411
rkey := e.Commit.RKey
412
413
ddb, ok := i.Db.Execer.(*db.DB)
···
460
return nil
461
}
462
463
+
func (i *Ingester) ingestSpindle(ctx context.Context, e *jmodels.Event) error {
464
did := e.Did
465
var err error
466
···
468
l = l.With("nsid", e.Commit.Collection)
469
470
switch e.Commit.Operation {
471
+
case jmodels.CommitOperationCreate:
472
raw := json.RawMessage(e.Commit.Record)
473
record := tangled.Spindle{}
474
err = json.Unmarshal(raw, &record)
···
484
return fmt.Errorf("failed to index profile record, invalid db cast")
485
}
486
487
+
err := db.AddSpindle(ddb, models.Spindle{
488
Owner: syntax.DID(did),
489
Instance: instance,
490
})
···
506
507
return nil
508
509
+
case jmodels.CommitOperationDelete:
510
instance := e.Commit.RKey
511
512
ddb, ok := i.Db.Execer.(*db.DB)
···
574
return nil
575
}
576
577
+
func (i *Ingester) ingestString(e *jmodels.Event) error {
578
did := e.Did
579
rkey := e.Commit.RKey
580
···
589
}
590
591
switch e.Commit.Operation {
592
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
593
raw := json.RawMessage(e.Commit.Record)
594
record := tangled.String{}
595
err = json.Unmarshal(raw, &record)
···
598
return err
599
}
600
601
+
string := models.StringFromRecord(did, rkey, record)
602
603
+
if err = i.Validator.ValidateString(&string); err != nil {
604
l.Error("invalid record", "err", err)
605
return err
606
}
···
612
613
return nil
614
615
+
case jmodels.CommitOperationDelete:
616
if err := db.DeleteString(
617
ddb,
618
db.FilterEq("did", did),
···
628
return nil
629
}
630
631
+
func (i *Ingester) ingestKnotMember(e *jmodels.Event) error {
632
did := e.Did
633
var err error
634
···
636
l = l.With("nsid", e.Commit.Collection)
637
638
switch e.Commit.Operation {
639
+
case jmodels.CommitOperationCreate:
640
raw := json.RawMessage(e.Commit.Record)
641
record := tangled.KnotMember{}
642
err = json.Unmarshal(raw, &record)
···
666
}
667
668
l.Info("added knot member")
669
+
case jmodels.CommitOperationDelete:
670
// we don't store knot members in a table (like we do for spindle)
671
// and we can't remove this just yet. possibly fixed if we switch
672
// to either:
···
680
return nil
681
}
682
683
+
func (i *Ingester) ingestKnot(e *jmodels.Event) error {
684
did := e.Did
685
var err error
686
···
688
l = l.With("nsid", e.Commit.Collection)
689
690
switch e.Commit.Operation {
691
+
case jmodels.CommitOperationCreate:
692
raw := json.RawMessage(e.Commit.Record)
693
record := tangled.Knot{}
694
err = json.Unmarshal(raw, &record)
···
723
724
return nil
725
726
+
case jmodels.CommitOperationDelete:
727
domain := e.Commit.RKey
728
729
ddb, ok := i.Db.Execer.(*db.DB)
···
783
784
return nil
785
}
786
+
func (i *Ingester) ingestIssue(ctx context.Context, e *jmodels.Event) error {
787
did := e.Did
788
rkey := e.Commit.RKey
789
···
798
}
799
800
switch e.Commit.Operation {
801
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
802
raw := json.RawMessage(e.Commit.Record)
803
record := tangled.RepoIssue{}
804
err = json.Unmarshal(raw, &record)
···
807
return err
808
}
809
810
+
issue := models.IssueFromRecord(did, rkey, record)
811
812
if err := i.Validator.ValidateIssue(&issue); err != nil {
813
return fmt.Errorf("failed to validate issue: %w", err)
···
834
835
return nil
836
837
+
case jmodels.CommitOperationDelete:
838
if err := db.DeleteIssues(
839
ddb,
840
db.FilterEq("did", did),
···
850
return nil
851
}
852
853
+
func (i *Ingester) ingestIssueComment(e *jmodels.Event) error {
854
did := e.Did
855
rkey := e.Commit.RKey
856
···
865
}
866
867
switch e.Commit.Operation {
868
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
869
raw := json.RawMessage(e.Commit.Record)
870
record := tangled.RepoIssueComment{}
871
err = json.Unmarshal(raw, &record)
···
873
return fmt.Errorf("invalid record: %w", err)
874
}
875
876
+
comment, err := models.IssueCommentFromRecord(did, rkey, record)
877
if err != nil {
878
return fmt.Errorf("failed to parse comment from record: %w", err)
879
}
···
889
890
return nil
891
892
+
case jmodels.CommitOperationDelete:
893
if err := db.DeleteIssueComments(
894
ddb,
895
db.FilterEq("did", did),
···
904
return nil
905
}
906
907
+
func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error {
908
did := e.Did
909
rkey := e.Commit.RKey
910
···
919
}
920
921
switch e.Commit.Operation {
922
+
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
923
raw := json.RawMessage(e.Commit.Record)
924
record := tangled.LabelDefinition{}
925
err = json.Unmarshal(raw, &record)
···
927
return fmt.Errorf("invalid record: %w", err)
928
}
929
930
+
def, err := models.LabelDefinitionFromRecord(did, rkey, record)
931
if err != nil {
932
return fmt.Errorf("failed to parse labeldef from record: %w", err)
933
}
···
943
944
return nil
945
946
+
case jmodels.CommitOperationDelete:
947
if err := db.DeleteLabelDefinition(
948
ddb,
949
db.FilterEq("did", did),
···
957
958
return nil
959
}
960
+
961
+
func (i *Ingester) ingestLabelOp(e *jmodels.Event) error {
962
+
did := e.Did
963
+
rkey := e.Commit.RKey
964
+
965
+
var err error
966
+
967
+
l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey)
968
+
l.Info("ingesting record")
969
+
970
+
ddb, ok := i.Db.Execer.(*db.DB)
971
+
if !ok {
972
+
return fmt.Errorf("failed to index label op, invalid db cast")
973
+
}
974
+
975
+
switch e.Commit.Operation {
976
+
case jmodels.CommitOperationCreate:
977
+
raw := json.RawMessage(e.Commit.Record)
978
+
record := tangled.LabelOp{}
979
+
err = json.Unmarshal(raw, &record)
980
+
if err != nil {
981
+
return fmt.Errorf("invalid record: %w", err)
982
+
}
983
+
984
+
subject := syntax.ATURI(record.Subject)
985
+
collection := subject.Collection()
986
+
987
+
var repo *models.Repo
988
+
switch collection {
989
+
case tangled.RepoIssueNSID:
990
+
i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject))
991
+
if err != nil || len(i) != 1 {
992
+
return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i))
993
+
}
994
+
repo = i[0].Repo
995
+
default:
996
+
return fmt.Errorf("unsupport label subject: %s", collection)
997
+
}
998
+
999
+
actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels))
1000
+
if err != nil {
1001
+
return fmt.Errorf("failed to build label application ctx: %w", err)
1002
+
}
1003
+
1004
+
ops := models.LabelOpsFromRecord(did, rkey, record)
1005
+
1006
+
for _, o := range ops {
1007
+
def, ok := actx.Defs[o.OperandKey]
1008
+
if !ok {
1009
+
return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs)))
1010
+
}
1011
+
if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil {
1012
+
return fmt.Errorf("failed to validate labelop: %w", err)
1013
+
}
1014
+
}
1015
+
1016
+
tx, err := ddb.Begin()
1017
+
if err != nil {
1018
+
return err
1019
+
}
1020
+
defer tx.Rollback()
1021
+
1022
+
for _, o := range ops {
1023
+
_, err = db.AddLabelOp(tx, &o)
1024
+
if err != nil {
1025
+
return fmt.Errorf("failed to add labelop: %w", err)
1026
+
}
1027
+
}
1028
+
1029
+
if err = tx.Commit(); err != nil {
1030
+
return err
1031
+
}
1032
+
}
1033
+
1034
+
return nil
1035
+
}
+44
-31
appview/issues/issues.go
+44
-31
appview/issues/issues.go
···
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/go-chi/chi/v5"
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/config"
21
-
"tangled.sh/tangled.sh/core/appview/db"
22
-
"tangled.sh/tangled.sh/core/appview/notify"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages"
25
-
"tangled.sh/tangled.sh/core/appview/pagination"
26
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
-
"tangled.sh/tangled.sh/core/appview/validator"
28
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
29
-
"tangled.sh/tangled.sh/core/idresolver"
30
-
tlog "tangled.sh/tangled.sh/core/log"
31
-
"tangled.sh/tangled.sh/core/tid"
32
)
33
34
type Issues struct {
···
75
return
76
}
77
78
-
issue, ok := r.Context().Value("issue").(*db.Issue)
79
if !ok {
80
l.Error("failed to get issue")
81
rp.pages.Error404(w)
···
87
l.Error("failed to get issue reactions", "err", err)
88
}
89
90
-
userReactions := map[db.ReactionKind]bool{}
91
if user != nil {
92
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
93
}
···
103
return
104
}
105
106
-
defs := make(map[string]*db.LabelDefinition)
107
for _, l := range labelDefs {
108
defs[l.AtUri().String()] = &l
109
}
···
113
RepoInfo: f.RepoInfo(user),
114
Issue: issue,
115
CommentList: issue.CommentList(),
116
-
OrderedReactionKinds: db.OrderedReactionKinds,
117
Reactions: reactionCountMap,
118
UserReacted: userReactions,
119
LabelDefs: defs,
···
129
return
130
}
131
132
-
issue, ok := r.Context().Value("issue").(*db.Issue)
133
if !ok {
134
l.Error("failed to get issue")
135
rp.pages.Error404(w)
···
225
return
226
}
227
228
-
issue, ok := r.Context().Value("issue").(*db.Issue)
229
if !ok {
230
l.Error("failed to get issue")
231
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···
272
return
273
}
274
275
-
issue, ok := r.Context().Value("issue").(*db.Issue)
276
if !ok {
277
l.Error("failed to get issue")
278
rp.pages.Error404(w)
···
300
return
301
}
302
303
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
304
return
305
} else {
···
318
return
319
}
320
321
-
issue, ok := r.Context().Value("issue").(*db.Issue)
322
if !ok {
323
l.Error("failed to get issue")
324
rp.pages.Error404(w)
···
362
return
363
}
364
365
-
issue, ok := r.Context().Value("issue").(*db.Issue)
366
if !ok {
367
l.Error("failed to get issue")
368
rp.pages.Error404(w)
···
381
replyTo = &replyToUri
382
}
383
384
-
comment := db.IssueComment{
385
Did: user.Did,
386
Rkey: tid.TID(),
387
IssueAt: issue.AtUri().String(),
···
433
434
// reset atUri to make rollback a no-op
435
atUri = ""
436
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
437
}
438
···
445
return
446
}
447
448
-
issue, ok := r.Context().Value("issue").(*db.Issue)
449
if !ok {
450
l.Error("failed to get issue")
451
rp.pages.Error404(w)
···
486
return
487
}
488
489
-
issue, ok := r.Context().Value("issue").(*db.Issue)
490
if !ok {
491
l.Error("failed to get issue")
492
rp.pages.Error404(w)
···
590
return
591
}
592
593
-
issue, ok := r.Context().Value("issue").(*db.Issue)
594
if !ok {
595
l.Error("failed to get issue")
596
rp.pages.Error404(w)
···
631
return
632
}
633
634
-
issue, ok := r.Context().Value("issue").(*db.Issue)
635
if !ok {
636
l.Error("failed to get issue")
637
rp.pages.Error404(w)
···
672
return
673
}
674
675
-
issue, ok := r.Context().Value("issue").(*db.Issue)
676
if !ok {
677
l.Error("failed to get issue")
678
rp.pages.Error404(w)
···
789
return
790
}
791
792
-
labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels))
793
if err != nil {
794
log.Println("failed to fetch labels", err)
795
rp.pages.Error503(w)
796
return
797
}
798
799
-
defs := make(map[string]*db.LabelDefinition)
800
for _, l := range labelDefs {
801
defs[l.AtUri().String()] = &l
802
}
···
828
RepoInfo: f.RepoInfo(user),
829
})
830
case http.MethodPost:
831
-
issue := &db.Issue{
832
RepoAt: f.RepoAt(),
833
Rkey: tid.TID(),
834
Title: r.FormValue("title"),
···
16
lexutil "github.com/bluesky-social/indigo/lex/util"
17
"github.com/go-chi/chi/v5"
18
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/config"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/notify"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/pages"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/appview/reporesolver"
28
+
"tangled.org/core/appview/validator"
29
+
"tangled.org/core/appview/xrpcclient"
30
+
"tangled.org/core/idresolver"
31
+
tlog "tangled.org/core/log"
32
+
"tangled.org/core/tid"
33
)
34
35
type Issues struct {
···
76
return
77
}
78
79
+
issue, ok := r.Context().Value("issue").(*models.Issue)
80
if !ok {
81
l.Error("failed to get issue")
82
rp.pages.Error404(w)
···
88
l.Error("failed to get issue reactions", "err", err)
89
}
90
91
+
userReactions := map[models.ReactionKind]bool{}
92
if user != nil {
93
userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri())
94
}
···
104
return
105
}
106
107
+
defs := make(map[string]*models.LabelDefinition)
108
for _, l := range labelDefs {
109
defs[l.AtUri().String()] = &l
110
}
···
114
RepoInfo: f.RepoInfo(user),
115
Issue: issue,
116
CommentList: issue.CommentList(),
117
+
OrderedReactionKinds: models.OrderedReactionKinds,
118
Reactions: reactionCountMap,
119
UserReacted: userReactions,
120
LabelDefs: defs,
···
130
return
131
}
132
133
+
issue, ok := r.Context().Value("issue").(*models.Issue)
134
if !ok {
135
l.Error("failed to get issue")
136
rp.pages.Error404(w)
···
226
return
227
}
228
229
+
issue, ok := r.Context().Value("issue").(*models.Issue)
230
if !ok {
231
l.Error("failed to get issue")
232
rp.pages.Notice(w, noticeId, "Failed to delete issue.")
···
273
return
274
}
275
276
+
issue, ok := r.Context().Value("issue").(*models.Issue)
277
if !ok {
278
l.Error("failed to get issue")
279
rp.pages.Error404(w)
···
301
return
302
}
303
304
+
// notify about the issue closure
305
+
rp.notifier.NewIssueClosed(r.Context(), issue)
306
+
307
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId))
308
return
309
} else {
···
322
return
323
}
324
325
+
issue, ok := r.Context().Value("issue").(*models.Issue)
326
if !ok {
327
l.Error("failed to get issue")
328
rp.pages.Error404(w)
···
366
return
367
}
368
369
+
issue, ok := r.Context().Value("issue").(*models.Issue)
370
if !ok {
371
l.Error("failed to get issue")
372
rp.pages.Error404(w)
···
385
replyTo = &replyToUri
386
}
387
388
+
comment := models.IssueComment{
389
Did: user.Did,
390
Rkey: tid.TID(),
391
IssueAt: issue.AtUri().String(),
···
437
438
// reset atUri to make rollback a no-op
439
atUri = ""
440
+
441
+
// notify about the new comment
442
+
comment.Id = commentId
443
+
rp.notifier.NewIssueComment(r.Context(), &comment)
444
+
445
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId))
446
}
447
···
454
return
455
}
456
457
+
issue, ok := r.Context().Value("issue").(*models.Issue)
458
if !ok {
459
l.Error("failed to get issue")
460
rp.pages.Error404(w)
···
495
return
496
}
497
498
+
issue, ok := r.Context().Value("issue").(*models.Issue)
499
if !ok {
500
l.Error("failed to get issue")
501
rp.pages.Error404(w)
···
599
return
600
}
601
602
+
issue, ok := r.Context().Value("issue").(*models.Issue)
603
if !ok {
604
l.Error("failed to get issue")
605
rp.pages.Error404(w)
···
640
return
641
}
642
643
+
issue, ok := r.Context().Value("issue").(*models.Issue)
644
if !ok {
645
l.Error("failed to get issue")
646
rp.pages.Error404(w)
···
681
return
682
}
683
684
+
issue, ok := r.Context().Value("issue").(*models.Issue)
685
if !ok {
686
l.Error("failed to get issue")
687
rp.pages.Error404(w)
···
798
return
799
}
800
801
+
labelDefs, err := db.GetLabelDefinitions(
802
+
rp.db,
803
+
db.FilterIn("at_uri", f.Repo.Labels),
804
+
db.FilterContains("scope", tangled.RepoIssueNSID),
805
+
)
806
if err != nil {
807
log.Println("failed to fetch labels", err)
808
rp.pages.Error503(w)
809
return
810
}
811
812
+
defs := make(map[string]*models.LabelDefinition)
813
for _, l := range labelDefs {
814
defs[l.AtUri().String()] = &l
815
}
···
841
RepoInfo: f.RepoInfo(user),
842
})
843
case http.MethodPost:
844
+
issue := &models.Issue{
845
RepoAt: f.RepoAt(),
846
Rkey: tid.TID(),
847
Title: r.FormValue("title"),
+1
-1
appview/issues/router.go
+1
-1
appview/issues/router.go
+14
-13
appview/knots/knots.go
+14
-13
appview/knots/knots.go
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/eventconsumer"
21
-
"tangled.sh/tangled.sh/core/idresolver"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/tid"
24
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
lexutil "github.com/bluesky-social/indigo/lex/util"
···
119
}
120
121
// organize repos by did
122
-
repoMap := make(map[string][]db.Repo)
123
for _, r := range repos {
124
repoMap[r.Did] = append(repoMap[r.Did], r)
125
}
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/eventconsumer"
22
+
"tangled.org/core/idresolver"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/tid"
25
26
comatproto "github.com/bluesky-social/indigo/api/atproto"
27
lexutil "github.com/bluesky-social/indigo/lex/util"
···
120
}
121
122
// organize repos by did
123
+
repoMap := make(map[string][]models.Repo)
124
for _, r := range repos {
125
repoMap[r.Did] = append(repoMap[r.Did], r)
126
}
+32
-24
appview/labels/labels.go
+32
-24
appview/labels/labels.go
···
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
"github.com/go-chi/chi/v5"
16
17
-
"tangled.sh/tangled.sh/core/api/tangled"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/middleware"
20
-
"tangled.sh/tangled.sh/core/appview/oauth"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
-
"tangled.sh/tangled.sh/core/appview/validator"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/log"
25
-
"tangled.sh/tangled.sh/core/tid"
26
)
27
28
type Labels struct {
···
31
db *db.DB
32
logger *slog.Logger
33
validator *validator.Validator
34
}
35
36
func New(
···
38
pages *pages.Pages,
39
db *db.DB,
40
validator *validator.Validator,
41
) *Labels {
42
logger := log.New("labels")
43
···
47
db: db,
48
logger: logger,
49
validator: validator,
50
}
51
}
52
···
85
repoAt := r.Form.Get("repo")
86
subjectUri := r.Form.Get("subject")
87
88
// find all the labels that this repo subscribes to
89
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
90
if err != nil {
···
103
return
104
}
105
106
-
l.logger.Info("actx", "labels", labelAts)
107
-
l.logger.Info("actx", "defs", actx.Defs)
108
-
109
// calculate the start state by applying already known labels
110
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
111
if err != nil {
···
113
return
114
}
115
116
-
labelState := db.NewLabelState()
117
actx.ApplyLabelOps(labelState, existingOps)
118
119
-
var labelOps []db.LabelOp
120
121
// first delete all existing state
122
for key, vals := range labelState.Inner() {
123
for val := range vals {
124
-
labelOps = append(labelOps, db.LabelOp{
125
Did: did,
126
Rkey: rkey,
127
Subject: syntax.ATURI(subjectUri),
128
-
Operation: db.LabelOperationDel,
129
OperandKey: key,
130
OperandValue: val,
131
PerformedAt: performedAt,
···
141
}
142
143
for _, val := range vals {
144
-
labelOps = append(labelOps, db.LabelOp{
145
Did: did,
146
Rkey: rkey,
147
Subject: syntax.ATURI(subjectUri),
148
-
Operation: db.LabelOperationAdd,
149
OperandKey: key,
150
OperandValue: val,
151
PerformedAt: performedAt,
···
154
}
155
}
156
157
-
// reduce the opset
158
-
labelOps = db.ReduceLabelOps(labelOps)
159
-
160
for i := range labelOps {
161
def := actx.Defs[labelOps[i].OperandKey]
162
-
if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil {
163
fail(fmt.Sprintf("Invalid form data: %s", err), err)
164
return
165
}
166
}
167
168
// next, apply all ops introduced in this request and filter out ones that are no-ops
169
validLabelOps := labelOps[:0]
170
for _, op := range labelOps {
171
-
if err = actx.ApplyLabelOp(labelState, op); err != db.LabelNoOpError {
172
validLabelOps = append(validLabelOps, op)
173
}
174
}
···
180
}
181
182
// create an atproto record of valid ops
183
-
record := db.LabelOpsAsRecord(validLabelOps)
184
185
client, err := l.oauth.AuthorizedClient(r)
186
if err != nil {
···
14
lexutil "github.com/bluesky-social/indigo/lex/util"
15
"github.com/go-chi/chi/v5"
16
17
+
"tangled.org/core/api/tangled"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/middleware"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/oauth"
22
+
"tangled.org/core/appview/pages"
23
+
"tangled.org/core/appview/validator"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/log"
26
+
"tangled.org/core/rbac"
27
+
"tangled.org/core/tid"
28
)
29
30
type Labels struct {
···
33
db *db.DB
34
logger *slog.Logger
35
validator *validator.Validator
36
+
enforcer *rbac.Enforcer
37
}
38
39
func New(
···
41
pages *pages.Pages,
42
db *db.DB,
43
validator *validator.Validator,
44
+
enforcer *rbac.Enforcer,
45
) *Labels {
46
logger := log.New("labels")
47
···
51
db: db,
52
logger: logger,
53
validator: validator,
54
+
enforcer: enforcer,
55
}
56
}
57
···
90
repoAt := r.Form.Get("repo")
91
subjectUri := r.Form.Get("subject")
92
93
+
repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt))
94
+
if err != nil {
95
+
fail("Failed to get repository.", err)
96
+
return
97
+
}
98
+
99
// find all the labels that this repo subscribes to
100
repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt))
101
if err != nil {
···
114
return
115
}
116
117
// calculate the start state by applying already known labels
118
existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri))
119
if err != nil {
···
121
return
122
}
123
124
+
labelState := models.NewLabelState()
125
actx.ApplyLabelOps(labelState, existingOps)
126
127
+
var labelOps []models.LabelOp
128
129
// first delete all existing state
130
for key, vals := range labelState.Inner() {
131
for val := range vals {
132
+
labelOps = append(labelOps, models.LabelOp{
133
Did: did,
134
Rkey: rkey,
135
Subject: syntax.ATURI(subjectUri),
136
+
Operation: models.LabelOperationDel,
137
OperandKey: key,
138
OperandValue: val,
139
PerformedAt: performedAt,
···
149
}
150
151
for _, val := range vals {
152
+
labelOps = append(labelOps, models.LabelOp{
153
Did: did,
154
Rkey: rkey,
155
Subject: syntax.ATURI(subjectUri),
156
+
Operation: models.LabelOperationAdd,
157
OperandKey: key,
158
OperandValue: val,
159
PerformedAt: performedAt,
···
162
}
163
}
164
165
for i := range labelOps {
166
def := actx.Defs[labelOps[i].OperandKey]
167
+
if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil {
168
fail(fmt.Sprintf("Invalid form data: %s", err), err)
169
return
170
}
171
}
172
173
+
// reduce the opset
174
+
labelOps = models.ReduceLabelOps(labelOps)
175
+
176
// next, apply all ops introduced in this request and filter out ones that are no-ops
177
validLabelOps := labelOps[:0]
178
for _, op := range labelOps {
179
+
if err = actx.ApplyLabelOp(labelState, op); err != models.LabelNoOpError {
180
validLabelOps = append(validLabelOps, op)
181
}
182
}
···
188
}
189
190
// create an atproto record of valid ops
191
+
record := models.LabelOpsAsRecord(validLabelOps)
192
193
client, err := l.oauth.AuthorizedClient(r)
194
if err != nil {
+16
-7
appview/middleware/middleware.go
+16
-7
appview/middleware/middleware.go
···
12
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/pagination"
19
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
)
23
24
type Middleware struct {
···
42
}
43
44
type middlewareFunc func(http.Handler) http.Handler
45
46
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47
return func(next http.Handler) http.Handler {
···
12
13
"github.com/bluesky-social/indigo/atproto/identity"
14
"github.com/go-chi/chi/v5"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pagination"
19
+
"tangled.org/core/appview/reporesolver"
20
+
"tangled.org/core/idresolver"
21
+
"tangled.org/core/rbac"
22
)
23
24
type Middleware struct {
···
42
}
43
44
type middlewareFunc func(http.Handler) http.Handler
45
+
46
+
func (mw *Middleware) TryRefreshSession() middlewareFunc {
47
+
return func(next http.Handler) http.Handler {
48
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+
_, _, _ = mw.oauth.GetSession(r)
50
+
next.ServeHTTP(w, r)
51
+
})
52
+
}
53
+
}
54
55
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
56
return func(next http.Handler) http.Handler {
+30
appview/models/artifact.go
+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
import (
4
"context"
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
7
)
8
9
type mergedNotifier struct {
···
16
17
var _ Notifier = &mergedNotifier{}
18
19
-
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) {
20
for _, notifier := range m.notifiers {
21
notifier.NewRepo(ctx, repo)
22
}
23
}
24
25
-
func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) {
26
for _, notifier := range m.notifiers {
27
notifier.NewStar(ctx, star)
28
}
29
}
30
-
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) {
31
for _, notifier := range m.notifiers {
32
notifier.DeleteStar(ctx, star)
33
}
34
}
35
36
-
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) {
37
for _, notifier := range m.notifiers {
38
notifier.NewIssue(ctx, issue)
39
}
40
}
41
42
-
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) {
43
for _, notifier := range m.notifiers {
44
notifier.NewFollow(ctx, follow)
45
}
46
}
47
-
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {
48
for _, notifier := range m.notifiers {
49
notifier.DeleteFollow(ctx, follow)
50
}
51
}
52
53
-
func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) {
54
for _, notifier := range m.notifiers {
55
notifier.NewPull(ctx, pull)
56
}
57
}
58
-
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {
59
for _, notifier := range m.notifiers {
60
notifier.NewPullComment(ctx, comment)
61
}
62
}
63
64
-
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {
65
for _, notifier := range m.notifiers {
66
notifier.UpdateProfile(ctx, profile)
67
}
68
}
69
70
-
func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) {
71
for _, notifier := range m.notifiers {
72
notifier.NewString(ctx, string)
73
}
74
}
75
76
-
func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) {
77
for _, notifier := range m.notifiers {
78
notifier.EditString(ctx, string)
79
}
···
3
import (
4
"context"
5
6
+
"tangled.org/core/appview/models"
7
)
8
9
type mergedNotifier struct {
···
16
17
var _ Notifier = &mergedNotifier{}
18
19
+
func (m *mergedNotifier) NewRepo(ctx context.Context, repo *models.Repo) {
20
for _, notifier := range m.notifiers {
21
notifier.NewRepo(ctx, repo)
22
}
23
}
24
25
+
func (m *mergedNotifier) NewStar(ctx context.Context, star *models.Star) {
26
for _, notifier := range m.notifiers {
27
notifier.NewStar(ctx, star)
28
}
29
}
30
+
func (m *mergedNotifier) DeleteStar(ctx context.Context, star *models.Star) {
31
for _, notifier := range m.notifiers {
32
notifier.DeleteStar(ctx, star)
33
}
34
}
35
36
+
func (m *mergedNotifier) NewIssue(ctx context.Context, issue *models.Issue) {
37
for _, notifier := range m.notifiers {
38
notifier.NewIssue(ctx, issue)
39
}
40
}
41
+
func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {
42
+
for _, notifier := range m.notifiers {
43
+
notifier.NewIssueComment(ctx, comment)
44
+
}
45
+
}
46
47
+
func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {
48
+
for _, notifier := range m.notifiers {
49
+
notifier.NewIssueClosed(ctx, issue)
50
+
}
51
+
}
52
+
53
+
func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) {
54
for _, notifier := range m.notifiers {
55
notifier.NewFollow(ctx, follow)
56
}
57
}
58
+
func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {
59
for _, notifier := range m.notifiers {
60
notifier.DeleteFollow(ctx, follow)
61
}
62
}
63
64
+
func (m *mergedNotifier) NewPull(ctx context.Context, pull *models.Pull) {
65
for _, notifier := range m.notifiers {
66
notifier.NewPull(ctx, pull)
67
}
68
}
69
+
func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) {
70
for _, notifier := range m.notifiers {
71
notifier.NewPullComment(ctx, comment)
72
}
73
}
74
75
+
func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {
76
+
for _, notifier := range m.notifiers {
77
+
notifier.NewPullMerged(ctx, pull)
78
+
}
79
+
}
80
+
81
+
func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {
82
+
for _, notifier := range m.notifiers {
83
+
notifier.NewPullClosed(ctx, pull)
84
+
}
85
+
}
86
+
87
+
func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {
88
for _, notifier := range m.notifiers {
89
notifier.UpdateProfile(ctx, profile)
90
}
91
}
92
93
+
func (m *mergedNotifier) NewString(ctx context.Context, string *models.String) {
94
for _, notifier := range m.notifiers {
95
notifier.NewString(ctx, string)
96
}
97
}
98
99
+
func (m *mergedNotifier) EditString(ctx context.Context, string *models.String) {
100
for _, notifier := range m.notifiers {
101
notifier.EditString(ctx, string)
102
}
+31
-23
appview/notify/notifier.go
+31
-23
appview/notify/notifier.go
···
3
import (
4
"context"
5
6
-
"tangled.sh/tangled.sh/core/appview/db"
7
)
8
9
type Notifier interface {
10
-
NewRepo(ctx context.Context, repo *db.Repo)
11
12
-
NewStar(ctx context.Context, star *db.Star)
13
-
DeleteStar(ctx context.Context, star *db.Star)
14
15
-
NewIssue(ctx context.Context, issue *db.Issue)
16
17
-
NewFollow(ctx context.Context, follow *db.Follow)
18
-
DeleteFollow(ctx context.Context, follow *db.Follow)
19
20
-
NewPull(ctx context.Context, pull *db.Pull)
21
-
NewPullComment(ctx context.Context, comment *db.PullComment)
22
23
-
UpdateProfile(ctx context.Context, profile *db.Profile)
24
25
-
NewString(ctx context.Context, s *db.String)
26
-
EditString(ctx context.Context, s *db.String)
27
DeleteString(ctx context.Context, did, rkey string)
28
}
29
···
32
33
var _ Notifier = &BaseNotifier{}
34
35
-
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {}
36
37
-
func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {}
38
-
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {}
39
40
-
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {}
41
42
-
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {}
43
-
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {}
44
45
-
func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {}
46
-
func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {}
47
48
-
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
49
50
-
func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {}
51
-
func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {}
52
func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
···
3
import (
4
"context"
5
6
+
"tangled.org/core/appview/models"
7
)
8
9
type Notifier interface {
10
+
NewRepo(ctx context.Context, repo *models.Repo)
11
12
+
NewStar(ctx context.Context, star *models.Star)
13
+
DeleteStar(ctx context.Context, star *models.Star)
14
15
+
NewIssue(ctx context.Context, issue *models.Issue)
16
+
NewIssueComment(ctx context.Context, comment *models.IssueComment)
17
+
NewIssueClosed(ctx context.Context, issue *models.Issue)
18
19
+
NewFollow(ctx context.Context, follow *models.Follow)
20
+
DeleteFollow(ctx context.Context, follow *models.Follow)
21
22
+
NewPull(ctx context.Context, pull *models.Pull)
23
+
NewPullComment(ctx context.Context, comment *models.PullComment)
24
+
NewPullMerged(ctx context.Context, pull *models.Pull)
25
+
NewPullClosed(ctx context.Context, pull *models.Pull)
26
27
+
UpdateProfile(ctx context.Context, profile *models.Profile)
28
29
+
NewString(ctx context.Context, s *models.String)
30
+
EditString(ctx context.Context, s *models.String)
31
DeleteString(ctx context.Context, did, rkey string)
32
}
33
···
36
37
var _ Notifier = &BaseNotifier{}
38
39
+
func (m *BaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) {}
40
41
+
func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {}
42
+
func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {}
43
44
+
func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {}
45
+
func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {}
46
+
func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {}
47
48
+
func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {}
49
+
func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {}
50
51
+
func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {}
52
+
func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {}
53
+
func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {}
54
+
func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {}
55
56
+
func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {}
57
58
+
func (m *BaseNotifier) NewString(ctx context.Context, s *models.String) {}
59
+
func (m *BaseNotifier) EditString(ctx context.Context, s *models.String) {}
60
func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+219
appview/notify/posthog/notifier.go
+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
"github.com/gorilla/sessions"
17
"github.com/lestrrat-go/jwx/v2/jwk"
18
"github.com/posthog/posthog-go"
19
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
20
-
tangled "tangled.sh/tangled.sh/core/api/tangled"
21
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
22
-
"tangled.sh/tangled.sh/core/appview/config"
23
-
"tangled.sh/tangled.sh/core/appview/db"
24
-
"tangled.sh/tangled.sh/core/appview/middleware"
25
-
"tangled.sh/tangled.sh/core/appview/oauth"
26
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
27
-
"tangled.sh/tangled.sh/core/appview/pages"
28
-
"tangled.sh/tangled.sh/core/consts"
29
-
"tangled.sh/tangled.sh/core/idresolver"
30
-
"tangled.sh/tangled.sh/core/rbac"
31
-
"tangled.sh/tangled.sh/core/tid"
32
)
33
34
const (
···
16
"github.com/gorilla/sessions"
17
"github.com/lestrrat-go/jwx/v2/jwk"
18
"github.com/posthog/posthog-go"
19
+
tangled "tangled.org/core/api/tangled"
20
+
sessioncache "tangled.org/core/appview/cache/session"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/middleware"
24
+
"tangled.org/core/appview/oauth"
25
+
"tangled.org/core/appview/oauth/client"
26
+
"tangled.org/core/appview/pages"
27
+
"tangled.org/core/consts"
28
+
"tangled.org/core/idresolver"
29
+
"tangled.org/core/rbac"
30
+
"tangled.org/core/tid"
31
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
32
)
33
34
const (
+4
-4
appview/oauth/oauth.go
+4
-4
appview/oauth/oauth.go
···
9
10
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
oauth "tangled.sh/icyphox.sh/atproto-oauth"
13
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
14
-
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/oauth/client"
17
-
xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient"
18
)
19
20
type OAuth struct {
···
9
10
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
11
"github.com/gorilla/sessions"
12
+
sessioncache "tangled.org/core/appview/cache/session"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/oauth/client"
15
+
xrpc "tangled.org/core/appview/xrpcclient"
16
oauth "tangled.sh/icyphox.sh/atproto-oauth"
17
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
18
)
19
20
type OAuth struct {
+18
-18
appview/pages/funcmap.go
+18
-18
appview/pages/funcmap.go
···
19
20
"github.com/dustin/go-humanize"
21
"github.com/go-enry/go-enry/v2"
22
-
"tangled.sh/tangled.sh/core/appview/filetree"
23
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
24
-
"tangled.sh/tangled.sh/core/crypto"
25
)
26
27
func (p *Pages) funcMap() template.FuncMap {
···
141
"relTimeFmt": humanize.Time,
142
"shortRelTimeFmt": func(t time.Time) string {
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
144
-
{time.Second, "now", time.Second},
145
-
{2 * time.Second, "1s %s", 1},
146
-
{time.Minute, "%ds %s", time.Second},
147
-
{2 * time.Minute, "1min %s", 1},
148
-
{time.Hour, "%dmin %s", time.Minute},
149
-
{2 * time.Hour, "1hr %s", 1},
150
-
{humanize.Day, "%dhrs %s", time.Hour},
151
-
{2 * humanize.Day, "1d %s", 1},
152
-
{20 * humanize.Day, "%dd %s", humanize.Day},
153
-
{8 * humanize.Week, "%dw %s", humanize.Week},
154
-
{humanize.Year, "%dmo %s", humanize.Month},
155
-
{18 * humanize.Month, "1y %s", 1},
156
-
{2 * humanize.Year, "2y %s", 1},
157
-
{humanize.LongTime, "%dy %s", humanize.Year},
158
-
{math.MaxInt64, "a long while %s", 1},
159
})
160
},
161
"longTimeFmt": func(t time.Time) string {
···
19
20
"github.com/dustin/go-humanize"
21
"github.com/go-enry/go-enry/v2"
22
+
"tangled.org/core/appview/filetree"
23
+
"tangled.org/core/appview/pages/markup"
24
+
"tangled.org/core/crypto"
25
)
26
27
func (p *Pages) funcMap() template.FuncMap {
···
141
"relTimeFmt": humanize.Time,
142
"shortRelTimeFmt": func(t time.Time) string {
143
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
144
+
{D: time.Second, Format: "now", DivBy: time.Second},
145
+
{D: 2 * time.Second, Format: "1s %s", DivBy: 1},
146
+
{D: time.Minute, Format: "%ds %s", DivBy: time.Second},
147
+
{D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
148
+
{D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
149
+
{D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
150
+
{D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
151
+
{D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
152
+
{D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
153
+
{D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
154
+
{D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
155
+
{D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
156
+
{D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
157
+
{D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
158
+
{D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
159
})
160
},
161
"longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
+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
package markup
2
3
-
import "strings"
4
5
type Format string
6
···
10
)
11
12
var FileTypes map[Format][]string = map[Format][]string{
13
-
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
}
15
16
-
// ReadmeFilenames contains the list of common README filenames to search for,
17
-
// in order of preference. Only includes well-supported formats.
18
-
var ReadmeFilenames = []string{
19
-
"README.md", "readme.md",
20
-
"README",
21
-
"readme",
22
-
"README.markdown",
23
-
"readme.markdown",
24
-
"README.txt",
25
-
"readme.txt",
26
}
27
28
func GetFormat(filename string) Format {
29
-
for format, extensions := range FileTypes {
30
-
for _, extension := range extensions {
31
-
if strings.HasSuffix(filename, extension) {
32
-
return format
33
-
}
34
}
35
}
36
// default format
···
1
package markup
2
3
+
import (
4
+
"regexp"
5
+
)
6
7
type Format string
8
···
12
)
13
14
var FileTypes map[Format][]string = map[Format][]string{
15
+
FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
16
}
17
18
+
var FileTypePatterns = map[Format]*regexp.Regexp{
19
+
FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`),
20
+
}
21
+
22
+
var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`)
23
+
24
+
func IsReadmeFile(filename string) bool {
25
+
return ReadmePattern.MatchString(filename)
26
}
27
28
func GetFormat(filename string) Format {
29
+
for format, pattern := range FileTypePatterns {
30
+
if pattern.MatchString(filename) {
31
+
return format
32
}
33
}
34
// default format
+2
-2
appview/pages/markup/markdown.go
+2
-2
appview/pages/markup/markdown.go
+210
-130
appview/pages/pages.go
+210
-130
appview/pages/pages.go
···
16
"strings"
17
"sync"
18
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/commitverify"
21
-
"tangled.sh/tangled.sh/core/appview/config"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/oauth"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26
-
"tangled.sh/tangled.sh/core/appview/pagination"
27
-
"tangled.sh/tangled.sh/core/idresolver"
28
-
"tangled.sh/tangled.sh/core/patchutil"
29
-
"tangled.sh/tangled.sh/core/types"
30
31
"github.com/alecthomas/chroma/v2"
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
···
38
"github.com/go-git/go-git/v5/plumbing/object"
39
)
40
41
-
//go:embed templates/* static
42
var Files embed.FS
43
44
type Pages struct {
···
81
}
82
83
return p
84
-
}
85
-
86
-
func (p *Pages) pathToName(s string) string {
87
-
return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html")
88
}
89
90
// reverse of pathToName
···
230
return p.executePlain("user/login", w, params)
231
}
232
233
-
func (p *Pages) Signup(w io.Writer) error {
234
-
return p.executePlain("user/signup", w, nil)
235
}
236
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
filename := "terms.md"
248
filePath := filepath.Join("legal", filename)
249
-
markdownBytes, err := os.ReadFile(filePath)
250
if err != nil {
251
return fmt.Errorf("failed to read %s: %w", filename, err)
252
}
···
267
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
268
filename := "privacy.md"
269
filePath := filepath.Join("legal", filename)
270
-
markdownBytes, err := os.ReadFile(filePath)
271
if err != nil {
272
return fmt.Errorf("failed to read %s: %w", filename, err)
273
}
···
280
return p.execute("legal/privacy", w, params)
281
}
282
283
type TimelineParams struct {
284
LoggedInUser *oauth.User
285
-
Timeline []db.TimelineEvent
286
-
Repos []db.Repo
287
}
288
289
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
290
return p.execute("timeline/timeline", w, params)
291
}
292
293
type UserProfileSettingsParams struct {
294
LoggedInUser *oauth.User
295
Tabs []map[string]any
···
300
return p.execute("user/settings/profile", w, params)
301
}
302
303
type UserKeysSettingsParams struct {
304
LoggedInUser *oauth.User
305
-
PubKeys []db.PublicKey
306
Tabs []map[string]any
307
Tab string
308
}
···
313
314
type UserEmailsSettingsParams struct {
315
LoggedInUser *oauth.User
316
-
Emails []db.Email
317
Tabs []map[string]any
318
Tab string
319
}
···
322
return p.execute("user/settings/emails", w, params)
323
}
324
325
type UpgradeBannerParams struct {
326
-
Registrations []db.Registration
327
-
Spindles []db.Spindle
328
}
329
330
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
···
333
334
type KnotsParams struct {
335
LoggedInUser *oauth.User
336
-
Registrations []db.Registration
337
}
338
339
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
342
343
type KnotParams struct {
344
LoggedInUser *oauth.User
345
-
Registration *db.Registration
346
Members []string
347
-
Repos map[string][]db.Repo
348
IsOwner bool
349
}
350
···
353
}
354
355
type KnotListingParams struct {
356
-
*db.Registration
357
}
358
359
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
···
362
363
type SpindlesParams struct {
364
LoggedInUser *oauth.User
365
-
Spindles []db.Spindle
366
}
367
368
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
370
}
371
372
type SpindleListingParams struct {
373
-
db.Spindle
374
}
375
376
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
379
380
type SpindleDashboardParams struct {
381
LoggedInUser *oauth.User
382
-
Spindle db.Spindle
383
Members []string
384
-
Repos map[string][]db.Repo
385
}
386
387
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
410
type ProfileCard struct {
411
UserDid string
412
UserHandle string
413
-
FollowStatus db.FollowStatus
414
-
Punchcard *db.Punchcard
415
-
Profile *db.Profile
416
Stats ProfileStats
417
Active string
418
}
···
438
439
type ProfileOverviewParams struct {
440
LoggedInUser *oauth.User
441
-
Repos []db.Repo
442
-
CollaboratingRepos []db.Repo
443
-
ProfileTimeline *db.ProfileTimeline
444
Card *ProfileCard
445
Active string
446
}
···
452
453
type ProfileReposParams struct {
454
LoggedInUser *oauth.User
455
-
Repos []db.Repo
456
Card *ProfileCard
457
Active string
458
}
···
464
465
type ProfileStarredParams struct {
466
LoggedInUser *oauth.User
467
-
Repos []db.Repo
468
Card *ProfileCard
469
Active string
470
}
···
476
477
type ProfileStringsParams struct {
478
LoggedInUser *oauth.User
479
-
Strings []db.String
480
Card *ProfileCard
481
Active string
482
}
···
488
489
type FollowCard struct {
490
UserDid string
491
-
FollowStatus db.FollowStatus
492
FollowersCount int64
493
FollowingCount int64
494
-
Profile *db.Profile
495
}
496
497
type ProfileFollowersParams struct {
···
520
521
type FollowFragmentParams struct {
522
UserDid string
523
-
FollowStatus db.FollowStatus
524
}
525
526
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
···
529
530
type EditBioParams struct {
531
LoggedInUser *oauth.User
532
-
Profile *db.Profile
533
}
534
535
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
···
538
539
type EditPinsParams struct {
540
LoggedInUser *oauth.User
541
-
Profile *db.Profile
542
AllRepos []PinnedRepo
543
}
544
545
type PinnedRepo struct {
546
IsPinned bool
547
-
db.Repo
548
}
549
550
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
···
554
type RepoStarFragmentParams struct {
555
IsStarred bool
556
RepoAt syntax.ATURI
557
-
Stats db.RepoStats
558
}
559
560
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
···
587
EmailToDidOrHandle map[string]string
588
VerifiedCommits commitverify.VerifiedCommits
589
Languages []types.RepoLanguageDetails
590
-
Pipelines map[string]db.Pipeline
591
NeedsKnotUpgrade bool
592
types.RepoIndexResponse
593
}
···
630
Active string
631
EmailToDidOrHandle map[string]string
632
VerifiedCommits commitverify.VerifiedCommits
633
-
Pipelines map[string]db.Pipeline
634
}
635
636
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
643
RepoInfo repoinfo.RepoInfo
644
Active string
645
EmailToDidOrHandle map[string]string
646
-
Pipeline *db.Pipeline
647
DiffOpts types.DiffOpts
648
649
// singular because it's always going to be just one
···
658
}
659
660
type RepoTreeParams struct {
661
-
LoggedInUser *oauth.User
662
-
RepoInfo repoinfo.RepoInfo
663
-
Active string
664
-
BreadCrumbs [][]string
665
-
TreePath string
666
-
Readme string
667
-
ReadmeFileName string
668
-
HTMLReadme template.HTML
669
-
Raw bool
670
types.RepoTreeResponse
671
}
672
···
694
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
695
params.Active = "overview"
696
697
-
if params.ReadmeFileName != "" {
698
-
params.ReadmeFileName = filepath.Base(params.ReadmeFileName)
699
700
ext := filepath.Ext(params.ReadmeFileName)
701
switch ext {
702
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
729
RepoInfo repoinfo.RepoInfo
730
Active string
731
types.RepoTagsResponse
732
-
ArtifactMap map[plumbing.Hash][]db.Artifact
733
-
DanglingArtifacts []db.Artifact
734
}
735
736
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
···
741
type RepoArtifactParams struct {
742
LoggedInUser *oauth.User
743
RepoInfo repoinfo.RepoInfo
744
-
Artifact db.Artifact
745
}
746
747
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
···
838
}
839
840
type RepoGeneralSettingsParams struct {
841
-
LoggedInUser *oauth.User
842
-
RepoInfo repoinfo.RepoInfo
843
-
Labels []db.LabelDefinition
844
-
DefaultLabels []db.LabelDefinition
845
-
SubscribedLabels map[string]struct{}
846
-
Active string
847
-
Tabs []map[string]any
848
-
Tab string
849
-
Branches []types.Branch
850
}
851
852
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
888
LoggedInUser *oauth.User
889
RepoInfo repoinfo.RepoInfo
890
Active string
891
-
Issues []db.Issue
892
-
LabelDefs map[string]*db.LabelDefinition
893
Page pagination.Page
894
FilteringByOpen bool
895
}
···
903
LoggedInUser *oauth.User
904
RepoInfo repoinfo.RepoInfo
905
Active string
906
-
Issue *db.Issue
907
-
CommentList []db.CommentListItem
908
-
LabelDefs map[string]*db.LabelDefinition
909
910
-
OrderedReactionKinds []db.ReactionKind
911
-
Reactions map[db.ReactionKind]int
912
-
UserReacted map[db.ReactionKind]bool
913
}
914
915
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
920
type EditIssueParams struct {
921
LoggedInUser *oauth.User
922
RepoInfo repoinfo.RepoInfo
923
-
Issue *db.Issue
924
Action string
925
}
926
···
931
932
type ThreadReactionFragmentParams struct {
933
ThreadAt syntax.ATURI
934
-
Kind db.ReactionKind
935
Count int
936
IsReacted bool
937
}
···
943
type RepoNewIssueParams struct {
944
LoggedInUser *oauth.User
945
RepoInfo repoinfo.RepoInfo
946
-
Issue *db.Issue // existing issue if any -- passed when editing
947
Active string
948
Action string
949
}
···
957
type EditIssueCommentParams struct {
958
LoggedInUser *oauth.User
959
RepoInfo repoinfo.RepoInfo
960
-
Issue *db.Issue
961
-
Comment *db.IssueComment
962
}
963
964
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
968
type ReplyIssueCommentPlaceholderParams struct {
969
LoggedInUser *oauth.User
970
RepoInfo repoinfo.RepoInfo
971
-
Issue *db.Issue
972
-
Comment *db.IssueComment
973
}
974
975
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
979
type ReplyIssueCommentParams struct {
980
LoggedInUser *oauth.User
981
RepoInfo repoinfo.RepoInfo
982
-
Issue *db.Issue
983
-
Comment *db.IssueComment
984
}
985
986
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
990
type IssueCommentBodyParams struct {
991
LoggedInUser *oauth.User
992
RepoInfo repoinfo.RepoInfo
993
-
Issue *db.Issue
994
-
Comment *db.IssueComment
995
}
996
997
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1018
type RepoPullsParams struct {
1019
LoggedInUser *oauth.User
1020
RepoInfo repoinfo.RepoInfo
1021
-
Pulls []*db.Pull
1022
Active string
1023
-
FilteringBy db.PullState
1024
-
Stacks map[string]db.Stack
1025
-
Pipelines map[string]db.Pipeline
1026
}
1027
1028
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1052
LoggedInUser *oauth.User
1053
RepoInfo repoinfo.RepoInfo
1054
Active string
1055
-
Pull *db.Pull
1056
-
Stack db.Stack
1057
-
AbandonedPulls []*db.Pull
1058
MergeCheck types.MergeCheckResponse
1059
ResubmitCheck ResubmitResult
1060
-
Pipelines map[string]db.Pipeline
1061
1062
-
OrderedReactionKinds []db.ReactionKind
1063
-
Reactions map[db.ReactionKind]int
1064
-
UserReacted map[db.ReactionKind]bool
1065
}
1066
1067
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1072
type RepoPullPatchParams struct {
1073
LoggedInUser *oauth.User
1074
RepoInfo repoinfo.RepoInfo
1075
-
Pull *db.Pull
1076
-
Stack db.Stack
1077
Diff *types.NiceDiff
1078
Round int
1079
-
Submission *db.PullSubmission
1080
-
OrderedReactionKinds []db.ReactionKind
1081
DiffOpts types.DiffOpts
1082
}
1083
···
1089
type RepoPullInterdiffParams struct {
1090
LoggedInUser *oauth.User
1091
RepoInfo repoinfo.RepoInfo
1092
-
Pull *db.Pull
1093
Round int
1094
Interdiff *patchutil.InterdiffResult
1095
-
OrderedReactionKinds []db.ReactionKind
1096
DiffOpts types.DiffOpts
1097
}
1098
···
1121
1122
type PullCompareForkParams struct {
1123
RepoInfo repoinfo.RepoInfo
1124
-
Forks []db.Repo
1125
Selected string
1126
}
1127
···
1142
type PullResubmitParams struct {
1143
LoggedInUser *oauth.User
1144
RepoInfo repoinfo.RepoInfo
1145
-
Pull *db.Pull
1146
SubmissionId int
1147
}
1148
···
1153
type PullActionsParams struct {
1154
LoggedInUser *oauth.User
1155
RepoInfo repoinfo.RepoInfo
1156
-
Pull *db.Pull
1157
RoundNumber int
1158
MergeCheck types.MergeCheckResponse
1159
ResubmitCheck ResubmitResult
1160
-
Stack db.Stack
1161
}
1162
1163
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1167
type PullNewCommentParams struct {
1168
LoggedInUser *oauth.User
1169
RepoInfo repoinfo.RepoInfo
1170
-
Pull *db.Pull
1171
RoundNumber int
1172
}
1173
···
1178
type RepoCompareParams struct {
1179
LoggedInUser *oauth.User
1180
RepoInfo repoinfo.RepoInfo
1181
-
Forks []db.Repo
1182
Branches []types.Branch
1183
Tags []*types.TagReference
1184
Base string
···
1197
type RepoCompareNewParams struct {
1198
LoggedInUser *oauth.User
1199
RepoInfo repoinfo.RepoInfo
1200
-
Forks []db.Repo
1201
Branches []types.Branch
1202
Tags []*types.TagReference
1203
Base string
···
1235
type LabelPanelParams struct {
1236
LoggedInUser *oauth.User
1237
RepoInfo repoinfo.RepoInfo
1238
-
Defs map[string]*db.LabelDefinition
1239
Subject string
1240
-
State db.LabelState
1241
}
1242
1243
func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
···
1247
type EditLabelPanelParams struct {
1248
LoggedInUser *oauth.User
1249
RepoInfo repoinfo.RepoInfo
1250
-
Defs map[string]*db.LabelDefinition
1251
Subject string
1252
-
State db.LabelState
1253
}
1254
1255
func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
···
1259
type PipelinesParams struct {
1260
LoggedInUser *oauth.User
1261
RepoInfo repoinfo.RepoInfo
1262
-
Pipelines []db.Pipeline
1263
Active string
1264
}
1265
···
1291
type WorkflowParams struct {
1292
LoggedInUser *oauth.User
1293
RepoInfo repoinfo.RepoInfo
1294
-
Pipeline db.Pipeline
1295
Workflow string
1296
LogUrl string
1297
Active string
···
1307
Action string
1308
1309
// this is supplied in the case of editing an existing string
1310
-
String db.String
1311
}
1312
1313
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
···
1317
type StringsDashboardParams struct {
1318
LoggedInUser *oauth.User
1319
Card ProfileCard
1320
-
Strings []db.String
1321
}
1322
1323
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
···
1326
1327
type StringTimelineParams struct {
1328
LoggedInUser *oauth.User
1329
-
Strings []db.String
1330
}
1331
1332
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
···
1338
ShowRendered bool
1339
RenderToggle bool
1340
RenderedContents template.HTML
1341
-
String db.String
1342
-
Stats db.StringStats
1343
Owner identity.Identity
1344
}
1345
···
16
"strings"
17
"sync"
18
19
+
"tangled.org/core/api/tangled"
20
+
"tangled.org/core/appview/commitverify"
21
+
"tangled.org/core/appview/config"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/oauth"
24
+
"tangled.org/core/appview/pages/markup"
25
+
"tangled.org/core/appview/pages/repoinfo"
26
+
"tangled.org/core/appview/pagination"
27
+
"tangled.org/core/idresolver"
28
+
"tangled.org/core/patchutil"
29
+
"tangled.org/core/types"
30
31
"github.com/alecthomas/chroma/v2"
32
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
···
38
"github.com/go-git/go-git/v5/plumbing/object"
39
)
40
41
+
//go:embed templates/* static legal
42
var Files embed.FS
43
44
type Pages struct {
···
81
}
82
83
return p
84
}
85
86
// reverse of pathToName
···
226
return p.executePlain("user/login", w, params)
227
}
228
229
+
type SignupParams struct {
230
+
CloudflareSiteKey string
231
+
}
232
+
233
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
234
+
return p.executePlain("user/signup", w, params)
235
}
236
237
func (p *Pages) CompleteSignup(w io.Writer) error {
···
246
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
247
filename := "terms.md"
248
filePath := filepath.Join("legal", filename)
249
+
250
+
file, err := p.embedFS.Open(filePath)
251
+
if err != nil {
252
+
return fmt.Errorf("failed to read %s: %w", filename, err)
253
+
}
254
+
defer file.Close()
255
+
256
+
markdownBytes, err := io.ReadAll(file)
257
if err != nil {
258
return fmt.Errorf("failed to read %s: %w", filename, err)
259
}
···
274
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
275
filename := "privacy.md"
276
filePath := filepath.Join("legal", filename)
277
+
278
+
file, err := p.embedFS.Open(filePath)
279
+
if err != nil {
280
+
return fmt.Errorf("failed to read %s: %w", filename, err)
281
+
}
282
+
defer file.Close()
283
+
284
+
markdownBytes, err := io.ReadAll(file)
285
if err != nil {
286
return fmt.Errorf("failed to read %s: %w", filename, err)
287
}
···
294
return p.execute("legal/privacy", w, params)
295
}
296
297
+
type BrandParams struct {
298
+
LoggedInUser *oauth.User
299
+
}
300
+
301
+
func (p *Pages) Brand(w io.Writer, params BrandParams) error {
302
+
return p.execute("brand/brand", w, params)
303
+
}
304
+
305
type TimelineParams struct {
306
LoggedInUser *oauth.User
307
+
Timeline []models.TimelineEvent
308
+
Repos []models.Repo
309
+
GfiLabel *models.LabelDefinition
310
}
311
312
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
313
return p.execute("timeline/timeline", w, params)
314
}
315
316
+
type GoodFirstIssuesParams struct {
317
+
LoggedInUser *oauth.User
318
+
Issues []models.Issue
319
+
RepoGroups []*models.RepoGroup
320
+
LabelDefs map[string]*models.LabelDefinition
321
+
GfiLabel *models.LabelDefinition
322
+
Page pagination.Page
323
+
}
324
+
325
+
func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
326
+
return p.execute("goodfirstissues/index", w, params)
327
+
}
328
+
329
type UserProfileSettingsParams struct {
330
LoggedInUser *oauth.User
331
Tabs []map[string]any
···
336
return p.execute("user/settings/profile", w, params)
337
}
338
339
+
type NotificationsParams struct {
340
+
LoggedInUser *oauth.User
341
+
Notifications []*models.NotificationWithEntity
342
+
UnreadCount int
343
+
Page pagination.Page
344
+
Total int64
345
+
}
346
+
347
+
func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
348
+
return p.execute("notifications/list", w, params)
349
+
}
350
+
351
+
type NotificationItemParams struct {
352
+
Notification *models.Notification
353
+
}
354
+
355
+
func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
356
+
return p.executePlain("notifications/fragments/item", w, params)
357
+
}
358
+
359
+
type NotificationCountParams struct {
360
+
Count int64
361
+
}
362
+
363
+
func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
364
+
return p.executePlain("notifications/fragments/count", w, params)
365
+
}
366
+
367
type UserKeysSettingsParams struct {
368
LoggedInUser *oauth.User
369
+
PubKeys []models.PublicKey
370
Tabs []map[string]any
371
Tab string
372
}
···
377
378
type UserEmailsSettingsParams struct {
379
LoggedInUser *oauth.User
380
+
Emails []models.Email
381
Tabs []map[string]any
382
Tab string
383
}
···
386
return p.execute("user/settings/emails", w, params)
387
}
388
389
+
type UserNotificationSettingsParams struct {
390
+
LoggedInUser *oauth.User
391
+
Preferences *models.NotificationPreferences
392
+
Tabs []map[string]any
393
+
Tab string
394
+
}
395
+
396
+
func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
397
+
return p.execute("user/settings/notifications", w, params)
398
+
}
399
+
400
type UpgradeBannerParams struct {
401
+
Registrations []models.Registration
402
+
Spindles []models.Spindle
403
}
404
405
func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
···
408
409
type KnotsParams struct {
410
LoggedInUser *oauth.User
411
+
Registrations []models.Registration
412
}
413
414
func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
···
417
418
type KnotParams struct {
419
LoggedInUser *oauth.User
420
+
Registration *models.Registration
421
Members []string
422
+
Repos map[string][]models.Repo
423
IsOwner bool
424
}
425
···
428
}
429
430
type KnotListingParams struct {
431
+
*models.Registration
432
}
433
434
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
···
437
438
type SpindlesParams struct {
439
LoggedInUser *oauth.User
440
+
Spindles []models.Spindle
441
}
442
443
func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
···
445
}
446
447
type SpindleListingParams struct {
448
+
models.Spindle
449
}
450
451
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
···
454
455
type SpindleDashboardParams struct {
456
LoggedInUser *oauth.User
457
+
Spindle models.Spindle
458
Members []string
459
+
Repos map[string][]models.Repo
460
}
461
462
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
···
485
type ProfileCard struct {
486
UserDid string
487
UserHandle string
488
+
FollowStatus models.FollowStatus
489
+
Punchcard *models.Punchcard
490
+
Profile *models.Profile
491
Stats ProfileStats
492
Active string
493
}
···
513
514
type ProfileOverviewParams struct {
515
LoggedInUser *oauth.User
516
+
Repos []models.Repo
517
+
CollaboratingRepos []models.Repo
518
+
ProfileTimeline *models.ProfileTimeline
519
Card *ProfileCard
520
Active string
521
}
···
527
528
type ProfileReposParams struct {
529
LoggedInUser *oauth.User
530
+
Repos []models.Repo
531
Card *ProfileCard
532
Active string
533
}
···
539
540
type ProfileStarredParams struct {
541
LoggedInUser *oauth.User
542
+
Repos []models.Repo
543
Card *ProfileCard
544
Active string
545
}
···
551
552
type ProfileStringsParams struct {
553
LoggedInUser *oauth.User
554
+
Strings []models.String
555
Card *ProfileCard
556
Active string
557
}
···
563
564
type FollowCard struct {
565
UserDid string
566
+
LoggedInUser *oauth.User
567
+
FollowStatus models.FollowStatus
568
FollowersCount int64
569
FollowingCount int64
570
+
Profile *models.Profile
571
}
572
573
type ProfileFollowersParams struct {
···
596
597
type FollowFragmentParams struct {
598
UserDid string
599
+
FollowStatus models.FollowStatus
600
}
601
602
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
···
605
606
type EditBioParams struct {
607
LoggedInUser *oauth.User
608
+
Profile *models.Profile
609
}
610
611
func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
···
614
615
type EditPinsParams struct {
616
LoggedInUser *oauth.User
617
+
Profile *models.Profile
618
AllRepos []PinnedRepo
619
}
620
621
type PinnedRepo struct {
622
IsPinned bool
623
+
models.Repo
624
}
625
626
func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
···
630
type RepoStarFragmentParams struct {
631
IsStarred bool
632
RepoAt syntax.ATURI
633
+
Stats models.RepoStats
634
}
635
636
func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error {
···
663
EmailToDidOrHandle map[string]string
664
VerifiedCommits commitverify.VerifiedCommits
665
Languages []types.RepoLanguageDetails
666
+
Pipelines map[string]models.Pipeline
667
NeedsKnotUpgrade bool
668
types.RepoIndexResponse
669
}
···
706
Active string
707
EmailToDidOrHandle map[string]string
708
VerifiedCommits commitverify.VerifiedCommits
709
+
Pipelines map[string]models.Pipeline
710
}
711
712
func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
···
719
RepoInfo repoinfo.RepoInfo
720
Active string
721
EmailToDidOrHandle map[string]string
722
+
Pipeline *models.Pipeline
723
DiffOpts types.DiffOpts
724
725
// singular because it's always going to be just one
···
734
}
735
736
type RepoTreeParams struct {
737
+
LoggedInUser *oauth.User
738
+
RepoInfo repoinfo.RepoInfo
739
+
Active string
740
+
BreadCrumbs [][]string
741
+
TreePath string
742
+
Raw bool
743
+
HTMLReadme template.HTML
744
types.RepoTreeResponse
745
}
746
···
768
func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
769
params.Active = "overview"
770
771
+
p.rctx.RepoInfo = params.RepoInfo
772
+
p.rctx.RepoInfo.Ref = params.Ref
773
+
p.rctx.RendererType = markup.RendererTypeRepoMarkdown
774
775
+
if params.ReadmeFileName != "" {
776
ext := filepath.Ext(params.ReadmeFileName)
777
switch ext {
778
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
···
805
RepoInfo repoinfo.RepoInfo
806
Active string
807
types.RepoTagsResponse
808
+
ArtifactMap map[plumbing.Hash][]models.Artifact
809
+
DanglingArtifacts []models.Artifact
810
}
811
812
func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
···
817
type RepoArtifactParams struct {
818
LoggedInUser *oauth.User
819
RepoInfo repoinfo.RepoInfo
820
+
Artifact models.Artifact
821
}
822
823
func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
···
914
}
915
916
type RepoGeneralSettingsParams struct {
917
+
LoggedInUser *oauth.User
918
+
RepoInfo repoinfo.RepoInfo
919
+
Labels []models.LabelDefinition
920
+
DefaultLabels []models.LabelDefinition
921
+
SubscribedLabels map[string]struct{}
922
+
ShouldSubscribeAll bool
923
+
Active string
924
+
Tabs []map[string]any
925
+
Tab string
926
+
Branches []types.Branch
927
}
928
929
func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
···
965
LoggedInUser *oauth.User
966
RepoInfo repoinfo.RepoInfo
967
Active string
968
+
Issues []models.Issue
969
+
LabelDefs map[string]*models.LabelDefinition
970
Page pagination.Page
971
FilteringByOpen bool
972
}
···
980
LoggedInUser *oauth.User
981
RepoInfo repoinfo.RepoInfo
982
Active string
983
+
Issue *models.Issue
984
+
CommentList []models.CommentListItem
985
+
LabelDefs map[string]*models.LabelDefinition
986
987
+
OrderedReactionKinds []models.ReactionKind
988
+
Reactions map[models.ReactionKind]int
989
+
UserReacted map[models.ReactionKind]bool
990
}
991
992
func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
···
997
type EditIssueParams struct {
998
LoggedInUser *oauth.User
999
RepoInfo repoinfo.RepoInfo
1000
+
Issue *models.Issue
1001
Action string
1002
}
1003
···
1008
1009
type ThreadReactionFragmentParams struct {
1010
ThreadAt syntax.ATURI
1011
+
Kind models.ReactionKind
1012
Count int
1013
IsReacted bool
1014
}
···
1020
type RepoNewIssueParams struct {
1021
LoggedInUser *oauth.User
1022
RepoInfo repoinfo.RepoInfo
1023
+
Issue *models.Issue // existing issue if any -- passed when editing
1024
Active string
1025
Action string
1026
}
···
1034
type EditIssueCommentParams struct {
1035
LoggedInUser *oauth.User
1036
RepoInfo repoinfo.RepoInfo
1037
+
Issue *models.Issue
1038
+
Comment *models.IssueComment
1039
}
1040
1041
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1045
type ReplyIssueCommentPlaceholderParams struct {
1046
LoggedInUser *oauth.User
1047
RepoInfo repoinfo.RepoInfo
1048
+
Issue *models.Issue
1049
+
Comment *models.IssueComment
1050
}
1051
1052
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1056
type ReplyIssueCommentParams struct {
1057
LoggedInUser *oauth.User
1058
RepoInfo repoinfo.RepoInfo
1059
+
Issue *models.Issue
1060
+
Comment *models.IssueComment
1061
}
1062
1063
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1067
type IssueCommentBodyParams struct {
1068
LoggedInUser *oauth.User
1069
RepoInfo repoinfo.RepoInfo
1070
+
Issue *models.Issue
1071
+
Comment *models.IssueComment
1072
}
1073
1074
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1095
type RepoPullsParams struct {
1096
LoggedInUser *oauth.User
1097
RepoInfo repoinfo.RepoInfo
1098
+
Pulls []*models.Pull
1099
Active string
1100
+
FilteringBy models.PullState
1101
+
Stacks map[string]models.Stack
1102
+
Pipelines map[string]models.Pipeline
1103
+
LabelDefs map[string]*models.LabelDefinition
1104
}
1105
1106
func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
···
1130
LoggedInUser *oauth.User
1131
RepoInfo repoinfo.RepoInfo
1132
Active string
1133
+
Pull *models.Pull
1134
+
Stack models.Stack
1135
+
AbandonedPulls []*models.Pull
1136
MergeCheck types.MergeCheckResponse
1137
ResubmitCheck ResubmitResult
1138
+
Pipelines map[string]models.Pipeline
1139
+
1140
+
OrderedReactionKinds []models.ReactionKind
1141
+
Reactions map[models.ReactionKind]int
1142
+
UserReacted map[models.ReactionKind]bool
1143
1144
+
LabelDefs map[string]*models.LabelDefinition
1145
}
1146
1147
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
1152
type RepoPullPatchParams struct {
1153
LoggedInUser *oauth.User
1154
RepoInfo repoinfo.RepoInfo
1155
+
Pull *models.Pull
1156
+
Stack models.Stack
1157
Diff *types.NiceDiff
1158
Round int
1159
+
Submission *models.PullSubmission
1160
+
OrderedReactionKinds []models.ReactionKind
1161
DiffOpts types.DiffOpts
1162
}
1163
···
1169
type RepoPullInterdiffParams struct {
1170
LoggedInUser *oauth.User
1171
RepoInfo repoinfo.RepoInfo
1172
+
Pull *models.Pull
1173
Round int
1174
Interdiff *patchutil.InterdiffResult
1175
+
OrderedReactionKinds []models.ReactionKind
1176
DiffOpts types.DiffOpts
1177
}
1178
···
1201
1202
type PullCompareForkParams struct {
1203
RepoInfo repoinfo.RepoInfo
1204
+
Forks []models.Repo
1205
Selected string
1206
}
1207
···
1222
type PullResubmitParams struct {
1223
LoggedInUser *oauth.User
1224
RepoInfo repoinfo.RepoInfo
1225
+
Pull *models.Pull
1226
SubmissionId int
1227
}
1228
···
1233
type PullActionsParams struct {
1234
LoggedInUser *oauth.User
1235
RepoInfo repoinfo.RepoInfo
1236
+
Pull *models.Pull
1237
RoundNumber int
1238
MergeCheck types.MergeCheckResponse
1239
ResubmitCheck ResubmitResult
1240
+
Stack models.Stack
1241
}
1242
1243
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
···
1247
type PullNewCommentParams struct {
1248
LoggedInUser *oauth.User
1249
RepoInfo repoinfo.RepoInfo
1250
+
Pull *models.Pull
1251
RoundNumber int
1252
}
1253
···
1258
type RepoCompareParams struct {
1259
LoggedInUser *oauth.User
1260
RepoInfo repoinfo.RepoInfo
1261
+
Forks []models.Repo
1262
Branches []types.Branch
1263
Tags []*types.TagReference
1264
Base string
···
1277
type RepoCompareNewParams struct {
1278
LoggedInUser *oauth.User
1279
RepoInfo repoinfo.RepoInfo
1280
+
Forks []models.Repo
1281
Branches []types.Branch
1282
Tags []*types.TagReference
1283
Base string
···
1315
type LabelPanelParams struct {
1316
LoggedInUser *oauth.User
1317
RepoInfo repoinfo.RepoInfo
1318
+
Defs map[string]*models.LabelDefinition
1319
Subject string
1320
+
State models.LabelState
1321
}
1322
1323
func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
···
1327
type EditLabelPanelParams struct {
1328
LoggedInUser *oauth.User
1329
RepoInfo repoinfo.RepoInfo
1330
+
Defs map[string]*models.LabelDefinition
1331
Subject string
1332
+
State models.LabelState
1333
}
1334
1335
func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
···
1339
type PipelinesParams struct {
1340
LoggedInUser *oauth.User
1341
RepoInfo repoinfo.RepoInfo
1342
+
Pipelines []models.Pipeline
1343
Active string
1344
}
1345
···
1371
type WorkflowParams struct {
1372
LoggedInUser *oauth.User
1373
RepoInfo repoinfo.RepoInfo
1374
+
Pipeline models.Pipeline
1375
Workflow string
1376
LogUrl string
1377
Active string
···
1387
Action string
1388
1389
// this is supplied in the case of editing an existing string
1390
+
String models.String
1391
}
1392
1393
func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
···
1397
type StringsDashboardParams struct {
1398
LoggedInUser *oauth.User
1399
Card ProfileCard
1400
+
Strings []models.String
1401
}
1402
1403
func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
···
1406
1407
type StringTimelineParams struct {
1408
LoggedInUser *oauth.User
1409
+
Strings []models.String
1410
}
1411
1412
func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
···
1418
ShowRendered bool
1419
RenderToggle bool
1420
RenderedContents template.HTML
1421
+
String models.String
1422
+
Stats models.StringStats
1423
Owner identity.Identity
1424
}
1425
+4
-4
appview/pages/repoinfo/repoinfo.go
+4
-4
appview/pages/repoinfo/repoinfo.go
···
7
"strings"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
-
"tangled.sh/tangled.sh/core/appview/db"
11
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
12
)
13
14
func (r RepoInfo) OwnerWithAt() string {
···
60
Spindle string
61
RepoAt syntax.ATURI
62
IsStarred bool
63
-
Stats db.RepoStats
64
Roles RolesInRepo
65
-
Source *db.Repo
66
SourceHandle string
67
Ref string
68
DisableFork bool
···
7
"strings"
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
+
"tangled.org/core/appview/models"
11
+
"tangled.org/core/appview/state/userutil"
12
)
13
14
func (r RepoInfo) OwnerWithAt() string {
···
60
Spindle string
61
RepoAt syntax.ATURI
62
IsStarred bool
63
+
Stats models.RepoStats
64
Roles RolesInRepo
65
+
Source *models.Repo
66
SourceHandle string
67
Ref string
68
DisableFork bool
+224
appview/pages/templates/brand/brand.html
+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
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
<div class="mb-6">
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
-
{{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
</div>
10
</div>
11
···
14
500 — internal server error
15
</h1>
16
<p class="text-gray-600 dark:text-gray-300">
17
-
Something went wrong on our end. We've been notified and are working to fix the issue.
18
-
</p>
19
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200">
20
-
<div class="flex items-center gap-2">
21
-
{{ i "info" "w-4 h-4" }}
22
-
<span class="font-medium">we're on it!</span>
23
-
</div>
24
-
<p class="mt-1">Our team has been automatically notified about this error.</p>
25
-
</div>
26
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
27
<button onclick="location.reload()" class="btn-create gap-2">
28
{{ i "refresh-cw" "w-4 h-4" }}
29
try again
30
</button>
31
<a href="/" class="btn no-underline hover:no-underline gap-2">
32
-
{{ i "home" "w-4 h-4" }}
33
back to home
34
</a>
35
</div>
···
5
<div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto">
6
<div class="mb-6">
7
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
8
+
{{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }}
9
</div>
10
</div>
11
···
14
500 — internal server error
15
</h1>
16
<p class="text-gray-600 dark:text-gray-300">
17
+
We encountered an error while processing your request. Please try again later.
18
+
</p>
19
<div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6">
20
<button onclick="location.reload()" class="btn-create gap-2">
21
{{ i "refresh-cw" "w-4 h-4" }}
22
try again
23
</button>
24
<a href="/" class="btn no-underline hover:no-underline gap-2">
25
+
{{ i "arrow-left" "w-4 h-4" }}
26
back to home
27
</a>
28
</div>
+167
appview/pages/templates/goodfirstissues/index.html
+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
{{ $d := .def }}
3
{{ $v := .val }}
4
{{ $withPrefix := .withPrefix }}
5
-
<span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
8
{{ $lhs := printf "%s" $d.Name }}
···
2
{{ $d := .def }}
3
{{ $v := .val }}
4
{{ $withPrefix := .withPrefix }}
5
+
<span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm">
6
{{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }}
7
8
{{ $lhs := printf "%s" $d.Name }}
+16
-12
appview/pages/templates/layouts/base.html
+16
-12
appview/pages/templates/layouts/base.html
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
<!-- preload main font -->
18
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
19
···
21
<title>{{ block "title" . }}{{ end }} · tangled</title>
22
{{ block "extrameta" . }}{{ end }}
23
</head>
24
-
<body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"
25
-
style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);">
26
{{ block "topbarLayout" . }}
27
-
<header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
28
29
{{ if .LoggedInUser }}
30
<div id="upgrade-banner"
···
38
{{ end }}
39
40
{{ block "mainLayout" . }}
41
-
<div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4">
42
-
{{ block "contentLayout" . }}
43
-
<main class="col-span-1 md:col-span-8">
44
{{ block "content" . }}{{ end }}
45
</main>
46
-
{{ end }}
47
-
48
-
{{ block "contentAfterLayout" . }}
49
-
<main class="col-span-1 md:col-span-8">
50
{{ block "contentAfter" . }}{{ end }}
51
</main>
52
-
{{ end }}
53
</div>
54
{{ end }}
55
56
{{ block "footerLayout" . }}
57
-
<footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12">
58
{{ template "layouts/fragments/footer" . }}
59
</footer>
60
{{ end }}
···
14
<link rel="preconnect" href="https://avatar.tangled.sh" />
15
<link rel="preconnect" href="https://camo.tangled.sh" />
16
17
+
<!-- pwa manifest -->
18
+
<link rel="manifest" href="/pwa-manifest.json" />
19
+
20
<!-- preload main font -->
21
<link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin />
22
···
24
<title>{{ block "title" . }}{{ end }} · tangled</title>
25
{{ block "extrameta" . }}{{ end }}
26
</head>
27
+
<body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
28
{{ block "topbarLayout" . }}
29
+
<header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;">
30
31
{{ if .LoggedInUser }}
32
<div id="upgrade-banner"
···
40
{{ end }}
41
42
{{ block "mainLayout" . }}
43
+
<div class="flex-grow">
44
+
<div class="max-w-screen-lg mx-auto flex flex-col gap-4">
45
+
{{ block "contentLayout" . }}
46
+
<main>
47
{{ block "content" . }}{{ end }}
48
</main>
49
+
{{ end }}
50
+
51
+
{{ block "contentAfterLayout" . }}
52
+
<main>
53
{{ block "contentAfter" . }}{{ end }}
54
</main>
55
+
{{ end }}
56
+
</div>
57
</div>
58
{{ end }}
59
60
{{ block "footerLayout" . }}
61
+
<footer class="bg-white dark:bg-gray-800 mt-12">
62
{{ template "layouts/fragments/footer" . }}
63
</footer>
64
{{ end }}
+17
-7
appview/pages/templates/layouts/fragments/topbar.html
+17
-7
appview/pages/templates/layouts/fragments/topbar.html
···
1
{{ define "layouts/fragments/topbar" }}
2
-
<nav class="space-x-4 px-6 py-2 rounded-b bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
-
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline">
6
-
{{ template "fragments/logotypeSmall" }}
7
</a>
8
</div>
9
10
-
<div id="right-items" class="flex items-center gap-2">
11
{{ with .LoggedInUser }}
12
{{ block "newButton" . }} {{ end }}
13
{{ block "dropDown" . }} {{ end }}
14
{{ else }}
15
<a href="/login">login</a>
···
26
{{ define "newButton" }}
27
<details class="relative inline-block text-left nav-dropdown">
28
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
-
{{ i "plus" "w-4 h-4" }} new
30
</summary>
31
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
<a href="/repo/new" class="flex items-center gap-2">
···
44
{{ define "dropDown" }}
45
<details class="relative inline-block text-left nav-dropdown">
46
<summary
47
-
class="cursor-pointer list-none flex items-center"
48
>
49
{{ $user := didOrHandle .Did .Handle }}
50
-
{{ template "user/fragments/picHandle" $user }}
51
</summary>
52
<div
53
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
···
1
{{ define "layouts/fragments/topbar" }}
2
+
<nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm">
3
<div class="flex justify-between p-0 items-center">
4
<div id="left-items">
5
+
<a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2">
6
+
{{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }}
7
+
<span class="font-bold text-xl not-italic hidden md:inline">tangled</span>
8
+
<span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline">
9
+
alpha
10
+
</span>
11
</a>
12
</div>
13
14
+
<div id="right-items" class="flex items-center gap-4">
15
{{ with .LoggedInUser }}
16
{{ block "newButton" . }} {{ end }}
17
+
{{ template "notifications/fragments/bell" }}
18
{{ block "dropDown" . }} {{ end }}
19
{{ else }}
20
<a href="/login">login</a>
···
31
{{ define "newButton" }}
32
<details class="relative inline-block text-left nav-dropdown">
33
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
34
+
{{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span>
35
</summary>
36
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
37
<a href="/repo/new" class="flex items-center gap-2">
···
49
{{ define "dropDown" }}
50
<details class="relative inline-block text-left nav-dropdown">
51
<summary
52
+
class="cursor-pointer list-none flex items-center gap-1"
53
>
54
{{ $user := didOrHandle .Did .Handle }}
55
+
<img
56
+
src="{{ tinyAvatar $user }}"
57
+
alt=""
58
+
class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700"
59
+
/>
60
+
<span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span>
61
</summary>
62
<div
63
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+13
-6
appview/pages/templates/legal/privacy.html
+13
-6
appview/pages/templates/legal/privacy.html
···
1
{{ define "title" }}privacy policy{{ end }}
2
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
9
</div>
10
</div>
11
-
{{ end }}
···
1
{{ define "title" }}privacy policy{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Learn how we collect, use, and protect your personal information.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
15
</div>
16
+
</main>
17
</div>
18
+
{{ end }}
+13
-6
appview/pages/templates/legal/terms.html
+13
-6
appview/pages/templates/legal/terms.html
···
1
{{ define "title" }}terms of service{{ end }}
2
3
{{ define "content" }}
4
-
<div class="max-w-4xl mx-auto px-4 py-8">
5
-
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
-
<div class="prose prose-gray dark:prose-invert max-w-none">
7
-
{{ .Content }}
8
-
</div>
9
</div>
10
</div>
11
-
{{ end }}
···
1
{{ define "title" }}terms of service{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-10">
5
+
<header class="col-span-full md:col-span-10 px-6 py-2 mb-4">
6
+
<h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
A few things you should know.
9
+
</p>
10
+
</header>
11
+
12
+
<main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
13
+
<div class="prose prose-gray dark:prose-invert max-w-none">
14
+
{{ .Content }}
15
</div>
16
+
</main>
17
</div>
18
+
{{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
+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
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
<fieldset class="space-y-3">
10
<legend class="dark:text-white">Select a knot to fork into</legend>
11
<div class="space-y-2">
···
6
</div>
7
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
+
10
+
<fieldset class="space-y-3">
11
+
<legend for="repo_name" class="dark:text-white">Repository name</legend>
12
+
<input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}"
13
+
class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" />
14
+
</fieldset>
15
+
16
<fieldset class="space-y-3">
17
<legend class="dark:text-white">Select a knot to fork into</legend>
18
<div class="space-y-2">
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
+3
-3
appview/pages/templates/repo/fragments/cloneDropdown.html
···
1
{{ define "repo/fragments/cloneDropdown" }}
2
{{ $knot := .RepoInfo.Knot }}
3
{{ if eq $knot "knot1.tangled.sh" }}
4
-
{{ $knot = "tangled.sh" }}
5
{{ end }}
6
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
<code
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
onclick="window.getSelection().selectAllChildren(this)"
32
-
data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
-
>https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
<button
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
···
1
{{ define "repo/fragments/cloneDropdown" }}
2
{{ $knot := .RepoInfo.Knot }}
3
{{ if eq $knot "knot1.tangled.sh" }}
4
+
{{ $knot = "tangled.org" }}
5
{{ end }}
6
7
<details id="clone-dropdown" class="relative inline-block text-left group">
···
29
<code
30
class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto"
31
onclick="window.getSelection().selectAllChildren(this)"
32
+
data-url="https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}"
33
+
>https://tangled.org/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
34
<button
35
onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))"
36
class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600"
+1
-1
appview/pages/templates/repo/fragments/labelPanel.html
+1
-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
{{ define "repo/fragments/readme" }}
2
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
{{- if .ReadmeFileName -}}
4
-
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
</div>
8
{{- end -}}
9
<section
10
-
class="p-6 overflow-auto {{ if not .Raw }}
11
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
dark:[&_pre]:border dark:[&_pre]:border-gray-700
···
1
{{ define "repo/fragments/readme" }}
2
<div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden">
3
{{- if .ReadmeFileName -}}
4
+
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2">
5
{{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }}
6
<span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span>
7
</div>
8
{{- end -}}
9
<section
10
+
class="px-6 pb-6 overflow-auto {{ if not .Raw }}
11
prose dark:prose-invert dark:[&_pre]:bg-gray-900
12
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
13
dark:[&_pre]:border dark:[&_pre]:border-gray-700
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
+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
"Defs" $.LabelDefs
23
"Subject" $.Issue.AtUri
24
"State" $.Issue.Labels) }}
25
-
{{ template "issueParticipants" . }}
26
</div>
27
</div>
28
{{ end }}
···
122
</div>
123
{{ end }}
124
125
-
{{ define "issueParticipants" }}
126
-
{{ $all := .Issue.Participants }}
127
-
{{ $ps := take $all 5 }}
128
-
<div>
129
-
<div class="py-1 flex items-center text-sm">
130
-
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
131
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
132
-
</div>
133
-
<div class="flex items-center -space-x-3 mt-2">
134
-
{{ $c := "z-50 z-40 z-30 z-20 z-10" }}
135
-
{{ range $i, $p := $ps }}
136
-
<img
137
-
src="{{ tinyAvatar . }}"
138
-
alt=""
139
-
class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0"
140
-
/>
141
-
{{ end }}
142
-
143
-
{{ if gt (len $all) 5 }}
144
-
<span class="pl-4 text-gray-500 dark:text-gray-400 text-sm">
145
-
+{{ sub (len $all) 5 }}
146
-
</span>
147
-
{{ end }}
148
-
</div>
149
-
</div>
150
-
{{ end }}
151
152
{{ define "repoAfter" }}
153
<div class="flex flex-col gap-4 mt-4">
+2
-52
appview/pages/templates/repo/issues/issues.html
+2
-52
appview/pages/templates/repo/issues/issues.html
···
37
{{ end }}
38
39
{{ define "repoAfter" }}
40
-
<div class="flex flex-col gap-2 mt-2">
41
-
{{ range .Issues }}
42
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
43
-
<div class="pb-2">
44
-
<a
45
-
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
46
-
class="no-underline hover:underline"
47
-
>
48
-
{{ .Title | description }}
49
-
<span class="text-gray-500">#{{ .IssueId }}</span>
50
-
</a>
51
-
</div>
52
-
<div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1">
53
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
54
-
{{ $icon := "ban" }}
55
-
{{ $state := "closed" }}
56
-
{{ if .Open }}
57
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
58
-
{{ $icon = "circle-dot" }}
59
-
{{ $state = "open" }}
60
-
{{ end }}
61
-
62
-
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
63
-
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
64
-
<span class="text-white dark:text-white">{{ $state }}</span>
65
-
</span>
66
-
67
-
<span class="ml-1">
68
-
{{ template "user/fragments/picHandleLink" .Did }}
69
-
</span>
70
-
71
-
<span class="before:content-['·']">
72
-
{{ template "repo/fragments/time" .Created }}
73
-
</span>
74
-
75
-
<span class="before:content-['·']">
76
-
{{ $s := "s" }}
77
-
{{ if eq (len .Comments) 1 }}
78
-
{{ $s = "" }}
79
-
{{ end }}
80
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a>
81
-
</span>
82
-
83
-
{{ $state := .Labels }}
84
-
{{ range $k, $d := $.LabelDefs }}
85
-
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
86
-
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
87
-
{{ end }}
88
-
{{ end }}
89
-
</div>
90
-
</div>
91
-
{{ end }}
92
</div>
93
{{ block "pagination" . }} {{ end }}
94
{{ end }}
+163
-61
appview/pages/templates/repo/new.html
+163
-61
appview/pages/templates/repo/new.html
···
1
{{ define "title" }}new repo{{ end }}
2
3
{{ define "content" }}
4
-
<div class="p-6">
5
-
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
6
</div>
7
-
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
-
<form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner">
9
-
<div class="space-y-2">
10
-
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
-
<input
12
-
type="text"
13
-
id="name"
14
-
name="name"
15
-
required
16
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
-
/>
18
-
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
19
20
-
<label for="branch" class="dark:text-white">Default branch</label>
21
-
<input
22
-
type="text"
23
-
id="branch"
24
-
name="branch"
25
-
value="main"
26
-
required
27
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
-
/>
29
30
-
<label for="description" class="dark:text-white">Description</label>
31
-
<input
32
-
type="text"
33
-
id="description"
34
-
name="description"
35
-
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
-
/>
37
</div>
38
39
-
<fieldset class="space-y-3">
40
-
<legend class="dark:text-white">Select a knot</legend>
41
<div class="space-y-2">
42
-
<div class="flex flex-col">
43
-
{{ range .Knots }}
44
-
<div class="flex items-center">
45
-
<input
46
-
type="radio"
47
-
name="domain"
48
-
value="{{ . }}"
49
-
class="mr-2"
50
-
id="domain-{{ . }}"
51
-
/>
52
-
<label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label>
53
-
</div>
54
-
{{ else }}
55
-
<p class="dark:text-white">No knots available.</p>
56
-
{{ end }}
57
-
</div>
58
</div>
59
-
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
60
-
</fieldset>
61
62
-
<div class="space-y-2">
63
-
<button type="submit" class="btn-create flex items-center gap-2">
64
-
{{ i "book-plus" "w-4 h-4" }}
65
-
create repo
66
-
<span id="spinner" class="group">
67
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
68
-
</span>
69
-
</button>
70
-
<div id="repo" class="error"></div>
71
</div>
72
-
</form>
73
-
</div>
74
{{ end }}
···
1
{{ define "title" }}new repo{{ end }}
2
3
{{ define "content" }}
4
+
<div class="grid grid-cols-12">
5
+
<div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4">
6
+
<h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">
8
+
Repositories contain a project's files and version history. All
9
+
repositories are publicly accessible.
10
+
</p>
11
+
</div>
12
+
{{ template "newRepoPanel" . }}
13
</div>
14
+
{{ end }}
15
16
+
{{ define "newRepoPanel" }}
17
+
<div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10">
18
+
{{ template "newRepoForm" . }}
19
+
</div>
20
+
{{ end }}
21
22
+
{{ define "newRepoForm" }}
23
+
<form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner">
24
+
{{ template "step-1" . }}
25
+
{{ template "step-2" . }}
26
+
27
+
<div class="mt-8 flex justify-end">
28
+
<button type="submit" class="btn-create flex items-center gap-2">
29
+
{{ i "book-plus" "w-4 h-4" }}
30
+
create repo
31
+
<span id="spinner" class="group">
32
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
33
+
</span>
34
+
</button>
35
</div>
36
+
<div id="repo" class="error mt-2"></div>
37
38
+
</form>
39
+
{{ end }}
40
+
41
+
{{ define "step-1" }}
42
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
43
+
<div class="absolute -left-3 -top-0">
44
+
{{ template "numberCircle" 1 }}
45
+
</div>
46
+
47
+
<!-- Content column -->
48
+
<div class="flex-1 pb-12">
49
+
<h2 class="text-lg font-semibold dark:text-white">General</h2>
50
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div>
51
+
52
<div class="space-y-2">
53
+
{{ template "name" . }}
54
+
{{ template "description" . }}
55
</div>
56
+
</div>
57
+
</div>
58
+
{{ end }}
59
60
+
{{ define "step-2" }}
61
+
<div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6">
62
+
<div class="absolute -left-3 -top-0">
63
+
{{ template "numberCircle" 2 }}
64
</div>
65
+
66
+
<div class="flex-1">
67
+
<h2 class="text-lg font-semibold dark:text-white">Configuration</h2>
68
+
<div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div>
69
+
70
+
<div class="space-y-2">
71
+
{{ template "defaultBranch" . }}
72
+
{{ template "knot" . }}
73
+
</div>
74
+
</div>
75
+
</div>
76
+
{{ end }}
77
+
78
+
{{ define "name" }}
79
+
<!-- Repository Name with Owner -->
80
+
<div>
81
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
82
+
Repository name
83
+
</label>
84
+
<div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full">
85
+
<div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700">
86
+
{{ template "user/fragments/picHandle" .LoggedInUser.Did }}
87
+
</div>
88
+
<input
89
+
type="text"
90
+
id="name"
91
+
name="name"
92
+
required
93
+
class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2"
94
+
placeholder="repository-name"
95
+
/>
96
+
</div>
97
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
98
+
Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens.
99
+
</p>
100
+
</div>
101
+
{{ end }}
102
+
103
+
{{ define "description" }}
104
+
<!-- Description -->
105
+
<div>
106
+
<label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1">
107
+
Description
108
+
</label>
109
+
<input
110
+
type="text"
111
+
id="description"
112
+
name="description"
113
+
class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
114
+
placeholder="A brief description of your project..."
115
+
/>
116
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
117
+
Optional. A short description to help others understand what your project does.
118
+
</p>
119
+
</div>
120
+
{{ end }}
121
+
122
+
{{ define "defaultBranch" }}
123
+
<!-- Default Branch -->
124
+
<div>
125
+
<label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1">
126
+
Default branch
127
+
</label>
128
+
<input
129
+
type="text"
130
+
id="branch"
131
+
name="branch"
132
+
value="main"
133
+
required
134
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
135
+
/>
136
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
137
+
The primary branch where development happens. Common choices are "main" or "master".
138
+
</p>
139
+
</div>
140
+
{{ end }}
141
+
142
+
{{ define "knot" }}
143
+
<!-- Knot Selection -->
144
+
<div>
145
+
<label class="block text-sm font-bold uppercase dark:text-white mb-1">
146
+
Select a knot
147
+
</label>
148
+
<div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2">
149
+
{{ range .Knots }}
150
+
<div class="flex items-center">
151
+
<input
152
+
type="radio"
153
+
name="domain"
154
+
value="{{ . }}"
155
+
class="mr-2"
156
+
id="domain-{{ . }}"
157
+
required
158
+
/>
159
+
<label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label>
160
+
</div>
161
+
{{ else }}
162
+
<p class="dark:text-white">no knots available.</p>
163
+
{{ end }}
164
+
</div>
165
+
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
166
+
A knot hosts repository data and handles Git operations.
167
+
You can also <a href="/knots" class="underline">register your own knot</a>.
168
+
</p>
169
+
</div>
170
+
{{ end }}
171
+
172
+
{{ define "numberCircle" }}
173
+
<div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1">
174
+
{{.}}
175
+
</div>
176
{{ end }}
+30
-12
appview/pages/templates/repo/pulls/pull.html
+30
-12
appview/pages/templates/repo/pulls/pull.html
···
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
13
{{ define "repoContent" }}
14
{{ template "repo/pulls/fragments/pullHeader" . }}
···
39
{{ with $item }}
40
<details {{ if eq $idx $lastIdx }}open{{ end }}>
41
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
42
-
<div class="flex flex-wrap gap-2 items-center">
43
<!-- round number -->
44
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
45
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
46
</div>
47
<!-- round summary -->
48
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
49
<span class="gap-1 flex items-center">
50
{{ $owner := resolve $.Pull.OwnerDid }}
51
{{ $re := "re" }}
···
72
<span class="hidden md:inline">diff</span>
73
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
74
</a>
75
-
{{ if not (eq .RoundNumber 0) }}
76
-
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
77
-
hx-boost="true"
78
-
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
79
-
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
80
-
<span class="hidden md:inline">interdiff</span>
81
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
-
</a>
83
-
<span id="interdiff-error-{{.RoundNumber}}"></span>
84
{{ end }}
85
</div>
86
</summary>
87
···
146
147
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
148
{{ range $cidx, $c := .Comments }}
149
-
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
150
{{ if gt $cidx 0 }}
151
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
152
{{ end }}
···
9
{{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10
{{ end }}
11
12
+
{{ define "repoContentLayout" }}
13
+
<div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full">
14
+
<div class="col-span-1 md:col-span-8">
15
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white">
16
+
{{ block "repoContent" . }}{{ end }}
17
+
</section>
18
+
{{ block "repoAfter" . }}{{ end }}
19
+
</div>
20
+
<div class="col-span-1 md:col-span-2 flex flex-col gap-6">
21
+
{{ template "repo/fragments/labelPanel"
22
+
(dict "RepoInfo" $.RepoInfo
23
+
"Defs" $.LabelDefs
24
+
"Subject" $.Pull.PullAt
25
+
"State" $.Pull.Labels) }}
26
+
{{ template "repo/fragments/participants" $.Pull.Participants }}
27
+
</div>
28
+
</div>
29
+
{{ end }}
30
31
{{ define "repoContent" }}
32
{{ template "repo/pulls/fragments/pullHeader" . }}
···
57
{{ with $item }}
58
<details {{ if eq $idx $lastIdx }}open{{ end }}>
59
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
60
+
<div class="flex flex-wrap gap-2 items-stretch">
61
<!-- round number -->
62
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
63
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
64
</div>
65
<!-- round summary -->
66
+
<div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
67
<span class="gap-1 flex items-center">
68
{{ $owner := resolve $.Pull.OwnerDid }}
69
{{ $re := "re" }}
···
90
<span class="hidden md:inline">diff</span>
91
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
92
</a>
93
+
{{ if ne $idx 0 }}
94
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
95
+
hx-boost="true"
96
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
97
+
{{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }}
98
+
<span class="hidden md:inline">interdiff</span>
99
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
100
+
</a>
101
{{ end }}
102
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
103
</div>
104
</summary>
105
···
164
165
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
166
{{ range $cidx, $c := .Comments }}
167
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full">
168
{{ if gt $cidx 0 }}
169
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
170
{{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
+7
appview/pages/templates/repo/pulls/pulls.html
···
108
<span class="before:content-['·']"></span>
109
{{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }}
110
{{ end }}
111
+
112
+
{{ $state := .Labels }}
113
+
{{ range $k, $d := $.LabelDefs }}
114
+
{{ range $v, $s := $state.GetValSet $d.AtUri.String }}
115
+
{{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }}
116
+
{{ end }}
117
+
{{ end }}
118
</div>
119
</div>
120
{{ if .StackId }}
+36
-6
appview/pages/templates/repo/settings/general.html
+36
-6
appview/pages/templates/repo/settings/general.html
···
46
47
{{ define "defaultLabelSettings" }}
48
<div class="flex flex-col gap-2">
49
-
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
50
-
<p class="text-gray-500 dark:text-gray-400">
51
-
Manage your issues and pulls by creating labels to categorize them. Only
52
-
repository owners may configure labels. You may choose to subscribe to
53
-
default labels, or create entirely custom labels.
54
-
</p>
55
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
56
{{ range .DefaultLabels }}
57
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
···
46
47
{{ define "defaultLabelSettings" }}
48
<div class="flex flex-col gap-2">
49
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
50
+
<div class="col-span-1 md:col-span-2">
51
+
<h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2>
52
+
<p class="text-gray-500 dark:text-gray-400">
53
+
Manage your issues and pulls by creating labels to categorize them. Only
54
+
repository owners may configure labels. You may choose to subscribe to
55
+
default labels, or create entirely custom labels.
56
+
<p>
57
+
</div>
58
+
<form class="col-span-1 md:col-span-1 md:justify-self-end">
59
+
{{ $title := "Unubscribe from all labels" }}
60
+
{{ $icon := "x" }}
61
+
{{ $text := "unsubscribe all" }}
62
+
{{ $action := "unsubscribe" }}
63
+
{{ if $.ShouldSubscribeAll }}
64
+
{{ $title = "Subscribe to all labels" }}
65
+
{{ $icon = "check-check" }}
66
+
{{ $text = "subscribe all" }}
67
+
{{ $action = "subscribe" }}
68
+
{{ end }}
69
+
{{ range .DefaultLabels }}
70
+
<input type="hidden" name="label" value="{{ .AtUri.String }}">
71
+
{{ end }}
72
+
<button
73
+
type="submit"
74
+
title="{{$title}}"
75
+
class="btn flex items-center gap-2 group"
76
+
hx-swap="none"
77
+
hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}"
78
+
{{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}>
79
+
{{ i $icon "size-4" }}
80
+
{{ $text }}
81
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
82
+
</button>
83
+
</form>
84
+
</div>
85
<div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full">
86
{{ range .DefaultLabels }}
87
<div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1
-1
appview/pages/templates/repo/tree.html
+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
{{ define "content" }}
4
<div class="px-6 py-2 mb-4">
5
{{ if eq .Action "new" }}
6
-
<p class="text-xl font-bold dark:text-white">Create a new string</p>
7
-
<p class="">Store and share code snippets with ease.</p>
8
{{ else }}
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
{{ end }}
···
3
{{ define "content" }}
4
<div class="px-6 py-2 mb-4">
5
{{ if eq .Action "new" }}
6
+
<p class="text-xl font-bold dark:text-white mb-1">Create a new string</p>
7
+
<p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p>
8
{{ else }}
9
<p class="text-xl font-bold dark:text-white">Edit string</p>
10
{{ end }}
+5
-7
appview/pages/templates/strings/timeline.html
+5
-7
appview/pages/templates/strings/timeline.html
···
26
{{ end }}
27
28
{{ define "stringCard" }}
29
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
30
-
<div class="font-medium dark:text-white flex gap-2 items-center">
31
-
<a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a>
32
</div>
33
{{ with .Description }}
34
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
42
43
{{ define "stringCardInfo" }}
44
{{ $stat := .Stats }}
45
-
{{ $resolved := resolve .Did.String }}
46
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
47
-
<a href="/strings/{{ $resolved }}" class="flex items-center">
48
-
{{ template "user/fragments/picHandle" $resolved }}
49
-
</a>
50
-
<span class="select-none [&:before]:content-['·']"></span>
51
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
52
<span class="select-none [&:before]:content-['·']"></span>
53
{{ with .Edited }}
···
26
{{ end }}
27
28
{{ define "stringCard" }}
29
+
{{ $resolved := resolve .Did.String }}
30
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
31
+
<div class="font-medium dark:text-white flex flex-wrap gap-1 items-center">
32
+
<a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a>
33
+
<span class="select-none">/</span>
34
+
<a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a>
35
</div>
36
{{ with .Description }}
37
<div class="text-gray-600 dark:text-gray-300 text-sm">
···
45
46
{{ define "stringCardInfo" }}
47
{{ $stat := .Stats }}
48
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto">
49
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
50
<span class="select-none [&:before]:content-['·']"></span>
51
{{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
+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
{{ $event := index . 1 }}
83
{{ $follow := $event.Follow }}
84
{{ $profile := $event.Profile }}
85
-
{{ $stat := $event.FollowStats }}
86
87
{{ $userHandle := resolve $follow.UserDid }}
88
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
92
{{ template "user/fragments/picHandleLink" $subjectHandle }}
93
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
94
</div>
95
-
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4">
96
-
<div class="flex items-center gap-4 flex-1">
97
-
<div class="flex-shrink-0 max-h-full w-24 h-24">
98
-
<img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" />
99
-
</div>
100
-
101
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
102
-
<a href="/{{ $subjectHandle }}">
103
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span>
104
-
</a>
105
-
{{ with $profile }}
106
-
{{ with .Description }}
107
-
<p class="text-sm pb-2 md:pb-2">{{.}}</p>
108
-
{{ end }}
109
-
{{ end }}
110
-
{{ with $stat }}
111
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
112
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
113
-
<span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span>
114
-
<span class="select-none after:content-['·']"></span>
115
-
<span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span>
116
-
</div>
117
-
{{ end }}
118
-
</div>
119
-
</div>
120
-
121
-
{{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }}
122
-
<div class="flex-shrink-0 w-fit ml-auto">
123
-
{{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }}
124
-
</div>
125
-
{{ end }}
126
-
</div>
127
{{ end }}
···
82
{{ $event := index . 1 }}
83
{{ $follow := $event.Follow }}
84
{{ $profile := $event.Profile }}
85
+
{{ $followStats := $event.FollowStats }}
86
+
{{ $followStatus := $event.FollowStatus }}
87
88
{{ $userHandle := resolve $follow.UserDid }}
89
{{ $subjectHandle := resolve $follow.SubjectDid }}
···
93
{{ template "user/fragments/picHandleLink" $subjectHandle }}
94
<span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span>
95
</div>
96
+
{{ template "user/fragments/followCard"
97
+
(dict
98
+
"LoggedInUser" $root.LoggedInUser
99
+
"UserDid" $follow.SubjectDid
100
+
"Profile" $profile
101
+
"FollowStatus" $followStatus
102
+
"FollowersCount" $followStats.Followers
103
+
"FollowingCount" $followStats.Following) }}
104
{{ end }}
+1
appview/pages/templates/timeline/home.html
+1
appview/pages/templates/timeline/home.html
···
12
<div class="flex flex-col gap-4">
13
{{ template "timeline/fragments/hero" . }}
14
{{ template "features" . }}
15
+
{{ template "timeline/fragments/goodfirstissues" . }}
16
{{ template "timeline/fragments/trending" . }}
17
{{ template "timeline/fragments/timeline" . }}
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
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Followers }}
13
-
{{ template "user/fragments/followCard" . }}
14
{{ else }}
15
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
16
{{ end }}
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p>
11
<div id="followers" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Followers }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
<p class="px-6 dark:text-white">This user does not have any followers yet.</p>
23
{{ end }}
+8
-1
appview/pages/templates/user/following.html
+8
-1
appview/pages/templates/user/following.html
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Following }}
13
-
{{ template "user/fragments/followCard" . }}
14
{{ else }}
15
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
16
{{ end }}
···
10
<p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p>
11
<div id="following" class="grid grid-cols-1 gap-4 mb-6">
12
{{ range .Following }}
13
+
{{ template "user/fragments/followCard"
14
+
(dict
15
+
"LoggedInUser" $.LoggedInUser
16
+
"UserDid" .UserDid
17
+
"Profile" .Profile
18
+
"FollowStatus" .FollowStatus
19
+
"FollowersCount" .FollowersCount
20
+
"FollowingCount" .FollowingCount) }}
21
{{ else }}
22
<p class="px-6 dark:text-white">This user does not follow anyone yet.</p>
23
{{ end }}
+6
-2
appview/pages/templates/user/fragments/follow.html
+6
-2
appview/pages/templates/user/fragments/follow.html
···
1
{{ define "user/fragments/follow" }}
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
-
class="btn mt-2 flex gap-2 items-center group"
4
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
hx-post="/follow?subject={{.UserDid}}"
···
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
hx-swap="outerHTML"
14
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }}
16
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
17
</button>
18
{{ end }}
···
1
{{ define "user/fragments/follow" }}
2
<button id="{{ normalizeForHtmlId .UserDid }}"
3
+
class="btn w-full flex gap-2 items-center group"
4
5
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
hx-post="/follow?subject={{.UserDid}}"
···
12
hx-target="#{{ normalizeForHtmlId .UserDid }}"
13
hx-swap="outerHTML"
14
>
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
16
+
{{ i "user-round-plus" "w-4 h-4" }} follow
17
+
{{ else }}
18
+
{{ i "user-round-minus" "w-4 h-4" }} unfollow
19
+
{{ end }}
20
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
21
</button>
22
{{ end }}
+20
-17
appview/pages/templates/user/fragments/followCard.html
+20
-17
appview/pages/templates/user/fragments/followCard.html
···
1
{{ define "user/fragments/followCard" }}
2
{{ $userIdent := resolve .UserDid }}
3
-
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
</div>
8
9
-
<div class="flex-1 min-h-0 justify-around flex flex-col">
10
-
<a href="/{{ $userIdent }}">
11
-
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
12
-
</a>
13
-
<p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p>
14
-
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
15
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
16
-
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
17
-
<span class="select-none after:content-['·']"></span>
18
-
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
19
</div>
20
-
</div>
21
-
22
-
{{ if ne .FollowStatus.String "IsSelf" }}
23
-
<div class="max-w-24">
24
{{ template "user/fragments/follow" . }}
25
</div>
26
-
{{ end }}
27
</div>
28
</div>
29
-
{{ end }}
···
1
{{ define "user/fragments/followCard" }}
2
{{ $userIdent := resolve .UserDid }}
3
+
<div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm">
4
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4">
5
<div class="flex-shrink-0 max-h-full w-24 h-24">
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" />
7
</div>
8
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
+
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
+
<a href="/{{ $userIdent }}">
12
+
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
+
</a>
14
+
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
+
{{ end }}
17
+
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
19
+
<span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span>
20
+
<span class="select-none after:content-['·']"></span>
21
+
<span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span>
22
+
</div>
23
</div>
24
+
{{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }}
25
+
<div class="w-full md:w-auto md:max-w-24 order-last md:order-none">
26
{{ template "user/fragments/follow" . }}
27
</div>
28
+
{{ end }}
29
+
</div>
30
</div>
31
</div>
32
+
{{ end }}
+2
-2
appview/pages/templates/user/fragments/picHandle.html
+2
-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
+10
-10
appview/pages/templates/user/fragments/repoCard.html
+10
-10
appview/pages/templates/user/fragments/repoCard.html
···
14
{{ with $repo }}
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
<div class="font-medium dark:text-white flex items-center justify-between">
17
-
<div class="flex items-center">
18
-
{{ if .Source }}
19
-
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
-
{{ else }}
21
-
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
-
{{ end }}
23
-
24
{{ $repoOwner := resolve .Did }}
25
{{- if $fullName -}}
26
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
27
{{- else -}}
28
-
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
29
{{- end -}}
30
</div>
31
-
32
{{ if and $starButton $root.LoggedInUser }}
33
{{ template "repo/fragments/repoStar" $starData }}
34
{{ end }}
35
</div>
36
{{ with .Description }}
···
14
{{ with $repo }}
15
<div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32">
16
<div class="font-medium dark:text-white flex items-center justify-between">
17
+
<div class="flex items-center min-w-0 flex-1 mr-2">
18
+
{{ if .Source }}
19
+
{{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }}
20
+
{{ else }}
21
+
{{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }}
22
+
{{ end }}
23
{{ $repoOwner := resolve .Did }}
24
{{- if $fullName -}}
25
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a>
26
{{- else -}}
27
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a>
28
{{- end -}}
29
</div>
30
{{ if and $starButton $root.LoggedInUser }}
31
+
<div class="shrink-0">
32
{{ template "repo/fragments/repoStar" $starData }}
33
+
</div>
34
{{ end }}
35
</div>
36
{{ with .Description }}
+2
-1
appview/pages/templates/user/login.html
+2
-1
appview/pages/templates/user/login.html
···
8
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>login · tangled</title>
13
</head>
···
36
placeholder="akshay.tngl.sh"
37
/>
38
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
40
handle to log in. If you're unsure, this is likely
41
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
</span>
···
8
<meta property="og:url" content="https://tangled.org/login" />
9
<meta property="og:description" content="login to for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>login · tangled</title>
14
</head>
···
37
placeholder="akshay.tngl.sh"
38
/>
39
<span class="text-sm text-gray-500 mt-1">
40
+
Use your <a href="https://atproto.com">AT Protocol</a>
41
handle to log in. If you're unsure, this is likely
42
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
43
</span>
+173
appview/pages/templates/user/settings/notifications.html
+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
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
<title>sign up · tangled</title>
13
</head>
14
<body class="flex items-center justify-center min-h-screen">
15
<main class="max-w-md px-6 -mt-4">
···
39
invite code, desired username, and password in the next
40
page to complete your registration.
41
</span>
42
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
43
<span>join now</span>
44
</button>
45
</form>
46
<p class="text-sm text-gray-500">
47
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
48
</p>
49
50
<p id="signup-msg" class="error w-full"></p>
···
8
<meta property="og:url" content="https://tangled.org/signup" />
9
<meta property="og:description" content="sign up for tangled" />
10
<script src="/static/htmx.min.js"></script>
11
+
<link rel="manifest" href="/pwa-manifest.json" />
12
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
<title>sign up · tangled</title>
14
+
15
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
16
</head>
17
<body class="flex items-center justify-center min-h-screen">
18
<main class="max-w-md px-6 -mt-4">
···
42
invite code, desired username, and password in the next
43
page to complete your registration.
44
</span>
45
+
<div class="w-full mt-4 text-center">
46
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
47
+
</div>
48
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49
<span>join now</span>
50
</button>
51
</form>
52
<p class="text-sm text-gray-500">
53
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
54
</p>
55
56
<p id="signup-msg" class="error w-full"></p>
+1
-1
appview/pagination/page.go
+1
-1
appview/pagination/page.go
+10
-10
appview/pipelines/pipelines.go
+10
-10
appview/pipelines/pipelines.go
···
9
"strings"
10
"time"
11
12
-
"tangled.sh/tangled.sh/core/appview/config"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/oauth"
15
-
"tangled.sh/tangled.sh/core/appview/pages"
16
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
17
-
"tangled.sh/tangled.sh/core/eventconsumer"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/log"
20
-
"tangled.sh/tangled.sh/core/rbac"
21
-
spindlemodel "tangled.sh/tangled.sh/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
···
9
"strings"
10
"time"
11
12
+
"tangled.org/core/appview/config"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/oauth"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/appview/reporesolver"
17
+
"tangled.org/core/eventconsumer"
18
+
"tangled.org/core/idresolver"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindlemodel "tangled.org/core/spindle/models"
22
23
"github.com/go-chi/chi/v5"
24
"github.com/gorilla/websocket"
+1
-1
appview/pipelines/router.go
+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
"strings"
13
"time"
14
15
-
"tangled.sh/tangled.sh/core/api/tangled"
16
-
"tangled.sh/tangled.sh/core/appview/config"
17
-
"tangled.sh/tangled.sh/core/appview/db"
18
-
"tangled.sh/tangled.sh/core/appview/notify"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/idresolver"
25
-
"tangled.sh/tangled.sh/core/patchutil"
26
-
"tangled.sh/tangled.sh/core/tid"
27
-
"tangled.sh/tangled.sh/core/types"
28
29
"github.com/bluekeyes/go-gitdiff/gitdiff"
30
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
75
return
76
}
77
78
-
pull, ok := r.Context().Value("pull").(*db.Pull)
79
if !ok {
80
log.Println("failed to get pull")
81
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
83
}
84
85
// can be nil if this pull is not stacked
86
-
stack, _ := r.Context().Value("stack").(db.Stack)
87
88
roundNumberStr := chi.URLParam(r, "round")
89
roundNumber, err := strconv.Atoi(roundNumberStr)
···
123
return
124
}
125
126
-
pull, ok := r.Context().Value("pull").(*db.Pull)
127
if !ok {
128
log.Println("failed to get pull")
129
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
131
}
132
133
// can be nil if this pull is not stacked
134
-
stack, _ := r.Context().Value("stack").(db.Stack)
135
-
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
136
137
totalIdents := 1
138
for _, submission := range pull.Submissions {
···
159
160
repoInfo := f.RepoInfo(user)
161
162
-
m := make(map[string]db.Pipeline)
163
164
var shas []string
165
for _, s := range pull.Submissions {
···
194
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
195
}
196
197
-
userReactions := map[db.ReactionKind]bool{}
198
if user != nil {
199
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
200
}
201
202
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
203
LoggedInUser: user,
204
RepoInfo: repoInfo,
···
209
ResubmitCheck: resubmitResult,
210
Pipelines: m,
211
212
-
OrderedReactionKinds: db.OrderedReactionKinds,
213
Reactions: reactionCountMap,
214
UserReacted: userReactions,
215
})
216
}
217
218
-
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
219
-
if pull.State == db.PullMerged {
220
return types.MergeCheckResponse{}
221
}
222
···
282
return result
283
}
284
285
-
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
286
-
if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
287
return pages.Unknown
288
}
289
···
356
diffOpts.Split = true
357
}
358
359
-
pull, ok := r.Context().Value("pull").(*db.Pull)
360
if !ok {
361
log.Println("failed to get pull")
362
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
363
return
364
}
365
366
-
stack, _ := r.Context().Value("stack").(db.Stack)
367
368
roundId := chi.URLParam(r, "round")
369
roundIdInt, err := strconv.Atoi(roundId)
···
403
diffOpts.Split = true
404
}
405
406
-
pull, ok := r.Context().Value("pull").(*db.Pull)
407
if !ok {
408
log.Println("failed to get pull")
409
s.pages.Notice(w, "pull-error", "Failed to get pull.")
···
451
}
452
453
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
454
-
pull, ok := r.Context().Value("pull").(*db.Pull)
455
if !ok {
456
log.Println("failed to get pull")
457
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
474
user := s.oauth.GetUser(r)
475
params := r.URL.Query()
476
477
-
state := db.PullOpen
478
switch params.Get("state") {
479
case "closed":
480
-
state = db.PullClosed
481
case "merged":
482
-
state = db.PullMerged
483
}
484
485
f, err := s.repoResolver.Resolve(r)
···
500
}
501
502
for _, p := range pulls {
503
-
var pullSourceRepo *db.Repo
504
if p.PullSource != nil {
505
if p.PullSource.RepoAt != nil {
506
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
···
515
}
516
517
// we want to group all stacked PRs into just one list
518
-
stacks := make(map[string]db.Stack)
519
var shas []string
520
n := 0
521
for _, p := range pulls {
···
551
log.Printf("failed to fetch pipeline statuses: %s", err)
552
// non-fatal
553
}
554
-
m := make(map[string]db.Pipeline)
555
for _, p := range ps {
556
m[p.Sha] = p
557
}
558
559
s.pages.RepoPulls(w, pages.RepoPullsParams{
560
LoggedInUser: s.oauth.GetUser(r),
561
RepoInfo: f.RepoInfo(user),
562
Pulls: pulls,
563
FilteringBy: state,
564
Stacks: stacks,
565
Pipelines: m,
···
574
return
575
}
576
577
-
pull, ok := r.Context().Value("pull").(*db.Pull)
578
if !ok {
579
log.Println("failed to get pull")
580
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
647
return
648
}
649
650
-
comment := &db.PullComment{
651
OwnerDid: user.Did,
652
RepoAt: f.RepoAt().String(),
653
PullId: pull.PullId,
···
890
return
891
}
892
893
-
pullSource := &db.PullSource{
894
Branch: sourceBranch,
895
}
896
recordPullSource := &tangled.RepoPull_Source{
···
1000
forkAtUri := fork.RepoAt()
1001
forkAtUriStr := forkAtUri.String()
1002
1003
-
pullSource := &db.PullSource{
1004
Branch: sourceBranch,
1005
RepoAt: &forkAtUri,
1006
}
···
1021
title, body, targetBranch string,
1022
patch string,
1023
sourceRev string,
1024
-
pullSource *db.PullSource,
1025
recordPullSource *tangled.RepoPull_Source,
1026
isStacked bool,
1027
) {
···
1057
1058
// We've already checked earlier if it's diff-based and title is empty,
1059
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1060
-
if title == "" {
1061
formatPatches, err := patchutil.ExtractPatches(patch)
1062
if err != nil {
1063
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1068
return
1069
}
1070
1071
-
title = formatPatches[0].Title
1072
-
body = formatPatches[0].Body
1073
}
1074
1075
rkey := tid.TID()
1076
-
initialSubmission := db.PullSubmission{
1077
Patch: patch,
1078
SourceRev: sourceRev,
1079
}
1080
-
pull := &db.Pull{
1081
Title: title,
1082
Body: body,
1083
TargetBranch: targetBranch,
1084
OwnerDid: user.Did,
1085
RepoAt: f.RepoAt(),
1086
Rkey: rkey,
1087
-
Submissions: []*db.PullSubmission{
1088
&initialSubmission,
1089
},
1090
PullSource: pullSource,
···
1143
targetBranch string,
1144
patch string,
1145
sourceRev string,
1146
-
pullSource *db.PullSource,
1147
) {
1148
// run some necessary checks for stacked-prs first
1149
···
1451
return
1452
}
1453
1454
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1455
if !ok {
1456
log.Println("failed to get pull")
1457
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1482
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1483
user := s.oauth.GetUser(r)
1484
1485
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1486
if !ok {
1487
log.Println("failed to get pull")
1488
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1509
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1510
user := s.oauth.GetUser(r)
1511
1512
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1513
if !ok {
1514
log.Println("failed to get pull")
1515
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1572
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1573
user := s.oauth.GetUser(r)
1574
1575
-
pull, ok := r.Context().Value("pull").(*db.Pull)
1576
if !ok {
1577
log.Println("failed to get pull")
1578
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1665
}
1666
1667
// validate a resubmission against a pull request
1668
-
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1669
if patch == "" {
1670
return fmt.Errorf("Patch is empty.")
1671
}
···
1686
r *http.Request,
1687
f *reporesolver.ResolvedRepo,
1688
user *oauth.User,
1689
-
pull *db.Pull,
1690
patch string,
1691
sourceRev string,
1692
) {
···
1790
r *http.Request,
1791
f *reporesolver.ResolvedRepo,
1792
user *oauth.User,
1793
-
pull *db.Pull,
1794
patch string,
1795
stackId string,
1796
) {
1797
targetBranch := pull.TargetBranch
1798
1799
-
origStack, _ := r.Context().Value("stack").(db.Stack)
1800
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1801
if err != nil {
1802
log.Println("failed to create resubmitted stack", err)
···
1805
}
1806
1807
// find the diff between the stacks, first, map them by changeId
1808
-
origById := make(map[string]*db.Pull)
1809
-
newById := make(map[string]*db.Pull)
1810
for _, p := range origStack {
1811
origById[p.ChangeId] = p
1812
}
···
1819
// commits that got updated: corresponding pull is resubmitted & new round begins
1820
//
1821
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1822
-
additions := make(map[string]*db.Pull)
1823
-
deletions := make(map[string]*db.Pull)
1824
unchanged := make(map[string]struct{})
1825
updated := make(map[string]struct{})
1826
···
1880
// deleted pulls are marked as deleted in the DB
1881
for _, p := range deletions {
1882
// do not do delete already merged PRs
1883
-
if p.State == db.PullMerged {
1884
continue
1885
}
1886
···
1925
np, _ := newById[id]
1926
1927
// do not update already merged PRs
1928
-
if op.State == db.PullMerged {
1929
continue
1930
}
1931
···
2046
return
2047
}
2048
2049
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2050
if !ok {
2051
log.Println("failed to get pull")
2052
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2053
return
2054
}
2055
2056
-
var pullsToMerge db.Stack
2057
pullsToMerge = append(pullsToMerge, pull)
2058
if pull.IsStacked() {
2059
-
stack, ok := r.Context().Value("stack").(db.Stack)
2060
if !ok {
2061
log.Println("failed to get stack")
2062
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
···
2146
return
2147
}
2148
2149
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2150
}
2151
···
2158
return
2159
}
2160
2161
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2162
if !ok {
2163
log.Println("failed to get pull")
2164
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2186
}
2187
defer tx.Rollback()
2188
2189
-
var pullsToClose []*db.Pull
2190
pullsToClose = append(pullsToClose, pull)
2191
2192
// if this PR is stacked, then we want to close all PRs below this one on the stack
2193
if pull.IsStacked() {
2194
-
stack := r.Context().Value("stack").(db.Stack)
2195
subStack := stack.StrictlyBelow(pull)
2196
pullsToClose = append(pullsToClose, subStack...)
2197
}
···
2213
return
2214
}
2215
2216
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2217
}
2218
···
2226
return
2227
}
2228
2229
-
pull, ok := r.Context().Value("pull").(*db.Pull)
2230
if !ok {
2231
log.Println("failed to get pull")
2232
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2254
}
2255
defer tx.Rollback()
2256
2257
-
var pullsToReopen []*db.Pull
2258
pullsToReopen = append(pullsToReopen, pull)
2259
2260
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
2261
if pull.IsStacked() {
2262
-
stack := r.Context().Value("stack").(db.Stack)
2263
subStack := stack.StrictlyAbove(pull)
2264
pullsToReopen = append(pullsToReopen, subStack...)
2265
}
···
2284
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2285
}
2286
2287
-
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2288
formatPatches, err := patchutil.ExtractPatches(patch)
2289
if err != nil {
2290
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2296
}
2297
2298
// the stack is identified by a UUID
2299
-
var stack db.Stack
2300
parentChangeId := ""
2301
for _, fp := range formatPatches {
2302
// all patches must have a jj change-id
···
2309
body := fp.Body
2310
rkey := tid.TID()
2311
2312
-
initialSubmission := db.PullSubmission{
2313
Patch: fp.Raw,
2314
SourceRev: fp.SHA,
2315
}
2316
-
pull := db.Pull{
2317
Title: title,
2318
Body: body,
2319
TargetBranch: targetBranch,
2320
OwnerDid: user.Did,
2321
RepoAt: f.RepoAt(),
2322
Rkey: rkey,
2323
-
Submissions: []*db.PullSubmission{
2324
&initialSubmission,
2325
},
2326
PullSource: pullSource,
···
12
"strings"
13
"time"
14
15
+
"tangled.org/core/api/tangled"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/models"
19
+
"tangled.org/core/appview/notify"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/markup"
23
+
"tangled.org/core/appview/reporesolver"
24
+
"tangled.org/core/appview/xrpcclient"
25
+
"tangled.org/core/idresolver"
26
+
"tangled.org/core/patchutil"
27
+
"tangled.org/core/tid"
28
+
"tangled.org/core/types"
29
30
"github.com/bluekeyes/go-gitdiff/gitdiff"
31
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
76
return
77
}
78
79
+
pull, ok := r.Context().Value("pull").(*models.Pull)
80
if !ok {
81
log.Println("failed to get pull")
82
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
84
}
85
86
// can be nil if this pull is not stacked
87
+
stack, _ := r.Context().Value("stack").(models.Stack)
88
89
roundNumberStr := chi.URLParam(r, "round")
90
roundNumber, err := strconv.Atoi(roundNumberStr)
···
124
return
125
}
126
127
+
pull, ok := r.Context().Value("pull").(*models.Pull)
128
if !ok {
129
log.Println("failed to get pull")
130
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
132
}
133
134
// can be nil if this pull is not stacked
135
+
stack, _ := r.Context().Value("stack").(models.Stack)
136
+
abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
137
138
totalIdents := 1
139
for _, submission := range pull.Submissions {
···
160
161
repoInfo := f.RepoInfo(user)
162
163
+
m := make(map[string]models.Pipeline)
164
165
var shas []string
166
for _, s := range pull.Submissions {
···
195
s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
196
}
197
198
+
userReactions := map[models.ReactionKind]bool{}
199
if user != nil {
200
userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt())
201
}
202
203
+
labelDefs, err := db.GetLabelDefinitions(
204
+
s.db,
205
+
db.FilterIn("at_uri", f.Repo.Labels),
206
+
db.FilterContains("scope", tangled.RepoPullNSID),
207
+
)
208
+
if err != nil {
209
+
log.Println("failed to fetch labels", err)
210
+
s.pages.Error503(w)
211
+
return
212
+
}
213
+
214
+
defs := make(map[string]*models.LabelDefinition)
215
+
for _, l := range labelDefs {
216
+
defs[l.AtUri().String()] = &l
217
+
}
218
+
219
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
220
LoggedInUser: user,
221
RepoInfo: repoInfo,
···
226
ResubmitCheck: resubmitResult,
227
Pipelines: m,
228
229
+
OrderedReactionKinds: models.OrderedReactionKinds,
230
Reactions: reactionCountMap,
231
UserReacted: userReactions,
232
+
233
+
LabelDefs: defs,
234
})
235
}
236
237
+
func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
238
+
if pull.State == models.PullMerged {
239
return types.MergeCheckResponse{}
240
}
241
···
301
return result
302
}
303
304
+
func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
305
+
if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
306
return pages.Unknown
307
}
308
···
375
diffOpts.Split = true
376
}
377
378
+
pull, ok := r.Context().Value("pull").(*models.Pull)
379
if !ok {
380
log.Println("failed to get pull")
381
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
382
return
383
}
384
385
+
stack, _ := r.Context().Value("stack").(models.Stack)
386
387
roundId := chi.URLParam(r, "round")
388
roundIdInt, err := strconv.Atoi(roundId)
···
422
diffOpts.Split = true
423
}
424
425
+
pull, ok := r.Context().Value("pull").(*models.Pull)
426
if !ok {
427
log.Println("failed to get pull")
428
s.pages.Notice(w, "pull-error", "Failed to get pull.")
···
470
}
471
472
func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
473
+
pull, ok := r.Context().Value("pull").(*models.Pull)
474
if !ok {
475
log.Println("failed to get pull")
476
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
493
user := s.oauth.GetUser(r)
494
params := r.URL.Query()
495
496
+
state := models.PullOpen
497
switch params.Get("state") {
498
case "closed":
499
+
state = models.PullClosed
500
case "merged":
501
+
state = models.PullMerged
502
}
503
504
f, err := s.repoResolver.Resolve(r)
···
519
}
520
521
for _, p := range pulls {
522
+
var pullSourceRepo *models.Repo
523
if p.PullSource != nil {
524
if p.PullSource.RepoAt != nil {
525
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
···
534
}
535
536
// we want to group all stacked PRs into just one list
537
+
stacks := make(map[string]models.Stack)
538
var shas []string
539
n := 0
540
for _, p := range pulls {
···
570
log.Printf("failed to fetch pipeline statuses: %s", err)
571
// non-fatal
572
}
573
+
m := make(map[string]models.Pipeline)
574
for _, p := range ps {
575
m[p.Sha] = p
576
}
577
578
+
labelDefs, err := db.GetLabelDefinitions(
579
+
s.db,
580
+
db.FilterIn("at_uri", f.Repo.Labels),
581
+
db.FilterContains("scope", tangled.RepoPullNSID),
582
+
)
583
+
if err != nil {
584
+
log.Println("failed to fetch labels", err)
585
+
s.pages.Error503(w)
586
+
return
587
+
}
588
+
589
+
defs := make(map[string]*models.LabelDefinition)
590
+
for _, l := range labelDefs {
591
+
defs[l.AtUri().String()] = &l
592
+
}
593
+
594
s.pages.RepoPulls(w, pages.RepoPullsParams{
595
LoggedInUser: s.oauth.GetUser(r),
596
RepoInfo: f.RepoInfo(user),
597
Pulls: pulls,
598
+
LabelDefs: defs,
599
FilteringBy: state,
600
Stacks: stacks,
601
Pipelines: m,
···
610
return
611
}
612
613
+
pull, ok := r.Context().Value("pull").(*models.Pull)
614
if !ok {
615
log.Println("failed to get pull")
616
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
683
return
684
}
685
686
+
comment := &models.PullComment{
687
OwnerDid: user.Did,
688
RepoAt: f.RepoAt().String(),
689
PullId: pull.PullId,
···
926
return
927
}
928
929
+
pullSource := &models.PullSource{
930
Branch: sourceBranch,
931
}
932
recordPullSource := &tangled.RepoPull_Source{
···
1036
forkAtUri := fork.RepoAt()
1037
forkAtUriStr := forkAtUri.String()
1038
1039
+
pullSource := &models.PullSource{
1040
Branch: sourceBranch,
1041
RepoAt: &forkAtUri,
1042
}
···
1057
title, body, targetBranch string,
1058
patch string,
1059
sourceRev string,
1060
+
pullSource *models.PullSource,
1061
recordPullSource *tangled.RepoPull_Source,
1062
isStacked bool,
1063
) {
···
1093
1094
// We've already checked earlier if it's diff-based and title is empty,
1095
// so if it's still empty now, it's intentionally skipped owing to format-patch.
1096
+
if title == "" || body == "" {
1097
formatPatches, err := patchutil.ExtractPatches(patch)
1098
if err != nil {
1099
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
···
1104
return
1105
}
1106
1107
+
if title == "" {
1108
+
title = formatPatches[0].Title
1109
+
}
1110
+
if body == "" {
1111
+
body = formatPatches[0].Body
1112
+
}
1113
}
1114
1115
rkey := tid.TID()
1116
+
initialSubmission := models.PullSubmission{
1117
Patch: patch,
1118
SourceRev: sourceRev,
1119
}
1120
+
pull := &models.Pull{
1121
Title: title,
1122
Body: body,
1123
TargetBranch: targetBranch,
1124
OwnerDid: user.Did,
1125
RepoAt: f.RepoAt(),
1126
Rkey: rkey,
1127
+
Submissions: []*models.PullSubmission{
1128
&initialSubmission,
1129
},
1130
PullSource: pullSource,
···
1183
targetBranch string,
1184
patch string,
1185
sourceRev string,
1186
+
pullSource *models.PullSource,
1187
) {
1188
// run some necessary checks for stacked-prs first
1189
···
1491
return
1492
}
1493
1494
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1495
if !ok {
1496
log.Println("failed to get pull")
1497
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1522
func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1523
user := s.oauth.GetUser(r)
1524
1525
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1526
if !ok {
1527
log.Println("failed to get pull")
1528
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
1549
func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1550
user := s.oauth.GetUser(r)
1551
1552
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1553
if !ok {
1554
log.Println("failed to get pull")
1555
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1612
func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1613
user := s.oauth.GetUser(r)
1614
1615
+
pull, ok := r.Context().Value("pull").(*models.Pull)
1616
if !ok {
1617
log.Println("failed to get pull")
1618
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
···
1705
}
1706
1707
// validate a resubmission against a pull request
1708
+
func validateResubmittedPatch(pull *models.Pull, patch string) error {
1709
if patch == "" {
1710
return fmt.Errorf("Patch is empty.")
1711
}
···
1726
r *http.Request,
1727
f *reporesolver.ResolvedRepo,
1728
user *oauth.User,
1729
+
pull *models.Pull,
1730
patch string,
1731
sourceRev string,
1732
) {
···
1830
r *http.Request,
1831
f *reporesolver.ResolvedRepo,
1832
user *oauth.User,
1833
+
pull *models.Pull,
1834
patch string,
1835
stackId string,
1836
) {
1837
targetBranch := pull.TargetBranch
1838
1839
+
origStack, _ := r.Context().Value("stack").(models.Stack)
1840
newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1841
if err != nil {
1842
log.Println("failed to create resubmitted stack", err)
···
1845
}
1846
1847
// find the diff between the stacks, first, map them by changeId
1848
+
origById := make(map[string]*models.Pull)
1849
+
newById := make(map[string]*models.Pull)
1850
for _, p := range origStack {
1851
origById[p.ChangeId] = p
1852
}
···
1859
// commits that got updated: corresponding pull is resubmitted & new round begins
1860
//
1861
// for commits that were unchanged: no changes, parent-change-id is updated as necessary
1862
+
additions := make(map[string]*models.Pull)
1863
+
deletions := make(map[string]*models.Pull)
1864
unchanged := make(map[string]struct{})
1865
updated := make(map[string]struct{})
1866
···
1920
// deleted pulls are marked as deleted in the DB
1921
for _, p := range deletions {
1922
// do not do delete already merged PRs
1923
+
if p.State == models.PullMerged {
1924
continue
1925
}
1926
···
1965
np, _ := newById[id]
1966
1967
// do not update already merged PRs
1968
+
if op.State == models.PullMerged {
1969
continue
1970
}
1971
···
2086
return
2087
}
2088
2089
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2090
if !ok {
2091
log.Println("failed to get pull")
2092
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2093
return
2094
}
2095
2096
+
var pullsToMerge models.Stack
2097
pullsToMerge = append(pullsToMerge, pull)
2098
if pull.IsStacked() {
2099
+
stack, ok := r.Context().Value("stack").(models.Stack)
2100
if !ok {
2101
log.Println("failed to get stack")
2102
s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
···
2186
return
2187
}
2188
2189
+
// notify about the pull merge
2190
+
for _, p := range pullsToMerge {
2191
+
s.notifier.NewPullMerged(r.Context(), p)
2192
+
}
2193
+
2194
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2195
}
2196
···
2203
return
2204
}
2205
2206
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2207
if !ok {
2208
log.Println("failed to get pull")
2209
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2231
}
2232
defer tx.Rollback()
2233
2234
+
var pullsToClose []*models.Pull
2235
pullsToClose = append(pullsToClose, pull)
2236
2237
// if this PR is stacked, then we want to close all PRs below this one on the stack
2238
if pull.IsStacked() {
2239
+
stack := r.Context().Value("stack").(models.Stack)
2240
subStack := stack.StrictlyBelow(pull)
2241
pullsToClose = append(pullsToClose, subStack...)
2242
}
···
2258
return
2259
}
2260
2261
+
for _, p := range pullsToClose {
2262
+
s.notifier.NewPullClosed(r.Context(), p)
2263
+
}
2264
+
2265
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2266
}
2267
···
2275
return
2276
}
2277
2278
+
pull, ok := r.Context().Value("pull").(*models.Pull)
2279
if !ok {
2280
log.Println("failed to get pull")
2281
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
···
2303
}
2304
defer tx.Rollback()
2305
2306
+
var pullsToReopen []*models.Pull
2307
pullsToReopen = append(pullsToReopen, pull)
2308
2309
// if this PR is stacked, then we want to reopen all PRs above this one on the stack
2310
if pull.IsStacked() {
2311
+
stack := r.Context().Value("stack").(models.Stack)
2312
subStack := stack.StrictlyAbove(pull)
2313
pullsToReopen = append(pullsToReopen, subStack...)
2314
}
···
2333
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2334
}
2335
2336
+
func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2337
formatPatches, err := patchutil.ExtractPatches(patch)
2338
if err != nil {
2339
return nil, fmt.Errorf("Failed to extract patches: %v", err)
···
2345
}
2346
2347
// the stack is identified by a UUID
2348
+
var stack models.Stack
2349
parentChangeId := ""
2350
for _, fp := range formatPatches {
2351
// all patches must have a jj change-id
···
2358
body := fp.Body
2359
rkey := tid.TID()
2360
2361
+
initialSubmission := models.PullSubmission{
2362
Patch: fp.Raw,
2363
SourceRev: fp.SHA,
2364
}
2365
+
pull := models.Pull{
2366
Title: title,
2367
Body: body,
2368
TargetBranch: targetBranch,
2369
OwnerDid: user.Did,
2370
RepoAt: f.RepoAt(),
2371
Rkey: rkey,
2372
+
Submissions: []*models.PullSubmission{
2373
&initialSubmission,
2374
},
2375
PullSource: pullSource,
+1
-1
appview/pulls/router.go
+1
-1
appview/pulls/router.go
+49
-22
appview/repo/artifact.go
+49
-22
appview/repo/artifact.go
···
4
"context"
5
"encoding/json"
6
"fmt"
7
"log"
8
"net/http"
9
"net/url"
···
16
"github.com/go-chi/chi/v5"
17
"github.com/go-git/go-git/v5/plumbing"
18
"github.com/ipfs/go-cid"
19
-
"tangled.sh/tangled.sh/core/api/tangled"
20
-
"tangled.sh/tangled.sh/core/appview/db"
21
-
"tangled.sh/tangled.sh/core/appview/pages"
22
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
23
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
24
-
"tangled.sh/tangled.sh/core/tid"
25
-
"tangled.sh/tangled.sh/core/types"
26
)
27
28
// TODO: proper statuses here on early exit
···
100
}
101
defer tx.Rollback()
102
103
-
artifact := db.Artifact{
104
Did: user.Did,
105
Rkey: rkey,
106
RepoAt: f.RepoAt(),
···
133
})
134
}
135
136
-
// TODO: proper statuses here on early exit
137
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
138
-
tagParam := chi.URLParam(r, "tag")
139
-
filename := chi.URLParam(r, "file")
140
f, err := rp.repoResolver.Resolve(r)
141
if err != nil {
142
log.Println("failed to get repo and knot", err)
143
return
144
}
145
146
tag, err := rp.resolveTag(r.Context(), f, tagParam)
147
if err != nil {
148
log.Println("failed to resolve tag", err)
···
150
return
151
}
152
153
-
client, err := rp.oauth.AuthorizedClient(r)
154
-
if err != nil {
155
-
log.Println("failed to get authorized client", err)
156
-
return
157
-
}
158
-
159
artifacts, err := db.GetArtifact(
160
rp.db,
161
db.FilterEq("repo_at", f.RepoAt()),
···
164
)
165
if err != nil {
166
log.Println("failed to get artifacts", err)
167
return
168
}
169
if len(artifacts) != 1 {
170
-
log.Printf("too many or too little artifacts found")
171
return
172
}
173
174
artifact := artifacts[0]
175
176
-
getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did)
177
if err != nil {
178
-
log.Println("failed to get blob from pds", err)
179
return
180
}
181
182
-
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
183
-
w.Write(getBlobResp)
184
}
185
186
// TODO: proper statuses here on early exit
···
4
"context"
5
"encoding/json"
6
"fmt"
7
+
"io"
8
"log"
9
"net/http"
10
"net/url"
···
17
"github.com/go-chi/chi/v5"
18
"github.com/go-git/go-git/v5/plumbing"
19
"github.com/ipfs/go-cid"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/db"
22
+
"tangled.org/core/appview/models"
23
+
"tangled.org/core/appview/pages"
24
+
"tangled.org/core/appview/reporesolver"
25
+
"tangled.org/core/appview/xrpcclient"
26
+
"tangled.org/core/tid"
27
+
"tangled.org/core/types"
28
)
29
30
// TODO: proper statuses here on early exit
···
102
}
103
defer tx.Rollback()
104
105
+
artifact := models.Artifact{
106
Did: user.Did,
107
Rkey: rkey,
108
RepoAt: f.RepoAt(),
···
135
})
136
}
137
138
func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) {
139
f, err := rp.repoResolver.Resolve(r)
140
if err != nil {
141
log.Println("failed to get repo and knot", err)
142
+
http.Error(w, "failed to resolve repo", http.StatusInternalServerError)
143
return
144
}
145
146
+
tagParam := chi.URLParam(r, "tag")
147
+
filename := chi.URLParam(r, "file")
148
+
149
tag, err := rp.resolveTag(r.Context(), f, tagParam)
150
if err != nil {
151
log.Println("failed to resolve tag", err)
···
153
return
154
}
155
156
artifacts, err := db.GetArtifact(
157
rp.db,
158
db.FilterEq("repo_at", f.RepoAt()),
···
161
)
162
if err != nil {
163
log.Println("failed to get artifacts", err)
164
+
http.Error(w, "failed to get artifact", http.StatusInternalServerError)
165
return
166
}
167
+
168
if len(artifacts) != 1 {
169
+
log.Printf("too many or too few artifacts found")
170
+
http.Error(w, "artifact not found", http.StatusNotFound)
171
return
172
}
173
174
artifact := artifacts[0]
175
176
+
ownerPds := f.OwnerId.PDSEndpoint()
177
+
url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds))
178
+
q := url.Query()
179
+
q.Set("cid", artifact.BlobCid.String())
180
+
q.Set("did", artifact.Did)
181
+
url.RawQuery = q.Encode()
182
+
183
+
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
184
if err != nil {
185
+
log.Println("failed to create request", err)
186
+
http.Error(w, "failed to create request", http.StatusInternalServerError)
187
+
return
188
+
}
189
+
req.Header.Set("Content-Type", "application/json")
190
+
191
+
resp, err := http.DefaultClient.Do(req)
192
+
if err != nil {
193
+
log.Println("failed to make request", err)
194
+
http.Error(w, "failed to make request to PDS", http.StatusInternalServerError)
195
return
196
}
197
+
defer resp.Body.Close()
198
199
+
// copy status code and relevant headers from upstream response
200
+
w.WriteHeader(resp.StatusCode)
201
+
for key, values := range resp.Header {
202
+
for _, v := range values {
203
+
w.Header().Add(key, v)
204
+
}
205
+
}
206
+
207
+
// stream the body directly to the client
208
+
if _, err := io.Copy(w, resp.Body); err != nil {
209
+
log.Println("error streaming response to client:", err)
210
+
}
211
}
212
213
// TODO: proper statuses here on early exit
+10
-9
appview/repo/feed.go
+10
-9
appview/repo/feed.go
···
8
"slices"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pagination"
13
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
14
15
"github.com/bluesky-social/indigo/atproto/syntax"
16
"github.com/gorilla/feeds"
···
70
return feed, nil
71
}
72
73
-
func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
74
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
75
if err != nil {
76
return nil, err
···
108
return items, nil
109
}
110
111
-
func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
112
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
113
if err != nil {
114
return nil, err
···
128
}, nil
129
}
130
131
-
func (rp *Repo) getPullState(pull *db.Pull) string {
132
-
if pull.State == db.PullOpen {
133
return "opened"
134
}
135
return pull.State.String()
136
}
137
138
-
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string {
139
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
140
141
-
if pull.State == db.PullMerged {
142
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
143
}
144
···
8
"slices"
9
"time"
10
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pagination"
14
+
"tangled.org/core/appview/reporesolver"
15
16
"github.com/bluesky-social/indigo/atproto/syntax"
17
"github.com/gorilla/feeds"
···
71
return feed, nil
72
}
73
74
+
func (rp *Repo) createPullItems(ctx context.Context, pull *models.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) {
75
owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid)
76
if err != nil {
77
return nil, err
···
109
return items, nil
110
}
111
112
+
func (rp *Repo) createIssueItem(ctx context.Context, issue models.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) {
113
owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did)
114
if err != nil {
115
return nil, err
···
129
}, nil
130
}
131
132
+
func (rp *Repo) getPullState(pull *models.Pull) string {
133
+
if pull.State == models.PullOpen {
134
return "opened"
135
}
136
return pull.State.String()
137
}
138
139
+
func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *models.Pull, repoName string) string {
140
base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId)
141
142
+
if pull.State == models.PullMerged {
143
return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName)
144
}
145
+26
-30
appview/repo/index.go
+26
-30
appview/repo/index.go
···
17
18
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
"github.com/go-git/go-git/v5/plumbing"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview/commitverify"
22
-
"tangled.sh/tangled.sh/core/appview/db"
23
-
"tangled.sh/tangled.sh/core/appview/pages"
24
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
26
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
27
-
"tangled.sh/tangled.sh/core/types"
28
29
"github.com/go-chi/chi/v5"
30
"github.com/go-enry/go-enry/v2"
···
191
}
192
193
for _, lang := range ls.Languages {
194
-
langs = append(langs, db.RepoLanguage{
195
RepoAt: f.RepoAt(),
196
Ref: currentRef,
197
IsDefaultRef: isDefaultRef,
···
200
})
201
}
202
203
// update appview's cache
204
-
err = db.InsertRepoLanguages(rp.db, langs)
205
if err != nil {
206
// non-fatal
207
log.Println("failed to cache lang results", err)
208
}
209
}
210
211
var total int64
···
327
}
328
}()
329
330
-
// readme content
331
-
wg.Add(1)
332
-
go func() {
333
-
defer wg.Done()
334
-
for _, filename := range markup.ReadmeFilenames {
335
-
blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo)
336
-
if err != nil {
337
-
continue
338
-
}
339
-
340
-
if blobResp == nil {
341
-
continue
342
-
}
343
-
344
-
readmeContent = blobResp.Content
345
-
readmeFileName = filename
346
-
break
347
-
}
348
-
}()
349
-
350
wg.Wait()
351
352
if errs != nil {
···
373
}
374
files = append(files, niceFile)
375
}
376
}
377
378
result := &types.RepoIndexResponse{
···
17
18
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
19
"github.com/go-git/go-git/v5/plumbing"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview/commitverify"
22
+
"tangled.org/core/appview/db"
23
+
"tangled.org/core/appview/models"
24
+
"tangled.org/core/appview/pages"
25
+
"tangled.org/core/appview/reporesolver"
26
+
"tangled.org/core/appview/xrpcclient"
27
+
"tangled.org/core/types"
28
29
"github.com/go-chi/chi/v5"
30
"github.com/go-enry/go-enry/v2"
···
191
}
192
193
for _, lang := range ls.Languages {
194
+
langs = append(langs, models.RepoLanguage{
195
RepoAt: f.RepoAt(),
196
Ref: currentRef,
197
IsDefaultRef: isDefaultRef,
···
200
})
201
}
202
203
+
tx, err := rp.db.Begin()
204
+
if err != nil {
205
+
return nil, err
206
+
}
207
+
defer tx.Rollback()
208
+
209
// update appview's cache
210
+
err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs)
211
if err != nil {
212
// non-fatal
213
log.Println("failed to cache lang results", err)
214
}
215
+
216
+
err = tx.Commit()
217
+
if err != nil {
218
+
return nil, err
219
+
}
220
}
221
222
var total int64
···
338
}
339
}()
340
341
wg.Wait()
342
343
if errs != nil {
···
364
}
365
files = append(files, niceFile)
366
}
367
+
}
368
+
369
+
if treeResp != nil && treeResp.Readme != nil {
370
+
readmeFileName = treeResp.Readme.Filename
371
+
readmeContent = treeResp.Readme.Contents
372
}
373
374
result := &types.RepoIndexResponse{
+110
-83
appview/repo/repo.go
+110
-83
appview/repo/repo.go
···
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
-
"tangled.sh/tangled.sh/core/api/tangled"
24
-
"tangled.sh/tangled.sh/core/appview/commitverify"
25
-
"tangled.sh/tangled.sh/core/appview/config"
26
-
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/notify"
28
-
"tangled.sh/tangled.sh/core/appview/oauth"
29
-
"tangled.sh/tangled.sh/core/appview/pages"
30
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
31
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
32
-
"tangled.sh/tangled.sh/core/appview/validator"
33
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
34
-
"tangled.sh/tangled.sh/core/eventconsumer"
35
-
"tangled.sh/tangled.sh/core/idresolver"
36
-
"tangled.sh/tangled.sh/core/patchutil"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
"tangled.sh/tangled.sh/core/types"
40
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
41
42
securejoin "github.com/cyphar/filepath-securejoin"
43
"github.com/go-chi/chi/v5"
···
399
log.Println(err)
400
// non-fatal
401
}
402
-
var pipeline *db.Pipeline
403
if p, ok := pipelines[result.Diff.Commit.This]; ok {
404
pipeline = &p
405
}
···
448
return
449
}
450
451
-
// readme content
452
-
var (
453
-
readmeContent string
454
-
readmeFileName string
455
-
)
456
-
457
-
for _, filename := range markup.ReadmeFilenames {
458
-
path := fmt.Sprintf("%s/%s", treePath, filename)
459
-
blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo)
460
-
if err != nil {
461
-
continue
462
-
}
463
-
464
-
if blobResp == nil {
465
-
continue
466
-
}
467
-
468
-
readmeContent = blobResp.Content
469
-
readmeFileName = path
470
-
break
471
-
}
472
-
473
// Convert XRPC response to internal types.RepoTreeResponse
474
files := make([]types.NiceTree, len(xrpcResp.Files))
475
for i, xrpcFile := range xrpcResp.Files {
···
505
if xrpcResp.Dotdot != nil {
506
result.DotDot = *xrpcResp.Dotdot
507
}
508
509
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
510
// so we can safely redirect to the "parent" (which is the same file).
···
531
BreadCrumbs: breadcrumbs,
532
TreePath: treePath,
533
RepoInfo: f.RepoInfo(user),
534
-
Readme: readmeContent,
535
-
ReadmeFileName: readmeFileName,
536
RepoTreeResponse: result,
537
})
538
}
···
575
}
576
577
// convert artifacts to map for easy UI building
578
-
artifactMap := make(map[plumbing.Hash][]db.Artifact)
579
for _, a := range artifacts {
580
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
581
}
582
583
-
var danglingArtifacts []db.Artifact
584
for _, a := range artifacts {
585
found := false
586
for _, t := range result.Tags {
···
1004
concreteType = "null"
1005
}
1006
1007
-
format := db.ValueTypeFormatAny
1008
if valueFormat == "did" {
1009
-
format = db.ValueTypeFormatDid
1010
}
1011
1012
-
valueType := db.ValueType{
1013
-
Type: db.ConcreteType(concreteType),
1014
Format: format,
1015
Enum: variants,
1016
}
1017
1018
-
label := db.LabelDefinition{
1019
Did: user.Did,
1020
Rkey: tid.TID(),
1021
Name: name,
···
1109
return
1110
}
1111
1112
-
err = db.SubscribeLabel(tx, &db.RepoLabel{
1113
RepoAt: f.RepoAt(),
1114
LabelAt: label.AtUri(),
1115
})
···
1247
return
1248
}
1249
1250
errorId := "default-label-operation"
1251
fail := func(msg string, err error) {
1252
l.Error(msg, "err", err)
1253
rp.pages.Notice(w, errorId, msg)
1254
}
1255
1256
-
labelAt := r.FormValue("label")
1257
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
1258
if err != nil {
1259
fail("Failed to subscribe to label.", err)
1260
return
1261
}
1262
1263
newRepo := f.Repo
1264
-
newRepo.Labels = append(newRepo.Labels, labelAt)
1265
repoRecord := newRepo.AsRecord()
1266
1267
client, err := rp.oauth.AuthorizedClient(r)
···
1285
},
1286
})
1287
1288
-
err = db.SubscribeLabel(rp.db, &db.RepoLabel{
1289
-
RepoAt: f.RepoAt(),
1290
-
LabelAt: syntax.ATURI(labelAt),
1291
-
})
1292
if err != nil {
1293
fail("Failed to subscribe to label.", err)
1294
return
1295
}
···
1310
return
1311
}
1312
1313
errorId := "default-label-operation"
1314
fail := func(msg string, err error) {
1315
l.Error(msg, "err", err)
1316
rp.pages.Notice(w, errorId, msg)
1317
}
1318
1319
-
labelAt := r.FormValue("label")
1320
-
_, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt))
1321
if err != nil {
1322
fail("Failed to unsubscribe to label.", err)
1323
return
···
1327
newRepo := f.Repo
1328
var updated []string
1329
for _, l := range newRepo.Labels {
1330
-
if l != labelAt {
1331
updated = append(updated, l)
1332
}
1333
}
···
1358
err = db.UnsubscribeLabel(
1359
rp.db,
1360
db.FilterEq("repo_at", f.RepoAt()),
1361
-
db.FilterEq("label_at", labelAt),
1362
)
1363
if err != nil {
1364
fail("Failed to unsubscribe label.", err)
···
1395
return
1396
}
1397
1398
-
defs := make(map[string]*db.LabelDefinition)
1399
for _, l := range labelDefs {
1400
defs[l.AtUri().String()] = &l
1401
}
···
1443
return
1444
}
1445
1446
-
defs := make(map[string]*db.LabelDefinition)
1447
for _, l := range labelDefs {
1448
defs[l.AtUri().String()] = &l
1449
}
···
1566
return
1567
}
1568
1569
-
err = db.AddCollaborator(tx, db.Collaborator{
1570
Did: syntax.DID(currentUser.Did),
1571
Rkey: rkey,
1572
SubjectDid: collaboratorIdent.DID,
···
1894
return
1895
}
1896
1897
-
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", db.DefaultLabelDefs()))
1898
if err != nil {
1899
log.Println("failed to fetch labels", err)
1900
rp.pages.Error503(w)
···
1926
subscribedLabels[l] = struct{}{}
1927
}
1928
1929
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1930
-
LoggedInUser: user,
1931
-
RepoInfo: f.RepoInfo(user),
1932
-
Branches: result.Branches,
1933
-
Labels: labels,
1934
-
DefaultLabels: defaultLabels,
1935
-
SubscribedLabels: subscribedLabels,
1936
-
Tabs: settingsTabs,
1937
-
Tab: "general",
1938
})
1939
}
1940
···
2107
}
2108
2109
// choose a name for a fork
2110
-
forkName := f.Name
2111
// this check is *only* to see if the forked repo name already exists
2112
// in the user's account.
2113
existingRepo, err := db.GetRepo(
2114
rp.db,
2115
db.FilterEq("did", user.Did),
2116
-
db.FilterEq("name", f.Name),
2117
)
2118
if err != nil {
2119
-
if errors.Is(err, sql.ErrNoRows) {
2120
-
// no existing repo with this name found, we can use the name as is
2121
-
} else {
2122
log.Println("error fetching existing repo from db", "err", err)
2123
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2124
return
2125
}
2126
} else if existingRepo != nil {
2127
-
// repo with this name already exists, append random string
2128
-
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
2129
}
2130
l = l.With("forkName", forkName)
2131
···
2141
2142
// create an atproto record for this fork
2143
rkey := tid.TID()
2144
-
repo := &db.Repo{
2145
Did: user.Did,
2146
Name: forkName,
2147
Knot: targetKnot,
2148
Rkey: rkey,
2149
Source: sourceAt,
2150
-
Description: existingRepo.Description,
2151
Created: time.Now(),
2152
}
2153
record := repo.AsRecord()
2154
···
20
comatproto "github.com/bluesky-social/indigo/api/atproto"
21
lexutil "github.com/bluesky-social/indigo/lex/util"
22
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
23
+
"tangled.org/core/api/tangled"
24
+
"tangled.org/core/appview/commitverify"
25
+
"tangled.org/core/appview/config"
26
+
"tangled.org/core/appview/db"
27
+
"tangled.org/core/appview/models"
28
+
"tangled.org/core/appview/notify"
29
+
"tangled.org/core/appview/oauth"
30
+
"tangled.org/core/appview/pages"
31
+
"tangled.org/core/appview/pages/markup"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/patchutil"
38
+
"tangled.org/core/rbac"
39
+
"tangled.org/core/tid"
40
+
"tangled.org/core/types"
41
+
"tangled.org/core/xrpc/serviceauth"
42
43
securejoin "github.com/cyphar/filepath-securejoin"
44
"github.com/go-chi/chi/v5"
···
400
log.Println(err)
401
// non-fatal
402
}
403
+
var pipeline *models.Pipeline
404
if p, ok := pipelines[result.Diff.Commit.This]; ok {
405
pipeline = &p
406
}
···
449
return
450
}
451
452
// Convert XRPC response to internal types.RepoTreeResponse
453
files := make([]types.NiceTree, len(xrpcResp.Files))
454
for i, xrpcFile := range xrpcResp.Files {
···
484
if xrpcResp.Dotdot != nil {
485
result.DotDot = *xrpcResp.Dotdot
486
}
487
+
if xrpcResp.Readme != nil {
488
+
result.ReadmeFileName = xrpcResp.Readme.Filename
489
+
result.Readme = xrpcResp.Readme.Contents
490
+
}
491
492
// redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
493
// so we can safely redirect to the "parent" (which is the same file).
···
514
BreadCrumbs: breadcrumbs,
515
TreePath: treePath,
516
RepoInfo: f.RepoInfo(user),
517
RepoTreeResponse: result,
518
})
519
}
···
556
}
557
558
// convert artifacts to map for easy UI building
559
+
artifactMap := make(map[plumbing.Hash][]models.Artifact)
560
for _, a := range artifacts {
561
artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
562
}
563
564
+
var danglingArtifacts []models.Artifact
565
for _, a := range artifacts {
566
found := false
567
for _, t := range result.Tags {
···
985
concreteType = "null"
986
}
987
988
+
format := models.ValueTypeFormatAny
989
if valueFormat == "did" {
990
+
format = models.ValueTypeFormatDid
991
}
992
993
+
valueType := models.ValueType{
994
+
Type: models.ConcreteType(concreteType),
995
Format: format,
996
Enum: variants,
997
}
998
999
+
label := models.LabelDefinition{
1000
Did: user.Did,
1001
Rkey: tid.TID(),
1002
Name: name,
···
1090
return
1091
}
1092
1093
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1094
RepoAt: f.RepoAt(),
1095
LabelAt: label.AtUri(),
1096
})
···
1228
return
1229
}
1230
1231
+
if err := r.ParseForm(); err != nil {
1232
+
l.Error("invalid form", "err", err)
1233
+
return
1234
+
}
1235
+
1236
errorId := "default-label-operation"
1237
fail := func(msg string, err error) {
1238
l.Error(msg, "err", err)
1239
rp.pages.Notice(w, errorId, msg)
1240
}
1241
1242
+
labelAts := r.Form["label"]
1243
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1244
if err != nil {
1245
fail("Failed to subscribe to label.", err)
1246
return
1247
}
1248
1249
newRepo := f.Repo
1250
+
newRepo.Labels = append(newRepo.Labels, labelAts...)
1251
+
1252
+
// dedup
1253
+
slices.Sort(newRepo.Labels)
1254
+
newRepo.Labels = slices.Compact(newRepo.Labels)
1255
+
1256
repoRecord := newRepo.AsRecord()
1257
1258
client, err := rp.oauth.AuthorizedClient(r)
···
1276
},
1277
})
1278
1279
+
tx, err := rp.db.Begin()
1280
if err != nil {
1281
+
fail("Failed to subscribe to label.", err)
1282
+
return
1283
+
}
1284
+
defer tx.Rollback()
1285
+
1286
+
for _, l := range labelAts {
1287
+
err = db.SubscribeLabel(tx, &models.RepoLabel{
1288
+
RepoAt: f.RepoAt(),
1289
+
LabelAt: syntax.ATURI(l),
1290
+
})
1291
+
if err != nil {
1292
+
fail("Failed to subscribe to label.", err)
1293
+
return
1294
+
}
1295
+
}
1296
+
1297
+
if err := tx.Commit(); err != nil {
1298
fail("Failed to subscribe to label.", err)
1299
return
1300
}
···
1315
return
1316
}
1317
1318
+
if err := r.ParseForm(); err != nil {
1319
+
l.Error("invalid form", "err", err)
1320
+
return
1321
+
}
1322
+
1323
errorId := "default-label-operation"
1324
fail := func(msg string, err error) {
1325
l.Error(msg, "err", err)
1326
rp.pages.Notice(w, errorId, msg)
1327
}
1328
1329
+
labelAts := r.Form["label"]
1330
+
_, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts))
1331
if err != nil {
1332
fail("Failed to unsubscribe to label.", err)
1333
return
···
1337
newRepo := f.Repo
1338
var updated []string
1339
for _, l := range newRepo.Labels {
1340
+
if !slices.Contains(labelAts, l) {
1341
updated = append(updated, l)
1342
}
1343
}
···
1368
err = db.UnsubscribeLabel(
1369
rp.db,
1370
db.FilterEq("repo_at", f.RepoAt()),
1371
+
db.FilterIn("label_at", labelAts),
1372
)
1373
if err != nil {
1374
fail("Failed to unsubscribe label.", err)
···
1405
return
1406
}
1407
1408
+
defs := make(map[string]*models.LabelDefinition)
1409
for _, l := range labelDefs {
1410
defs[l.AtUri().String()] = &l
1411
}
···
1453
return
1454
}
1455
1456
+
defs := make(map[string]*models.LabelDefinition)
1457
for _, l := range labelDefs {
1458
defs[l.AtUri().String()] = &l
1459
}
···
1576
return
1577
}
1578
1579
+
err = db.AddCollaborator(tx, models.Collaborator{
1580
Did: syntax.DID(currentUser.Did),
1581
Rkey: rkey,
1582
SubjectDid: collaboratorIdent.DID,
···
1904
return
1905
}
1906
1907
+
defaultLabels, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", models.DefaultLabelDefs()))
1908
if err != nil {
1909
log.Println("failed to fetch labels", err)
1910
rp.pages.Error503(w)
···
1936
subscribedLabels[l] = struct{}{}
1937
}
1938
1939
+
// if there is atleast 1 unsubbed default label, show the "subscribe all" button,
1940
+
// if all default labels are subbed, show the "unsubscribe all" button
1941
+
shouldSubscribeAll := false
1942
+
for _, dl := range defaultLabels {
1943
+
if _, ok := subscribedLabels[dl.AtUri().String()]; !ok {
1944
+
// one of the default labels is not subscribed to
1945
+
shouldSubscribeAll = true
1946
+
break
1947
+
}
1948
+
}
1949
+
1950
rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{
1951
+
LoggedInUser: user,
1952
+
RepoInfo: f.RepoInfo(user),
1953
+
Branches: result.Branches,
1954
+
Labels: labels,
1955
+
DefaultLabels: defaultLabels,
1956
+
SubscribedLabels: subscribedLabels,
1957
+
ShouldSubscribeAll: shouldSubscribeAll,
1958
+
Tabs: settingsTabs,
1959
+
Tab: "general",
1960
})
1961
}
1962
···
2129
}
2130
2131
// choose a name for a fork
2132
+
forkName := r.FormValue("repo_name")
2133
+
if forkName == "" {
2134
+
rp.pages.Notice(w, "repo", "Repository name cannot be empty.")
2135
+
return
2136
+
}
2137
+
2138
// this check is *only* to see if the forked repo name already exists
2139
// in the user's account.
2140
existingRepo, err := db.GetRepo(
2141
rp.db,
2142
db.FilterEq("did", user.Did),
2143
+
db.FilterEq("name", forkName),
2144
)
2145
if err != nil {
2146
+
if !errors.Is(err, sql.ErrNoRows) {
2147
log.Println("error fetching existing repo from db", "err", err)
2148
rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
2149
return
2150
}
2151
} else if existingRepo != nil {
2152
+
// repo with this name already exists
2153
+
rp.pages.Notice(w, "repo", "A repository with this name already exists.")
2154
+
return
2155
}
2156
l = l.With("forkName", forkName)
2157
···
2167
2168
// create an atproto record for this fork
2169
rkey := tid.TID()
2170
+
repo := &models.Repo{
2171
Did: user.Did,
2172
Name: forkName,
2173
Knot: targetKnot,
2174
Rkey: rkey,
2175
Source: sourceAt,
2176
+
Description: f.Repo.Description,
2177
Created: time.Now(),
2178
+
Labels: models.DefaultLabelDefs(),
2179
}
2180
record := repo.AsRecord()
2181
+6
-5
appview/repo/repo_util.go
+6
-5
appview/repo/repo_util.go
···
9
"sort"
10
"strings"
11
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
14
-
"tangled.sh/tangled.sh/core/types"
15
16
"github.com/go-git/go-git/v5/plumbing/object"
17
)
···
143
d *db.DB,
144
repoInfo repoinfo.RepoInfo,
145
shas []string,
146
-
) (map[string]db.Pipeline, error) {
147
-
m := make(map[string]db.Pipeline)
148
149
if len(shas) == 0 {
150
return m, nil
···
9
"sort"
10
"strings"
11
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages/repoinfo"
15
+
"tangled.org/core/types"
16
17
"github.com/go-git/go-git/v5/plumbing/object"
18
)
···
144
d *db.DB,
145
repoInfo repoinfo.RepoInfo,
146
shas []string,
147
+
) (map[string]models.Pipeline, error) {
148
+
m := make(map[string]models.Pipeline)
149
150
if len(shas) == 0 {
151
return m, nil
+3
-4
appview/repo/router.go
+3
-4
appview/repo/router.go
···
4
"net/http"
5
6
"github.com/go-chi/chi/v5"
7
-
"tangled.sh/tangled.sh/core/appview/middleware"
8
)
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
···
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
24
-
r.Use(middleware.AuthMiddleware(rp.oauth))
25
-
// require auth to download for now
26
r.Get("/download/{file}", rp.DownloadArtifact)
27
28
// require repo:push to upload or delete artifacts
···
30
// additionally: only the uploader can truly delete an artifact
31
// (record+blob will live on their pds)
32
r.Group(func(r chi.Router) {
33
-
r.With(mw.RepoPermissionMiddleware("repo:push"))
34
r.Post("/upload", rp.AttachArtifact)
35
r.Delete("/{file}", rp.DeleteArtifact)
36
})
···
4
"net/http"
5
6
"github.com/go-chi/chi/v5"
7
+
"tangled.org/core/appview/middleware"
8
)
9
10
func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
···
21
r.Route("/tags", func(r chi.Router) {
22
r.Get("/", rp.RepoTags)
23
r.Route("/{tag}", func(r chi.Router) {
24
r.Get("/download/{file}", rp.DownloadArtifact)
25
26
// require repo:push to upload or delete artifacts
···
28
// additionally: only the uploader can truly delete an artifact
29
// (record+blob will live on their pds)
30
r.Group(func(r chi.Router) {
31
+
r.Use(middleware.AuthMiddleware(rp.oauth))
32
+
r.Use(mw.RepoPermissionMiddleware("repo:push"))
33
r.Post("/upload", rp.AttachArtifact)
34
r.Delete("/{file}", rp.DeleteArtifact)
35
})
+13
-12
appview/reporesolver/resolver.go
+13
-12
appview/reporesolver/resolver.go
···
14
"github.com/bluesky-social/indigo/atproto/identity"
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/go-chi/chi/v5"
17
-
"tangled.sh/tangled.sh/core/appview/config"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
22
-
"tangled.sh/tangled.sh/core/idresolver"
23
-
"tangled.sh/tangled.sh/core/rbac"
24
)
25
26
type ResolvedRepo struct {
27
-
db.Repo
28
OwnerId identity.Identity
29
CurrentDir string
30
Ref string
···
44
}
45
46
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
47
-
repo, ok := r.Context().Value("repo").(*db.Repo)
48
if !ok {
49
log.Println("malformed middleware: `repo` not exist in context")
50
return nil, fmt.Errorf("malformed middleware")
···
162
log.Println("failed to get repo source for ", repoAt, err)
163
}
164
165
-
var sourceRepo *db.Repo
166
if source != "" {
167
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
168
if err != nil {
···
191
Knot: knot,
192
Spindle: f.Spindle,
193
Roles: f.RolesInRepo(user),
194
-
Stats: db.RepoStats{
195
StarCount: starCount,
196
IssueCount: issueCount,
197
PullCount: pullCount,
···
211
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
212
if u != nil {
213
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
214
-
return repoinfo.RolesInRepo{r}
215
} else {
216
return repoinfo.RolesInRepo{}
217
}
···
14
"github.com/bluesky-social/indigo/atproto/identity"
15
securejoin "github.com/cyphar/filepath-securejoin"
16
"github.com/go-chi/chi/v5"
17
+
"tangled.org/core/appview/config"
18
+
"tangled.org/core/appview/db"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/pages/repoinfo"
23
+
"tangled.org/core/idresolver"
24
+
"tangled.org/core/rbac"
25
)
26
27
type ResolvedRepo struct {
28
+
models.Repo
29
OwnerId identity.Identity
30
CurrentDir string
31
Ref string
···
45
}
46
47
func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) {
48
+
repo, ok := r.Context().Value("repo").(*models.Repo)
49
if !ok {
50
log.Println("malformed middleware: `repo` not exist in context")
51
return nil, fmt.Errorf("malformed middleware")
···
163
log.Println("failed to get repo source for ", repoAt, err)
164
}
165
166
+
var sourceRepo *models.Repo
167
if source != "" {
168
sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source)
169
if err != nil {
···
192
Knot: knot,
193
Spindle: f.Spindle,
194
Roles: f.RolesInRepo(user),
195
+
Stats: models.RepoStats{
196
StarCount: starCount,
197
IssueCount: issueCount,
198
PullCount: pullCount,
···
212
func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo {
213
if u != nil {
214
r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
215
+
return repoinfo.RolesInRepo{Roles: r}
216
} else {
217
return repoinfo.RolesInRepo{}
218
}
+4
-4
appview/serververify/verify.go
+4
-4
appview/serververify/verify.go
+62
-10
appview/settings/settings.go
+62
-10
appview/settings/settings.go
···
11
"time"
12
13
"github.com/go-chi/chi/v5"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/appview/config"
16
-
"tangled.sh/tangled.sh/core/appview/db"
17
-
"tangled.sh/tangled.sh/core/appview/email"
18
-
"tangled.sh/tangled.sh/core/appview/middleware"
19
-
"tangled.sh/tangled.sh/core/appview/oauth"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
"tangled.sh/tangled.sh/core/tid"
22
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
lexutil "github.com/bluesky-social/indigo/lex/util"
···
40
{"Name": "profile", "Icon": "user"},
41
{"Name": "keys", "Icon": "key"},
42
{"Name": "emails", "Icon": "mail"},
43
}
44
)
45
···
67
r.Post("/primary", s.emailsPrimary)
68
})
69
70
return r
71
}
72
···
80
})
81
}
82
83
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
84
user := s.OAuth.GetUser(r)
85
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
···
185
}
186
defer tx.Rollback()
187
188
-
if err := db.AddEmail(tx, db.Email{
189
Did: did,
190
Address: emAddr,
191
Verified: false,
···
246
if s.Config.Core.Dev {
247
appUrl = "http://" + s.Config.Core.ListenAddr
248
} else {
249
-
appUrl = "https://tangled.sh"
250
}
251
252
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
···
11
"time"
12
13
"github.com/go-chi/chi/v5"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/appview/config"
16
+
"tangled.org/core/appview/db"
17
+
"tangled.org/core/appview/email"
18
+
"tangled.org/core/appview/middleware"
19
+
"tangled.org/core/appview/models"
20
+
"tangled.org/core/appview/oauth"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/tid"
23
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
lexutil "github.com/bluesky-social/indigo/lex/util"
···
41
{"Name": "profile", "Icon": "user"},
42
{"Name": "keys", "Icon": "key"},
43
{"Name": "emails", "Icon": "mail"},
44
+
{"Name": "notifications", "Icon": "bell"},
45
}
46
)
47
···
69
r.Post("/primary", s.emailsPrimary)
70
})
71
72
+
r.Route("/notifications", func(r chi.Router) {
73
+
r.Get("/", s.notificationsSettings)
74
+
r.Put("/", s.updateNotificationPreferences)
75
+
})
76
+
77
return r
78
}
79
···
87
})
88
}
89
90
+
func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
91
+
user := s.OAuth.GetUser(r)
92
+
did := s.OAuth.GetDid(r)
93
+
94
+
prefs, err := s.Db.GetNotificationPreferences(r.Context(), did)
95
+
if err != nil {
96
+
log.Printf("failed to get notification preferences: %s", err)
97
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
98
+
return
99
+
}
100
+
101
+
s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
102
+
LoggedInUser: user,
103
+
Preferences: prefs,
104
+
Tabs: settingsTabs,
105
+
Tab: "notifications",
106
+
})
107
+
}
108
+
109
+
func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
110
+
did := s.OAuth.GetDid(r)
111
+
112
+
prefs := &models.NotificationPreferences{
113
+
UserDid: did,
114
+
RepoStarred: r.FormValue("repo_starred") == "on",
115
+
IssueCreated: r.FormValue("issue_created") == "on",
116
+
IssueCommented: r.FormValue("issue_commented") == "on",
117
+
IssueClosed: r.FormValue("issue_closed") == "on",
118
+
PullCreated: r.FormValue("pull_created") == "on",
119
+
PullCommented: r.FormValue("pull_commented") == "on",
120
+
PullMerged: r.FormValue("pull_merged") == "on",
121
+
Followed: r.FormValue("followed") == "on",
122
+
EmailNotifications: r.FormValue("email_notifications") == "on",
123
+
}
124
+
125
+
err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
126
+
if err != nil {
127
+
log.Printf("failed to update notification preferences: %s", err)
128
+
s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
129
+
return
130
+
}
131
+
132
+
s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
133
+
}
134
+
135
func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
136
user := s.OAuth.GetUser(r)
137
pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
···
237
}
238
defer tx.Rollback()
239
240
+
if err := db.AddEmail(tx, models.Email{
241
Did: did,
242
Address: emAddr,
243
Verified: false,
···
298
if s.Config.Core.Dev {
299
appUrl = "http://" + s.Config.Core.ListenAddr
300
} else {
301
+
appUrl = s.Config.Core.AppviewHost
302
}
303
304
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+76
-11
appview/signup/signup.go
+76
-11
appview/signup/signup.go
···
2
3
import (
4
"bufio"
5
"fmt"
6
"log/slog"
7
"net/http"
8
"os"
9
"strings"
10
11
"github.com/go-chi/chi/v5"
12
"github.com/posthog/posthog-go"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/dns"
16
-
"tangled.sh/tangled.sh/core/appview/email"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
)
22
23
type Signup struct {
···
115
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
116
switch r.Method {
117
case http.MethodGet:
118
-
s.pages.Signup(w)
119
case http.MethodPost:
120
if s.cf == nil {
121
http.Error(w, "signup is disabled", http.StatusFailedDependency)
122
}
123
emailId := r.FormValue("email")
124
125
noticeId := "signup-msg"
126
if !email.IsValidEmail(emailId) {
127
s.pages.Notice(w, noticeId, "Invalid email address.")
128
return
···
163
s.pages.Notice(w, noticeId, "Failed to send email.")
164
return
165
}
166
-
err = db.AddInflightSignup(s.db, db.InflightSignup{
167
Email: emailId,
168
InviteCode: code,
169
})
···
229
return
230
}
231
232
-
err = db.AddEmail(s.db, db.Email{
233
Did: did,
234
Address: email,
235
Verified: true,
···
254
return
255
}
256
}
···
2
3
import (
4
"bufio"
5
+
"encoding/json"
6
+
"errors"
7
"fmt"
8
"log/slog"
9
"net/http"
10
+
"net/url"
11
"os"
12
"strings"
13
14
"github.com/go-chi/chi/v5"
15
"github.com/posthog/posthog-go"
16
+
"tangled.org/core/appview/config"
17
+
"tangled.org/core/appview/db"
18
+
"tangled.org/core/appview/dns"
19
+
"tangled.org/core/appview/email"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
22
+
"tangled.org/core/appview/state/userutil"
23
+
"tangled.org/core/appview/xrpcclient"
24
+
"tangled.org/core/idresolver"
25
)
26
27
type Signup struct {
···
119
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
120
switch r.Method {
121
case http.MethodGet:
122
+
s.pages.Signup(w, pages.SignupParams{
123
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
124
+
})
125
case http.MethodPost:
126
if s.cf == nil {
127
http.Error(w, "signup is disabled", http.StatusFailedDependency)
128
+
return
129
}
130
emailId := r.FormValue("email")
131
+
cfToken := r.FormValue("cf-turnstile-response")
132
133
noticeId := "signup-msg"
134
+
135
+
if err := s.validateCaptcha(cfToken, r); err != nil {
136
+
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
137
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
+
return
139
+
}
140
+
141
if !email.IsValidEmail(emailId) {
142
s.pages.Notice(w, noticeId, "Invalid email address.")
143
return
···
178
s.pages.Notice(w, noticeId, "Failed to send email.")
179
return
180
}
181
+
err = db.AddInflightSignup(s.db, models.InflightSignup{
182
Email: emailId,
183
InviteCode: code,
184
})
···
244
return
245
}
246
247
+
err = db.AddEmail(s.db, models.Email{
248
Did: did,
249
Address: email,
250
Verified: true,
···
269
return
270
}
271
}
272
+
273
+
type turnstileResponse struct {
274
+
Success bool `json:"success"`
275
+
ErrorCodes []string `json:"error-codes,omitempty"`
276
+
ChallengeTs string `json:"challenge_ts,omitempty"`
277
+
Hostname string `json:"hostname,omitempty"`
278
+
}
279
+
280
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
281
+
if cfToken == "" {
282
+
return errors.New("captcha token is empty")
283
+
}
284
+
285
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
286
+
return errors.New("turnstile secret key not configured")
287
+
}
288
+
289
+
data := url.Values{}
290
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
291
+
data.Set("response", cfToken)
292
+
293
+
// include the client IP if we have it
294
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
295
+
data.Set("remoteip", remoteIP)
296
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
297
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
298
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
299
+
}
300
+
} else {
301
+
data.Set("remoteip", r.RemoteAddr)
302
+
}
303
+
304
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
305
+
if err != nil {
306
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
307
+
}
308
+
defer resp.Body.Close()
309
+
310
+
var turnstileResp turnstileResponse
311
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
312
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
313
+
}
314
+
315
+
if !turnstileResp.Success {
316
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
317
+
return errors.New("turnstile validation failed")
318
+
}
319
+
320
+
return nil
321
+
}
+15
-14
appview/spindles/spindles.go
+15
-14
appview/spindles/spindles.go
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
"tangled.sh/tangled.sh/core/appview/middleware"
16
-
"tangled.sh/tangled.sh/core/appview/oauth"
17
-
"tangled.sh/tangled.sh/core/appview/pages"
18
-
"tangled.sh/tangled.sh/core/appview/serververify"
19
-
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
-
"tangled.sh/tangled.sh/core/idresolver"
21
-
"tangled.sh/tangled.sh/core/rbac"
22
-
"tangled.sh/tangled.sh/core/tid"
23
24
comatproto "github.com/bluesky-social/indigo/api/atproto"
25
"github.com/bluesky-social/indigo/atproto/syntax"
···
115
}
116
117
// organize repos by did
118
-
repoMap := make(map[string][]db.Repo)
119
for _, r := range repos {
120
repoMap[r.Did] = append(repoMap[r.Did], r)
121
}
···
163
s.Enforcer.E.LoadPolicy()
164
}()
165
166
-
err = db.AddSpindle(tx, db.Spindle{
167
Owner: syntax.DID(user.Did),
168
Instance: instance,
169
})
···
524
rkey := tid.TID()
525
526
// add member to db
527
-
if err = db.AddSpindleMember(tx, db.SpindleMember{
528
Did: syntax.DID(user.Did),
529
Rkey: rkey,
530
Instance: instance,
···
9
"time"
10
11
"github.com/go-chi/chi/v5"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/middleware"
16
+
"tangled.org/core/appview/models"
17
+
"tangled.org/core/appview/oauth"
18
+
"tangled.org/core/appview/pages"
19
+
"tangled.org/core/appview/serververify"
20
+
"tangled.org/core/appview/xrpcclient"
21
+
"tangled.org/core/idresolver"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/tid"
24
25
comatproto "github.com/bluesky-social/indigo/api/atproto"
26
"github.com/bluesky-social/indigo/atproto/syntax"
···
116
}
117
118
// organize repos by did
119
+
repoMap := make(map[string][]models.Repo)
120
for _, r := range repos {
121
repoMap[r.Did] = append(repoMap[r.Did], r)
122
}
···
164
s.Enforcer.E.LoadPolicy()
165
}()
166
167
+
err = db.AddSpindle(tx, models.Spindle{
168
Owner: syntax.DID(user.Did),
169
Instance: instance,
170
})
···
525
rkey := tid.TID()
526
527
// add member to db
528
+
if err = db.AddSpindleMember(tx, models.SpindleMember{
529
Did: syntax.DID(user.Did),
530
Rkey: rkey,
531
Instance: instance,
+8
-7
appview/state/follow.go
+8
-7
appview/state/follow.go
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
-
"tangled.sh/tangled.sh/core/appview/pages"
13
-
"tangled.sh/tangled.sh/core/tid"
14
)
15
16
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
59
60
log.Println("created atproto record: ", resp.Uri)
61
62
-
follow := &db.Follow{
63
UserDid: currentUser.Did,
64
SubjectDid: subjectIdent.DID.String(),
65
Rkey: rkey,
···
75
76
s.pages.FollowFragment(w, pages.FollowFragmentParams{
77
UserDid: subjectIdent.DID.String(),
78
-
FollowStatus: db.IsFollowing,
79
})
80
81
return
···
106
107
s.pages.FollowFragment(w, pages.FollowFragmentParams{
108
UserDid: subjectIdent.DID.String(),
109
-
FollowStatus: db.IsNotFollowing,
110
})
111
112
s.notifier.DeleteFollow(r.Context(), follow)
···
7
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/appview/db"
12
+
"tangled.org/core/appview/models"
13
+
"tangled.org/core/appview/pages"
14
+
"tangled.org/core/tid"
15
)
16
17
func (s *State) Follow(w http.ResponseWriter, r *http.Request) {
···
60
61
log.Println("created atproto record: ", resp.Uri)
62
63
+
follow := &models.Follow{
64
UserDid: currentUser.Did,
65
SubjectDid: subjectIdent.DID.String(),
66
Rkey: rkey,
···
76
77
s.pages.FollowFragment(w, pages.FollowFragmentParams{
78
UserDid: subjectIdent.DID.String(),
79
+
FollowStatus: models.IsFollowing,
80
})
81
82
return
···
107
108
s.pages.FollowFragment(w, pages.FollowFragmentParams{
109
UserDid: subjectIdent.DID.String(),
110
+
FollowStatus: models.IsNotFollowing,
111
})
112
113
s.notifier.DeleteFollow(r.Context(), follow)
+151
appview/state/gfi.go
+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
9
"github.com/bluesky-social/indigo/atproto/identity"
10
"github.com/go-chi/chi/v5"
11
-
"tangled.sh/tangled.sh/core/appview/db"
12
)
13
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
-
repo := r.Context().Value("repo").(*db.Repo)
17
18
scheme := "https"
19
if s.config.Core.Dev {
···
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
return
33
}
34
-
repo := r.Context().Value("repo").(*db.Repo)
35
36
scheme := "https"
37
if s.config.Core.Dev {
···
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
return
50
}
51
-
repo := r.Context().Value("repo").(*db.Repo)
52
53
scheme := "https"
54
if s.config.Core.Dev {
···
8
9
"github.com/bluesky-social/indigo/atproto/identity"
10
"github.com/go-chi/chi/v5"
11
+
"tangled.org/core/appview/models"
12
)
13
14
func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) {
15
user := r.Context().Value("resolvedId").(identity.Identity)
16
+
repo := r.Context().Value("repo").(*models.Repo)
17
18
scheme := "https"
19
if s.config.Core.Dev {
···
31
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
return
33
}
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
36
scheme := "https"
37
if s.config.Core.Dev {
···
48
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
49
return
50
}
51
+
repo := r.Context().Value("repo").(*models.Repo)
52
53
scheme := "https"
54
if s.config.Core.Dev {
+29
-15
appview/state/knotstream.go
+29
-15
appview/state/knotstream.go
···
8
"slices"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/cache"
13
-
"tangled.sh/tangled.sh/core/appview/config"
14
-
"tangled.sh/tangled.sh/core/appview/db"
15
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
16
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/rbac"
19
-
"tangled.sh/tangled.sh/core/workflow"
20
21
"github.com/bluesky-social/indigo/atproto/syntax"
22
"github.com/go-git/go-git/v5/plumbing"
···
124
}
125
}
126
127
-
punch := db.Punch{
128
Did: record.CommitterDid,
129
Date: time.Now(),
130
Count: count,
···
156
return fmt.Errorf("%s is not a valid reference name", ref)
157
}
158
159
-
var langs []db.RepoLanguage
160
for _, l := range record.Meta.LangBreakdown.Inputs {
161
if l == nil {
162
continue
163
}
164
165
-
langs = append(langs, db.RepoLanguage{
166
RepoAt: repo.RepoAt(),
167
Ref: ref.Short(),
168
IsDefaultRef: record.Meta.IsDefaultRef,
···
171
})
172
}
173
174
-
return db.InsertRepoLanguages(d, langs)
175
}
176
177
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
207
}
208
209
// trigger info
210
-
var trigger db.Trigger
211
var sha string
212
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
213
switch trigger.Kind {
···
234
return fmt.Errorf("failed to add trigger entry: %w", err)
235
}
236
237
-
pipeline := db.Pipeline{
238
Rkey: msg.Rkey,
239
Knot: source.Key(),
240
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
···
8
"slices"
9
"time"
10
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/cache"
13
+
"tangled.org/core/appview/config"
14
+
"tangled.org/core/appview/db"
15
+
"tangled.org/core/appview/models"
16
+
ec "tangled.org/core/eventconsumer"
17
+
"tangled.org/core/eventconsumer/cursor"
18
+
"tangled.org/core/log"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/workflow"
21
22
"github.com/bluesky-social/indigo/atproto/syntax"
23
"github.com/go-git/go-git/v5/plumbing"
···
125
}
126
}
127
128
+
punch := models.Punch{
129
Did: record.CommitterDid,
130
Date: time.Now(),
131
Count: count,
···
157
return fmt.Errorf("%s is not a valid reference name", ref)
158
}
159
160
+
var langs []models.RepoLanguage
161
for _, l := range record.Meta.LangBreakdown.Inputs {
162
if l == nil {
163
continue
164
}
165
166
+
langs = append(langs, models.RepoLanguage{
167
RepoAt: repo.RepoAt(),
168
Ref: ref.Short(),
169
IsDefaultRef: record.Meta.IsDefaultRef,
···
172
})
173
}
174
175
+
tx, err := d.Begin()
176
+
if err != nil {
177
+
return err
178
+
}
179
+
defer tx.Rollback()
180
+
181
+
// update appview's cache
182
+
err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs)
183
+
if err != nil {
184
+
fmt.Printf("failed; %s\n", err)
185
+
// non-fatal
186
+
}
187
+
188
+
return tx.Commit()
189
}
190
191
func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
···
221
}
222
223
// trigger info
224
+
var trigger models.Trigger
225
var sha string
226
trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind)
227
switch trigger.Kind {
···
248
return fmt.Errorf("failed to add trigger entry: %w", err)
249
}
250
251
+
pipeline := models.Pipeline{
252
Rkey: msg.Rkey,
253
Knot: source.Key(),
254
RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
+30
-37
appview/state/profile.go
+30
-37
appview/state/profile.go
···
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
"github.com/gorilla/feeds"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/appview/db"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
)
22
23
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
76
}
77
78
loggedInUser := s.oauth.GetUser(r)
79
-
followStatus := db.IsNotFollowing
80
if loggedInUser != nil {
81
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
82
}
···
130
}
131
132
// filter out ones that are pinned
133
-
pinnedRepos := []db.Repo{}
134
for i, r := range repos {
135
// if this is a pinned repo, add it
136
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
148
l.Error("failed to fetch collaborating repos", "err", err)
149
}
150
151
-
pinnedCollaboratingRepos := []db.Repo{}
152
for _, r := range collaboratingRepos {
153
// if this is a pinned repo, add it
154
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
216
s.pages.Error500(w)
217
return
218
}
219
-
var repoAts []string
220
for _, s := range stars {
221
-
repoAts = append(repoAts, string(s.RepoAt))
222
-
}
223
-
224
-
repos, err := db.GetRepos(
225
-
s.db,
226
-
0,
227
-
db.FilterIn("at_uri", repoAts),
228
-
)
229
-
if err != nil {
230
-
l.Error("failed to get repos", "err", err)
231
-
s.pages.Error500(w)
232
-
return
233
}
234
235
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
271
272
func (s *State) followPage(
273
r *http.Request,
274
-
fetchFollows func(db.Execer, string) ([]db.Follow, error),
275
-
extractDid func(db.Follow) string,
276
) (*FollowsPageParams, error) {
277
l := s.logger.With("handler", "reposPage")
278
···
329
followCards := make([]pages.FollowCard, len(follows))
330
for i, did := range followDids {
331
followStats := followStatsMap[did]
332
-
followStatus := db.IsNotFollowing
333
if _, exists := loggedInUserFollowing[did]; exists {
334
-
followStatus = db.IsFollowing
335
} else if loggedInUser != nil && loggedInUser.Did == did {
336
-
followStatus = db.IsSelf
337
}
338
339
-
var profile *db.Profile
340
if p, exists := profiles[did]; exists {
341
profile = p
342
} else {
343
-
profile = &db.Profile{}
344
profile.Did = did
345
}
346
followCards[i] = pages.FollowCard{
347
UserDid: did,
348
FollowStatus: followStatus,
349
FollowersCount: followStats.Followers,
···
358
}
359
360
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
361
-
followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid })
362
if err != nil {
363
s.pages.Notice(w, "all-followers", "Failed to load followers")
364
return
···
372
}
373
374
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
375
-
followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid })
376
if err != nil {
377
s.pages.Notice(w, "all-following", "Failed to load following")
378
return
···
453
return &feed, nil
454
}
455
456
-
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error {
457
for _, pull := range pulls {
458
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
459
if err != nil {
···
466
return nil
467
}
468
469
-
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error {
470
for _, issue := range issues {
471
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
472
if err != nil {
···
478
return nil
479
}
480
481
-
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error {
482
for _, repo := range repos {
483
item, err := s.createRepoItem(ctx, repo, author)
484
if err != nil {
···
489
return nil
490
}
491
492
-
func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
493
return &feeds.Item{
494
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
495
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
···
498
}
499
}
500
501
-
func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
502
return &feeds.Item{
503
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
504
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
···
507
}
508
}
509
510
-
func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
511
var title string
512
if repo.Source != nil {
513
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
···
558
stat1 := r.FormValue("stat1")
559
560
if stat0 != "" {
561
-
profile.Stats[0].Kind = db.VanityStatKind(stat0)
562
}
563
564
if stat1 != "" {
565
-
profile.Stats[1].Kind = db.VanityStatKind(stat1)
566
}
567
568
if err := db.ValidateProfile(s.db, profile); err != nil {
···
613
s.updateProfile(profile, w, r)
614
}
615
616
-
func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) {
617
user := s.oauth.GetUser(r)
618
tx, err := s.db.BeginTx(r.Context(), nil)
619
if err != nil {
···
15
lexutil "github.com/bluesky-social/indigo/lex/util"
16
"github.com/go-chi/chi/v5"
17
"github.com/gorilla/feeds"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/appview/db"
20
+
"tangled.org/core/appview/models"
21
+
"tangled.org/core/appview/pages"
22
)
23
24
func (s *State) Profile(w http.ResponseWriter, r *http.Request) {
···
77
}
78
79
loggedInUser := s.oauth.GetUser(r)
80
+
followStatus := models.IsNotFollowing
81
if loggedInUser != nil {
82
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did)
83
}
···
131
}
132
133
// filter out ones that are pinned
134
+
pinnedRepos := []models.Repo{}
135
for i, r := range repos {
136
// if this is a pinned repo, add it
137
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
149
l.Error("failed to fetch collaborating repos", "err", err)
150
}
151
152
+
pinnedCollaboratingRepos := []models.Repo{}
153
for _, r := range collaboratingRepos {
154
// if this is a pinned repo, add it
155
if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) {
···
217
s.pages.Error500(w)
218
return
219
}
220
+
var repos []models.Repo
221
for _, s := range stars {
222
+
if s.Repo != nil {
223
+
repos = append(repos, *s.Repo)
224
+
}
225
}
226
227
err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{
···
263
264
func (s *State) followPage(
265
r *http.Request,
266
+
fetchFollows func(db.Execer, string) ([]models.Follow, error),
267
+
extractDid func(models.Follow) string,
268
) (*FollowsPageParams, error) {
269
l := s.logger.With("handler", "reposPage")
270
···
321
followCards := make([]pages.FollowCard, len(follows))
322
for i, did := range followDids {
323
followStats := followStatsMap[did]
324
+
followStatus := models.IsNotFollowing
325
if _, exists := loggedInUserFollowing[did]; exists {
326
+
followStatus = models.IsFollowing
327
} else if loggedInUser != nil && loggedInUser.Did == did {
328
+
followStatus = models.IsSelf
329
}
330
331
+
var profile *models.Profile
332
if p, exists := profiles[did]; exists {
333
profile = p
334
} else {
335
+
profile = &models.Profile{}
336
profile.Did = did
337
}
338
followCards[i] = pages.FollowCard{
339
+
LoggedInUser: loggedInUser,
340
UserDid: did,
341
FollowStatus: followStatus,
342
FollowersCount: followStats.Followers,
···
351
}
352
353
func (s *State) followersPage(w http.ResponseWriter, r *http.Request) {
354
+
followPage, err := s.followPage(r, db.GetFollowers, func(f models.Follow) string { return f.UserDid })
355
if err != nil {
356
s.pages.Notice(w, "all-followers", "Failed to load followers")
357
return
···
365
}
366
367
func (s *State) followingPage(w http.ResponseWriter, r *http.Request) {
368
+
followPage, err := s.followPage(r, db.GetFollowing, func(f models.Follow) string { return f.SubjectDid })
369
if err != nil {
370
s.pages.Notice(w, "all-following", "Failed to load following")
371
return
···
446
return &feed, nil
447
}
448
449
+
func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*models.Pull, author *feeds.Author) error {
450
for _, pull := range pulls {
451
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
452
if err != nil {
···
459
return nil
460
}
461
462
+
func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*models.Issue, author *feeds.Author) error {
463
for _, issue := range issues {
464
owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did)
465
if err != nil {
···
471
return nil
472
}
473
474
+
func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []models.RepoEvent, author *feeds.Author) error {
475
for _, repo := range repos {
476
item, err := s.createRepoItem(ctx, repo, author)
477
if err != nil {
···
482
return nil
483
}
484
485
+
func (s *State) createPullRequestItem(pull *models.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item {
486
return &feeds.Item{
487
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
488
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
···
491
}
492
}
493
494
+
func (s *State) createIssueItem(issue *models.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item {
495
return &feeds.Item{
496
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name),
497
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
···
500
}
501
}
502
503
+
func (s *State) createRepoItem(ctx context.Context, repo models.RepoEvent, author *feeds.Author) (*feeds.Item, error) {
504
var title string
505
if repo.Source != nil {
506
sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
···
551
stat1 := r.FormValue("stat1")
552
553
if stat0 != "" {
554
+
profile.Stats[0].Kind = models.VanityStatKind(stat0)
555
}
556
557
if stat1 != "" {
558
+
profile.Stats[1].Kind = models.VanityStatKind(stat1)
559
}
560
561
if err := db.ValidateProfile(s.db, profile); err != nil {
···
606
s.updateProfile(profile, w, r)
607
}
608
609
+
func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) {
610
user := s.oauth.GetUser(r)
611
tx, err := s.db.BeginTx(r.Context(), nil)
612
if err != nil {
+6
-5
appview/state/reaction.go
+6
-5
appview/state/reaction.go
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/db"
14
-
"tangled.sh/tangled.sh/core/appview/pages"
15
-
"tangled.sh/tangled.sh/core/tid"
16
)
17
18
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
30
return
31
}
32
33
-
reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind"))
34
if !ok {
35
log.Println("invalid reaction kind")
36
return
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
11
lexutil "github.com/bluesky-social/indigo/lex/util"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/db"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/pages"
16
+
"tangled.org/core/tid"
17
)
18
19
func (s *State) React(w http.ResponseWriter, r *http.Request) {
···
31
return
32
}
33
34
+
reactionKind, ok := models.ParseReactionKind(r.URL.Query().Get("kind"))
35
if !ok {
36
log.Println("invalid reaction kind")
37
return
+46
-16
appview/state/router.go
+46
-16
appview/state/router.go
···
6
7
"github.com/go-chi/chi/v5"
8
"github.com/gorilla/sessions"
9
-
"tangled.sh/tangled.sh/core/appview/issues"
10
-
"tangled.sh/tangled.sh/core/appview/knots"
11
-
"tangled.sh/tangled.sh/core/appview/labels"
12
-
"tangled.sh/tangled.sh/core/appview/middleware"
13
-
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
14
-
"tangled.sh/tangled.sh/core/appview/pipelines"
15
-
"tangled.sh/tangled.sh/core/appview/pulls"
16
-
"tangled.sh/tangled.sh/core/appview/repo"
17
-
"tangled.sh/tangled.sh/core/appview/settings"
18
-
"tangled.sh/tangled.sh/core/appview/signup"
19
-
"tangled.sh/tangled.sh/core/appview/spindles"
20
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
21
-
avstrings "tangled.sh/tangled.sh/core/appview/strings"
22
-
"tangled.sh/tangled.sh/core/log"
23
)
24
25
func (s *State) Router() http.Handler {
···
33
s.pages,
34
)
35
36
router.Get("/favicon.svg", s.Favicon)
37
router.Get("/favicon.ico", s.Favicon)
38
39
userRouter := s.UserRouter(&middleware)
40
standardRouter := s.StandardRouter(&middleware)
···
115
116
r.Get("/", s.HomeOrTimeline)
117
r.Get("/timeline", s.Timeline)
118
-
r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner)
119
120
r.Route("/repo", func(r chi.Router) {
121
r.Route("/new", func(r chi.Router) {
···
126
// r.Post("/import", s.ImportRepo)
127
})
128
129
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
130
r.Post("/", s.Follow)
131
r.Delete("/", s.Follow)
···
153
r.Mount("/strings", s.StringsRouter(mw))
154
r.Mount("/knots", s.KnotsRouter())
155
r.Mount("/spindles", s.SpindlesRouter())
156
r.Mount("/signup", s.SignupRouter())
157
r.Mount("/", s.OAuthRouter())
158
159
r.Get("/keys/{user}", s.Keys)
160
r.Get("/terms", s.TermsOfService)
161
r.Get("/privacy", s.PrivacyPolicy)
162
163
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
164
s.pages.Error404(w)
165
})
166
return r
167
}
168
169
func (s *State) OAuthRouter() http.Handler {
···
253
}
254
255
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
256
-
ls := labels.New(s.oauth, s.pages, s.db, s.validator)
257
return ls.Router(mw)
258
}
259
260
func (s *State) SignupRouter() http.Handler {
···
6
7
"github.com/go-chi/chi/v5"
8
"github.com/gorilla/sessions"
9
+
"tangled.org/core/appview/issues"
10
+
"tangled.org/core/appview/knots"
11
+
"tangled.org/core/appview/labels"
12
+
"tangled.org/core/appview/middleware"
13
+
"tangled.org/core/appview/notifications"
14
+
oauthhandler "tangled.org/core/appview/oauth/handler"
15
+
"tangled.org/core/appview/pipelines"
16
+
"tangled.org/core/appview/pulls"
17
+
"tangled.org/core/appview/repo"
18
+
"tangled.org/core/appview/settings"
19
+
"tangled.org/core/appview/signup"
20
+
"tangled.org/core/appview/spindles"
21
+
"tangled.org/core/appview/state/userutil"
22
+
avstrings "tangled.org/core/appview/strings"
23
+
"tangled.org/core/log"
24
)
25
26
func (s *State) Router() http.Handler {
···
34
s.pages,
35
)
36
37
+
router.Use(middleware.TryRefreshSession())
38
router.Get("/favicon.svg", s.Favicon)
39
router.Get("/favicon.ico", s.Favicon)
40
+
router.Get("/pwa-manifest.json", s.PWAManifest)
41
42
userRouter := s.UserRouter(&middleware)
43
standardRouter := s.StandardRouter(&middleware)
···
118
119
r.Get("/", s.HomeOrTimeline)
120
r.Get("/timeline", s.Timeline)
121
+
r.Get("/upgradeBanner", s.UpgradeBanner)
122
+
123
+
// special-case handler for serving tangled.org/core
124
+
r.Get("/core", s.Core())
125
126
r.Route("/repo", func(r chi.Router) {
127
r.Route("/new", func(r chi.Router) {
···
132
// r.Post("/import", s.ImportRepo)
133
})
134
135
+
r.Get("/goodfirstissues", s.GoodFirstIssues)
136
+
137
r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
138
r.Post("/", s.Follow)
139
r.Delete("/", s.Follow)
···
161
r.Mount("/strings", s.StringsRouter(mw))
162
r.Mount("/knots", s.KnotsRouter())
163
r.Mount("/spindles", s.SpindlesRouter())
164
+
r.Mount("/notifications", s.NotificationsRouter(mw))
165
+
166
r.Mount("/signup", s.SignupRouter())
167
r.Mount("/", s.OAuthRouter())
168
169
r.Get("/keys/{user}", s.Keys)
170
r.Get("/terms", s.TermsOfService)
171
r.Get("/privacy", s.PrivacyPolicy)
172
+
r.Get("/brand", s.Brand)
173
174
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
175
s.pages.Error404(w)
176
})
177
return r
178
+
}
179
+
180
+
// Core serves tangled.org/core go-import meta tags, and redirects
181
+
// to the core repository if accessed normally.
182
+
func (s *State) Core() http.HandlerFunc {
183
+
return func(w http.ResponseWriter, r *http.Request) {
184
+
if r.URL.Query().Get("go-get") == "1" {
185
+
w.Header().Set("Content-Type", "text/html")
186
+
w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
187
+
return
188
+
}
189
+
190
+
http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
191
+
}
192
}
193
194
func (s *State) OAuthRouter() http.Handler {
···
278
}
279
280
func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler {
281
+
ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer)
282
return ls.Router(mw)
283
+
}
284
+
285
+
func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
286
+
notifs := notifications.New(s.db, s.oauth, s.pages)
287
+
return notifs.Router(mw)
288
}
289
290
func (s *State) SignupRouter() http.Handler {
+11
-10
appview/state/spindlestream.go
+11
-10
appview/state/spindlestream.go
···
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/appview/cache"
14
-
"tangled.sh/tangled.sh/core/appview/config"
15
-
"tangled.sh/tangled.sh/core/appview/db"
16
-
ec "tangled.sh/tangled.sh/core/eventconsumer"
17
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
18
-
"tangled.sh/tangled.sh/core/log"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
spindle "tangled.sh/tangled.sh/core/spindle/models"
21
)
22
23
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
89
created = t
90
}
91
92
-
status := db.PipelineStatus{
93
Spindle: source.Key(),
94
Rkey: msg.Rkey,
95
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
···
9
"time"
10
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/appview/cache"
14
+
"tangled.org/core/appview/config"
15
+
"tangled.org/core/appview/db"
16
+
"tangled.org/core/appview/models"
17
+
ec "tangled.org/core/eventconsumer"
18
+
"tangled.org/core/eventconsumer/cursor"
19
+
"tangled.org/core/log"
20
+
"tangled.org/core/rbac"
21
+
spindle "tangled.org/core/spindle/models"
22
)
23
24
func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) {
···
90
created = t
91
}
92
93
+
status := models.PipelineStatus{
94
Spindle: source.Key(),
95
Rkey: msg.Rkey,
96
PipelineKnot: strings.TrimPrefix(pipelineUri.Authority().String(), "did:web:"),
+8
-7
appview/state/star.go
+8
-7
appview/state/star.go
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/pages"
14
-
"tangled.sh/tangled.sh/core/tid"
15
)
16
17
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
55
}
56
log.Println("created atproto record: ", resp.Uri)
57
58
-
star := &db.Star{
59
StarredByDid: currentUser.Did,
60
RepoAt: subjectUri,
61
Rkey: rkey,
···
77
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
78
IsStarred: true,
79
RepoAt: subjectUri,
80
-
Stats: db.RepoStats{
81
StarCount: starCount,
82
},
83
})
···
119
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
120
IsStarred: false,
121
RepoAt: subjectUri,
122
-
Stats: db.RepoStats{
123
StarCount: starCount,
124
},
125
})
···
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/models"
14
+
"tangled.org/core/appview/pages"
15
+
"tangled.org/core/tid"
16
)
17
18
func (s *State) Star(w http.ResponseWriter, r *http.Request) {
···
56
}
57
log.Println("created atproto record: ", resp.Uri)
58
59
+
star := &models.Star{
60
StarredByDid: currentUser.Did,
61
RepoAt: subjectUri,
62
Rkey: rkey,
···
78
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
79
IsStarred: true,
80
RepoAt: subjectUri,
81
+
Stats: models.RepoStats{
82
StarCount: starCount,
83
},
84
})
···
120
s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{
121
IsStarred: false,
122
RepoAt: subjectUri,
123
+
Stats: models.RepoStats{
124
StarCount: starCount,
125
},
126
})
+105
-26
appview/state/state.go
+105
-26
appview/state/state.go
···
17
securejoin "github.com/cyphar/filepath-securejoin"
18
"github.com/go-chi/chi/v5"
19
"github.com/posthog/posthog-go"
20
-
"tangled.sh/tangled.sh/core/api/tangled"
21
-
"tangled.sh/tangled.sh/core/appview"
22
-
"tangled.sh/tangled.sh/core/appview/cache"
23
-
"tangled.sh/tangled.sh/core/appview/cache/session"
24
-
"tangled.sh/tangled.sh/core/appview/config"
25
-
"tangled.sh/tangled.sh/core/appview/db"
26
-
"tangled.sh/tangled.sh/core/appview/notify"
27
-
"tangled.sh/tangled.sh/core/appview/oauth"
28
-
"tangled.sh/tangled.sh/core/appview/pages"
29
-
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
30
-
"tangled.sh/tangled.sh/core/appview/reporesolver"
31
-
"tangled.sh/tangled.sh/core/appview/validator"
32
-
xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"
33
-
"tangled.sh/tangled.sh/core/eventconsumer"
34
-
"tangled.sh/tangled.sh/core/idresolver"
35
-
"tangled.sh/tangled.sh/core/jetstream"
36
-
tlog "tangled.sh/tangled.sh/core/log"
37
-
"tangled.sh/tangled.sh/core/rbac"
38
-
"tangled.sh/tangled.sh/core/tid"
39
-
// xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
40
)
41
42
type State struct {
···
78
cache := cache.New(config.Redis.Addr)
79
sess := session.New(cache)
80
oauth := oauth.NewOAuth(config, sess)
81
-
validator := validator.New(d, res)
82
83
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
84
if err != nil {
···
87
88
repoResolver := reporesolver.New(config, enforcer, res, d)
89
90
-
wrapper := db.DbWrapper{d}
91
jc, err := jetstream.NewJetstreamClient(
92
config.Jetstream.Endpoint,
93
"appview",
···
103
tangled.RepoIssueNSID,
104
tangled.RepoIssueCommentNSID,
105
tangled.LabelDefinitionNSID,
106
},
107
nil,
108
slog.Default(),
···
115
)
116
if err != nil {
117
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
118
}
119
120
ingester := appview.Ingester{
···
143
spindlestream.Start(ctx)
144
145
var notifiers []notify.Notifier
146
if !config.Core.Dev {
147
-
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
148
}
149
notifier := notify.NewMergedNotifier(notifiers...)
150
···
187
s.pages.Favicon(w)
188
}
189
190
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
191
user := s.oauth.GetUser(r)
192
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
201
})
202
}
203
204
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
205
if s.oauth.GetUser(r) != nil {
206
s.Timeline(w, r)
···
229
return
230
}
231
232
-
s.pages.Timeline(w, pages.TimelineParams{
233
LoggedInUser: user,
234
Timeline: timeline,
235
Repos: repos,
236
-
})
237
}
238
239
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
240
user := s.oauth.GetUser(r)
241
l := s.logger.With("handler", "UpgradeBanner")
242
l = l.With("did", user.Did)
243
l = l.With("handle", user.Handle)
···
433
434
// create atproto record for this repo
435
rkey := tid.TID()
436
-
repo := &db.Repo{
437
Did: user.Did,
438
Name: repoName,
439
Knot: domain,
440
Rkey: rkey,
441
Description: description,
442
Created: time.Now(),
443
}
444
record := repo.AsRecord()
445
···
580
})
581
return err
582
}
···
17
securejoin "github.com/cyphar/filepath-securejoin"
18
"github.com/go-chi/chi/v5"
19
"github.com/posthog/posthog-go"
20
+
"tangled.org/core/api/tangled"
21
+
"tangled.org/core/appview"
22
+
"tangled.org/core/appview/cache"
23
+
"tangled.org/core/appview/cache/session"
24
+
"tangled.org/core/appview/config"
25
+
"tangled.org/core/appview/db"
26
+
"tangled.org/core/appview/models"
27
+
"tangled.org/core/appview/notify"
28
+
dbnotify "tangled.org/core/appview/notify/db"
29
+
phnotify "tangled.org/core/appview/notify/posthog"
30
+
"tangled.org/core/appview/oauth"
31
+
"tangled.org/core/appview/pages"
32
+
"tangled.org/core/appview/reporesolver"
33
+
"tangled.org/core/appview/validator"
34
+
xrpcclient "tangled.org/core/appview/xrpcclient"
35
+
"tangled.org/core/eventconsumer"
36
+
"tangled.org/core/idresolver"
37
+
"tangled.org/core/jetstream"
38
+
tlog "tangled.org/core/log"
39
+
"tangled.org/core/rbac"
40
+
"tangled.org/core/tid"
41
)
42
43
type State struct {
···
79
cache := cache.New(config.Redis.Addr)
80
sess := session.New(cache)
81
oauth := oauth.NewOAuth(config, sess)
82
+
validator := validator.New(d, res, enforcer)
83
84
posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
85
if err != nil {
···
88
89
repoResolver := reporesolver.New(config, enforcer, res, d)
90
91
+
wrapper := db.DbWrapper{Execer: d}
92
jc, err := jetstream.NewJetstreamClient(
93
config.Jetstream.Endpoint,
94
"appview",
···
104
tangled.RepoIssueNSID,
105
tangled.RepoIssueCommentNSID,
106
tangled.LabelDefinitionNSID,
107
+
tangled.LabelOpNSID,
108
},
109
nil,
110
slog.Default(),
···
117
)
118
if err != nil {
119
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
120
+
}
121
+
122
+
if err := BackfillDefaultDefs(d, res); err != nil {
123
+
return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
124
}
125
126
ingester := appview.Ingester{
···
149
spindlestream.Start(ctx)
150
151
var notifiers []notify.Notifier
152
+
153
+
// Always add the database notifier
154
+
notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
155
+
156
+
// Add other notifiers in production only
157
if !config.Core.Dev {
158
+
notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
159
}
160
notifier := notify.NewMergedNotifier(notifiers...)
161
···
198
s.pages.Favicon(w)
199
}
200
201
+
// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
202
+
const manifestJson = `{
203
+
"name": "tangled",
204
+
"description": "tightly-knit social coding.",
205
+
"icons": [
206
+
{
207
+
"src": "/favicon.svg",
208
+
"sizes": "144x144"
209
+
}
210
+
],
211
+
"start_url": "/",
212
+
"id": "org.tangled",
213
+
214
+
"display": "standalone",
215
+
"background_color": "#111827",
216
+
"theme_color": "#111827"
217
+
}`
218
+
219
+
func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
220
+
w.Header().Set("Content-Type", "application/json")
221
+
w.Write([]byte(manifestJson))
222
+
}
223
+
224
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
225
user := s.oauth.GetUser(r)
226
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
···
235
})
236
}
237
238
+
func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
239
+
user := s.oauth.GetUser(r)
240
+
s.pages.Brand(w, pages.BrandParams{
241
+
LoggedInUser: user,
242
+
})
243
+
}
244
+
245
func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
246
if s.oauth.GetUser(r) != nil {
247
s.Timeline(w, r)
···
270
return
271
}
272
273
+
gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue))
274
+
if err != nil {
275
+
// non-fatal
276
+
}
277
+
278
+
fmt.Println(s.pages.Timeline(w, pages.TimelineParams{
279
LoggedInUser: user,
280
Timeline: timeline,
281
Repos: repos,
282
+
GfiLabel: gfiLabel,
283
+
}))
284
}
285
286
func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
287
user := s.oauth.GetUser(r)
288
+
if user == nil {
289
+
return
290
+
}
291
+
292
l := s.logger.With("handler", "UpgradeBanner")
293
l = l.With("did", user.Did)
294
l = l.With("handle", user.Handle)
···
484
485
// create atproto record for this repo
486
rkey := tid.TID()
487
+
repo := &models.Repo{
488
Did: user.Did,
489
Name: repoName,
490
Knot: domain,
491
Rkey: rkey,
492
Description: description,
493
Created: time.Now(),
494
+
Labels: models.DefaultLabelDefs(),
495
}
496
record := repo.AsRecord()
497
···
632
})
633
return err
634
}
635
+
636
+
func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error {
637
+
defaults := models.DefaultLabelDefs()
638
+
defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults))
639
+
if err != nil {
640
+
return err
641
+
}
642
+
// already present
643
+
if len(defaultLabels) == len(defaults) {
644
+
return nil
645
+
}
646
+
647
+
labelDefs, err := models.FetchDefaultDefs(r)
648
+
if err != nil {
649
+
return err
650
+
}
651
+
652
+
// Insert each label definition to the database
653
+
for _, labelDef := range labelDefs {
654
+
_, err = db.AddLabelDefinition(e, &labelDef)
655
+
if err != nil {
656
+
return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
657
+
}
658
+
}
659
+
660
+
return nil
661
+
}
+12
-11
appview/strings/strings.go
+12
-11
appview/strings/strings.go
···
8
"strconv"
9
"time"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
-
"tangled.sh/tangled.sh/core/appview/middleware"
14
-
"tangled.sh/tangled.sh/core/appview/notify"
15
-
"tangled.sh/tangled.sh/core/appview/oauth"
16
-
"tangled.sh/tangled.sh/core/appview/pages"
17
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
18
-
"tangled.sh/tangled.sh/core/idresolver"
19
-
"tangled.sh/tangled.sh/core/tid"
20
21
"github.com/bluesky-social/indigo/api/atproto"
22
"github.com/bluesky-social/indigo/atproto/identity"
···
235
description := r.FormValue("description")
236
237
// construct new string from form values
238
-
entry := db.String{
239
Did: first.Did,
240
Rkey: first.Rkey,
241
Filename: filename,
···
318
319
description := r.FormValue("description")
320
321
-
string := db.String{
322
Did: syntax.DID(user.Did),
323
Rkey: tid.TID(),
324
Filename: filename,
···
8
"strconv"
9
"time"
10
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/db"
13
+
"tangled.org/core/appview/middleware"
14
+
"tangled.org/core/appview/models"
15
+
"tangled.org/core/appview/notify"
16
+
"tangled.org/core/appview/oauth"
17
+
"tangled.org/core/appview/pages"
18
+
"tangled.org/core/appview/pages/markup"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/tid"
21
22
"github.com/bluesky-social/indigo/api/atproto"
23
"github.com/bluesky-social/indigo/atproto/identity"
···
236
description := r.FormValue("description")
237
238
// construct new string from form values
239
+
entry := models.String{
240
Did: first.Did,
241
Rkey: first.Rkey,
242
Filename: filename,
···
319
320
description := r.FormValue("description")
321
322
+
string := models.String{
323
Did: syntax.DID(user.Did),
324
Rkey: tid.TID(),
325
Filename: filename,
+4
-3
appview/validator/issue.go
+4
-3
appview/validator/issue.go
···
4
"fmt"
5
"strings"
6
7
-
"tangled.sh/tangled.sh/core/appview/db"
8
)
9
10
-
func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error {
11
// if comments have parents, only ingest ones that are 1 level deep
12
if comment.ReplyTo != nil {
13
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
32
return nil
33
}
34
35
-
func (v *Validator) ValidateIssue(issue *db.Issue) error {
36
if issue.Title == "" {
37
return fmt.Errorf("issue title is empty")
38
}
···
4
"fmt"
5
"strings"
6
7
+
"tangled.org/core/appview/db"
8
+
"tangled.org/core/appview/models"
9
)
10
11
+
func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error {
12
// if comments have parents, only ingest ones that are 1 level deep
13
if comment.ReplyTo != nil {
14
parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo))
···
33
return nil
34
}
35
36
+
func (v *Validator) ValidateIssue(issue *models.Issue) error {
37
if issue.Title == "" {
38
return fmt.Errorf("issue title is empty")
39
}
+27
-13
appview/validator/label.go
+27
-13
appview/validator/label.go
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"golang.org/x/exp/slices"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/appview/db"
13
)
14
15
var (
···
21
validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22
)
23
24
-
func (v *Validator) ValidateLabelDefinition(label *db.LabelDefinition) error {
25
if label.Name == "" {
26
return fmt.Errorf("label name is empty")
27
}
···
95
return nil
96
}
97
98
-
func (v *Validator) ValidateLabelOp(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
99
if labelDef == nil {
100
return fmt.Errorf("label definition is required")
101
}
102
if labelOp == nil {
103
return fmt.Errorf("label operation is required")
104
}
105
106
expectedKey := labelDef.AtUri().String()
107
if labelOp.OperandKey != expectedKey {
108
return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
109
}
110
111
-
if labelOp.Operation != db.LabelOperationAdd && labelOp.Operation != db.LabelOperationDel {
112
return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
113
}
114
···
131
return nil
132
}
133
134
-
func (v *Validator) validateOperandValue(labelDef *db.LabelDefinition, labelOp *db.LabelOp) error {
135
valueType := labelDef.ValueType
136
137
// this is permitted, it "unsets" a label
138
if labelOp.OperandValue == "" {
139
-
labelOp.Operation = db.LabelOperationDel
140
return nil
141
}
142
143
switch valueType.Type {
144
-
case db.ConcreteTypeNull:
145
// For null type, value should be empty
146
if labelOp.OperandValue != "null" {
147
return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
148
}
149
150
-
case db.ConcreteTypeString:
151
// For string type, validate enum constraints if present
152
if valueType.IsEnum() {
153
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
···
156
}
157
158
switch valueType.Format {
159
-
case db.ValueTypeFormatDid:
160
id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
161
if err != nil {
162
return fmt.Errorf("failed to resolve did/handle: %w", err)
···
164
165
labelOp.OperandValue = id.DID.String()
166
167
-
case db.ValueTypeFormatAny, "":
168
default:
169
return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
170
}
171
172
-
case db.ConcreteTypeInt:
173
if labelOp.OperandValue == "" {
174
return fmt.Errorf("integer type requires non-empty value")
175
}
···
183
}
184
}
185
186
-
case db.ConcreteTypeBool:
187
if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
188
return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
189
}
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"golang.org/x/exp/slices"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/appview/models"
13
)
14
15
var (
···
21
validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID}
22
)
23
24
+
func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error {
25
if label.Name == "" {
26
return fmt.Errorf("label name is empty")
27
}
···
95
return nil
96
}
97
98
+
func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error {
99
if labelDef == nil {
100
return fmt.Errorf("label definition is required")
101
}
102
+
if repo == nil {
103
+
return fmt.Errorf("repo is required")
104
+
}
105
if labelOp == nil {
106
return fmt.Errorf("label operation is required")
107
}
108
109
+
// validate permissions: only collaborators can apply labels currently
110
+
//
111
+
// TODO: introduce a repo:triage permission
112
+
ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo())
113
+
if err != nil {
114
+
return fmt.Errorf("failed to enforce permissions: %w", err)
115
+
}
116
+
if !ok {
117
+
return fmt.Errorf("unauhtorized label operation")
118
+
}
119
+
120
expectedKey := labelDef.AtUri().String()
121
if labelOp.OperandKey != expectedKey {
122
return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey)
123
}
124
125
+
if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel {
126
return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation)
127
}
128
···
145
return nil
146
}
147
148
+
func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error {
149
valueType := labelDef.ValueType
150
151
// this is permitted, it "unsets" a label
152
if labelOp.OperandValue == "" {
153
+
labelOp.Operation = models.LabelOperationDel
154
return nil
155
}
156
157
switch valueType.Type {
158
+
case models.ConcreteTypeNull:
159
// For null type, value should be empty
160
if labelOp.OperandValue != "null" {
161
return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue)
162
}
163
164
+
case models.ConcreteTypeString:
165
// For string type, validate enum constraints if present
166
if valueType.IsEnum() {
167
if !slices.Contains(valueType.Enum, labelOp.OperandValue) {
···
170
}
171
172
switch valueType.Format {
173
+
case models.ValueTypeFormatDid:
174
id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue)
175
if err != nil {
176
return fmt.Errorf("failed to resolve did/handle: %w", err)
···
178
179
labelOp.OperandValue = id.DID.String()
180
181
+
case models.ValueTypeFormatAny, "":
182
default:
183
return fmt.Errorf("unsupported format constraint: %q", valueType.Format)
184
}
185
186
+
case models.ConcreteTypeInt:
187
if labelOp.OperandValue == "" {
188
return fmt.Errorf("integer type requires non-empty value")
189
}
···
197
}
198
}
199
200
+
case models.ConcreteTypeBool:
201
if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" {
202
return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue)
203
}
+27
appview/validator/string.go
+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
package validator
2
3
import (
4
-
"tangled.sh/tangled.sh/core/appview/db"
5
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
6
-
"tangled.sh/tangled.sh/core/idresolver"
7
)
8
9
type Validator struct {
10
db *db.DB
11
sanitizer markup.Sanitizer
12
resolver *idresolver.Resolver
13
}
14
15
-
func New(db *db.DB, res *idresolver.Resolver) *Validator {
16
return &Validator{
17
db: db,
18
sanitizer: markup.NewSanitizer(),
19
resolver: res,
20
}
21
}
···
1
package validator
2
3
import (
4
+
"tangled.org/core/appview/db"
5
+
"tangled.org/core/appview/pages/markup"
6
+
"tangled.org/core/idresolver"
7
+
"tangled.org/core/rbac"
8
)
9
10
type Validator struct {
11
db *db.DB
12
sanitizer markup.Sanitizer
13
resolver *idresolver.Resolver
14
+
enforcer *rbac.Enforcer
15
}
16
17
+
func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator {
18
return &Validator{
19
db: db,
20
sanitizer: markup.NewSanitizer(),
21
resolver: res,
22
+
enforcer: enforcer,
23
}
24
}
+2
-2
cmd/appview/main.go
+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
+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
First, clone this repository:
20
21
```
22
-
git clone https://tangled.sh/@tangled.sh/core
23
```
24
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
131
You should now have a running knot server! You can finalize
132
your registration by hitting the `verify` button on the
133
-
[/knots](https://tangled.sh/knots) page. This simply creates
134
a record on your PDS to announce the existence of the knot.
135
136
### custom paths
···
19
First, clone this repository:
20
21
```
22
+
git clone https://tangled.org/@tangled.org/core
23
```
24
25
Then, build the `knot` CLI. This is the knot administration and operation tool.
···
130
131
You should now have a running knot server! You can finalize
132
your registration by hitting the `verify` button on the
133
+
[/knots](https://tangled.org/knots) page. This simply creates
134
a record on your PDS to announce the existence of the knot.
135
136
### custom paths
+4
-5
docs/migrations.md
+4
-5
docs/migrations.md
···
14
For knots:
15
16
- Upgrade to latest tag (v1.9.0 or above)
17
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
18
hit the "retry" button to verify your knot
19
20
For spindles:
21
22
- Upgrade to latest tag (v1.9.0 or above)
23
- Head to the [spindle
24
-
dashboard](https://tangled.sh/spindles) and hit the
25
"retry" button to verify your spindle
26
27
## Upgrading from v1.7.x
···
38
environment variable entirely
39
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
your DID. You can find your DID in the
41
-
[settings](https://tangled.sh/settings) page.
42
- Restart your knot once you have replaced the environment
43
variable
44
-
- Head to the [knot dashboard](https://tangled.sh/knots) and
45
hit the "retry" button to verify your knot. This simply
46
writes a `sh.tangled.knot` record to your PDS.
47
···
57
};
58
};
59
```
60
-
···
14
For knots:
15
16
- Upgrade to latest tag (v1.9.0 or above)
17
+
- Head to the [knot dashboard](https://tangled.org/knots) and
18
hit the "retry" button to verify your knot
19
20
For spindles:
21
22
- Upgrade to latest tag (v1.9.0 or above)
23
- Head to the [spindle
24
+
dashboard](https://tangled.org/spindles) and hit the
25
"retry" button to verify your spindle
26
27
## Upgrading from v1.7.x
···
38
environment variable entirely
39
- `KNOT_SERVER_OWNER` is now required on boot, set this to
40
your DID. You can find your DID in the
41
+
[settings](https://tangled.org/settings) page.
42
- Restart your knot once you have replaced the environment
43
variable
44
+
- Head to the [knot dashboard](https://tangled.org/knots) and
45
hit the "retry" button to verify your knot. This simply
46
writes a `sh.tangled.knot` record to your PDS.
47
···
57
};
58
};
59
```
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
+3
-3
docs/spindle/pipeline.md
+3
-3
docs/spindle/pipeline.md
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
-
For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
···
73
- nodejs
74
- go
75
# custom registry
76
-
git+https://tangled.sh/@example.com/my_pkg:
77
- my_pkg
78
```
79
···
141
- nodejs
142
- go
143
# custom registry
144
-
git+https://tangled.sh/@example.com/my_pkg:
145
- my_pkg
146
147
environment:
···
21
- `manual`: The workflow can be triggered manually.
22
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
23
24
+
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
25
26
```yaml
27
when:
···
73
- nodejs
74
- go
75
# custom registry
76
+
git+https://tangled.org/@example.com/my_pkg:
77
- my_pkg
78
```
79
···
141
- nodejs
142
- go
143
# custom registry
144
+
git+https://tangled.org/@example.com/my_pkg:
145
- my_pkg
146
147
environment:
+2
-2
eventconsumer/consumer.go
+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
2
3
go 1.24.4
4
···
43
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
golang.org/x/net v0.42.0
47
golang.org/x/sync v0.16.0
48
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
168
go.uber.org/atomic v1.11.0 // indirect
169
go.uber.org/multierr v1.11.0 // indirect
170
go.uber.org/zap v1.27.0 // indirect
171
-
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
···
1
+
module tangled.org/core
2
3
go 1.24.4
4
···
43
github.com/yuin/goldmark v1.7.12
44
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
45
golang.org/x/crypto v0.40.0
46
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
47
golang.org/x/net v0.42.0
48
golang.org/x/sync v0.16.0
49
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
···
169
go.uber.org/atomic v1.11.0 // indirect
170
go.uber.org/multierr v1.11.0 // indirect
171
go.uber.org/zap v1.27.0 // indirect
172
golang.org/x/sys v0.34.0 // indirect
173
golang.org/x/text v0.27.0 // indirect
174
golang.org/x/time v0.12.0 // indirect
+2
-2
guard/guard.go
+2
-2
guard/guard.go
+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
+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
-103
knotserver/git/git.go
-103
knotserver/git/git.go
···
27
h plumbing.Hash
28
}
29
30
-
type TagList struct {
31
-
refs []*TagReference
32
-
r *git.Repository
33
-
}
34
-
35
-
// TagReference is used to list both tag and non-annotated tags.
36
-
// Non-annotated tags should only contains a reference.
37
-
// Annotated tags should contain its reference and its tag information.
38
-
type TagReference struct {
39
-
ref *plumbing.Reference
40
-
tag *object.Tag
41
-
}
42
-
43
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
44
// to tar WriteHeader
45
type infoWrapper struct {
···
48
mode fs.FileMode
49
modTime time.Time
50
isDir bool
51
-
}
52
-
53
-
func (self *TagList) Len() int {
54
-
return len(self.refs)
55
-
}
56
-
57
-
func (self *TagList) Swap(i, j int) {
58
-
self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
59
-
}
60
-
61
-
// sorting tags in reverse chronological order
62
-
func (self *TagList) Less(i, j int) bool {
63
-
var dateI time.Time
64
-
var dateJ time.Time
65
-
66
-
if self.refs[i].tag != nil {
67
-
dateI = self.refs[i].tag.Tagger.When
68
-
} else {
69
-
c, err := self.r.CommitObject(self.refs[i].ref.Hash())
70
-
if err != nil {
71
-
dateI = time.Now()
72
-
} else {
73
-
dateI = c.Committer.When
74
-
}
75
-
}
76
-
77
-
if self.refs[j].tag != nil {
78
-
dateJ = self.refs[j].tag.Tagger.When
79
-
} else {
80
-
c, err := self.r.CommitObject(self.refs[j].ref.Hash())
81
-
if err != nil {
82
-
dateJ = time.Now()
83
-
} else {
84
-
dateJ = c.Committer.When
85
-
}
86
-
}
87
-
88
-
return dateI.After(dateJ)
89
}
90
91
func Open(path string, ref string) (*GitRepo, error) {
···
171
return g.r.CommitObject(h)
172
}
173
174
-
func (g *GitRepo) LastCommit() (*object.Commit, error) {
175
-
c, err := g.r.CommitObject(g.h)
176
-
if err != nil {
177
-
return nil, fmt.Errorf("last commit: %w", err)
178
-
}
179
-
return c, nil
180
-
}
181
-
182
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
183
c, err := g.r.CommitObject(g.h)
184
if err != nil {
···
211
}
212
213
return buf.Bytes(), nil
214
-
}
215
-
216
-
func (g *GitRepo) FileContent(path string) (string, error) {
217
-
c, err := g.r.CommitObject(g.h)
218
-
if err != nil {
219
-
return "", fmt.Errorf("commit object: %w", err)
220
-
}
221
-
222
-
tree, err := c.Tree()
223
-
if err != nil {
224
-
return "", fmt.Errorf("file tree: %w", err)
225
-
}
226
-
227
-
file, err := tree.File(path)
228
-
if err != nil {
229
-
return "", err
230
-
}
231
-
232
-
isbin, _ := file.IsBinary()
233
-
234
-
if !isbin {
235
-
return file.Contents()
236
-
} else {
237
-
return "", ErrBinaryFile
238
-
}
239
}
240
241
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
410
func (i *infoWrapper) Sys() any {
411
return nil
412
}
413
-
414
-
func (t *TagReference) Name() string {
415
-
return t.ref.Name().Short()
416
-
}
417
-
418
-
func (t *TagReference) Message() string {
419
-
if t.tag != nil {
420
-
return t.tag.Message
421
-
}
422
-
return ""
423
-
}
424
-
425
-
func (t *TagReference) TagObject() *object.Tag {
426
-
return t.tag
427
-
}
428
-
429
-
func (t *TagReference) Hash() plumbing.Hash {
430
-
return t.ref.Hash()
431
-
}
···
27
h plumbing.Hash
28
}
29
30
// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
31
// to tar WriteHeader
32
type infoWrapper struct {
···
35
mode fs.FileMode
36
modTime time.Time
37
isDir bool
38
}
39
40
func Open(path string, ref string) (*GitRepo, error) {
···
120
return g.r.CommitObject(h)
121
}
122
123
func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
124
c, err := g.r.CommitObject(g.h)
125
if err != nil {
···
152
}
153
154
return buf.Bytes(), nil
155
}
156
157
func (g *GitRepo) RawContent(path string) ([]byte, error) {
···
326
func (i *infoWrapper) Sys() any {
327
return nil
328
}
+1
-1
knotserver/git/post_receive.go
+1
-1
knotserver/git/post_receive.go
+1
-3
knotserver/git/tag.go
+1
-3
knotserver/git/tag.go
···
2
3
import (
4
"fmt"
5
-
"slices"
6
"strconv"
7
"strings"
8
"time"
···
35
outFormat.WriteString("")
36
outFormat.WriteString(recordSeparator)
37
38
-
output, err := g.forEachRef(outFormat.String(), "refs/tags")
39
if err != nil {
40
return nil, fmt.Errorf("failed to get tags: %w", err)
41
}
···
94
tags = append(tags, tag)
95
}
96
97
-
slices.Reverse(tags)
98
return tags, nil
99
}
···
2
3
import (
4
"fmt"
5
"strconv"
6
"strings"
7
"time"
···
34
outFormat.WriteString("")
35
outFormat.WriteString(recordSeparator)
36
37
+
output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags")
38
if err != nil {
39
return nil, fmt.Errorf("failed to get tags: %w", err)
40
}
···
93
tags = append(tags, tag)
94
}
95
96
return tags, nil
97
}
+1
-1
knotserver/git/tree.go
+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
"github.com/bluesky-social/indigo/xrpc"
16
"github.com/bluesky-social/jetstream/pkg/models"
17
securejoin "github.com/cyphar/filepath-securejoin"
18
-
"tangled.sh/tangled.sh/core/api/tangled"
19
-
"tangled.sh/tangled.sh/core/idresolver"
20
-
"tangled.sh/tangled.sh/core/knotserver/db"
21
-
"tangled.sh/tangled.sh/core/knotserver/git"
22
-
"tangled.sh/tangled.sh/core/log"
23
-
"tangled.sh/tangled.sh/core/rbac"
24
-
"tangled.sh/tangled.sh/core/workflow"
25
)
26
27
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
151
return fmt.Errorf("failed to construct absolute repo path: %w", err)
152
}
153
154
-
gr, err := git.Open(repoPath, record.Source.Branch)
155
if err != nil {
156
return fmt.Errorf("failed to open git repository: %w", err)
157
}
···
15
"github.com/bluesky-social/indigo/xrpc"
16
"github.com/bluesky-social/jetstream/pkg/models"
17
securejoin "github.com/cyphar/filepath-securejoin"
18
+
"tangled.org/core/api/tangled"
19
+
"tangled.org/core/idresolver"
20
+
"tangled.org/core/knotserver/db"
21
+
"tangled.org/core/knotserver/git"
22
+
"tangled.org/core/log"
23
+
"tangled.org/core/rbac"
24
+
"tangled.org/core/workflow"
25
)
26
27
func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error {
···
151
return fmt.Errorf("failed to construct absolute repo path: %w", err)
152
}
153
154
+
gr, err := git.Open(repoPath, record.Source.Sha)
155
if err != nil {
156
return fmt.Errorf("failed to open git repository: %w", err)
157
}
+8
-8
knotserver/internal.go
+8
-8
knotserver/internal.go
···
13
securejoin "github.com/cyphar/filepath-securejoin"
14
"github.com/go-chi/chi/v5"
15
"github.com/go-chi/chi/v5/middleware"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/config"
19
-
"tangled.sh/tangled.sh/core/knotserver/db"
20
-
"tangled.sh/tangled.sh/core/knotserver/git"
21
-
"tangled.sh/tangled.sh/core/notifier"
22
-
"tangled.sh/tangled.sh/core/rbac"
23
-
"tangled.sh/tangled.sh/core/workflow"
24
)
25
26
type InternalHandle struct {
···
13
securejoin "github.com/cyphar/filepath-securejoin"
14
"github.com/go-chi/chi/v5"
15
"github.com/go-chi/chi/v5/middleware"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/config"
19
+
"tangled.org/core/knotserver/db"
20
+
"tangled.org/core/knotserver/git"
21
+
"tangled.org/core/notifier"
22
+
"tangled.org/core/rbac"
23
+
"tangled.org/core/workflow"
24
)
25
26
type InternalHandle struct {
+9
-9
knotserver/router.go
+9
-9
knotserver/router.go
···
7
"net/http"
8
9
"github.com/go-chi/chi/v5"
10
-
"tangled.sh/tangled.sh/core/idresolver"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/knotserver/xrpc"
15
-
tlog "tangled.sh/tangled.sh/core/log"
16
-
"tangled.sh/tangled.sh/core/notifier"
17
-
"tangled.sh/tangled.sh/core/rbac"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
19
)
20
21
type Knot struct {
···
7
"net/http"
8
9
"github.com/go-chi/chi/v5"
10
+
"tangled.org/core/idresolver"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/knotserver/xrpc"
15
+
tlog "tangled.org/core/log"
16
+
"tangled.org/core/notifier"
17
+
"tangled.org/core/rbac"
18
+
"tangled.org/core/xrpc/serviceauth"
19
)
20
21
type Knot struct {
+8
-8
knotserver/server.go
+8
-8
knotserver/server.go
···
6
"net/http"
7
8
"github.com/urfave/cli/v3"
9
-
"tangled.sh/tangled.sh/core/api/tangled"
10
-
"tangled.sh/tangled.sh/core/hook"
11
-
"tangled.sh/tangled.sh/core/jetstream"
12
-
"tangled.sh/tangled.sh/core/knotserver/config"
13
-
"tangled.sh/tangled.sh/core/knotserver/db"
14
-
"tangled.sh/tangled.sh/core/log"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
17
)
18
19
func Command() *cli.Command {
···
6
"net/http"
7
8
"github.com/urfave/cli/v3"
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/hook"
11
+
"tangled.org/core/jetstream"
12
+
"tangled.org/core/knotserver/config"
13
+
"tangled.org/core/knotserver/db"
14
+
"tangled.org/core/log"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
)
18
19
func Command() *cli.Command {
+5
-5
knotserver/xrpc/create_repo.go
+5
-5
knotserver/xrpc/create_repo.go
···
13
"github.com/bluesky-social/indigo/xrpc"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
gogit "github.com/go-git/go-git/v5"
16
-
"tangled.sh/tangled.sh/core/api/tangled"
17
-
"tangled.sh/tangled.sh/core/hook"
18
-
"tangled.sh/tangled.sh/core/knotserver/git"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
21
)
22
23
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
···
13
"github.com/bluesky-social/indigo/xrpc"
14
securejoin "github.com/cyphar/filepath-securejoin"
15
gogit "github.com/go-git/go-git/v5"
16
+
"tangled.org/core/api/tangled"
17
+
"tangled.org/core/hook"
18
+
"tangled.org/core/knotserver/git"
19
+
"tangled.org/core/rbac"
20
+
xrpcerr "tangled.org/core/xrpc/errors"
21
)
22
23
func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/delete_repo.go
+3
-3
knotserver/xrpc/delete_repo.go
···
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"github.com/bluesky-social/indigo/xrpc"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
-
"tangled.sh/tangled.sh/core/api/tangled"
15
-
"tangled.sh/tangled.sh/core/rbac"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
···
11
"github.com/bluesky-social/indigo/atproto/syntax"
12
"github.com/bluesky-social/indigo/xrpc"
13
securejoin "github.com/cyphar/filepath-securejoin"
14
+
"tangled.org/core/api/tangled"
15
+
"tangled.org/core/rbac"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) {
+5
-5
knotserver/xrpc/fork_status.go
+5
-5
knotserver/xrpc/fork_status.go
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/types"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/types"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) {
+4
-4
knotserver/xrpc/fork_sync.go
+4
-4
knotserver/xrpc/fork_sync.go
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/rbac"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) {
+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
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/knotserver/git"
13
-
"tangled.sh/tangled.sh/core/patchutil"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/types"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
···
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
securejoin "github.com/cyphar/filepath-securejoin"
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/knotserver/git"
13
+
"tangled.org/core/patchutil"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/types"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) {
+3
-3
knotserver/xrpc/merge_check.go
+3
-3
knotserver/xrpc/merge_check.go
+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
+4
-4
knotserver/xrpc/repo_blob.go
+4
-4
knotserver/xrpc/repo_blob.go
···
9
"slices"
10
"strings"
11
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
44
45
contents, err := gr.RawContent(treePath)
46
if err != nil {
47
-
x.Logger.Error("file content", "error", err.Error())
48
writeError(w, xrpcerr.NewXrpcError(
49
xrpcerr.WithTag("FileNotFound"),
50
xrpcerr.WithMessage("file not found at the specified path"),
···
9
"slices"
10
"strings"
11
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
xrpcerr "tangled.org/core/xrpc/errors"
15
)
16
17
func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
···
44
45
contents, err := gr.RawContent(treePath)
46
if err != nil {
47
+
x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
48
writeError(w, xrpcerr.NewXrpcError(
49
xrpcerr.WithTag("FileNotFound"),
50
xrpcerr.WithMessage("file not found at the specified path"),
+3
-3
knotserver/xrpc/repo_branch.go
+3
-3
knotserver/xrpc/repo_branch.go
+3
-3
knotserver/xrpc/repo_branches.go
+3
-3
knotserver/xrpc/repo_branches.go
+3
-3
knotserver/xrpc/repo_compare.go
+3
-3
knotserver/xrpc/repo_compare.go
+3
-3
knotserver/xrpc/repo_diff.go
+3
-3
knotserver/xrpc/repo_diff.go
+3
-3
knotserver/xrpc/repo_get_default_branch.go
+3
-3
knotserver/xrpc/repo_get_default_branch.go
+3
-3
knotserver/xrpc/repo_languages.go
+3
-3
knotserver/xrpc/repo_languages.go
+3
-3
knotserver/xrpc/repo_log.go
+3
-3
knotserver/xrpc/repo_log.go
+27
-3
knotserver/xrpc/repo_tree.go
+27
-3
knotserver/xrpc/repo_tree.go
···
4
"net/http"
5
"path/filepath"
6
"time"
7
8
-
"tangled.sh/tangled.sh/core/api/tangled"
9
-
"tangled.sh/tangled.sh/core/knotserver/git"
10
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
11
)
12
13
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
···
43
return
44
}
45
46
// convert NiceTree -> tangled.RepoTree_TreeEntry
47
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
48
for i, file := range files {
···
83
Parent: parentPtr,
84
Dotdot: dotdotPtr,
85
Files: treeEntries,
86
}
87
88
writeJson(w, response)
···
4
"net/http"
5
"path/filepath"
6
"time"
7
+
"unicode/utf8"
8
9
+
"tangled.org/core/api/tangled"
10
+
"tangled.org/core/appview/pages/markup"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
)
14
15
func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) {
···
45
return
46
}
47
48
+
// if any of these files are a readme candidate, pass along its blob contents too
49
+
var readmeFileName string
50
+
var readmeContents string
51
+
for _, file := range files {
52
+
if markup.IsReadmeFile(file.Name) {
53
+
contents, err := gr.RawContent(filepath.Join(path, file.Name))
54
+
if err != nil {
55
+
x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name)
56
+
}
57
+
58
+
if utf8.Valid(contents) {
59
+
readmeFileName = file.Name
60
+
readmeContents = string(contents)
61
+
break
62
+
}
63
+
}
64
+
}
65
+
66
// convert NiceTree -> tangled.RepoTree_TreeEntry
67
treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files))
68
for i, file := range files {
···
103
Parent: parentPtr,
104
Dotdot: dotdotPtr,
105
Files: treeEntries,
106
+
Readme: &tangled.RepoTree_Readme{
107
+
Filename: readmeFileName,
108
+
Contents: readmeContents,
109
+
},
110
}
111
112
writeJson(w, response)
+4
-4
knotserver/xrpc/set_default_branch.go
+4
-4
knotserver/xrpc/set_default_branch.go
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/knotserver/git"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
const ActorDid string = "ActorDid"
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/knotserver/git"
14
+
"tangled.org/core/rbac"
15
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
const ActorDid string = "ActorDid"
+2
-2
knotserver/xrpc/version.go
+2
-2
knotserver/xrpc/version.go
+9
-9
knotserver/xrpc/xrpc.go
+9
-9
knotserver/xrpc/xrpc.go
···
7
"strings"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/idresolver"
12
-
"tangled.sh/tangled.sh/core/jetstream"
13
-
"tangled.sh/tangled.sh/core/knotserver/config"
14
-
"tangled.sh/tangled.sh/core/knotserver/db"
15
-
"tangled.sh/tangled.sh/core/notifier"
16
-
"tangled.sh/tangled.sh/core/rbac"
17
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
18
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
19
20
"github.com/go-chi/chi/v5"
21
)
···
7
"strings"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/idresolver"
12
+
"tangled.org/core/jetstream"
13
+
"tangled.org/core/knotserver/config"
14
+
"tangled.org/core/knotserver/db"
15
+
"tangled.org/core/notifier"
16
+
"tangled.org/core/rbac"
17
+
xrpcerr "tangled.org/core/xrpc/errors"
18
+
"tangled.org/core/xrpc/serviceauth"
19
20
"github.com/go-chi/chi/v5"
21
)
-158
legal/privacy.md
-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
"type": "string",
42
"description": "Parent directory path"
43
},
44
+
"readme": {
45
+
"type": "ref",
46
+
"ref": "#readme",
47
+
"description": "Readme for this file tree"
48
+
},
49
"files": {
50
"type": "array",
51
"items": {
···
74
"description": "Invalid request parameters"
75
}
76
]
77
+
},
78
+
"readme": {
79
+
"type": "object",
80
+
"required": ["filename", "contents"],
81
+
"properties": {
82
+
"filename": {
83
+
"type": "string",
84
+
"description": "Name of the readme file"
85
+
},
86
+
"contents": {
87
+
"type": "string",
88
+
"description": "Contents of the readme file"
89
+
}
90
+
}
91
},
92
"treeEntry": {
93
"type": "object",
+2
-2
nix/pkgs/knot-unwrapped.nix
+2
-2
nix/pkgs/knot-unwrapped.nix
+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
spindle/engine/engine.go
+5
-5
spindle/engine/engine.go
···
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
"golang.org/x/sync/errgroup"
11
-
"tangled.sh/tangled.sh/core/notifier"
12
-
"tangled.sh/tangled.sh/core/spindle/config"
13
-
"tangled.sh/tangled.sh/core/spindle/db"
14
-
"tangled.sh/tangled.sh/core/spindle/models"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
)
17
18
var (
+6
-6
spindle/engines/nixery/engine.go
+6
-6
spindle/engines/nixery/engine.go
···
19
"github.com/docker/docker/client"
20
"github.com/docker/docker/pkg/stdcopy"
21
"gopkg.in/yaml.v3"
22
-
"tangled.sh/tangled.sh/core/api/tangled"
23
-
"tangled.sh/tangled.sh/core/log"
24
-
"tangled.sh/tangled.sh/core/spindle/config"
25
-
"tangled.sh/tangled.sh/core/spindle/engine"
26
-
"tangled.sh/tangled.sh/core/spindle/models"
27
-
"tangled.sh/tangled.sh/core/spindle/secrets"
28
)
29
30
const (
···
19
"github.com/docker/docker/client"
20
"github.com/docker/docker/pkg/stdcopy"
21
"gopkg.in/yaml.v3"
22
+
"tangled.org/core/api/tangled"
23
+
"tangled.org/core/log"
24
+
"tangled.org/core/spindle/config"
25
+
"tangled.org/core/spindle/engine"
26
+
"tangled.org/core/spindle/models"
27
+
"tangled.org/core/spindle/secrets"
28
)
29
30
const (
+2
-2
spindle/engines/nixery/setup_steps.go
+2
-2
spindle/engines/nixery/setup_steps.go
+5
-5
spindle/ingester.go
+5
-5
spindle/ingester.go
···
7
"fmt"
8
"time"
9
10
-
"tangled.sh/tangled.sh/core/api/tangled"
11
-
"tangled.sh/tangled.sh/core/eventconsumer"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/db"
15
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
"github.com/bluesky-social/indigo/atproto/identity"
···
7
"fmt"
8
"time"
9
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/eventconsumer"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/db"
15
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
17
"github.com/bluesky-social/indigo/atproto/identity"
+2
-2
spindle/models/engine.go
+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
"net/http"
10
11
"github.com/go-chi/chi/v5"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/eventconsumer"
14
-
"tangled.sh/tangled.sh/core/eventconsumer/cursor"
15
-
"tangled.sh/tangled.sh/core/idresolver"
16
-
"tangled.sh/tangled.sh/core/jetstream"
17
-
"tangled.sh/tangled.sh/core/log"
18
-
"tangled.sh/tangled.sh/core/notifier"
19
-
"tangled.sh/tangled.sh/core/rbac"
20
-
"tangled.sh/tangled.sh/core/spindle/config"
21
-
"tangled.sh/tangled.sh/core/spindle/db"
22
-
"tangled.sh/tangled.sh/core/spindle/engine"
23
-
"tangled.sh/tangled.sh/core/spindle/engines/nixery"
24
-
"tangled.sh/tangled.sh/core/spindle/models"
25
-
"tangled.sh/tangled.sh/core/spindle/queue"
26
-
"tangled.sh/tangled.sh/core/spindle/secrets"
27
-
"tangled.sh/tangled.sh/core/spindle/xrpc"
28
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
29
)
30
31
//go:embed motd
···
9
"net/http"
10
11
"github.com/go-chi/chi/v5"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/eventconsumer"
14
+
"tangled.org/core/eventconsumer/cursor"
15
+
"tangled.org/core/idresolver"
16
+
"tangled.org/core/jetstream"
17
+
"tangled.org/core/log"
18
+
"tangled.org/core/notifier"
19
+
"tangled.org/core/rbac"
20
+
"tangled.org/core/spindle/config"
21
+
"tangled.org/core/spindle/db"
22
+
"tangled.org/core/spindle/engine"
23
+
"tangled.org/core/spindle/engines/nixery"
24
+
"tangled.org/core/spindle/models"
25
+
"tangled.org/core/spindle/queue"
26
+
"tangled.org/core/spindle/secrets"
27
+
"tangled.org/core/spindle/xrpc"
28
+
"tangled.org/core/xrpc/serviceauth"
29
)
30
31
//go:embed motd
+1
-1
spindle/stream.go
+1
-1
spindle/stream.go
+4
-4
spindle/xrpc/add_secret.go
+4
-4
spindle/xrpc/add_secret.go
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) {
+4
-4
spindle/xrpc/list_secrets.go
+4
-4
spindle/xrpc/list_secrets.go
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
-
"tangled.sh/tangled.sh/core/api/tangled"
14
-
"tangled.sh/tangled.sh/core/rbac"
15
-
"tangled.sh/tangled.sh/core/spindle/secrets"
16
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
···
10
"github.com/bluesky-social/indigo/atproto/syntax"
11
"github.com/bluesky-social/indigo/xrpc"
12
securejoin "github.com/cyphar/filepath-securejoin"
13
+
"tangled.org/core/api/tangled"
14
+
"tangled.org/core/rbac"
15
+
"tangled.org/core/spindle/secrets"
16
+
xrpcerr "tangled.org/core/xrpc/errors"
17
)
18
19
func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) {
+2
-2
spindle/xrpc/owner.go
+2
-2
spindle/xrpc/owner.go
+4
-4
spindle/xrpc/remove_secret.go
+4
-4
spindle/xrpc/remove_secret.go
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
-
"tangled.sh/tangled.sh/core/api/tangled"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/secrets"
15
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
···
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
"github.com/bluesky-social/indigo/xrpc"
11
securejoin "github.com/cyphar/filepath-securejoin"
12
+
"tangled.org/core/api/tangled"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/secrets"
15
+
xrpcerr "tangled.org/core/xrpc/errors"
16
)
17
18
func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) {
+9
-9
spindle/xrpc/xrpc.go
+9
-9
spindle/xrpc/xrpc.go
···
8
9
"github.com/go-chi/chi/v5"
10
11
-
"tangled.sh/tangled.sh/core/api/tangled"
12
-
"tangled.sh/tangled.sh/core/idresolver"
13
-
"tangled.sh/tangled.sh/core/rbac"
14
-
"tangled.sh/tangled.sh/core/spindle/config"
15
-
"tangled.sh/tangled.sh/core/spindle/db"
16
-
"tangled.sh/tangled.sh/core/spindle/models"
17
-
"tangled.sh/tangled.sh/core/spindle/secrets"
18
-
xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
19
-
"tangled.sh/tangled.sh/core/xrpc/serviceauth"
20
)
21
22
const ActorDid string = "ActorDid"
···
8
9
"github.com/go-chi/chi/v5"
10
11
+
"tangled.org/core/api/tangled"
12
+
"tangled.org/core/idresolver"
13
+
"tangled.org/core/rbac"
14
+
"tangled.org/core/spindle/config"
15
+
"tangled.org/core/spindle/db"
16
+
"tangled.org/core/spindle/models"
17
+
"tangled.org/core/spindle/secrets"
18
+
xrpcerr "tangled.org/core/xrpc/errors"
19
+
"tangled.org/core/xrpc/serviceauth"
20
)
21
22
const ActorDid string = "ActorDid"
+7
-5
types/repo.go
+7
-5
types/repo.go
···
41
}
42
43
type RepoTreeResponse struct {
44
-
Ref string `json:"ref,omitempty"`
45
-
Parent string `json:"parent,omitempty"`
46
-
Description string `json:"description,omitempty"`
47
-
DotDot string `json:"dotdot,omitempty"`
48
-
Files []NiceTree `json:"files,omitempty"`
49
}
50
51
type TagReference struct {
···
41
}
42
43
type RepoTreeResponse struct {
44
+
Ref string `json:"ref,omitempty"`
45
+
Parent string `json:"parent,omitempty"`
46
+
Description string `json:"description,omitempty"`
47
+
DotDot string `json:"dotdot,omitempty"`
48
+
Files []NiceTree `json:"files,omitempty"`
49
+
ReadmeFileName string `json:"readme_filename,omitempty"`
50
+
Readme string `json:"readme_contents,omitempty"`
51
}
52
53
type TagReference struct {
+1
-1
workflow/compile.go
+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